ProtoRune.Atproto.OAuth (proto_rune v0.3.0)

Copy Markdown

AT Protocol OAuth 2.0 authorization flow.

Implements the authorization code flow with PAR (Pushed Authorization Requests), PKCE and DPoP as described in https://atproto.com/specs/oauth, as an alternative to app password authentication (ProtoRune.login/3).

Only public clients are supported: the client authenticates with a client_id URL pointing to its client metadata document, and every authorization server request carries a DPoP proof signed with an ES256 key generated locally (see ProtoRune.Atproto.OAuth.DPoP). No external JWT dependency is required.

Flow overview

alias ProtoRune.Atproto.OAuth
alias ProtoRune.Atproto.OAuth.Client

{:ok, client} =
  Client.new(
    client_id: "https://myapp.example.com/oauth/client-metadata.json",
    redirect_uri: "https://myapp.example.com/oauth/callback"
  )

# 1. Send the user to the authorization server
{:ok, url, pending} = OAuth.authorization_url(client, "alice.bsky.social")
# redirect the user to `url`, persist `pending` (e.g. in the session)

# 2. Handle the callback with the received query params
{:ok, session} = OAuth.exchange_code(client, pending, callback_params)

# 3. Refresh when the access token expires
{:ok, fresh_session} = OAuth.refresh(client, session)

The pending map holds the flow state (PKCE verifier, state, DPoP nonce) and must be kept between steps 1 and 2. It contains the DPoP private key, so persist it securely.

Scope of this implementation

Issued access tokens are DPoP-bound. Using an OAuth session for XRPC calls requires the XRPC layer to attach DPoP proofs, which is not wired yet: exchange_code/3 and refresh/2 cover the authorization flow itself. Confidential clients (private_key_jwt) and client metadata document serving are out of scope.

Summary

Types

Flow state produced by authorization_url/3 and consumed by exchange_code/3.

Functions

Starts the authorization flow and returns the URL to send the user to.

Exchanges the authorization code received on the callback for tokens.

Refreshes an OAuth session using its refresh token.

Types

error()

@type error() :: {:error, term()}

pending()

@type pending() :: %{
  did: String.t(),
  handle: String.t() | nil,
  service_url: String.t(),
  issuer: String.t(),
  state: String.t(),
  code_verifier: String.t(),
  dpop_key: ProtoRune.Atproto.OAuth.DPoP.private_key(),
  dpop_jwk: ProtoRune.Atproto.OAuth.DPoP.jwk(),
  dpop_nonce: String.t() | nil,
  token_endpoint: String.t()
}

Flow state produced by authorization_url/3 and consumed by exchange_code/3.

Functions

authorization_url(client, identifier, opts \\ [])

@spec authorization_url(ProtoRune.Atproto.OAuth.Client.t(), String.t(), keyword()) ::
  {:ok, String.t(), pending()} | error()

Starts the authorization flow and returns the URL to send the user to.

Resolves the account's PDS from its handle or DID, discovers the authorization server through the OAuth metadata documents and pushes the authorization request (PAR) with PKCE and DPoP.

Returns {:ok, url, pending}. Redirect the user to url and keep pending until the callback is exchanged with exchange_code/3.

Options

  • :state - Explicit state value (a random one is generated by default).
  • :dpop_nonce - DPoP nonce from a previous interaction with the authorization server.

exchange_code(client, pending, params)

Exchanges the authorization code received on the callback for tokens.

params is the query string params map of the callback request (string or atom keys). The state param must match the one in pending and, when present, iss must match the discovered authorization server.

Returns an OAuth Session with DPoP-bound tokens.

refresh(client, session)

Refreshes an OAuth session using its refresh token.

Follows refresh token rotation: when the server issues a new refresh token it replaces the old one, otherwise the previous one is kept.