diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1dde340..a8189fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
* `Assent.Strategy.OIDC` now supports `none` authentication method
* `Assent.Strategy.Bitbucket` added
* `Assent.Strategy.Twitch` added
+* `Assent.Strategy.Telegram` added
* `Assent.Strategy.Facebook.fetch_user/2` fixed bug with user not being decoded
* `Assent.Strategy.OAuth2` now supports PKCE
* `Assent.Strategy.OAuth2.Base.authorize_url/2` incomplete typespec fixed
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
new file mode 100644
index 0000000..e06fb88
--- /dev/null
+++ b/lib/assent/strategies/telegram.ex
@@ -0,0 +1,236 @@
+defmodule Assent.Strategy.Telegram do
+ @moduledoc """
+ 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
+
+ The JavaScript Widget can be implemented with:
+
+
+
+ Configuration should have:
+
+ config = [
+ bot_token: "YOUR_FULL_BOT_TOKEN"
+ ]
+
+ Note that if a user declines to authorize access, you have to handle it
+ client-side with JavaScript.
+
+ ### Web Mini App
+
+ config = [
+ bot_token: "YOUR_FULL_BOT_TOKEN",
+ authorization_channel: :web_mini_app
+ ]
+
+ 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.{CallbackError, Config, MissingParamError, Strategy}
+
+ @auth_ttl_seconds 60
+ @web_mini_app :web_mini_app
+ @login_widget :login_widget
+
+ @impl Assent.Strategy
+ @spec authorize_url(Config.t()) :: {:ok, %{url: binary()}} | {:error, term()}
+ 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
+
+ @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 fetch_authorization_channel(config) do
+ case Config.get(config, :authorization_channel, @login_widget) do
+ @login_widget ->
+ {:ok, @login_widget}
+
+ @web_mini_app ->
+ {:ok, @web_mini_app}
+
+ other ->
+ {:error,
+ CallbackError.exception(
+ message: "Invalid `:authorization_channel` value: #{inspect(other)}"
+ )}
+ end
+ end
+
+ 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 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 split_hash_params(_config, params, @web_mini_app),
+ do: {:error, MissingParamError.exception(expected_key: "init_data", params: params)}
+
+ 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 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 verify_ttl(_config, %{"auth_date" => auth_date}) do
+ auth_timestamp = (is_binary(auth_date) && String.to_integer(auth_date)) || auth_date
+
+ DateTime.utc_now()
+ |> DateTime.to_unix(:second)
+ |> Kernel.-(auth_timestamp)
+ |> Kernel.<=(@auth_ttl_seconds)
+ |> case do
+ true -> :ok
+ false -> {:error, CallbackError.exception(message: "Authorization request has expired")}
+ end
+ end
+
+ defp verify_ttl(_config, params),
+ do: {:error, MissingParamError.exception(expected_key: "auth_date", params: params)}
+
+ defp verify_hash(secret, hash, params) do
+ data =
+ params
+ |> Enum.map(fn {key, value} -> "#{key}=#{value}" end)
+ |> Enum.sort()
+ |> Enum.join("\n")
+
+ data_hash =
+ :hmac
+ |> :crypto.mac(:sha256, secret, data)
+ |> Base.encode16(case: :lower)
+
+ case Assent.constant_time_compare(hash, data_hash) do
+ true ->
+ :ok
+
+ false ->
+ {:error, CallbackError.exception(message: "Authorization request has an invalid hash")}
+ end
+ end
+
+ 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 normalize(%{"id" => id} = params, config) when is_binary(id) do
+ normalize(%{params | "id" => String.to_integer(id)}, config)
+ end
+
+ 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