diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bfff0..2796748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * `Assent.Strategy.Bitbucket` added * `Assent.Strategy.Twitch` added * `Assent.Strategy.OAuth2` now supports PKCE +* `Assent.Strategy.Telegram` added ## v0.2.10 (2024-04-11) diff --git a/README.md b/README.md index 0ec10d0..1266113 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Multi-provider authentication framework. * Strava - `Assent.Strategy.Strava` * Slack - `Assent.Strategy.Slack` * Stripe Connect - `Assent.Strategy.Stripe` + * Telegram - `Assent.Strategy.Telegram` * Twitch - `Assent.Strategy.Twitch` * Twitter - `Assent.Strategy.Twitter` * VK - `Assent.Strategy.VK` diff --git a/lib/assent/strategies/telegram.ex b/lib/assent/strategies/telegram.ex index 8eb52bb..f228474 100644 --- a/lib/assent/strategies/telegram.ex +++ b/lib/assent/strategies/telegram.ex @@ -1,394 +1,236 @@ -defmodule Assent.Strategies.Telegram do +defmodule Assent.Strategy.Telegram do @moduledoc """ - ### Sign in with Telegram strategy - - As the [Telegram Login Widget](https://core.telegram.org/widgets/login) only supports authentication requests - via an embedded widget or a JS call, and for the [Web Mini App](https://core.telegram.org/bots/webapps) authentication data is - sent when a user opens a mini app in Telegram, the strategy does not implement the `authorize_url/1` method. - - The default TTL for the authentication data is 60 seconds. This can be increased by the `max_auth_validity_sec` config key. + Telegram authorization strategy. + + Supports both + [Telegram Login Widget](https://core.telegram.org/widgets/login), + and [Web Mini App](https://core.telegram.org/bots/webapps) authorizations. + + Note that using the `authorize_url/1` instead of the Telegram JavaScript + embed script, will send the end-user to the `:return_to` path with a base64 + url encoded JSON string in a URL fragment. This means that it can only be + accessed client-side, so it must be parsed with JavaScript and resubmitted + as query params: + + + + Note that the returned user claims can vary widelty, and are depend on the + authorization channel and user settings. + + ## Configuration + + - `:bot_token` - The telegram bot token, required + - `:authorization_channel` - The authorization channel, optional, defaults + to `:login_widget`, may be one of `:login_widget` or `:web_mini_app` + - `:origin` - The origin URL for `authorize_url/1`, required + - `:return_to` - The return URL for `authorize_url/1`, required ## Usage ### Login Widget - config = [ - authentication_channel: :login_widget, - bot_token: "YOUR_FULL_BOT_TOKEN", - max_auth_validity_sec: 60 - ] + The JavaScript Widget can be implemented with: + - Please note that in the case of the JavaScript authentication callback, if a user declines to authenticate, - the `false` response from the Telegram widget library should be handled client-side. + Configuration should have: - A basic implementation is described in the Telegram Login Widget docs. A more advanced option without - an embedded iframe via direct JS call and with custom login button can be found on [Stack Overflow](https://stackoverflow.com/a/63593384/899911). + config = [ + bot_token: "YOUR_FULL_BOT_TOKEN" + ] - The Telegram strategy supports both redirect and function callback options. + Note that if a user declines to authorize access, you have to handle it + client-side with JavaScript. ### Web Mini App config = [ - authentication_channel: :web_mini_app, bot_token: "YOUR_FULL_BOT_TOKEN", - max_auth_validity_sec: 60 + authorization_channel: :web_mini_app ] - For the Web Mini App authentication, the strategy expects the original `initData` string to be passed in as-is, - in URL-encoded form, wrapped by a map as the value for the `init_data` key: - - %{ init_data: "original%20initData%20string" } - - - ## Possible Response Details - - As Telegram states that the returning claims can vary (marked as `optional`) and heavily depend on the authentication - channel and user settings, the claims returned from `callback/2` can also vary. - - All fields have been renamed to comply with the OpenID Connect standard, and the `sub` claim is (likely) always present. - - The most complete set of claims looks like this: - - %{ - # Standard OpenID Connect claims - "sub" => integer(), - "name" => String.t(), - "given_name" => String.t(), - "family_name" => String.t(), - "preferred_username" => String.t(), - "picture" => String.t(), - "locale" => String.t(), - - # Extra claims - "is_bot" => boolean(), - "is_premium" => boolean(), - "added_to_attachment_menu" => boolean(), - "allows_write_to_pm" => boolean(), - "authenticated_at" => DateTime.t() - } - - - ### Original Telegram Full Login Success Response for the Login Widget: - - %{ - "id" => integer(), - "first_name" => String.t(), - "last_name" => String.t(), - "username" => String.t(), - "photo_url" => String.t(), - "auth_date" => integer(), - "hash" => String.t() - } - - ### Original possible Telegram full decoded initData for the Web Mini App: - - %{ - "query_id" => String.t(), - "user" => %{ - "id" => integer(), - "is_bot" => boolean(), - "first_name" => String.t(), - "last_name" => String.t(), - "username" => String.t(), - "language_code" => String.t(), - "is_premium" => boolean(), - "added_to_attachment_menu" => boolean(), - "allows_write_to_pm" => boolean(), - "photo_url" => String.t() - }, - "receiver" => %{ - "id" => integer(), - "is_bot" => boolean(), - "first_name" => String.t(), - "last_name" => String.t(), - "username" => String.t(), - "language_code" => String.t(), - "is_premium" => boolean(), - "added_to_attachment_menu" => boolean(), - "allows_write_to_pm" => boolean(), - "photo_url" => String.t() - }, - "chat" => %{ - "id" => integer(), - "type" => String.t(), - "title" => String.t(), - "username" => String.t(), - "photo_url" => String.t() - }, - "chat_type" => String.t(), - "chat_instance" => String.t(), - "start_param" => String.t(), - "can_send_after" => integer(), - "auth_date" => integer(), - "hash" => String.t() - } - ``` + For the Web Mini App authorization, the strategy expects the original + `initData` query param to be passed in as-is. """ @behaviour Assent.Strategy - alias Assent.Strategy - alias Assent.Config - alias Assent.CallbackError - - @default_config [ - max_auth_validity_sec: 60 - ] + alias Assent.{CallbackError, Config, MissingParamError, Strategy} - @web_app_key "WebAppData" + @auth_ttl_seconds 60 @web_mini_app :web_mini_app @login_widget :login_widget - @type login_widget_response :: %{String.t() => String.t()} - @type mini_app_init_data :: String.t() - @type mini_app_response :: - %{init_data: mini_app_init_data()} | %{String.t() => mini_app_init_data()} - @type response_params :: mini_app_response() | login_widget_response() - @impl Assent.Strategy - def authorize_url(_config) do - {:error, "Telegram does not support direct authorization request, please check docs"} - end - - @impl Assent.Strategy - def callback(config, %{"init_data" => init_data} = _response_params), - do: callback(config, %{init_data: init_data}) - - def callback(config, %{} = response_params) do - config = enrich_config(config) - - with :ok <- do_preflight_checks(config, response_params), - {:ok, params} <- maybe_convert_init_data(response_params), - :ok <- check_hash_key(params), - :ok <- check_auth_date_key(params) do - authenticate(config, params) + @spec authorize_url(Config.t()) :: {:ok, %{url: binary()}} + def authorize_url(config) do + with {:ok, bot_token} <- Config.fetch(config, :bot_token), + {:ok, origin} <- Config.fetch(config, :origin), + {:ok, return_to} <- Config.fetch(config, :return_to) do + [bot_id | _rest] = String.split(bot_token, ":") + + query = + URI.encode_query( + bot_id: bot_id, + origin: origin, + return_to: return_to, + request_access: "read", + embed: "0" + ) + + {:ok, %{url: "https://oauth.telegram.org/auth?#{query}"}} end end - ### Private part - - defp do_preflight_checks(config, response_params) do - with {:ok, auth_channel} <- fetch_authentication_channel(config), - :ok <- check_params_match_channel(response_params, auth_channel) do - :ok + @impl Assent.Strategy + @spec callback(Config.t(), map()) :: {:ok, %{user: map()} | {:error, term()}} + def callback(config, params) do + with {:ok, authorization_channel} <- fetch_authorization_channel(config), + {:ok, {hash, params}} <- split_hash_params(config, params, authorization_channel), + :ok <- verify_ttl(config, params), + {:ok, secret} <- generate_token_signature(config, authorization_channel), + :ok <- verify_hash(secret, hash, params), + {:ok, user} <- normalize(params, config) do + {:ok, %{user: user}} end end - defp check_params_match_channel(%{init_data: _}, @login_widget), - do: cerr(:init_data_with_login_widget) - - defp check_params_match_channel(%{init_data: ""}, @web_mini_app), - do: cerr(:init_data_empty) - - defp check_params_match_channel(params, @web_mini_app) when not is_map_key(params, :init_data), - do: cerr(:no_init_data) - - defp check_params_match_channel(%{init_data: init_data}, @web_mini_app) - when not is_binary(init_data), - do: cerr(:no_init_data) - - defp check_params_match_channel(_params, _auth_channel), do: :ok - - defp check_hash_key(%{"hash" => _}), do: :ok - defp check_hash_key(_), do: cerr(:missing_hash_key) - - defp check_auth_date_key(%{"auth_date" => _}), do: :ok - defp check_auth_date_key(_), do: cerr(:missing_auth_date_key) + defp fetch_authorization_channel(config) do + case Config.get(config, :authorization_channel, @login_widget) do + @login_widget -> + {:ok, @login_widget} - defp maybe_convert_init_data(%{init_data: init_data}), do: {:ok, URI.decode_query(init_data)} - defp maybe_convert_init_data(response_params), do: {:ok, response_params} + @web_mini_app -> + {:ok, @web_mini_app} - defp authenticate(config, response_params) do - with {:ok, bot_token} <- fetch_bot_token(config), - {:ok, auth_channel} <- fetch_authentication_channel(config), - secret_key = build_secret_key(auth_channel, bot_token), - :ok <- verify_authenticity(response_params, secret_key), - {:ok, max_auth_validity_sec} <- Config.fetch(config, :max_auth_validity_sec), - {:ok, auth_date} <- date_time_from_unix(response_params["auth_date"]), - :ok <- verify_ttl(auth_date, max_auth_validity_sec) do - claims = normalize(response_params, config) - {:ok, %{user: claims}} + other -> + {:error, + CallbackError.exception( + message: "Invalid `:authorization_channel` value: #{inspect(other)}" + )} end end - defp normalize(%{"user" => user} = response_params, config) do - with {:ok, user_as_map} <- Strategy.decode_json(user, config) do - response_params - |> Map.delete("user") - |> Map.merge(user_as_map) - |> normalize(config) + defp split_hash_params(_config, params, @login_widget) do + case Map.split(params, ["hash"]) do + {%{"hash" => hash}, params} -> {:ok, {hash, params}} + {_, _} -> {:error, MissingParamError.exception(expected_key: "hash", params: params)} end end - defp normalize(%{"id" => id} = response_params, config) when is_binary(id) do - normalize(%{response_params | "id" => String.to_integer(id)}, config) + defp split_hash_params(config, %{"init_data" => init_data}, @web_mini_app) do + split_hash_params(config, URI.decode_query(init_data), @login_widget) end - defp normalize(%{} = response_params, _config) do - {:ok, authenticated_at} = date_time_from_unix(response_params["auth_date"]) - - %{ - # standard OpenID Connect claims - "sub" => response_params["id"], - "name" => build_full_name(response_params), - "given_name" => response_params["first_name"], - "family_name" => response_params["last_name"], - "preferred_username" => response_params["username"], - "picture" => response_params["photo_url"], - "locale" => response_params["language_code"], - # extra claims - "is_bot" => response_params["is_bot"], - "is_premium" => response_params["is_premium"], - "added_to_attachment_menu" => response_params["added_to_attachment_menu"], - "allows_write_to_pm" => response_params["allows_write_to_pm"] - } - |> Strategy.prune() - |> Map.put("authenticated_at", authenticated_at) - end + defp split_hash_params(_config, params, @web_mini_app), + do: {:error, MissingParamError.exception(expected_key: "init_data", params: params)} - defp verify_authenticity(%{"hash" => provided_hash} = response_params, secret_key) do - response_params - |> calculate_actual_hash(secret_key) - |> case do - ^provided_hash -> :ok - _ -> cerr(:authenticity_check_failed) + defp generate_token_signature(config, @login_widget) do + case Config.fetch(config, :bot_token) do + {:ok, bot_token} -> {:ok, :crypto.hash(:sha256, bot_token)} + {:error, error} -> {:error, error} end end - defp calculate_actual_hash(response_params, secret_key) do - data_check_string = build_authenticity_check_string(response_params) - - :hmac - |> :crypto.mac(:sha256, secret_key, data_check_string) - |> Base.encode16(case: :lower) + defp generate_token_signature(config, @web_mini_app) do + case Config.fetch(config, :bot_token) do + {:ok, bot_token} -> {:ok, :crypto.mac(:hmac, :sha256, "WebAppData", bot_token)} + {:error, error} -> {:error, error} + end end - defp build_secret_key(@login_widget, bot_token), - do: :crypto.hash(:sha256, bot_token) - - defp build_secret_key(@web_mini_app, bot_token), - do: :crypto.mac(:hmac, :sha256, @web_app_key, bot_token) - - defp build_authenticity_check_string(response_params) do - response_params - |> Map.delete("hash") - |> Enum.sort_by(fn {key, _value} -> key end) - |> Enum.map(fn {key, value} -> "#{key}=#{value}" end) - |> Enum.join("\n") - end + defp verify_ttl(_config, %{"auth_date" => auth_date}) do + auth_timestamp = (is_binary(auth_date) && String.to_integer(auth_date)) || auth_date - defp verify_ttl(%DateTime{} = auth_date, max_auth_validity_sec) do DateTime.utc_now() - |> DateTime.diff(auth_date, :second) + |> DateTime.to_unix(:second) + |> Kernel.-(auth_timestamp) + |> Kernel.<=(@auth_ttl_seconds) |> case do - since when since > max_auth_validity_sec -> cerr(:auth_request_expired) - future when future < 0 -> cerr(:auth_date_in_future) - _ -> :ok + true -> :ok + false -> {:error, CallbackError.exception(message: "Authorization request has expired")} end end - defp date_time_from_unix(unix_time_string) when is_binary(unix_time_string) do - unix_time_string - |> Integer.parse() - |> case do - {unix_time_int, _} -> date_time_from_unix(unix_time_int) - _ -> cerr(:invalid_auth_date, details: [auto_date: unix_time_string]) - end - end + defp verify_ttl(_config, params), + do: {:error, MissingParamError.exception(expected_key: "auth_date", params: params)} - defp date_time_from_unix(unix_time) do - unix_time - |> DateTime.from_unix() - |> case do - {:ok, date} -> {:ok, date} - _ -> cerr(:invalid_auth_date, details: [auto_date: unix_time]) - end - end + defp verify_hash(secret, hash, params) do + data = + params + |> Enum.map(fn {key, value} -> "#{key}=#{value}" end) + |> Enum.sort() + |> Enum.join("\n") - defp build_full_name(response_params) do - [ - response_params["first_name"], - response_params["last_name"] - ] - |> Enum.join(" ") - |> String.trim() - end + data_hash = + :hmac + |> :crypto.mac(:sha256, secret, data) + |> Base.encode16(case: :lower) - defp fetch_bot_token(config) do - config - |> Config.fetch(:bot_token) - |> case do - {:ok, bot_token} when is_binary(bot_token) -> {:ok, bot_token} - _ -> cerr(:invalid_bot_token) - end - end - - @auth_channels [@login_widget, @web_mini_app] + case Assent.constant_time_compare(hash, data_hash) do + true -> + :ok - defp fetch_authentication_channel(config) do - config - |> Config.fetch(:authentication_channel) - |> case do - {:ok, auth_channel} when auth_channel in @auth_channels -> {:ok, auth_channel} - {:ok, auth_channel} -> cerr(:unknown_authentication_channel, details: auth_channel) - error -> error + false -> + {:error, CallbackError.exception(message: "Authorization request has an invalid hash")} end end - defp enrich_config(config) do - Keyword.merge(@default_config, config) - end - - defp cerr(error, opts \\ []) when is_atom(error) do - error_uri = Keyword.get(opts, :error_uri, nil) - message = get_error_message(error, opts) - - {:error, CallbackError.exception(message: message, error: error, error_uri: error_uri)} - end - - defp get_error_message(error, opts) do - error - |> error_to_message() - |> maybe_inject_details(opts) - end - - defp maybe_inject_details(message, opts) do - details = Keyword.get(opts, :details) - - if Keyword.has_key?(opts, :details), - do: "#{message}: #{inspect(details)}", - else: message + defp normalize(%{"user" => user} = params, config) do + with {:ok, user_params} <- Strategy.decode_json(user, config) do + params + |> Map.delete("user") + |> Map.merge(user_params) + |> normalize(config) + end end - defp error_to_message(error) do - [ - init_data_with_login_widget: "Init data provided for the login widget authentication", - init_data_empty: - "Empty init data string provided for the Web mini app authentication. The page opened not from Telegram?", - no_init_data: - "Web mini app authentication requires initial WebAppInitData.initData string as `:init_data` key in the callback params", - missing_hash_key: - "Missing hash key in the response params, cannot verify the authenticity of the response", - missing_auth_date_key: - "Missing auth_date key in the response params, cannot verify the response", - authenticity_check_failed: - "Data authenticity check failed: the provided hash does not match the data", - auth_request_expired: "The authentication request has expired", - auth_date_in_future: "Auth date is in the future, possible tampering or clock skew detected" - ] - |> Keyword.get(error, :none) - |> case do - :none -> stringify(error) - message -> message - end + defp normalize(%{"id" => id} = params, config) when is_binary(id) do + normalize(%{params | "id" => String.to_integer(id)}, config) end - defp stringify(atom) when is_atom(atom) do - atom - |> Atom.to_string() - |> String.split("_") - |> Enum.join(" ") - |> String.capitalize() + defp normalize(params, _config) do + Strategy.normalize_userinfo( + %{ + "sub" => params["id"], + "given_name" => params["first_name"], + "family_name" => params["last_name"], + "preferred_username" => params["username"], + "picture" => params["photo_url"], + "locale" => params["language_code"] + }, + Map.take(params, ~w(is_bot is_premium added_to_attachment_menu allows_write_to_pm)) + ) end end diff --git a/test/assent/strategies/telegram_test.exs b/test/assent/strategies/telegram_test.exs new file mode 100644 index 0000000..58701bd --- /dev/null +++ b/test/assent/strategies/telegram_test.exs @@ -0,0 +1,200 @@ +defmodule Assent.Strategy.TelegramTest do + use ExUnit.Case + + alias Assent.Strategy.Telegram + + @callback_params %{ + "first_name" => "Paul", + "last_name" => "Duroff", + "id" => "928474348", + "photo_url" => "https://t.me/i/userpic/320/H43c-6BjdPSD-gFkKcLU22upkRkJ5EsZ6Jy-3EvZqR4.jpg", + "username" => "duroff" + } + @user %{ + "sub" => 928_474_348, + "family_name" => "Duroff", + "given_name" => "Paul", + "preferred_username" => "duroff", + "picture" => "https://t.me/i/userpic/320/H43c-6BjdPSD-gFkKcLU22upkRkJ5EsZ6Jy-3EvZqR4.jpg" + } + + defp generate_hash(params, secret) do + data = + params + |> Enum.map(fn {key, value} -> "#{key}=#{value}" end) + |> Enum.sort() + |> Enum.join("\n") + + :hmac + |> :crypto.mac(:sha256, secret, data) + |> Base.encode16(case: :lower) + end + + setup context do + default_config = + [ + bot_token: "9999999999:yJUV5C4xrLSn9wA9HpF3r5vGfLm5cy3hWuH", + origin: "http://localhost:4000/login", + return_to: "http://localhost:4000/auth/callback" + ] + + auth_date = DateTime.utc_now() |> DateTime.to_unix() |> Integer.to_string() + default_params = Map.put(@callback_params, "auth_date", auth_date) + + {config, params} = + case Map.get(context, :authorization_channel, :login_widget) do + :login_widget -> + hash = generate_hash(default_params, :crypto.hash(:sha256, default_config[:bot_token])) + + {default_config, Map.put(default_params, "hash", hash)} + + :web_mini_app -> + hash = + generate_hash( + default_params, + :crypto.mac(:hmac, :sha256, "WebAppData", default_config[:bot_token]) + ) + + init_data = URI.encode_query(Map.put(default_params, "hash", hash)) + config = Keyword.put(default_config, :authorization_channel, :web_mini_app) + + {config, %{"init_data" => init_data}} + end + + {:ok, config: config, callback_params: params} + end + + describe "authorize_url/1" do + test "with missing `:bot_token` config", %{config: config} do + config = Keyword.delete(config, :bot_token) + + assert {:error, %Assent.Config.MissingKeyError{} = error} = Telegram.authorize_url(config) + assert error.key == :bot_token + end + + test "with missing `:origin` config", %{config: config} do + config = Keyword.delete(config, :origin) + + assert {:error, %Assent.Config.MissingKeyError{} = error} = Telegram.authorize_url(config) + assert error.key == :origin + end + + test "with missing `:return_to` config", %{config: config} do + config = Keyword.delete(config, :return_to) + + assert {:error, %Assent.Config.MissingKeyError{} = error} = Telegram.authorize_url(config) + assert error.key == :return_to + end + + test "returns", %{config: config} do + assert {:ok, %{url: url}} = Telegram.authorize_url(config) + + assert URI.decode_query(URI.parse(url).query) == %{ + "bot_id" => "9999999999", + "origin" => "http://localhost:4000/login", + "return_to" => "http://localhost:4000/auth/callback", + "request_access" => "read", + "embed" => "0" + } + end + end + + describe "callback/2" do + test "with invalid `:authorization_channel` config", %{ + config: config, + callback_params: callback_params + } do + config = Keyword.put(config, :authorization_channel, :invalid) + + assert {:error, %Assent.CallbackError{} = error} = + Telegram.callback(config, callback_params) + + assert error.message == "Invalid `:authorization_channel` value: :invalid" + end + + test "with missing hash param", %{config: config, callback_params: callback_params} do + callback_params = Map.delete(callback_params, "hash") + + assert {:error, %Assent.MissingParamError{} = error} = + Telegram.callback(config, callback_params) + + assert error.expected_key == "hash" + end + + @tag authorization_channel: :web_mini_app + test "with web mini app with missing init_data param", %{ + config: config, + callback_params: callback_params + } do + callback_params = Map.delete(callback_params, "init_data") + + assert {:error, %Assent.MissingParamError{} = error} = + Telegram.callback(config, callback_params) + + assert error.expected_key == "init_data" + end + + test "with missing auth_date param", %{config: config, callback_params: callback_params} do + callback_params = Map.delete(callback_params, "auth_date") + + assert {:error, %Assent.MissingParamError{} = error} = + Telegram.callback(config, callback_params) + + assert error.expected_key == "auth_date" + end + + test "with expired auth_date param", %{config: config, callback_params: callback_params} do + expired_auth_date = + DateTime.utc_now() |> DateTime.to_unix() |> Kernel.-(61) |> Integer.to_string() + + callback_params = Map.put(callback_params, "auth_date", expired_auth_date) + + assert {:error, %Assent.CallbackError{} = error} = + Telegram.callback(config, callback_params) + + assert error.message == "Authorization request has expired" + end + + test "with missing bot_token config", %{config: config, callback_params: callback_params} do + config = Keyword.delete(config, :bot_token) + + assert {:error, %Assent.Config.MissingKeyError{} = error} = + Telegram.callback(config, callback_params) + + assert error.key == :bot_token + end + + test "with invalid hash", %{config: config, callback_params: callback_params} do + config = Keyword.put(config, :bot_token, "other-token") + + assert {:error, %Assent.CallbackError{} = error} = + Telegram.callback(config, callback_params) + + assert error.message == "Authorization request has an invalid hash" + end + + @tag authorization_channel: :web_mini_app + test "with web mini app with invalid hash", %{ + config: config, + callback_params: callback_params + } do + config = Keyword.put(config, :bot_token, "other-token") + + assert {:error, %Assent.CallbackError{} = error} = + Telegram.callback(config, callback_params) + + assert error.message == "Authorization request has an invalid hash" + end + + test "returns user", %{config: config, callback_params: callback_params} do + assert {:ok, %{user: user}} = Telegram.callback(config, callback_params) + assert user == @user + end + + @tag authorization_channel: :web_mini_app + test "with web mini app", %{config: config, callback_params: callback_params} do + assert {:ok, %{user: user}} = Telegram.callback(config, callback_params) + assert user == @user + end + end +end diff --git a/test/assent/strategies/telgram_test.exs b/test/assent/strategies/telgram_test.exs deleted file mode 100644 index ae7ac9e..0000000 --- a/test/assent/strategies/telgram_test.exs +++ /dev/null @@ -1,171 +0,0 @@ -defmodule Assent.Strategies.TelgramTest do - use ExUnit.Case - - alias Assent.Strategies.Telegram - - # 1_000 years - @max_auth_validity_sec 31_536_000_000 - - @config_login [ - bot_token: "9957363869:yJUV5C4xrLSn9wA9HpF3r5vGfLm5cy3hWuH", - authentication_channel: :login_widget, - max_auth_validity_sec: @max_auth_validity_sec - ] - - @config_mini_app [ - bot_token: "9957363869:yJUV5C4xrLSn9wA9HpF3r5vGfLm5cy3hWuH", - authentication_channel: :web_mini_app, - max_auth_validity_sec: @max_auth_validity_sec - ] - - @login_widget_callback_params %{ - "auth_date" => "1718262224", - "first_name" => "Paul", - "last_name" => "Duroff", - "hash" => "ec9a1333e072bcba901e3fb8b1a124fa9c30234309d03f4e30f0c8ba58f7a43c", - "id" => "928474348", - "photo_url" => "https://t.me/i/userpic/320/H43c-6BjdPSD-gFkKcLU22upkRkJ5EsZ6Jy-3EvZqR4.jpg", - "username" => "duroff" - } - - @login_widget_claims %{ - "sub" => 928_474_348, - "name" => "Paul Duroff", - "family_name" => "Duroff", - "given_name" => "Paul", - "preferred_username" => "duroff", - "picture" => "https://t.me/i/userpic/320/H43c-6BjdPSD-gFkKcLU22upkRkJ5EsZ6Jy-3EvZqR4.jpg", - "authenticated_at" => ~U[2024-06-13 07:03:44Z] - } - - @login_widget_wrong_hash "ba7df7c892c36105172bc1e67ff4417c0f80f4b04d3defbef047cd5251f92972" - - @web_app_callback_request_params %{ - init_data: - ~s(user=%7B%22id%22%3A928474348%2C%22first_name%22%3A%22Paul%22%2C%22last_name%22%3A%22Duroff%22%2C%22language_code%22%3A%22en%22%2C%22allows_write_to_pm%22%3A%22true%22%7D&chat_instance=-6755728357363932889&chat_type=sender&auth_date=1718266103&hash=ba7df7c892c36105172bc1e67ff4417c0f80f4b04d3defbef047cd5251f92972) - } - - @web_app_claims %{ - "sub" => 928_474_348, - "allows_write_to_pm" => "true", - "family_name" => "Duroff", - "given_name" => "Paul", - "name" => "Paul Duroff", - "locale" => "en", - "authenticated_at" => ~U[2024-06-13 08:08:23Z] - } - - @web_app_wrong_hash "ec9a1333e072bcba901e3fb8b1a124fa9c30234309d03f4e30f0c8ba58f7a43c" - - test "authorize_url/1" do - {:error, "Telegram does not support direct authorization request, please check docs"} = - Telegram.authorize_url(@config_login) - - {:error, "Telegram does not support direct authorization request, please check docs"} = - Telegram.authorize_url(@config_mini_app) - end - - describe "callback/2 should return" do - test "user claims for the login widget" do - assert {:ok, %{user: user}} = - Telegram.callback(@config_login, @login_widget_callback_params) - - assert user == @login_widget_claims - end - - test "user claims for the web mini app" do - assert {:ok, %{user: user}} = - Telegram.callback(@config_mini_app, @web_app_callback_request_params) - - assert user == @web_app_claims - - request_params_with_string_key = %{ - "init_data" => @web_app_callback_request_params.init_data - } - - assert {:ok, %{user: user}} = - Telegram.callback(@config_mini_app, request_params_with_string_key) - - assert user == @web_app_claims - end - - test "error if max auth validity exceeded for the login widget" do - max_auth_validity_sec = 60 - config = Keyword.put(@config_login, :max_auth_validity_sec, max_auth_validity_sec) - - assert {:error, error} = - Telegram.callback(config, @login_widget_callback_params) - - assert error == - %Assent.CallbackError{ - message: "The authentication request has expired", - error: :auth_request_expired - } - end - - test "error if max auth validity exceeded the web mini app" do - max_auth_validity_sec = 60 - config = Keyword.put(@config_mini_app, :max_auth_validity_sec, max_auth_validity_sec) - - assert {:error, error} = - Telegram.callback(config, @web_app_callback_request_params) - - assert error == - %Assent.CallbackError{ - message: "The authentication request has expired", - error: :auth_request_expired - } - end - - test "error if hash is wrong for the login widget" do - login_widget_callback_wrong_hash_params = %{ - @login_widget_callback_params - | "hash" => @login_widget_wrong_hash - } - - assert {:error, error} = - Telegram.callback(@config_login, login_widget_callback_wrong_hash_params) - - assert error == - %Assent.CallbackError{ - error: :authenticity_check_failed, - message: - "Data authenticity check failed: the provided hash does not match the data" - } - end - - test "error if hash is wrong for the web mini app" do - init_data = @web_app_callback_request_params.init_data - - init_data_wrong_hash = - String.replace(init_data, ~r/hash=.*$/, "hash=#{@web_app_wrong_hash}") - - web_app_wrong_request_params = %{ - @web_app_callback_request_params - | init_data: init_data_wrong_hash - } - - assert {:error, error} = Telegram.callback(@config_mini_app, web_app_wrong_request_params) - - assert error == - %Assent.CallbackError{ - error: :authenticity_check_failed, - message: - "Data authenticity check failed: the provided hash does not match the data" - } - end - - test "error if initData string is empty for the web mini app" do - web_app_wrong_request_params = %{@web_app_callback_request_params | init_data: ""} - - assert {:error, error} = Telegram.callback(@config_mini_app, web_app_wrong_request_params) - - assert error == - %Assent.CallbackError{ - error: :init_data_empty, - message: - "Empty init data string provided for the Web mini app authentication. The page opened not from Telegram?" - } - end - end -end