Skip to content

Commit

Permalink
Merge pull request #152 from vheathen/telegram_strategy
Browse files Browse the repository at this point in the history
First draft of the Telegram strategy
  • Loading branch information
danschultzer authored Dec 29, 2024
2 parents 243a437 + e54c706 commit 1a04a45
Show file tree
Hide file tree
Showing 4 changed files with 438 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
236 changes: 236 additions & 0 deletions lib/assent/strategies/telegram.ex
Original file line number Diff line number Diff line change
@@ -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:
<script type="text/javascript">
// Function to decode base64 without padding
function decodeBase64Url(base64Url) {
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
switch (base64.length % 4) {
case 2: base64 += '=='; break;
case 3: base64 += '='; break;
}
return atob(base64);
}
// Parse the hash fragment
const hash = window.location.hash.substr(1);
const hashData = hash.split('=')
if (hashData[0] == "tgAuthResult") {
const data = JSON.parse(decodeBase64Url(hashData[1]))
const params = new URLSearchParams(data);
// Construct the new URL with query parameters
const newUrl = new URL(window.location.href.split('#')[0]);
params.forEach((value, key) => {
newUrl.searchParams.append(key, value);
});
// Redirect to the new URL
window.location.href = newUrl.toString();
}
</script>
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:
<script async
src="https://telegram.org/js/telegram-widget.js?22"
data-telegram-login="REPLACE_WITH_BOT_USERNAME"
data-auth-url="REPLACE_WITH_CALLBACK_URL"></script>
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
Loading

0 comments on commit 1a04a45

Please sign in to comment.