diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c62afd..238d1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to - Fix `raw_input` not actually being set as the request's body in `Atex.XRPC.post/3` when providing a struct as input. +### Breaking Changes + +- `Atex.OAuth.get_key/0` removed — use `Atex.Config.OAuth.get_key/0` directly +- `Atex.OAuth.create_client_metadata/1`, `create_client_assertion/3`, `create_authorization_url/5`, `validate_authorization_code/5`, `refresh_token/5`, `revoke_tokens/2` moved to `Atex.OAuth.Flow` +- `Atex.OAuth.create_dpop_token/4`, `send_oauth_dpop_request/3`, `request_protected_dpop_resource/5` moved to `Atex.OAuth.DPoP` +- `Atex.OAuth.get_authorization_server/2`, `get_authorization_server_metadata/2` moved to `Atex.OAuth.Discovery` +- Error atom `:invaild_issuer` corrected to `:invalid_issuer` + ## [0.9.1] - 2026-04-17 ### Fixed diff --git a/lib/atex/oauth.ex b/lib/atex/oauth.ex index df65807..c009820 100644 --- a/lib/atex/oauth.ex +++ b/lib/atex/oauth.ex @@ -1,841 +1,126 @@ defmodule Atex.OAuth do @moduledoc """ - OAuth 2.0 implementation for AT Protocol authentication. - - This module provides utilities for implementing OAuth flows compliant with the - AT Protocol specification. It includes support for: - - - Pushed Authorization Requests (PAR) - - DPoP (Demonstration of Proof of Possession) tokens - - JWT client assertions - - PKCE (Proof Key for Code Exchange) - - Token refresh - - Handle to PDS resolution - - ## Configuration - - See `Atex.Config.OAuth` module for configuration documentation. - - ## Usage Example - - iex> pds = "https://bsky.social" - iex> login_hint = "example.com" - iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds) - iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server) - iex> state = Atex.OAuth.create_nonce() - iex> code_verifier = Atex.OAuth.create_nonce() - iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url( - authz_metadata, - state, - code_verifier, - login_hint - ) - """ - - @type authorization_metadata() :: %{ - issuer: String.t(), - par_endpoint: String.t(), - token_endpoint: String.t(), - authorization_endpoint: String.t(), - revocation_endpoint: String.t() - } - - @type tokens() :: %{ - access_token: String.t(), - refresh_token: String.t(), - did: String.t(), - expires_at: NaiveDateTime.t() - } - - @type create_client_metadata_option :: - {:key, JOSE.JWK.t()} - | {:client_id, String.t()} - | {:redirect_uri, String.t()} - | {:extra_redirect_uris, list(String.t())} - | {:scopes, String.t()} - - @type create_authorization_url_option :: - {:key, JOSE.JWK.t()} - | {:client_id, String.t()} - | {:redirect_uri, String.t()} - | {:scopes, String.t()} - - @type validate_authorization_code_option :: - {:key, JOSE.JWK.t()} - | {:client_id, String.t()} - | {:redirect_uri, String.t()} - | {:scopes, String.t()} - - @type refresh_token_option :: - {:key, JOSE.JWK.t()} - | {:client_id, String.t()} - | {:redirect_uri, String.t()} - | {:scopes, String.t()} - - require Logger - - alias Atex.Config.OAuth, as: Config - alias Atex.OAuth.{Session, SessionStore} - - @session_keys_name :atex_sessions - @session_active_name :atex_active_session - - @doc """ - Returns the composite session key (`":"`) for the currently active - OAuth session on the given conn. + AT Protocol OAuth 2.0 session management. - This is the primary way to identify which session is active for a request. The - returned key can be passed directly to `Atex.OAuth.SessionStore.get/1` or used - to construct an `Atex.XRPC.OAuthClient`. + Provides Plug session helpers for managing OAuth sessions in a web application. + For the full OAuth flow, see `Atex.OAuth.Flow`. For authorization server + discovery, see `Atex.OAuth.Discovery`. For DPoP token handling, see + `Atex.OAuth.DPoP`. - ## Returns + ## Type re-exports - - `{:ok, session_key}` - The composite key for the active session - - `:error` - No active session found in the conn - - ## Examples - - case Atex.OAuth.current_session_key(conn) do - {:ok, key} -> {:ok, client} = Atex.XRPC.OAuthClient.new(key) - :error -> redirect_to_login(conn) - end + The following types are re-exported here for backward compatibility: + - `t:Atex.OAuth.Flow.authorization_metadata/0` + - `t:Atex.OAuth.Flow.tokens/0` """ - @spec current_session_key(Plug.Conn.t()) :: {:ok, String.t()} | :error - def current_session_key(%Plug.Conn{} = conn) do - case Plug.Conn.get_session(conn, @session_active_name) do - key when is_binary(key) -> {:ok, key} - _ -> :error - end - end - @doc """ - Returns all composite session keys stored for this device's conn session. + alias Atex.OAuth.SessionStore - Each key corresponds to a distinct authenticated account on this device. The - list is ordered with the most recently logged-in account first. + @type authorization_metadata() :: Atex.OAuth.Flow.authorization_metadata() + @type tokens() :: Atex.OAuth.Flow.tokens() - ## Examples - - keys = Atex.OAuth.list_session_keys(conn) - # => ["did:plc:abc:nonce1", "did:plc:xyz:nonce2"] - - """ - @spec list_session_keys(Plug.Conn.t()) :: [String.t()] - def list_session_keys(%Plug.Conn{} = conn) do - Plug.Conn.get_session(conn, @session_keys_name) || [] - end + @session_keys_name :atex_sessions + @session_active_name :atex_active_session @doc """ - Switches the active session to the given composite session key. - - Validates that the key is present in the conn's session list and that the - corresponding session still exists in the store before updating the conn. - - ## Returns - - - `{:ok, conn}` - Active session switched; the returned conn has the updated - session and should be used for subsequent operations - - `{:error, :not_found}` - The key is not in the session list or the session - no longer exists in the store - - ## Examples - - case Atex.OAuth.switch_session(conn, "did:plc:xyz:nonce2") do - {:ok, conn} -> send_resp(conn, 200, "Switched accounts") - {:error, :not_found} -> send_resp(conn, 404, "Session not found") - end + Return the session key atom used to store the list of session keys in a + `Plug.Conn` session. + Used by `Atex.OAuth.Plug` when reading and writing session data. """ - @spec switch_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found} - def switch_session(%Plug.Conn{} = conn, session_key) when is_binary(session_key) do - stored_keys = list_session_keys(conn) - - with true <- session_key in stored_keys, - {:ok, _session} <- Atex.OAuth.SessionStore.get(session_key) do - {:ok, Plug.Conn.put_session(conn, @session_active_name, session_key)} - else - _ -> {:error, :not_found} - end - end + @spec session_keys_name() :: atom() + def session_keys_name, do: @session_keys_name @doc """ - Get a map containing the client metadata information needed for an - authorization server to validate this client. + Return the session key atom used to store the active session key in a + `Plug.Conn` session. + + Used by `Atex.OAuth.Plug` when reading and writing session data. """ - @spec create_client_metadata(list(create_client_metadata_option())) :: map() - def create_client_metadata(opts \\ []) do - opts = - Keyword.validate!( - opts, - [:key, :client_id, :redirect_uri, :extra_redirect_uris, :scopes] - ) - - key = Keyword.get_lazy(opts, :key, &Config.get_key/0) - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) - redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) - - extra_redirect_uris = - Keyword.get_lazy(opts, :extra_redirect_uris, &Config.extra_redirect_uris/0) - - scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) - - {_, jwk} = key |> JOSE.JWK.to_public_map() - jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]}) - - %{ - client_id: client_id, - redirect_uris: [redirect_uri | extra_redirect_uris], - application_type: "web", - grant_types: ["authorization_code", "refresh_token"], - scope: scopes, - response_type: ["code"], - token_endpoint_auth_method: "private_key_jwt", - token_endpoint_auth_signing_alg: "ES256", - dpop_bound_access_tokens: true, - jwks: %{keys: [jwk]} - } - end + @spec session_active_session_name() :: atom() + def session_active_session_name, do: @session_active_name @doc """ - Retrieves the configured JWT private key for signing client assertions. + Generate a random base64url-encoded nonce suitable for use in OAuth flows. - Loads the private key from configuration, decodes the base64-encoded DER data, - and creates a JOSE JWK structure with the key ID field set. - - ## Returns - - A `JOSE.JWK` struct containing the private key and key identifier. - - ## Raises - - * `Application.Env.Error` if the private_key or key_id configuration is missing + Returns a 32-byte random value encoded as a URL-safe base64 string without + padding. Useful when building custom authorization flows. ## Examples - key = OAuth.get_key() - key = OAuth.get_key() + iex> nonce = Atex.OAuth.create_nonce() + iex> is_binary(nonce) + true """ - @spec get_key() :: JOSE.JWK.t() - def get_key(), do: Config.get_key() - - @doc false - @spec random_b64(integer()) :: String.t() - def random_b64(length) do - :crypto.strong_rand_bytes(length) - |> Base.url_encode64(padding: false) - end - - @doc false @spec create_nonce() :: String.t() - def create_nonce(), do: random_b64(32) - - @doc """ - Create an OAuth authorization URL for a PDS. - - Submits a PAR request to the authorization server and constructs the - authorization URL with the returned request URI. Supports PKCE, DPoP, and - client assertions as required by the AT Protocol. - - ## Parameters - - - `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1` - - `state` - Random token for session validation - - `code_verifier` - PKCE code verifier - - `login_hint` - User identifier (handle or DID) for pre-filled login - - ## Returns - - - `{:ok, authorization_url}` - Successfully created authorization URL - - `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request - - `{:error, reason}` - Error creating authorization URL - """ - @spec create_authorization_url( - authorization_metadata(), - String.t(), - String.t(), - String.t(), - list(create_authorization_url_option()) - ) :: {:ok, String.t()} | {:error, any()} - def create_authorization_url( - authz_metadata, - state, - code_verifier, - login_hint, - opts \\ [] - ) do - opts = - Keyword.validate!( - opts, - [:key, :client_id, :redirect_uri, :scopes] - ) - - key = Keyword.get_lazy(opts, :key, &Config.get_key/0) - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) - redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) - scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) - - code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false) - - client_assertion = - create_client_assertion(key, client_id, authz_metadata.issuer) - - body = - %{ - response_type: "code", - client_id: client_id, - redirect_uri: redirect_uri, - state: state, - code_challenge_method: "S256", - code_challenge: code_challenge, - scope: scopes, - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - client_assertion: client_assertion, - login_hint: login_hint - } - - case Req.post(authz_metadata.par_endpoint, form: body) do - {:ok, %{body: %{"request_uri" => request_uri}}} -> - query = - %{client_id: client_id, request_uri: request_uri} - |> URI.encode_query() - - {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"} - - {:ok, _} -> - {:error, :invalid_par_response} - - err -> - err - end - end - - @doc """ - Exchange an OAuth authorization code for a set of access and refresh tokens. - - Validates the authorization code by submitting it to the token endpoint along with - the PKCE code verifier and client assertion. Returns access tokens for making authenticated - requests to the relevant user's PDS. - - ## Parameters - - - `authz_metadata` - Authorization server metadata containing token endpoint - - `dpop_key` - JWK for DPoP token generation - - `code` - Authorization code from OAuth callback - - `code_verifier` - PKCE code verifier from authorization flow - - ## Returns - - - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce - - `{:error, reason}` - Error exchanging code for tokens - """ - @spec validate_authorization_code( - authorization_metadata(), - JOSE.JWK.t(), - String.t(), - String.t(), - list(validate_authorization_code_option()) - ) :: {:ok, tokens(), String.t()} | {:error, any()} - def validate_authorization_code( - authz_metadata, - dpop_key, - code, - code_verifier, - opts \\ [] - ) do - opts = - Keyword.validate!( - opts, - [:key, :client_id, :redirect_uri, :scopes] - ) - - key = Keyword.get_lazy(opts, :key, &get_key/0) - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) - redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) - - client_assertion = - create_client_assertion(key, client_id, authz_metadata.issuer) - - body = - %{ - grant_type: "authorization_code", - client_id: client_id, - redirect_uri: redirect_uri, - code: code, - code_verifier: code_verifier - } - - body = - if Config.is_localhost(), - do: body, - else: - Map.merge(body, %{ - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - client_assertion: client_assertion - }) - - Req.new(method: :post, url: authz_metadata.token_endpoint, form: body) - |> send_oauth_dpop_request(dpop_key) - |> case do - {:ok, - %{ - "access_token" => access_token, - "refresh_token" => refresh_token, - "expires_in" => expires_in, - "sub" => did - }, nonce} -> - expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) - - {:ok, - %{ - access_token: access_token, - refresh_token: refresh_token, - did: did, - expires_at: expires_at - }, nonce} - - err -> - err - end - end - - @spec refresh_token( - String.t(), - JOSE.JWK.t(), - String.t(), - String.t(), - list(refresh_token_option()) - ) :: - {:ok, tokens(), String.t()} | {:error, any()} - def refresh_token(refresh_token, dpop_key, issuer, token_endpoint, opts \\ []) do - opts = - Keyword.validate!( - opts, - [:key, :client_id, :redirect_uri, :scopes] - ) - - key = Keyword.get_lazy(opts, :key, &get_key/0) - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) - - client_assertion = - create_client_assertion(key, client_id, issuer) - - body = %{ - grant_type: "refresh_token", - refresh_token: refresh_token, - client_id: client_id, - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - client_assertion: client_assertion - } - - Req.new(method: :post, url: token_endpoint, form: body) - |> send_oauth_dpop_request(dpop_key) - |> case do - {:ok, - %{ - "access_token" => access_token, - "refresh_token" => refresh_token, - "expires_in" => expires_in, - "sub" => did - }, nonce} -> - expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) - - {:ok, - %{ - access_token: access_token, - refresh_token: refresh_token, - did: did, - expires_at: expires_at - }, nonce} - - err -> - err - end + def create_nonce do + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) end @doc """ - Fetch the authorization server for a given Personal Data Server (PDS). + Get the key of the currently active OAuth session from the connection. - Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint - to discover the associated authorization server that should be used for the - OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs. + Returns `nil` if no session is currently active. ## Parameters - - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") - - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) - - ## Returns - - - `{:ok, authorization_server}` - Successfully discovered authorization - server URL - - `{:error, :invalid_metadata}` - Server returned invalid metadata - - `{:error, reason}` - Error discovering authorization server + - `conn` - A `Plug.Conn` with session data loaded """ - @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()} - def get_authorization_server(pds_host, fresh \\ false) do - if fresh do - fetch_authorization_server(pds_host) - else - case Atex.OAuth.Cache.get_authorization_server(pds_host) do - {:ok, authz_server} -> - {:ok, authz_server} - - {:error, :not_found} -> - fetch_authorization_server(pds_host) - end - end - end - - defp fetch_authorization_server(pds_host) do - result = - "#{pds_host}/.well-known/oauth-protected-resource" - |> Req.get() - |> case do - # TODO: what to do when multiple authorization servers? - {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server} - {:ok, _} -> {:error, :invalid_metadata} - err -> err - end - - case result do - {:ok, authz_server} -> - Atex.OAuth.Cache.set_authorization_server(pds_host, authz_server) - {:ok, authz_server} - - error -> - error - end + @spec current_session_key(Plug.Conn.t()) :: String.t() | nil + def current_session_key(conn) do + Plug.Conn.get_session(conn, @session_active_name) end @doc """ - Fetch the metadata for an OAuth authorization server. - - Retrieves the metadata from the authorization server's - `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs - required for the OAuth flow. Results are cached for 1 hour to reduce load on - third-party PDSs. + List all OAuth session keys stored in the connection's session. ## Parameters - - `issuer` - Authorization server issuer URL - - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) - - ## Returns - - - `{:ok, metadata}` - Successfully retrieved authorization server metadata - - `{:error, :invalid_metadata}` - Server returned invalid metadata - - `{:error, :invalid_issuer}` - Issuer mismatch in metadata - - `{:error, any()}` - Other error fetching metadata + - `conn` - A `Plug.Conn` with session data loaded """ - @spec get_authorization_server_metadata(String.t(), boolean()) :: - {:ok, authorization_metadata()} | {:error, any()} - def get_authorization_server_metadata(issuer, fresh \\ false) do - if fresh do - fetch_authorization_server_metadata(issuer) - else - case Atex.OAuth.Cache.get_authorization_server_metadata(issuer) do - {:ok, metadata} -> - {:ok, metadata} - - {:error, :not_found} -> - fetch_authorization_server_metadata(issuer) - end - end - end - - defp fetch_authorization_server_metadata(issuer) do - result = - "#{issuer}/.well-known/oauth-authorization-server" - |> Req.get() - |> case do - {:ok, - %{ - body: %{ - "issuer" => metadata_issuer, - "pushed_authorization_request_endpoint" => par_endpoint, - "token_endpoint" => token_endpoint, - "authorization_endpoint" => authorization_endpoint, - "revocation_endpoint" => revocation_endpoint - } - }} -> - if issuer != metadata_issuer do - {:error, :invaild_issuer} - else - {:ok, - %{ - issuer: metadata_issuer, - par_endpoint: par_endpoint, - token_endpoint: token_endpoint, - authorization_endpoint: authorization_endpoint, - revocation_endpoint: revocation_endpoint - }} - end - - {:ok, _} -> - {:error, :invalid_metadata} - - err -> - err - end - - case result do - {:ok, metadata} -> - Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata) - {:ok, metadata} - - error -> - error - end - end - - @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: - {:ok, map(), String.t()} | {:error, any(), String.t()} - def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do - dpop_token = create_dpop_token(dpop_key, request, nonce) - - request - |> Req.Request.put_header("dpop", dpop_token) - |> Req.request() - |> case do - {:ok, resp} -> - dpop_nonce = - case resp.headers["dpop-nonce"] do - [new_nonce | _] -> new_nonce - _ -> nonce - end - - cond do - resp.status == 200 -> - {:ok, resp.body, dpop_nonce} - - resp.body["error"] === "use_dpop_nonce" -> - dpop_token = create_dpop_token(dpop_key, request, dpop_nonce) - - request - |> Req.Request.put_header("dpop", dpop_token) - |> Req.request() - |> case do - {:ok, %{status: 200, body: body}} -> - {:ok, body, dpop_nonce} - - {:ok, %{body: %{"error" => error, "error_description" => error_description}}} -> - {:error, {:oauth_error, error, error_description}, dpop_nonce} - - {:ok, _} -> - {:error, :unexpected_response, dpop_nonce} - - {:error, err} -> - {:error, err, dpop_nonce} - end - - true -> - {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]}, - dpop_nonce} - end - - {:error, err} -> - {:error, err, nonce} - end - end - - @spec request_protected_dpop_resource( - Req.Request.t(), - String.t(), - String.t(), - JOSE.JWK.t(), - String.t() | nil - ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} - def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do - access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false) - # access_token_hash = Base.url_encode64(access_token, padding: false) - - dpop_token = - create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash}) - - request - |> Req.Request.put_header("dpop", dpop_token) - |> Req.request() - |> case do - {:ok, resp} -> - dpop_nonce = - case resp.headers["dpop-nonce"] do - [new_nonce | _] -> new_nonce - _ -> nonce - end - - www_authenticate = Req.Response.get_header(resp, "www-authenticate") - - www_dpop_problem = - www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP") - - if resp.status != 401 || !www_dpop_problem do - {:ok, resp, dpop_nonce} - else - dpop_token = - create_dpop_token(dpop_key, request, dpop_nonce, %{ - iss: issuer, - ath: access_token_hash - }) - - request - |> Req.Request.put_header("dpop", dpop_token) - |> Req.request() - |> case do - {:ok, resp} -> - dpop_nonce = - case resp.headers["dpop-nonce"] do - [new_nonce | _] -> new_nonce - _ -> dpop_nonce - end - - {:ok, resp, dpop_nonce} - - err -> - err - end - end - end + @spec list_session_keys(Plug.Conn.t()) :: list(String.t()) + def list_session_keys(conn) do + Plug.Conn.get_session(conn, @session_keys_name) || [] end @doc """ - Revokes the access and refresh tokens with the authorization server. + Switch the active OAuth session to the given key. - Sends both tokens to the revocation endpoint as defined in RFC 7009. - This invalidates the tokens on the PDS side, preventing further use. + Updates the `:atex_active_session` value in the Plug session. ## Parameters - - `session` - The session containing tokens to revoke - - `authz_metadata` - Authorization server metadata including `revocation_endpoint` - - ## Returns - - - `:ok` - Tokens successfully revoked (or revocation endpoint unreachable) - - `{:error, reason}` - Revocation failed - + - `conn` - A `Plug.Conn` with session data loaded + - `session_key` - The session key to make active """ - @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok | {:error, any()} - def revoke_tokens(%Session{} = session, authz_metadata) do - client_id = Config.client_id() - - body = %{ - client_id: client_id, - token: session.refresh_token, - token_type_hint: "refresh_token" - } - - case Req.post(authz_metadata.revocation_endpoint, form: body) do - {:ok, %{status: status}} when status in [200, 204] -> - :ok - - {:ok, %{body: %{"error" => error}}} -> - Logger.warning("Token revocation failed: #{error}") - :ok - - {:error, reason} -> - Logger.warning("Token revocation request failed: #{inspect(reason)}") - :ok - - unexpected -> - Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}") - :ok - end + @spec switch_session(Plug.Conn.t(), String.t()) :: Plug.Conn.t() + def switch_session(conn, session_key) do + Plug.Conn.put_session(conn, @session_active_name, session_key) end @doc """ - Deletes a session from the store and revokes its tokens. + Delete the currently active OAuth session. - This is the primary function for logging out a session. It: - 1. Fetches the session data from the store if a key is provided - 2. Revokes the tokens with the authorization server - 3. Removes the session from the store + Removes the active session from `SessionStore`, removes its key from the + session key list, and clears the active session pointer in the Plug session. ## Parameters - - `session_or_key` - Either a `Session.t()` struct or a composite session key string - - ## Returns - - - `:ok` - Session deleted and tokens revoked - - `{:error, :not_found}` - Session not found in store - - `{:error, reason}` - Token revocation or store deletion failed - - ## Examples - - # Using a session key - case Atex.OAuth.delete_session("did:plc:abc123:device-nonce") do - :ok -> :logged_out - {:error, :not_found} -> :session_already_gone - end - - # Using a session struct - {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123:device-nonce") - :ok = Atex.OAuth.delete_session(session) - + - `conn` - A `Plug.Conn` with session data loaded """ - @spec delete_session(Session.t() | String.t()) :: :ok | {:error, :not_found | any()} - def delete_session(%Session{} = session) do - with {:ok, authz_metadata} <- get_authorization_server_metadata(session.iss, true), - :ok <- revoke_tokens(session, authz_metadata) do - SessionStore.delete(session) - end - end + @spec delete_session(Plug.Conn.t()) :: Plug.Conn.t() + def delete_session(conn) do + session_key = current_session_key(conn) - def delete_session(session_key) when is_binary(session_key) do - case SessionStore.get(session_key) do - {:ok, session} -> delete_session(session) - {:error, reason} -> {:error, reason} + if session_key do + SessionStore.delete(session_key) end - end - @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t() - def create_client_assertion(jwk, client_id, issuer) do - iat = System.os_time(:second) - jti = random_b64(20) - jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]} - - jwt = %{ - iss: client_id, - sub: client_id, - aud: issuer, - jti: jti, - iat: iat, - exp: iat + 60 - } - - JOSE.JWT.sign(jwk, jws, jwt) - |> JOSE.JWS.compact() - |> elem(1) - end - - @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t() - def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do - iat = System.os_time(:second) - jti = random_b64(20) - {_, public_jwk} = JOSE.JWK.to_public_map(jwk) - jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk} - [request_url | _] = request.url |> to_string() |> String.split("?") - - jwt = - Map.merge(attrs, %{ - jti: jti, - htm: atom_to_upcase_string(request.method), - htu: request_url, - iat: iat - }) - |> then(fn m -> - if nonce, do: Map.put(m, :nonce, nonce), else: m - end) - - JOSE.JWT.sign(jwk, jws, jwt) - |> JOSE.JWS.compact() - |> elem(1) - end + session_keys = list_session_keys(conn) |> List.delete(session_key) - @doc false - @spec atom_to_upcase_string(atom()) :: String.t() - def atom_to_upcase_string(atom) do - atom |> to_string() |> String.upcase() + conn + |> Plug.Conn.put_session(@session_keys_name, session_keys) + |> Plug.Conn.delete_session(@session_active_name) end end diff --git a/lib/atex/oauth/discovery.ex b/lib/atex/oauth/discovery.ex new file mode 100644 index 0000000..475ef04 --- /dev/null +++ b/lib/atex/oauth/discovery.ex @@ -0,0 +1,144 @@ +defmodule Atex.OAuth.Discovery do + @moduledoc """ + Authorization server discovery for AT Protocol OAuth. + + Resolves a PDS to its authorization server and fetches authorization server + metadata. Results are cached for 1 hour via `Atex.OAuth.Cache`. + """ + + alias Atex.OAuth.Cache + + @doc """ + Fetch the authorization server for a given Personal Data Server (PDS). + + Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint. + Results are cached for 1 hour to reduce load on third-party PDSs. + + ## Parameters + + - `pds_host` - Base URL of the PDS (e.g., `"https://bsky.social"`) + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) + + ## Returns + + - `{:ok, authorization_server}` - Successfully discovered authorization server URL + - `{:error, :invalid_metadata}` - Server returned invalid metadata + - `{:error, reason}` - Error discovering authorization server + """ + @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()} + def get_authorization_server(pds_host, fresh \\ false) do + if fresh do + fetch_authorization_server(pds_host) + else + case Cache.get_authorization_server(pds_host) do + {:ok, authz_server} -> {:ok, authz_server} + {:error, :not_found} -> fetch_authorization_server(pds_host) + end + end + end + + @doc """ + Fetch the metadata for an OAuth authorization server. + + Retrieves the metadata from `.well-known/oauth-authorization-server`. + Results are cached for 1 hour. + + ## Parameters + + - `issuer` - Authorization server issuer URL + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) + + ## Returns + + - `{:ok, metadata}` - Successfully retrieved authorization server metadata + - `{:error, :invalid_metadata}` - Server returned invalid metadata + - `{:error, :invalid_issuer}` - Issuer mismatch in metadata + - `{:error, any()}` - Other error fetching metadata + """ + @spec get_authorization_server_metadata(String.t(), boolean()) :: + {:ok, Atex.OAuth.Flow.authorization_metadata()} | {:error, any()} + def get_authorization_server_metadata(issuer, fresh \\ false) do + if fresh do + fetch_authorization_server_metadata(issuer) + else + case Cache.get_authorization_server_metadata(issuer) do + {:ok, metadata} -> {:ok, metadata} + {:error, :not_found} -> fetch_authorization_server_metadata(issuer) + end + end + end + + @spec fetch_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()} + defp fetch_authorization_server(pds_host) do + result = + "#{pds_host}/.well-known/oauth-protected-resource" + |> Req.get() + |> case do + # TODO: what to do when multiple authorization servers? + {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> + {:ok, authz_server} + + {:ok, _} -> + {:error, :invalid_metadata} + + err -> + err + end + + case result do + {:ok, authz_server} -> + Cache.set_authorization_server(pds_host, authz_server) + {:ok, authz_server} + + error -> + error + end + end + + @spec fetch_authorization_server_metadata(String.t()) :: + {:ok, Atex.OAuth.Flow.authorization_metadata()} | {:error, any()} + defp fetch_authorization_server_metadata(issuer) do + result = + "#{issuer}/.well-known/oauth-authorization-server" + |> Req.get() + |> case do + {:ok, + %{ + body: %{ + "issuer" => metadata_issuer, + "pushed_authorization_request_endpoint" => par_endpoint, + "token_endpoint" => token_endpoint, + "authorization_endpoint" => authorization_endpoint, + "revocation_endpoint" => revocation_endpoint + } + }} -> + if issuer != metadata_issuer do + {:error, :invalid_issuer} + else + {:ok, + %{ + issuer: metadata_issuer, + par_endpoint: par_endpoint, + token_endpoint: token_endpoint, + authorization_endpoint: authorization_endpoint, + revocation_endpoint: revocation_endpoint + }} + end + + {:ok, _} -> + {:error, :invalid_metadata} + + err -> + err + end + + case result do + {:ok, metadata} -> + Cache.set_authorization_server_metadata(issuer, metadata) + {:ok, metadata} + + error -> + error + end + end +end diff --git a/lib/atex/oauth/dpop.ex b/lib/atex/oauth/dpop.ex new file mode 100644 index 0000000..528c4c3 --- /dev/null +++ b/lib/atex/oauth/dpop.ex @@ -0,0 +1,185 @@ +defmodule Atex.OAuth.DPoP do + @moduledoc """ + DPoP (Demonstrating Proof of Possession) token creation and request handling. + + Provides functions to create DPoP proof JWTs and send DPoP-protected HTTP + requests, handling the nonce retry dance required by the AT Protocol OAuth + specification. + """ + + @doc """ + Create a DPoP proof token for a given request. + + Builds a signed JWT containing the HTTP method, URL (without query string), + a random `jti`, the current timestamp, and an optional server nonce. Extra + claims (e.g., `iss`, `ath`) can be merged in via `attrs`. + + ## Parameters + + - `jwk` - Private JWK used to sign the proof + - `request` - The `Req.Request` the token is being produced for + - `nonce` - Server-provided nonce (optional; omitted from JWT when `nil`) + - `attrs` - Extra claims to merge into the JWT payload (default: `%{}`) + """ + @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), String.t() | nil, map()) :: String.t() + def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do + iat = System.os_time(:second) + jti = random_b64(20) + {_, public_jwk} = JOSE.JWK.to_public_map(jwk) + jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk} + [request_url | _] = request.url |> to_string() |> String.split("?") + + jwt = + Map.merge(attrs, %{ + jti: jti, + htm: request.method |> to_string() |> String.upcase(), + htu: request_url, + iat: iat + }) + |> then(fn m -> if nonce, do: Map.put(m, :nonce, nonce), else: m end) + + JOSE.JWT.sign(jwk, jws, jwt) + |> JOSE.JWS.compact() + |> elem(1) + end + + @doc """ + Send a DPoP-protected request to a token endpoint. + + Attaches a DPoP proof to `request` and sends it. If the server responds with + `use_dpop_nonce`, retries once with the returned nonce. + + ## Parameters + + - `request` - A `Req.Request` already configured with URL, method, and body + - `dpop_key` - Private JWK for signing the DPoP proof + - `nonce` - Current DPoP nonce, if any (default: `nil`) + """ + @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: + {:ok, map(), String.t() | nil} | {:error, any(), String.t() | nil} + def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do + dpop_token = create_dpop_token(dpop_key, request, nonce) + + request + |> Req.Request.put_header("dpop", dpop_token) + |> Req.request() + |> case do + {:ok, %{status: 200, body: body} = resp} -> + {:ok, body, extract_nonce(resp, nonce)} + + {:ok, %{body: %{"error" => "use_dpop_nonce"}} = resp} -> + retry_token_request(request, dpop_key, extract_nonce(resp, nonce)) + + {:ok, %{body: %{"error" => error, "error_description" => description}} = resp} -> + {:error, {:oauth_error, error, description}, extract_nonce(resp, nonce)} + + {:ok, resp} -> + {:error, :unexpected_response, extract_nonce(resp, nonce)} + + {:error, err} -> + {:error, err, nonce} + end + end + + @doc """ + Send a DPoP-protected request to a resource server (e.g., a PDS endpoint). + + Attaches both the `Authorization: DPoP ` header (assumed already set on + `request`) and a fresh DPoP proof. If the server returns a 401 with a + `WWW-Authenticate: DPoP ...` header, retries once with the returned nonce. + + ## Parameters + + - `request` - A `Req.Request` with the Authorization header already set + - `issuer` - Authorization server issuer URL (used in the `iss` claim) + - `access_token` - The access token (used to compute the `ath` hash claim) + - `dpop_key` - Private JWK for signing the DPoP proof + - `nonce` - Current DPoP nonce, if any (default: `nil`) + """ + @spec request_protected_dpop_resource( + Req.Request.t(), + String.t(), + String.t(), + JOSE.JWK.t(), + String.t() | nil + ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} + def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do + access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false) + extra_claims = %{iss: issuer, ath: access_token_hash} + dpop_token = create_dpop_token(dpop_key, request, nonce, extra_claims) + + request + |> Req.Request.put_header("dpop", dpop_token) + |> Req.request() + |> case do + {:ok, %{status: 401} = resp} -> + dpop_nonce = extract_nonce(resp, nonce) + + case Req.Response.get_header(resp, "www-authenticate") do + ["DPoP" <> _ | _] -> retry_resource_request(request, dpop_key, dpop_nonce, extra_claims) + _ -> {:ok, resp, dpop_nonce} + end + + {:ok, resp} -> + {:ok, resp, extract_nonce(resp, nonce)} + + {:error, _} = err -> + err + end + end + + @spec retry_token_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: + {:ok, map(), String.t() | nil} | {:error, any(), String.t() | nil} + defp retry_token_request(request, dpop_key, nonce) do + dpop_token = create_dpop_token(dpop_key, request, nonce) + + request + |> Req.Request.put_header("dpop", dpop_token) + |> Req.request() + |> case do + {:ok, %{status: 200, body: body}} -> + {:ok, body, nonce} + + {:ok, %{body: %{"error" => error, "error_description" => description}}} -> + {:error, {:oauth_error, error, description}, nonce} + + {:ok, _} -> + {:error, :unexpected_response, nonce} + + {:error, err} -> + {:error, err, nonce} + end + end + + @spec retry_resource_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil, map()) :: + {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} + defp retry_resource_request(request, dpop_key, nonce, extra_claims) do + dpop_token = create_dpop_token(dpop_key, request, nonce, extra_claims) + + request + |> Req.Request.put_header("dpop", dpop_token) + |> Req.request() + |> case do + {:ok, resp} -> + dpop_nonce = extract_nonce(resp, nonce) + {:ok, resp, dpop_nonce} + + {:error, _} = err -> + err + end + end + + @spec extract_nonce(Req.Response.t(), String.t() | nil) :: String.t() | nil + defp extract_nonce(resp, fallback) do + case resp.headers["dpop-nonce"] do + [new_nonce | _] -> new_nonce + _ -> fallback + end + end + + @spec random_b64(integer()) :: String.t() + defp random_b64(length) do + :crypto.strong_rand_bytes(length) + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/atex/oauth/flow.ex b/lib/atex/oauth/flow.ex new file mode 100644 index 0000000..b83376b --- /dev/null +++ b/lib/atex/oauth/flow.ex @@ -0,0 +1,377 @@ +defmodule Atex.OAuth.Flow do + @moduledoc """ + AT Protocol OAuth 2.0 authorization flow. + + Handles the full OAuth protocol interactions: pushed authorization requests + (PAR), authorization code exchange, token refresh, token revocation, client + metadata, and client assertions. + + See `Atex.OAuth.Discovery` for authorization server discovery and + `Atex.OAuth.DPoP` for DPoP token creation. + """ + + require Logger + + alias Atex.Config.OAuth, as: Config + alias Atex.OAuth.{DPoP, Session} + + @type authorization_metadata() :: %{ + issuer: String.t(), + par_endpoint: String.t(), + token_endpoint: String.t(), + authorization_endpoint: String.t(), + revocation_endpoint: String.t() + } + + @type tokens() :: %{ + access_token: String.t(), + refresh_token: String.t(), + did: String.t(), + expires_at: NaiveDateTime.t() + } + + @type create_client_metadata_option :: + {:key, JOSE.JWK.t()} + | {:client_id, String.t()} + | {:redirect_uri, String.t()} + | {:extra_redirect_uris, list(String.t())} + | {:scopes, String.t()} + + @type create_authorization_url_option :: + {:key, JOSE.JWK.t()} + | {:client_id, String.t()} + | {:redirect_uri, String.t()} + | {:scopes, String.t()} + + @type validate_authorization_code_option :: + {:key, JOSE.JWK.t()} + | {:client_id, String.t()} + | {:redirect_uri, String.t()} + | {:scopes, String.t()} + + @type refresh_token_option :: + {:key, JOSE.JWK.t()} + | {:client_id, String.t()} + + @doc """ + Get a map containing the client metadata information needed for an + authorization server to validate this client. + """ + @spec create_client_metadata(list(create_client_metadata_option())) :: map() + def create_client_metadata(opts \\ []) do + opts = + Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :extra_redirect_uris, :scopes]) + + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) + redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) + + extra_redirect_uris = + Keyword.get_lazy(opts, :extra_redirect_uris, &Config.extra_redirect_uris/0) + + scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) + + {_, jwk} = key |> JOSE.JWK.to_public_map() + jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]}) + + %{ + client_id: client_id, + redirect_uris: [redirect_uri | extra_redirect_uris], + application_type: "web", + grant_types: ["authorization_code", "refresh_token"], + scope: scopes, + response_type: ["code"], + token_endpoint_auth_method: "private_key_jwt", + token_endpoint_auth_signing_alg: "ES256", + dpop_bound_access_tokens: true, + jwks: %{keys: [jwk]} + } + end + + @doc """ + Create a JWT client assertion for authenticating with an authorization server. + + Signs a short-lived (60 second) JWT with the client's private key, identifying + the client to the authorization server. + + ## Parameters + + - `jwk` - Client private key (must have a `kid` field set) + - `client_id` - OAuth client identifier + - `issuer` - Authorization server issuer URL (used as `aud`) + """ + @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t() + def create_client_assertion(jwk, client_id, issuer) do + iat = System.os_time(:second) + jti = random_b64(20) + jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]} + + jwt = %{ + iss: client_id, + sub: client_id, + aud: issuer, + jti: jti, + iat: iat, + exp: iat + 60 + } + + JOSE.JWT.sign(jwk, jws, jwt) + |> JOSE.JWS.compact() + |> elem(1) + end + + @doc """ + Create an OAuth authorization URL for a PDS. + + Submits a PAR request to the authorization server and constructs the + authorization URL with the returned request URI. Supports PKCE, DPoP, and + client assertions as required by the AT Protocol. + + ## Parameters + + - `authz_metadata` - Authorization server metadata, from `Atex.OAuth.Discovery.get_authorization_server_metadata/2` + - `state` - Random token for session validation + - `code_verifier` - PKCE code verifier + - `login_hint` - User identifier (handle or DID) for pre-filled login + - `opts` - Optional overrides for `:key`, `:client_id`, `:redirect_uri`, `:scopes` + + ## Returns + + - `{:ok, authorization_url}` - Successfully created authorization URL + - `{:error, :invalid_par_response}` - Server responded incorrectly to the PAR request + - `{:error, reason}` - Error creating authorization URL + """ + @spec create_authorization_url( + authorization_metadata(), + String.t(), + String.t(), + String.t(), + list(create_authorization_url_option()) + ) :: {:ok, String.t()} | {:error, any()} + def create_authorization_url(authz_metadata, state, code_verifier, login_hint, opts \\ []) do + opts = Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :scopes]) + + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) + redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) + scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) + + code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false) + client_assertion = create_client_assertion(key, client_id, authz_metadata.issuer) + + body = %{ + response_type: "code", + client_id: client_id, + redirect_uri: redirect_uri, + state: state, + code_challenge_method: "S256", + code_challenge: code_challenge, + scope: scopes, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: client_assertion, + login_hint: login_hint + } + + case Req.post(authz_metadata.par_endpoint, form: body) do + {:ok, %{body: %{"request_uri" => request_uri}}} -> + query = %{client_id: client_id, request_uri: request_uri} |> URI.encode_query() + {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"} + + {:ok, _} -> + {:error, :invalid_par_response} + + err -> + err + end + end + + @doc """ + Exchange an OAuth authorization code for a set of access and refresh tokens. + + Validates the authorization code by submitting it to the token endpoint along + with the PKCE code verifier and client assertion. Returns access tokens for + making authenticated requests to the relevant user's PDS. + + ## Parameters + + - `authz_metadata` - Authorization server metadata containing token endpoint + - `dpop_key` - JWK for DPoP token generation + - `code` - Authorization code from OAuth callback + - `code_verifier` - PKCE code verifier from authorization flow + - `opts` - Optional overrides for `:key`, `:client_id`, `:redirect_uri`, `:scopes` + + ## Returns + + - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce + - `{:error, reason}` - Error exchanging code for tokens + """ + @spec validate_authorization_code( + authorization_metadata(), + JOSE.JWK.t(), + String.t(), + String.t(), + list(validate_authorization_code_option()) + ) :: {:ok, tokens(), String.t() | nil} | {:error, any()} + def validate_authorization_code(authz_metadata, dpop_key, code, code_verifier, opts \\ []) do + opts = Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :scopes]) + + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) + redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) + + client_assertion = create_client_assertion(key, client_id, authz_metadata.issuer) + + body = %{ + grant_type: "authorization_code", + client_id: client_id, + redirect_uri: redirect_uri, + code: code, + code_verifier: code_verifier + } + + body = + if Config.is_localhost(), + do: body, + else: + Map.merge(body, %{ + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: client_assertion + }) + + Req.new(method: :post, url: authz_metadata.token_endpoint, form: body) + |> DPoP.send_oauth_dpop_request(dpop_key) + |> case do + {:ok, + %{ + "access_token" => access_token, + "refresh_token" => refresh_token, + "expires_in" => expires_in, + "sub" => did + }, nonce} -> + expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) + + {:ok, + %{ + access_token: access_token, + refresh_token: refresh_token, + did: did, + expires_at: expires_at + }, nonce} + + {:error, reason, _nonce} -> + {:error, reason} + end + end + + @doc """ + Refresh an existing set of OAuth tokens. + + Submits the refresh token to the token endpoint using DPoP authentication and + a client assertion. Returns the new token set with an updated DPoP nonce. + + ## Parameters + + - `refresh_token` - The refresh token to exchange + - `dpop_key` - JWK for DPoP token generation + - `issuer` - Authorization server issuer URL (for client assertion `aud`) + - `token_endpoint` - Token endpoint URL + - `opts` - Optional overrides for `:key`, `:client_id` + """ + @spec refresh_token( + String.t(), + JOSE.JWK.t(), + String.t(), + String.t(), + list(refresh_token_option()) + ) :: {:ok, tokens(), String.t() | nil} | {:error, any()} + def refresh_token(refresh_token, dpop_key, issuer, token_endpoint, opts \\ []) do + opts = Keyword.validate!(opts, [:key, :client_id]) + + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) + + client_assertion = create_client_assertion(key, client_id, issuer) + + body = %{ + grant_type: "refresh_token", + refresh_token: refresh_token, + client_id: client_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: client_assertion + } + + Req.new(method: :post, url: token_endpoint, form: body) + |> DPoP.send_oauth_dpop_request(dpop_key) + |> case do + {:ok, + %{ + "access_token" => access_token, + "refresh_token" => refresh_token, + "expires_in" => expires_in, + "sub" => did + }, nonce} -> + expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) + + {:ok, + %{ + access_token: access_token, + refresh_token: refresh_token, + did: did, + expires_at: expires_at + }, nonce} + + {:error, reason, _nonce} -> + {:error, reason} + end + end + + @doc """ + Revokes the access and refresh tokens with the authorization server. + + Sends the refresh token to the revocation endpoint as defined in RFC 7009. + Token revocation failures are logged as warnings rather than returned as + errors, since the primary goal (ending the session) is still achieved. + + ## Parameters + + - `session` - The session containing tokens to revoke + - `authz_metadata` - Authorization server metadata including `revocation_endpoint` + + ## Returns + + - `:ok` - Tokens revoked (or revocation endpoint unreachable - logged, not raised) + """ + @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok + def revoke_tokens(%Session{} = session, authz_metadata) do + client_id = Config.client_id() + + body = %{ + client_id: client_id, + token: session.refresh_token, + token_type_hint: "refresh_token" + } + + case Req.post(authz_metadata.revocation_endpoint, form: body) do + {:ok, %{status: status}} when status in [200, 204] -> + :ok + + {:ok, %{body: %{"error" => error}}} -> + Logger.warning("Token revocation failed: #{error}") + :ok + + {:error, reason} -> + Logger.warning("Token revocation request failed: #{inspect(reason)}") + :ok + + unexpected -> + Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}") + :ok + end + end + + @spec random_b64(integer()) :: String.t() + defp random_b64(length) do + :crypto.strong_rand_bytes(length) + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/atex/oauth/plug.ex b/lib/atex/oauth/plug.ex index cc09ec4..4f696d3 100644 --- a/lib/atex/oauth/plug.ex +++ b/lib/atex/oauth/plug.ex @@ -115,10 +115,9 @@ defmodule Atex.OAuth.Plug do use Plug.Router require Plug.Router alias Atex.{DID, IdentityResolver, OAuth} + alias Atex.OAuth.{Discovery, Flow} @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] - @session_keys_name :atex_sessions - @session_active_name :atex_active_session def init(opts) do callback = Keyword.get(opts, :callback, nil) @@ -158,12 +157,12 @@ defmodule Atex.OAuth.Plug do case IdentityResolver.resolve(handle) do {:ok, identity} -> pds = DID.Document.get_pds_endpoint(identity.document) - {:ok, authz_server} = OAuth.get_authorization_server(pds) - {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server) + {:ok, authz_server} = Discovery.get_authorization_server(pds) + {:ok, authz_metadata} = Discovery.get_authorization_server_metadata(authz_server) state = OAuth.create_nonce() code_verifier = OAuth.create_nonce() - case OAuth.create_authorization_url( + case Flow.create_authorization_url( authz_metadata, state, code_verifier, @@ -191,7 +190,7 @@ defmodule Atex.OAuth.Plug do get "/client-metadata.json" do conn |> put_resp_content_type("application/json") - |> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata())) + |> send_resp(200, JSON.encode_to_iodata!(Flow.create_client_metadata())) end get "/callback" do @@ -212,10 +211,10 @@ defmodule Atex.OAuth.Plug do reason: :invalid_callback_request end - with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer), + with {:ok, authz_metadata} <- Discovery.get_authorization_server_metadata(stored_issuer), dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}), {:ok, tokens, dpop_nonce} <- - OAuth.validate_authorization_code( + Flow.validate_authorization_code( authz_metadata, dpop_key, code, @@ -224,7 +223,7 @@ defmodule Atex.OAuth.Plug do {:ok, identity} <- IdentityResolver.resolve(tokens.did), # Make sure pds' issuer matches the stored one (just in case) pds <- DID.Document.get_pds_endpoint(identity.document), - {:ok, authz_server} <- OAuth.get_authorization_server(pds), + {:ok, authz_server} <- Discovery.get_authorization_server(pds), true <- authz_server == stored_issuer do device_nonce = OAuth.create_nonce() @@ -244,15 +243,15 @@ defmodule Atex.OAuth.Plug do case OAuth.SessionStore.insert(session) do :ok -> - existing_keys = get_session(conn, @session_keys_name) || [] + existing_keys = get_session(conn, OAuth.session_keys_name()) || [] conn = conn |> delete_resp_cookie("state", @oauth_cookie_opts) |> delete_resp_cookie("code_verifier", @oauth_cookie_opts) |> delete_resp_cookie("issuer", @oauth_cookie_opts) - |> put_session(@session_keys_name, [session_key | existing_keys]) - |> put_session(@session_active_name, session_key) + |> put_session(OAuth.session_keys_name(), [session_key | existing_keys]) + |> put_session(OAuth.session_active_session_name(), session_key) {mod, func, args} = callback apply(mod, func, [conn | args]) @@ -327,8 +326,8 @@ defmodule Atex.OAuth.Plug do def revoke_session(%Plug.Conn{} = conn, session_key) do case OAuth.delete_session(session_key) do :ok -> - session_keys = get_session(conn, @session_keys_name) || [] - active_key = get_session(conn, @session_active_name) + session_keys = get_session(conn, OAuth.session_keys_name()) || [] + active_key = get_session(conn, OAuth.session_active_session_name()) session_keys = List.delete(session_keys, session_key) @@ -337,10 +336,10 @@ defmodule Atex.OAuth.Plug do new_active = List.first(session_keys) conn - |> put_session(@session_active_name, new_active) - |> put_session(@session_keys_name, session_keys) + |> put_session(OAuth.session_active_session_name(), new_active) + |> put_session(OAuth.session_keys_name(), session_keys) else - put_session(conn, @session_keys_name, session_keys) + put_session(conn, OAuth.session_keys_name(), session_keys) end {:ok, conn} diff --git a/lib/atex/xrpc/oauth_client.ex b/lib/atex/xrpc/oauth_client.ex index ab4fe96..43e9108 100644 --- a/lib/atex/xrpc/oauth_client.ex +++ b/lib/atex/xrpc/oauth_client.ex @@ -26,6 +26,7 @@ defmodule Atex.XRPC.OAuthClient do """ alias Atex.OAuth + alias Atex.OAuth.{Discovery, DPoP, Flow} use TypedStruct @behaviour Atex.XRPC.Client @@ -143,10 +144,10 @@ defmodule Atex.XRPC.OAuthClient do @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()} defp do_refresh(%__MODULE__{session_key: session_key}) do with {:ok, session} <- OAuth.SessionStore.get(session_key), - {:ok, authz_server} <- OAuth.get_authorization_server(session.aud), + {:ok, authz_server} <- Discovery.get_authorization_server(session.aud), {:ok, %{token_endpoint: token_endpoint}} <- - OAuth.get_authorization_server_metadata(authz_server) do - case OAuth.refresh_token( + Discovery.get_authorization_server_metadata(authz_server) do + case Flow.refresh_token( session.refresh_token, session.dpop_key, session.iss, @@ -227,7 +228,7 @@ defmodule Atex.XRPC.OAuthClient do |> Req.new() |> Req.Request.put_header("authorization", "DPoP #{session.access_token}") - case OAuth.request_protected_dpop_resource( + case DPoP.request_protected_dpop_resource( request, session.iss, session.access_token, @@ -264,7 +265,7 @@ defmodule Atex.XRPC.OAuthClient do if auth_error?(response) do case do_refresh(client) do {:ok, session} -> - case OAuth.request_protected_dpop_resource( + case DPoP.request_protected_dpop_resource( request, session.iss, session.access_token, diff --git a/test/atex/oauth/dpop_test.exs b/test/atex/oauth/dpop_test.exs new file mode 100644 index 0000000..aaf99c7 --- /dev/null +++ b/test/atex/oauth/dpop_test.exs @@ -0,0 +1,94 @@ +defmodule Atex.OAuth.DPoPTest do + use ExUnit.Case, async: true + + alias Atex.OAuth.DPoP + + describe "create_dpop_token/4" do + test "returns a compact JWT string" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") + + token = DPoP.create_dpop_token(key, request) + + assert is_binary(token) + assert length(String.split(token, ".")) == 3 + end + + test "sets htm to uppercased HTTP method" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :post, url: "https://example.com/xrpc/foo") + + token = DPoP.create_dpop_token(key, request) + %{fields: claims} = JOSE.JWT.peek(token) + + assert claims["htm"] == "POST" + end + + test "sets htu to URL without query string" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :get, url: "https://example.com/xrpc/foo?bar=baz") + + token = DPoP.create_dpop_token(key, request) + %{fields: claims} = JOSE.JWT.peek(token) + + assert claims["htu"] == "https://example.com/xrpc/foo" + end + + test "includes nonce claim when provided" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") + + token = DPoP.create_dpop_token(key, request, "my-server-nonce") + %{fields: claims} = JOSE.JWT.peek(token) + + assert claims["nonce"] == "my-server-nonce" + end + + test "omits nonce claim when nil" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") + + token = DPoP.create_dpop_token(key, request, nil) + %{fields: claims} = JOSE.JWT.peek(token) + + refute Map.has_key?(claims, "nonce") + end + + test "merges extra claims into the JWT" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") + + token = + DPoP.create_dpop_token(key, request, nil, %{iss: "https://bsky.social", ath: "abc123"}) + + %{fields: claims} = JOSE.JWT.peek(token) + + assert claims["iss"] == "https://bsky.social" + assert claims["ath"] == "abc123" + end + + test "sets jti and iat" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") + + token = DPoP.create_dpop_token(key, request) + %{fields: claims} = JOSE.JWT.peek(token) + + assert is_binary(claims["jti"]) + assert String.length(claims["jti"]) > 0 + assert is_integer(claims["iat"]) + end + + test "generates unique jti per call" do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") + + token1 = DPoP.create_dpop_token(key, request) + token2 = DPoP.create_dpop_token(key, request) + %{fields: claims1} = JOSE.JWT.peek(token1) + %{fields: claims2} = JOSE.JWT.peek(token2) + + refute claims1["jti"] == claims2["jti"] + end + end +end diff --git a/test/atex/oauth/flow_test.exs b/test/atex/oauth/flow_test.exs new file mode 100644 index 0000000..a194778 --- /dev/null +++ b/test/atex/oauth/flow_test.exs @@ -0,0 +1,84 @@ +defmodule Atex.OAuth.FlowTest do + use ExUnit.Case, async: true + + alias Atex.OAuth.Flow + + describe "create_client_assertion/3" do + setup do + key = JOSE.JWK.generate_key({:ec, "P-256"}) + key = %{key | fields: Map.put(key.fields, "kid", "test-kid-123")} + %{key: key} + end + + test "returns a compact JWT string", %{key: key} do + token = + Flow.create_client_assertion( + key, + "https://example.com/client-metadata.json", + "https://bsky.social" + ) + + assert is_binary(token) + assert length(String.split(token, ".")) == 3 + end + + test "sets iss and sub to client_id", %{key: key} do + client_id = "https://example.com/client-metadata.json" + token = Flow.create_client_assertion(key, client_id, "https://bsky.social") + + %{fields: claims} = JOSE.JWT.peek(token) + + assert claims["iss"] == client_id + assert claims["sub"] == client_id + end + + test "sets aud to issuer", %{key: key} do + issuer = "https://bsky.social" + + token = + Flow.create_client_assertion(key, "https://example.com/client-metadata.json", issuer) + + %{fields: claims} = JOSE.JWT.peek(token) + + assert claims["aud"] == issuer + end + + test "expires 60 seconds after iat", %{key: key} do + token = + Flow.create_client_assertion( + key, + "https://example.com/client-metadata.json", + "https://bsky.social" + ) + + %{fields: claims} = JOSE.JWT.peek(token) + + assert claims["exp"] - claims["iat"] == 60 + end + + test "sets a non-empty jti", %{key: key} do + token = + Flow.create_client_assertion( + key, + "https://example.com/client-metadata.json", + "https://bsky.social" + ) + + %{fields: claims} = JOSE.JWT.peek(token) + + assert is_binary(claims["jti"]) + assert String.length(claims["jti"]) > 0 + end + + test "produces a validly signed JWT", %{key: key} do + token = + Flow.create_client_assertion( + key, + "https://example.com/client-metadata.json", + "https://bsky.social" + ) + + {true, %JOSE.JWT{}, _} = JOSE.JWT.verify(JOSE.JWK.to_public(key), token) + end + end +end diff --git a/test/atex/oauth_test.exs b/test/atex/oauth_test.exs new file mode 100644 index 0000000..7409b71 --- /dev/null +++ b/test/atex/oauth_test.exs @@ -0,0 +1,27 @@ +defmodule Atex.OAuthTest do + use ExUnit.Case, async: true + + alias Atex.OAuth + + describe "create_nonce/0" do + test "returns a binary" do + assert is_binary(OAuth.create_nonce()) + end + + test "returns unique values on each call" do + refute OAuth.create_nonce() == OAuth.create_nonce() + end + end + + describe "session_keys_name/0" do + test "returns the session keys atom" do + assert OAuth.session_keys_name() == :atex_sessions + end + end + + describe "session_active_session_name/0" do + test "returns the active session atom" do + assert OAuth.session_active_session_name() == :atex_active_session + end + end +end