ProtoRune.Atproto.OAuth (proto_rune v0.5.2)

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)

# 4. Revoke the refresh token on logout
{:ok, :revoked} = OAuth.revoke(session, client_id: client.client_id)

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. OAuth sessions implement the ProtoRune.Session behaviour, so they can be passed to any XRPC or DSL function; the session attaches the required DPoP proof to each request. For long-running applications, ProtoRune.Atproto.OAuth.SessionManager keeps a session fresh automatically. 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.

Revokes the session's refresh token at the authorization server.

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.

revoke(session, opts \\ [])

@spec revoke(
  ProtoRune.Atproto.OAuth.Session.t(),
  keyword()
) :: {:ok, :revoked} | error()

Revokes the session's refresh token at the authorization server.

Discovers the revocation_endpoint from the authorization server metadata (RFC 7009) of the session's issuer and posts a public-client revocation request carrying a DPoP proof, bound to the session's access token via the ath claim like the token endpoint calls.

Per RFC 7009 the server answers 200 even for unknown or already invalid tokens, so {:ok, :revoked} means the token is gone, not that it was still valid.

Options

  • :client_id - Required. The client_id the session was issued to, as configured on the Client that ran the flow.

Returns {:error, :revocation_not_supported} when the authorization server declares no revocation endpoint; no revocation request is made in that case.