defmodule TarakanWeb.API.ClientAuthController do use TarakanWeb, :controller alias Tarakan.Accounts.{ApiCredentials, ClientAuthorizations} alias TarakanWeb.BrowserRateLimit alias TarakanWeb.Plugs.ClientIp @doc "Starts a short-lived browser login for Tarakan Client." def start(conn, params) do if BrowserRateLimit.allowed?(:client_auth_start_ip, ClientIp.remote_ip_bucket(conn)) do attrs = %{"client_name" => params["client_name"] || "Tarakan Client"} case ClientAuthorizations.start(attrs) do {:ok, device_code, user_code, authorization} -> path = ~p"/client/authorize/#{user_code}" conn |> no_store() |> put_status(:created) |> json(%{ device_code: device_code, user_code: user_code, verification_uri: TarakanWeb.Endpoint.url() <> path, verification_uri_complete: TarakanWeb.Endpoint.url() <> path, expires_in: DateTime.diff(authorization.expires_at, DateTime.utc_now(), :second), interval: ClientAuthorizations.poll_interval_seconds() }) {:error, %Ecto.Changeset{} = changeset} -> conn |> no_store() |> put_status(:unprocessable_entity) |> json(%{error: "invalid_client", details: changeset_errors(changeset)}) end else rate_limited(conn) end end @doc "Exchanges an approved device code for a scoped API credential exactly once." def exchange(conn, %{"device_code" => device_code}) do if BrowserRateLimit.allowed?(:client_auth_exchange_ip, ClientIp.remote_ip_bucket(conn)) do case ClientAuthorizations.exchange(device_code) do {:ok, {token, credential}} -> conn |> no_store() |> json(%{ token: token, token_type: "Bearer", expires_at: credential.expires_at, scopes: credential.scopes }) {:error, :authorization_pending} -> conn |> no_store() |> put_status(:bad_request) |> json(%{error: "authorization_pending"}) {:error, :access_denied} -> conn |> no_store() |> put_status(:forbidden) |> json(%{error: "access_denied"}) {:error, :account_locked} -> conn |> no_store() |> put_status(:forbidden) |> json(%{error: "access_denied"}) {:error, :expired} -> conn |> no_store() |> put_status(:gone) |> json(%{error: "expired_token"}) {:error, :credential_limit} -> conn |> no_store() |> put_status(:conflict) |> json(%{error: "credential_limit"}) {:error, _reason} -> conn |> no_store() |> put_status(:bad_request) |> json(%{error: "invalid_device_code"}) end else rate_limited(conn) end end def exchange(conn, _params) do conn |> no_store() |> put_status(:bad_request) |> json(%{error: "invalid_device_code"}) end @doc "Revokes the API credential making this request." def revoke(conn, _params) do scope = conn.assigns.current_scope case ApiCredentials.revoke(scope.account, scope.token_id) do {:ok, _credential} -> conn |> no_store() |> send_resp(:no_content, "") {:error, :not_found} -> conn |> no_store() |> send_resp(:no_content, "") end end defp rate_limited(conn) do conn |> no_store() |> put_resp_header("retry-after", "60") |> put_status(:too_many_requests) |> json(%{error: "rate_limit_exceeded"}) end defp no_store(conn), do: put_resp_header(conn, "cache-control", "no-store") defp changeset_errors(changeset), do: TarakanWeb.ChangesetErrors.to_map(changeset) end