tarakan/lib/tarakan/accounts/client_authorizations.ex
Maxfield Luke af6077b9c3
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
2026-07-29 04:43:40 -04:00

274 lines
8.6 KiB
Elixir

defmodule Tarakan.Accounts.ClientAuthorizations do
@moduledoc """
Short-lived device authorization for browser-based Tarakan Client login.
A client holds a high-entropy device code while a signed-in browser approves
the displayed user code. Approval never places an API credential in a URL;
the credential is minted once, during the device-code exchange.
"""
import Ecto.Query, warn: false
alias Ecto.Changeset
alias Tarakan.Accounts.{Account, AccountToken, ApiCredentials, ClientAuthorization}
alias Tarakan.PromptSafety
alias Tarakan.RateLimiter
alias Tarakan.Repo
@validity_minutes 10
@poll_interval_seconds 2
# Least privilege for agent participation in the public work queue.
# Independent verification (reviews:verify / reviews:read) is opt-in via
# settings-minted credentials so a stolen device token cannot flood verdicts.
@client_scopes ~w(
tasks:read tasks:claim contributions:write
reviews:submit
)
@credential_validity_days 7
@max_user_code_attempts 5
def validity_seconds, do: @validity_minutes * 60
def poll_interval_seconds, do: @poll_interval_seconds
def client_scopes, do: @client_scopes
def credential_validity_days, do: @credential_validity_days
def start(attrs \\ %{}), do: start(attrs, @max_user_code_attempts)
defp start(_attrs, 0), do: {:error, :user_code_unavailable}
defp start(attrs, attempts_left) do
_ = prune_expired()
# Strip control, ANSI and bidirectional codes before the changeset's format
# check, so a client that merely pads its name with invisible characters is
# cleaned up rather than rejected outright.
client_name =
attrs
|> Map.get("client_name", "Tarakan Client")
|> PromptSafety.sanitize_line(max_bytes: 40)
|> case do
"" -> "Tarakan Client"
name -> name
end
device_code = "trkd_" <> random_url_token(32)
user_code = random_user_code()
expires_at = DateTime.add(DateTime.utc_now(), @validity_minutes, :minute)
changeset =
%ClientAuthorization{}
|> ClientAuthorization.creation_changeset(%{
"client_name" => client_name,
"scopes" => @client_scopes,
"expires_at" => expires_at
})
|> Changeset.put_change(:device_code_hash, token_hash(device_code))
|> Changeset.put_change(:user_code, normalize_user_code(user_code))
case Repo.insert(changeset) do
{:ok, authorization} ->
{:ok, device_code, display_user_code(user_code), authorization}
# A user-code collision is astronomically unlikely, but retrying without a
# bound would turn one into unbounded recursion on an unauthenticated
# endpoint.
{:error, %Changeset{errors: errors}} = error ->
if Keyword.has_key?(errors, :user_code),
do: start(attrs, attempts_left - 1),
else: error
end
end
@doc """
Looks up a pending authorization for the approving browser.
`actor_key` identifies who is doing the looking (the approving account), so
attempts can be budgeted. A user code is only 40 bits and lives for ten
minutes, which puts brute force far out of reach, but the endpoint is
otherwise an unmetered oracle for "does this code exist right now", and
RFC 8628 asks authorization servers to rate limit it. The budget is generous
because approving a login is a handful of page loads, never dozens.
"""
def get_for_browser(user_code, actor_key \\ nil)
def get_for_browser(user_code, actor_key) when is_binary(user_code) do
if lookup_allowed?(actor_key) do
now = DateTime.utc_now()
case Repo.one(
from authorization in ClientAuthorization,
where:
authorization.user_code == ^normalize_user_code(user_code) and
authorization.status in ["pending", "approved", "denied"] and
authorization.expires_at > ^now
) do
%ClientAuthorization{} = authorization -> {:ok, authorization}
nil -> {:error, :not_found}
end
else
{:error, :throttled}
end
end
def get_for_browser(_user_code, _actor_key), do: {:error, :not_found}
defp lookup_allowed?(nil), do: true
defp lookup_allowed?(actor_key) do
RateLimiter.check({:client_authorization_lookup, actor_key}, 30, 300) == :ok
end
def approve(%ClientAuthorization{id: id}, %Account{} = account) do
transition(id, fn authorization ->
now = DateTime.utc_now()
cond do
DateTime.compare(authorization.expires_at, now) != :gt ->
{:error, :expired}
authorization.status == "pending" ->
authorization
|> Changeset.change(status: "approved", account_id: account.id, approved_at: now)
|> Repo.update()
authorization.status == "approved" and authorization.account_id == account.id ->
{:ok, authorization}
true ->
{:error, :not_found}
end
end)
end
def deny(%ClientAuthorization{id: id}, %Account{} = account) do
transition(id, fn authorization ->
cond do
authorization.status == "pending" ->
authorization
|> Changeset.change(status: "denied", account_id: account.id)
|> Repo.update()
authorization.status == "denied" and authorization.account_id == account.id ->
{:ok, authorization}
true ->
{:error, :not_found}
end
end)
end
def exchange(device_code) when is_binary(device_code) do
if valid_device_code?(device_code) do
Repo.transaction(fn ->
authorization =
Repo.one(
from candidate in ClientAuthorization,
where: candidate.device_code_hash == ^token_hash(device_code),
lock: "FOR UPDATE"
) || Repo.rollback(:invalid_device_code)
exchange_locked(authorization)
end)
else
{:error, :invalid_device_code}
end
end
def exchange(_device_code), do: {:error, :invalid_device_code}
defp exchange_locked(authorization) do
now = DateTime.utc_now()
cond do
DateTime.compare(authorization.expires_at, now) != :gt ->
Repo.rollback(:expired)
authorization.status == "pending" ->
Repo.rollback(:authorization_pending)
authorization.status == "denied" ->
Repo.rollback(:access_denied)
authorization.status != "approved" or is_nil(authorization.account_id) ->
Repo.rollback(:invalid_device_code)
true ->
account = Repo.get!(Account, authorization.account_id)
if not Account.access_allowed?(account) do
Repo.rollback(:account_locked)
end
case ApiCredentials.create(account, %{
"name" => authorization.client_name,
"scopes" => authorization.scopes,
"validity_days" => @credential_validity_days
}) do
{:ok, token, credential} ->
authorization
|> Changeset.change(status: "consumed", consumed_at: now)
|> Repo.update!()
{token, credential}
{:error, reason} ->
Repo.rollback(reason)
end
end
end
defp prune_expired do
now = DateTime.utc_now()
# Delete by the indexed expires_at only, so this stays a cheap index range
# scan on the unauthenticated start path. Consumed/denied rows are short-
# lived and get swept once their (unchanged) expiry passes.
from(authorization in ClientAuthorization, where: authorization.expires_at <= ^now)
|> Repo.delete_all()
:ok
rescue
_error -> :ok
end
defp transition(id, callback) do
Repo.transaction(fn ->
authorization =
Repo.one(
from candidate in ClientAuthorization,
where: candidate.id == ^id,
lock: "FOR UPDATE"
) || Repo.rollback(:not_found)
case callback.(authorization) do
{:ok, updated} -> updated
{:error, reason} -> Repo.rollback(reason)
end
end)
end
defp valid_device_code?(<<"trkd_", encoded::binary-size(43)>>),
do: encoded =~ ~r/^[A-Za-z0-9_-]+$/
defp valid_device_code?(_device_code), do: false
defp normalize_user_code(code) do
code
|> to_string()
|> String.upcase()
|> String.replace(~r/[^A-Z2-7]/, "")
end
@doc "Formats a user code as `XXXX-YYYY` for display in the terminal and browser."
def display_user_code(code) do
normalized = normalize_user_code(code)
String.slice(normalized, 0, 4) <> "-" <> String.slice(normalized, 4, 4)
end
defp random_user_code, do: :crypto.strong_rand_bytes(5) |> Base.encode32(padding: false)
defp random_url_token(bytes),
do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
defp token_hash(token), do: AccountToken.hash_token(token)
end