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