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