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
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
@type error() :: {:error, term()}
@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
@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.
@spec exchange_code(ProtoRune.Atproto.OAuth.Client.t(), pending(), map()) :: {:ok, ProtoRune.Atproto.OAuth.Session.t()} | error()
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.
@spec refresh(ProtoRune.Atproto.OAuth.Client.t(), ProtoRune.Atproto.OAuth.Session.t()) :: {:ok, ProtoRune.Atproto.OAuth.Session.t()} | error()
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.