From c8987766fbb36b710e9924409f7b5232b1351cfc Mon Sep 17 00:00:00 2001 From: Vladimir Drobyshevskiy Date: Thu, 13 Jun 2024 17:28:05 +0400 Subject: [PATCH 1/4] First draft of the Telegram strategy --- lib/assent/strategies/telegram.ex | 384 ++++++++++++++++++++++++ test/assent/strategies/telgram_test.exs | 163 ++++++++++ 2 files changed, 547 insertions(+) create mode 100644 lib/assent/strategies/telegram.ex create mode 100644 test/assent/strategies/telgram_test.exs diff --git a/lib/assent/strategies/telegram.ex b/lib/assent/strategies/telegram.ex new file mode 100644 index 0000000..0496c62 --- /dev/null +++ b/lib/assent/strategies/telegram.ex @@ -0,0 +1,384 @@ +defmodule Assent.Strategies.Telegram do + @moduledoc """ + Sing in with Telegram strategy. + + As [Telegram Login Widget](https://core.telegram.org/widgets/login) only supports authentication requests via embedded widget or a JS call, + and for the [Web Mini App](https://core.telegram.org/bots/webapps) authentication data sent when a user opens a mini app in Telegram, + the strategy does not implement `authorize_url/1` method. + + Default TTL for the authentication data is 60 seconds. This can be increased by the `max_auth_validity_sec` config key. + + ## Usage + ### Login Widget + + config = [ + authentication_channel: :login_widget, + bot_token: "YOUR_FULL_BOT_TOKEN", + max_auth_validity_sec: 60 + ] + + Please note that in case of the JavaScript authentication callback if a user declined + to authenticate, the `false` respone from the telegram widget library should be handled + client-side. + + Basic implementation described in the Telegram Login Widget docs, more advanced option without embedded iframe + and with custom buttons can be found on [stackoverflow](https://stackoverflow.com/a/63593384/899911). + + Current strategy supports both redirect and function callback options. + + + ### Web Mini App + + config = [ + authentication_channel: :web_mini_app, + bot_token: "YOUR_FULL_BOT_TOKEN", + max_auth_validity_sec: 60 + ] + + For the Web mini app authentication, the strategy expects the original `initData` string + to be passed in as is, in the url-encoded form, wrapped by a map as value for the `init_data` key: + + %{ init_data: "original%20initData%20string" } + + + ## Possible response details + As Telegram states that returning claims are vary (marked as `optional`) and heavily depend on the authentication channel + and user settings, the returned from `callback/2` claims are also vary. + + All fields have been renamed to comply with the OpenID Connect standard, and the sub claim is (likely) always present. + + The most full 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 respose 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() + } + """ + + @behaviour Assent.Strategy + + alias Assent.Strategy + alias Assent.Config + alias Assent.CallbackError + + @default_config [ + max_auth_validity_sec: 60 + ] + + @web_app_key "WebAppData" + @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 response_params :: %{init_data: mini_app_init_data()} | 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, 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) + 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 + 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 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} + + 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}} + 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) + end + end + + defp normalize(%{"id" => id} = response_params, config) when is_binary(id) do + normalize(%{response_params | "id" => String.to_integer(id)}, config) + 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 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) + 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) + 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(%DateTime{} = auth_date, max_auth_validity_sec) do + DateTime.utc_now() + |> DateTime.diff(auth_date, :second) + |> case do + since when since > max_auth_validity_sec -> cerr(:auth_request_expired) + future when future < 0 -> cerr(:auth_date_in_future) + _ -> :ok + 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 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 build_full_name(response_params) do + [ + response_params["first_name"], + response_params["last_name"] + ] + |> Enum.join(" ") + |> String.trim() + end + + 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] + + 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 + 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 + 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 + end + + defp stringify(atom) when is_atom(atom) do + atom + |> Atom.to_string() + |> String.split("_") + |> Enum.join(" ") + |> String.capitalize() + end +end diff --git a/test/assent/strategies/telgram_test.exs b/test/assent/strategies/telgram_test.exs new file mode 100644 index 0000000..19a6a2d --- /dev/null +++ b/test/assent/strategies/telgram_test.exs @@ -0,0 +1,163 @@ +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 + 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 From 164a4e0f42ce97e58c05b07d624c6b9a982a9158 Mon Sep 17 00:00:00 2001 From: Vladimir Drobyshevskiy Date: Thu, 13 Jun 2024 17:41:34 +0400 Subject: [PATCH 2/4] Telegram strategy doc modified --- lib/assent/strategies/telegram.ex | 183 +++++++++++++++--------------- 1 file changed, 94 insertions(+), 89 deletions(-) diff --git a/lib/assent/strategies/telegram.ex b/lib/assent/strategies/telegram.ex index 0496c62..66892a6 100644 --- a/lib/assent/strategies/telegram.ex +++ b/lib/assent/strategies/telegram.ex @@ -1,124 +1,129 @@ defmodule Assent.Strategies.Telegram do @moduledoc """ - Sing in with Telegram strategy. + ### Sign in with Telegram strategy - As [Telegram Login Widget](https://core.telegram.org/widgets/login) only supports authentication requests via embedded widget or a JS call, - and for the [Web Mini App](https://core.telegram.org/bots/webapps) authentication data sent when a user opens a mini app in Telegram, - the strategy does not implement `authorize_url/1` method. + 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. - Default TTL for the authentication data is 60 seconds. This can be increased by the `max_auth_validity_sec` config key. + The default TTL for the authentication data is 60 seconds. This can be increased by the `max_auth_validity_sec` config key. ## Usage + ### Login Widget - config = [ - authentication_channel: :login_widget, - bot_token: "YOUR_FULL_BOT_TOKEN", - max_auth_validity_sec: 60 - ] + config = [ + authentication_channel: :login_widget, + bot_token: "YOUR_FULL_BOT_TOKEN", + max_auth_validity_sec: 60 + ] - Please note that in case of the JavaScript authentication callback if a user declined - to authenticate, the `false` respone from the telegram widget library should be handled - client-side. - Basic implementation described in the Telegram Login Widget docs, more advanced option without embedded iframe - and with custom buttons can be found on [stackoverflow](https://stackoverflow.com/a/63593384/899911). + 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. - Current strategy supports both redirect and function callback options. + 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). + The Telegram strategy supports both redirect and function callback options. ### Web Mini App - config = [ - authentication_channel: :web_mini_app, - bot_token: "YOUR_FULL_BOT_TOKEN", - max_auth_validity_sec: 60 - ] + config = [ + authentication_channel: :web_mini_app, + bot_token: "YOUR_FULL_BOT_TOKEN", + max_auth_validity_sec: 60 + ] - For the Web mini app authentication, the strategy expects the original `initData` string - to be passed in as is, in the url-encoded form, wrapped by a map as value for the `init_data` key: + 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" } + %{ init_data: "original%20initData%20string" } - ## Possible response details - As Telegram states that returning claims are vary (marked as `optional`) and heavily depend on the authentication channel - and user settings, the returned from `callback/2` claims are also vary. + ## Possible Response Details - All fields have been renamed to comply with the OpenID Connect standard, and the sub claim is (likely) always present. + 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. - The most full 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(), + All fields have been renamed to comply with the OpenID Connect standard, and the `sub` claim is (likely) always present. - # extra claims - "is_bot" => boolean(), - "is_premium" => boolean(), - "added_to_attachment_menu" => boolean(), - "allows_write_to_pm" => boolean(), - "authenticated_at" => DateTime.t() - } + 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(), - ### Original Telegram full login success respose 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(), + # Extra claims "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" => %{ + "authenticated_at" => DateTime.t() + } + + + ### Original Telegram Full Login Success Response for the Login Widget: + + %{ "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() - } + "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() + } + ``` """ @behaviour Assent.Strategy From 2a5f5dbaf6e77d8f3c2c341bb56b6a021d2f5451 Mon Sep 17 00:00:00 2001 From: Vladimir Drobyshevskiy Date: Thu, 13 Jun 2024 18:13:20 +0400 Subject: [PATCH 3/4] Telegram strategy: allow only maps as response_params --- lib/assent/strategies/telegram.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assent/strategies/telegram.ex b/lib/assent/strategies/telegram.ex index 66892a6..d3d5e28 100644 --- a/lib/assent/strategies/telegram.ex +++ b/lib/assent/strategies/telegram.ex @@ -150,7 +150,7 @@ defmodule Assent.Strategies.Telegram do end @impl Assent.Strategy - def callback(config, response_params) do + def callback(config, %{} = response_params) do config = enrich_config(config) with :ok <- do_preflight_checks(config, response_params), From b2422172f55a764607ce1ae6de5bafc8ff7511f6 Mon Sep 17 00:00:00 2001 From: Vladimir Drobyshevskiy Date: Mon, 17 Jun 2024 20:44:33 +0400 Subject: [PATCH 4/4] Telegram strategy: allow to invoke callback with string "init_data" key to support post request params --- lib/assent/strategies/telegram.ex | 7 ++++++- test/assent/strategies/telgram_test.exs | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/assent/strategies/telegram.ex b/lib/assent/strategies/telegram.ex index d3d5e28..8eb52bb 100644 --- a/lib/assent/strategies/telegram.ex +++ b/lib/assent/strategies/telegram.ex @@ -142,7 +142,9 @@ defmodule Assent.Strategies.Telegram do @type login_widget_response :: %{String.t() => String.t()} @type mini_app_init_data :: String.t() - @type response_params :: %{init_data: mini_app_init_data()} | login_widget_response() + @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 @@ -150,6 +152,9 @@ defmodule Assent.Strategies.Telegram do 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) diff --git a/test/assent/strategies/telgram_test.exs b/test/assent/strategies/telgram_test.exs index 19a6a2d..ae7ac9e 100644 --- a/test/assent/strategies/telgram_test.exs +++ b/test/assent/strategies/telgram_test.exs @@ -1,7 +1,6 @@ defmodule Assent.Strategies.TelgramTest do use ExUnit.Case - alias Assent.Strategies.Telegram # 1_000 years @@ -79,6 +78,15 @@ defmodule Assent.Strategies.TelgramTest do 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