Core concepts
OAuth and browser authentication
The OAuth authorization/resource server, granular permissions, passkeys, and TOTP authenticators.
OAuth DPoP verification foundation#
Atoll.OAuth.DPoP.verify/4 verifies a single DPoP header using ES256/P-256 and
returns its JWK thumbprint, proof ID, issue time, and nonce. It uses the existing
JOSE library for signature verification and RFC JWK thumbprints. This is an
internal cryptographic component used by the OAuth proof-admission layer.
Browser authorization, token exchange/refresh and local resource policies are
implemented by the adapters described below.
The caller supplies the externally visible method/URL, current time, and a recent
server-issued nonce. For protected-resource requests it must supply both the
validated access token and its bound jkt; the verifier checks both the SHA-256
ath and key binding. Token validity, account state, consent and scopes remain the
caller's responsibility. Successful proof verification must be followed by atomic
replay rejection before executing a request. The internal Atoll.OAuth.Proofs
guard below provides nonce validation and replay admission for PAR, token
requests and integrated resource policies.
Proofs are bounded to 8 KiB. The verifier rejects duplicate HTTP headers, duplicate JSON members (including nested JWK members), excessive JSON nesting, noncanonical base64url, private key material, unsupported algorithms and JOSE extensions, and invalid signatures. It accepts both valid ECDSA signature forms; replay identity must use the verified thumbprint/proof ID, not signature bytes. Proof IDs are bounded to 256 bytes. Required server nonces are 16–256 printable ASCII bytes. Proofs can be at most five minutes old or thirty seconds ahead of server time.
Request binding compares method and HTTP(S) target, excluding the actual request's query as required by DPoP. The proof target itself must omit query and fragment; userinfo is rejected. Scheme/host case, default ports, and an empty root path are normalized; other path spellings are compared exactly. Callers must build the expected URL from trusted public endpoint configuration rather than untrusted forwarding headers.
The implementation follows the mandatory ES256 requirement in the ATProto OAuth profile and the proof checks in RFC 9449 §4.3.
OAuth server nonces and shared replay protection#
Atoll.OAuth.Nonce.issue/2 issues unpredictable, HMAC-authenticated nonces bound
to the public issuer and the :authorization or :resource server role. Configure
ATOLL_OAUTH_NONCE_SECRET with a base64-encoded random 32-byte secret; instances
serving the same issuer must share this secret and PostgreSQL database. The default
issuer is AtollWeb.Endpoint.url(). Nonces expire five minutes after issuance,
with five seconds of tolerance for clocks ahead at issuance. Changing the secret
invalidates outstanding nonces. Missing configuration fails closed when used;
it does not prevent startup. The PAR HTTP adapter returns 503 until configured.
Atoll.OAuth.Proofs.verify/5 validates the nonce and signed proof, then atomically
admits its issuer/role/key-thumbprint/proof-ID digest into PostgreSQL. It uses the
database clock, checks expiry again after acquiring its shared admission lock,
and retains each marker until its nonce expires. Different signatures or a fresh
nonce cannot bypass a retained proof-ID marker. Resource proofs require both a
validated access token and its bound key thumbprint. The guard stores neither
proofs nor tokens, and does not establish account, client, consent, or scope
authorization.
Call this guard before the request's mutation transaction: admission commits independently so a later request rollback cannot restore a consumed proof. Calls inside an existing transaction are rejected. Storage is capped globally at 100,000 markers, with at most 1,000 expired markers reclaimed per successful admission. Capacity exhaustion and database failures reject admission, with no memory or Redis fallback. Queries use one-second lock and five-second statement timeouts. The serialized admission lock and table count bound this initial implementation's throughput; idle expired markers remain until new admissions reclaim them.
Tests cover nonce expiry and issuer/role separation, token binding, concurrent
admission through independent database transactions, rollback behavior, replay
with a fresh nonce, and capacity exhaustion/reclamation. PAR/token nonce challenges
and local resource-route policies are implemented. getSession and the other
resource integrations are described below.
OAuth client metadata foundation#
Atoll.OAuth.ClientMetadata.fetch/2 freshly retrieves a public HTTPS client-ID
document through the existing DNS-pinned resolver. It permits only public IP
destinations, preserves the original hostname for HTTP and TLS, refuses redirects
and compressed responses, and requires HTTP 200 with application/json. Response
bodies are capped at 64 KiB; duplicate JSON members and nesting beyond 16 levels
are rejected. DNS/connect timeouts are three seconds and the HTTP request timeout
is five seconds. There is no metadata cache or fallback to stale data. The
localhost virtual-client exception below synthesizes metadata without networking.
The returned document must exactly identify the requested client ID, declare
atproto, require DPoP, and declare the authorization-code flow. Refresh-token
grants are optional. Client IDs cannot contain credentials, fragments, or explicit
ports. URLs must use ASCII serialization (including punycode hostnames and
percent-encoded non-ASCII paths). Web callbacks require HTTPS; native callbacks
require the client's HTTPS origin or its reversed-domain custom scheme. Explicit
default HTTPS callback ports are rejected. redirect_allowed?/2 compares the
entire callback exactly, including any query, except for virtual localhost
clients where only the loopback port is ignored. scopes_allowed?/2 requires
atproto and checks that every requested scope is covered by the declaration.
Repository permissions may narrow collections/actions, blob permissions may
narrow accepted MIME patterns, and RPC permissions may narrow methods/audiences.
Coverage can combine declared scopes but never mix one RPC grant's method with
another grant's audience. Other scopes still require exact membership. This does
not grant permissions or replace consent and endpoint scope enforcement.
Local bounds are 32 distinct callbacks, 128 scope tokens in a 4 KiB scope string,
and 2 KiB URLs. The loader supports public none authentication and declarations
for confidential private_key_jwt clients using ES256. A confidential declaration
must identify exactly one inline or remote JWKS source; inline sets are limited
to 32 key objects. These declarations are not verified client authentication:
the ClientKeys loader below adds key validation and remote JWKS retrieval, while
the assertion guard below adds signature, replay, and supplied key-binding checks.
The code exchange below persists session bindings. Metadata branding is untrusted
and must not be displayed as verified application identity. Localhost virtual
clients and browser authorization are supported as described below.
The declaration rules follow the ATProto OAuth client profile.
Confidential-client verification keys#
Atoll.OAuth.ClientKeys.fetch/2 freshly fetches and validates client metadata,
requires private_key_jwt, and reads its inline JWKS or fetches the declared
HTTPS JWKS URL. Remote key documents use the same DNS pinning, public-address
checks, timeouts, 64 KiB limit, exact HTTP 200/JSON requirement, and duplicate-member
rejection as client metadata. Neither metadata nor keys are cached by this loader;
an unavailable or invalid response fails without returning previously seen keys.
Key sets contain 0–32 ES256/P-256 public keys with distinct, case-sensitive kid
values of 1–256 printable ASCII bytes. Coordinates must be canonical base64url
encodings of exactly 32 bytes, and OpenSSL validates the full elliptic-curve point.
Private/symmetric key fields, duplicate IDs, other algorithms/curves, incompatible
use/key_ops, and key-level remote references are rejected. Optional alg,
use, and key_ops must be ES256, sig, and ["verify"] when supplied. Only
the public curve and coordinates are passed to JOSE; unsupported keys invalidate
the set instead of being silently selected or ignored. An empty set represents
removal of all keys and cannot authenticate any assertion.
The result contains validated metadata and a map indexed by kid, with each
entry's public JOSE key, algorithm, and JWK thumbprint. Removal or replacement of
a key is visible on the next fetch, including replacement under an unchanged
kid. These are advertised verification keys, not proof of client authentication.
The assertion guard below verifies signatures, rejects replay, and can check an
original kid/alg/jkt binding. Code exchange persists that binding, and refresh
revokes its session when a freshly validated key set no longer contains that
key. The periodic worker below checks idle sessions as well.
Confidential-client JWT assertions#
Atoll.OAuth.ClientAssertions.authenticate/5 accepts a client ID, assertion type,
compact JWT, trusted authorization-server issuer, and options. The type must be
urn:ietf:params:oauth:client-assertion-type:jwt-bearer. It freshly fetches client
metadata and verification keys before starting its replay-admission transaction.
The internal verify/4 primitive performs only stateless checks against a
ClientKeys.fetch/2 result and must not be used alone to admit requests.
Assertions are limited to 8 KiB, use ES256, and select an advertised key by kid.
Both iss and sub must exactly match the client ID; aud must be the issuer
string or a one-element array containing it. Assertions require integer iat
and exp, with a maximum five-minute lifetime, at most thirty seconds of future
issue-time skew, and no tolerance past expiry. An optional integer nbf must
already have passed. jti and kid are 1–256 printable ASCII bytes. Duplicate
JSON members, noncanonical base64url, invalid signatures, alternate algorithms,
embedded/remote header keys, and unsupported critical extensions are rejected.
Optional typ must be JWT or jwt.
For an existing session, pass binding: %{kid: ..., alg: ..., jkt: ...} from its
stored original client authentication. All three values must match the selected
freshly advertised key. Missing keys, replacement under the same ID, and changing
the selected ID cannot satisfy that binding. Omitting the option is for initial
authentication only; an explicitly supplied nil/incomplete binding fails. This
does not itself create, look up, or revoke a session.
Admission uses PostgreSQL time and a dedicated shared transaction lock. It stores only a SHA-256 digest of issuer/client ID/assertion ID and the assertion's expiry. Different signatures or signing keys cannot reuse a retained assertion ID. Markers remain until expiry, with at most 1,000 expired markers reclaimed per successful admission and a global cap of 100,000 assertion markers. Capacity and database failures reject admission; SQL lock/statement timeouts are one/five seconds. There is no memory or Redis fallback. The shared lock/table count limit throughput, and idle expired markers remain until subsequent admissions reclaim them. This store is separate from DPoP proof admission.
Run authentication before the request mutation transaction so a later request rollback cannot restore a used assertion; nested calls fail. Concurrent submissions through independent connections admit once. Tests also cover malformed JWTs, key substitution, retained binding checks, expiry, capacity, and rollback behavior. The result authenticates client software only: account authorization, DPoP, PAR/PKCE, consent, OAuth sessions and token routes use the separate integrations described below. The assertion profile follows RFC 7523 and the ATProto confidential-client requirements.
PKCE and pushed authorization request storage#
Atoll.OAuth.PKCE validates canonical S256 challenges and compares a verifier's
SHA-256 challenge in constant time. Verifiers must contain 43–128 unreserved
ASCII characters. The test suite includes the RFC 7636 example. Only S256 is
accepted; plaintext challenges are rejected.
Atoll.OAuth.PAR.push/3 takes decoded parameters, the DPoP header list, and trusted
options. It validates the request, freshly fetches client metadata, authenticates
confidential clients through the assertion guard, and admits a DPoP proof for
POST <issuer>/oauth/par. Public clients must declare none; confidential
clients must supply their assertion and type. The issuer defaults to the public
endpoint URL, with trusted :issuer/:secret and transport options available
for integration/tests. No caller-supplied URL or forwarding header selects the
expected proof target.
Requests require response_type=code, state, an exactly registered callback,
declared scopes including atproto, and an S256 challenge. Optional login_hint
is preserved but is not account authentication. An optional dpop_jkt must match
the verified proof key. Scope admission accepts atproto, the three transitional
scopes and granular repo/blob/rpc/account/identity permissions; transition:chat.bsky requires
transition:generic. Other permission resource types and permission sets await
implementation. Unknown fields, client secrets,
verifiers, Request Objects, and supplied request URIs are rejected. Input is capped
at 16 KiB of decoded names/values; state and login hints are capped at 2 KiB.
The HTTP form adapter rejects duplicate fields before producing a map.
Successful admission atomically reserves the challenge for 24 hours and stores
validated parameters, issuer/client ID, DPoP thumbprint, and any confidential
client key binding. It returns a random 256-bit request_uri with expires_in: 90 (600 for prompt=create).
Only the reference's SHA-256 digest is stored, not its bearer value. Assertion and
DPoP JWT bytes are excluded from request storage; parameters include private state
and login hints, are redacted from struct inspection, and use queries without
parameter logging. Confidential key bindings in the database have JSON string
keys (kid, alg, jkt).
PAR.get/3 retrieves an unexpired request only for its original client and issuer.
This read does not consume the request, establish consent, or issue a grant. A
decision service below atomically consumes it during code issuance and preserves
its stored parameters and bindings. The browser flow is described below. Request expiry does not free its
challenge reservation, and another client of the same issuer cannot reuse that
challenge during the reservation period.
PostgreSQL time and a dedicated shared admission lock serialize reservations. Capacity is 10,000 stored pushed requests and 100,000 challenge markers globally; each successful admission reclaims at most 1,000 expired rows from each table. Full storage and database failures reject admission, with one-second lock and five-second statement timeouts. No memory/Redis fallback is used. The shared lock and table counts constrain throughput, and idle expired rows await later admission for cleanup. Failed storage commits reserve neither a request nor a challenge; already admitted assertions/DPoP proofs remain consumed, so retries need fresh proofs. Calls inside a caller transaction are rejected.
Tests cover both client types, key binding, client/issuer isolation, expiry, challenge reuse across clients, concurrent reservations, storage capacity, and bounded reclamation. The HTTP adapter below exposes PAR admission. Browser authorization and consent remain unfinished; code exchange and its HTTP adapter are described below. Protocol references: PKCE (RFC 7636) and PAR (RFC 9126).
OAuth server discovery#
GET /.well-known/oauth-protected-resource identifies this PDS and its colocated
authorization server. GET /.well-known/oauth-authorization-server advertises the
implemented authorization, PAR, token and revocation endpoints, PKCE S256, ES256 DPoP and
client assertions, public/confidential clients, refresh grants, transitional
scopes and prompt=create. Explicit response_mode=query is accepted by PAR;
other response modes are rejected. The static scope list includes repo:* and
blob:*/*; parameterized RPC permissions are also supported for service-token
issuance. The scope list is not exhaustive. Identity and account scopes are also advertised. Parameterized include: permission sets are supported.
Both documents derive their URLs from Phoenix Endpoint's configured public URL,
never request or forwarding headers. Configure a canonical HTTPS origin without a
path prefix; non-default ports are allowed. A mismatched request hostname returns
404, and invalid origin configuration returns 503. HTTP localhost discovery is
available only with the existing development/test localhost_dids_enabled opt-in.
Behind a reverse proxy, preserve the public Host header and configure the Endpoint
URL with the external scheme and port.
Discovery requires no credentials or cookies. GET returns JSON; HEAD returns the
same headers without a body. Both use a five-minute public cache lifetime and
Access-Control-Allow-Origin: *. OPTIONS
supports browser preflights for GET/HEAD and Accept, Accept-Language, Content-Type
headers. Query strings and encoded path aliases are rejected; unsupported methods
return 405 before request-body parsing. Error responses are not cached.
Metadata describes capabilities even when account signup is disabled or secrets are unconfigured. PAR/token/revocation still require the configured OAuth nonce secret, and account login requires the session secrets. Legacy Bearer sessions remain supported, so resource metadata does not claim every access token is DPoP-bound. No unimplemented registration, introspection, userinfo or JWKS endpoint is advertised. Discovery follows the ATProto server metadata profile. HTTP tests follow discovery through PAR, password login, consent, code exchange, a DPoP resource read, and source-session logout/revocation. They also cover origin configuration, hostname isolation, CORS, HEAD and early method validation.
PAR HTTP adapter#
POST /oauth/par accepts application/x-www-form-urlencoded with UTF-8 encoding
and returns HTTP 201 with request_uri and expires_in after successful admission.
Configure ATOLL_OAUTH_NONCE_SECRET as described above; without it this route
returns HTTP 503 temporarily_unavailable. Discovery, browser consent, granular
permissions and local resource policies are available. Full-profile interoperability
auditing remains pending, so full OAuth support remains unchecked.
The boundary runs before general body parsing, method rewriting, and Phoenix
controller parameter logging. Forms are flat, limited to 13 fields and 48 KiB of
encoded bytes, with a five-second body read timeout and the internal 16 KiB
decoded-parameter cap. Duplicate names after percent decoding, invalid percent
escapes/UTF-8, nested fields, query parameters, compressed bodies, and header-based
client authentication are rejected. Only the canonical /oauth/par path is
accepted. Request methods other than POST and CORS OPTIONS receive 405.
All configured responses carry a fresh DPoP-Nonce, Cache-Control: no-store,
and public-origin CORS headers without cookie credentials. A missing, expired, or
invalid server nonce in a supplied proof returns HTTP 400 use_dpop_nonce before
fetching metadata or consuming a confidential assertion. Missing/duplicate DPoP
headers, invalid signatures, and replay return invalid_dpop_proof. Retry nonce
challenges with a newly signed proof using the response nonce. The full admission
guard still verifies signature and replay; the preliminary nonce check alone
does not authenticate the request.
The existing configured request limiter applies 20 attempts per peer IP per five
minutes before reading the form, including malformed requests and preflights.
Trusted proxy settings govern the peer address. Exhaustion returns 429 with
Retry-After; storage failures return 503. Proof URLs use the configured public
endpoint, never incoming Host or forwarding headers. CORS preflights allow POST
with content-type and dpop, and expose dpop-nonce/retry-after. OAuth errors
use the error field without reflecting assertion data or internal error details.
Phoenix parameter filtering also covers assertions, verifiers, request references,
state, and login hints for subsequent OAuth routes.
Account approval and pending authorization codes#
Atoll.OAuth.AuthorizationCodes.decide/5 takes a full account access token,
client ID, pushed-request URI, explicit :deny or {:approve, granted_scope}
decision, and trusted options. It requires a live full-password session for an
active account; app-password sessions, deactivated accounts, revoked sessions,
and pending signups cannot approve. The future browser adapter must establish
CSRF-protected, explicit user consent before invoking it. Login hints and client
assertions never stand in for account authorization.
Approval may narrow the originally requested scope but cannot add scopes or
omit atproto; transitional chat scope still requires generic scope. Client
metadata and confidential keys are refreshed before committing approval. The
original callback and granted scopes must still be declared, public clients must
remain public, and confidential clients must retain the same kid/alg/jkt.
Denial requires account authorization but no client network lookup and returns
access_denied for the original callback/state/issuer.
Account and session locks are acquired before the shared PAR lock. After network retrieval and again after waiting for the PAR lock, authorization is rechecked; the request must still be unexpired and match the reviewed snapshot. Approval atomically deletes the request and inserts a random 256-bit code's SHA-256 digest. Denial deletes it without issuing a code. Concurrent decisions can consume it only once. Errors, including code-capacity exhaustion, leave the request available while its original lifetime permits. Its 24-hour PKCE reservation is retained.
Pending codes expire after two minutes and retain the account DID, issuer, client ID, exact redirect, granted scope, PKCE challenge, DPoP key, any confidential client key binding, and whether the refreshed client declaration allows refresh tokens. Plaintext codes are returned only to the caller; they are not stored. The response also returns the original callback, state, and issuer for the browser redirect adapter. Foreign keys remove pending codes when the account or authorizing password session is deleted, including session revocation/recovery.
Code storage is capped at 10,000 rows globally under the PAR lock, reclaiming at most 1,000 expired rows per successful approval. SQL lock/statement timeouts are one/five seconds; database errors fail closed. Nested caller transactions are rejected so client metadata retrieval never occurs inside the commit transaction. Tests cover scope narrowing, account/session restrictions, changed client policy and keys, revocation during metadata retrieval, expiry, capacity rollback, and concurrent decisions through independent database connections.
This service does not render login/consent. The browser adapter renders those screens, and the internal exchange below redeems the approved codes.
Authorization-code exchange and opaque sessions#
Atoll.OAuth.CodeExchange.exchange/3 accepts decoded token parameters, DPoP
headers, and trusted server options. It checks the exact client, issuer, redirect,
S256 verifier, original DPoP key, and fresh client metadata before issuing tokens.
Confidential clients must prove their original client key with a fresh assertion.
The account and authorizing full-access password session are checked again under
database locks after network verification. Nested caller transactions are rejected.
Issuance atomically creates an oauth_sessions row and an oauth_access_tokens
row, then records the code's redemption. Access and optional refresh tokens contain
32 random bytes with atoll_access_ and atoll_refresh_ prefixes; only SHA-256
digests are stored. Responses include token_type: DPoP, expires_in, the approved
scope, and the account DID as sub. Raw tokens are returned only to the caller.
Access tokens last up to five minutes. Sessions with refresh tokens last up to 14 days for public clients and 180 days for confidential clients; access-only sessions last five minutes. All lifetimes are also capped by the authorizing password session's expiry. Deleting that source session (including logout or recovery paths that delete it) cascades to derived OAuth sessions and tokens. This coupling is Atoll's current policy, not an independent browser-login session.
A subsequent exchange with valid client, PKCE, and DPoP bindings returns
invalid_grant and commits deletion of the original OAuth session and its tokens.
Incorrect bindings cannot revoke a session. Redemption markers survive the code's
original expiry until the issued session expires, including during approval
cleanup. Concurrent independent exchanges issue once, then revoke on verified
reuse. These checks implement the ATProto code-reuse rule.
The shared PAR lock serializes issuance and replay revocation. Storage permits 10,000 OAuth sessions globally and 100 live sessions per account, reclaiming at most 1,000 expired sessions per issuance. SQL lock and statement timeouts are one and five seconds. Database failures reject exchange; already consumed assertions and DPoP proofs require fresh proofs on retry. Retained redemption markers count against the separate 10,000-code cap.
Tests cover digest-only storage, binding failures, source-session revocation,
expiry, client metadata/key changes, access-only clients, capacity rollback,
marker retention, and concurrent redemption. The HTTP adapter below exposes this
service. Refresh rotation and DPoP getSession are described below. Browser
consent, discovery and local resource scope policies are implemented. The periodic
key checker is described below; full-profile interoperability auditing remains pending.
Token HTTP adapter#
POST /oauth/token accepts UTF-8 application/x-www-form-urlencoded requests
with grant_type=authorization_code, client_id, code, redirect_uri, and
code_verifier. Confidential clients additionally send client_assertion_type
and client_assertion. Supply a fresh DPoP proof targeting the configured
endpoint URL, using an authorization-server nonce; HTTP 400 use_dpop_nonce
provides a fresh DPoP-Nonce for retry before client metadata retrieval or
assertion consumption. Success returns HTTP 200 with the token response above.
PAR, token and revocation routes share AtollWeb.OAuthRequestPlug, ahead of general body
parsing, method overrides, and controller logging. The token route has a separate
20-request/five-minute peer budget, including malformed requests and preflight.
It inherits the 48 KiB encoded body limit, five-second body-read timeout, strict
flat/duplicate-free form decoding, canonical route checks, and trusted issuer
configuration. Code exchange further limits decoded fields to seven and their
combined size to 16 KiB. Query parameters, compressed bodies, unsupported media
types, unknown fields, and method overrides are rejected.
Configured responses include a fresh nonce, Cache-Control: no-store, and
Pragma: no-cache. CORS allows any origin without credentials, exposes the nonce
and retry headers, and permits only POST with content-type and dpop.
Unsupported methods return 405; unsupported grant types return HTTP 400
unsupported_grant_type. Invalid code bindings and verified reuse return
invalid_grant; malformed/replayed proofs return invalid_dpop_proof.
Authorization-header client credentials are unsupported and return
invalid_client, with HTTP 401 and a matching challenge for a valid scheme.
Client assertions belong in the form body.
Rate exhaustion returns 429; unavailable configuration or storage returns 503. HTTP tests cover issuance, nonce retry, code-reuse revocation, proof replay, malformed forms, CORS, peer limits, and configured-host binding. Response and error shapes follow RFC 6749 sections 5.1–5.2. Discovery and the public-client SDK flow are covered above; complete OAuth profile interoperability auditing remains pending. The refresh grant is described below.
Refresh rotation and token-family revocation#
POST /oauth/token also accepts grant_type=refresh_token, client_id, and
refresh_token, plus an optional scope and the confidential-client assertion
fields. Atoll.OAuth.Refresh.exchange/3 implements this grant. The route uses the
same nonce challenges, parsing, CORS, and peer budget as code exchange. Refresh
requires a new DPoP proof from the session's original key and freshly retrieved
client metadata; confidential clients must authenticate using the original
kid/alg/jkt. Proof and assertion replay admission commits before rotation.
Rotation locks and rechecks the active account, source password session, and
OAuth session. It atomically replaces the refresh digest, issues a five-minute
access token, and retains the consumed digest in oauth_refresh_uses until the
session expires. It never extends the original session lifetime. Source-session
expiry also caps new access tokens. Deleting the OAuth or source session removes
its access tokens and consumed-refresh markers through foreign keys.
Reusing any consumed refresh token with valid bindings revokes the session and
all its tokens, returning invalid_grant after committing the deletion. There
is no retry grace period: clients must serialize refresh calls, and losing a
successful response can require login again. Concurrent independent requests
produce one rotation followed by revocation. Wrong client IDs, wrong DPoP keys,
and invalid assertions cannot revoke an otherwise valid session.
A narrowed scope must be a subset of the original grant, retain atproto, and
respect the transitional chat dependency. It applies only to the new access
token, whose scope is stored explicitly; the replacement refresh token retains
the original grant. Omitting scope uses the original grant. The migration
backfills existing access-token scopes from their sessions before enforcing a
non-null column. Resource authorization must enforce the access token's scope;
getSession, repository record writes, and blob uploads now do so; other
resource integrations remain pending.
On refresh, a valid current confidential key set with the bound key removed or replaced causes permanent session revocation, including an empty inline or remote JWKS. Metadata/network validation failures reject refresh without treating a failed lookup as evidence of removal. Refresh checks the requesting session; the periodic checker below also checks idle sessions.
The existing shared PAR lock serializes rotation with code exchange and reuse
revocation. There is a global cap of 100,000 consumed refresh markers and a cap
of 100 live access tokens per session. Rotation reclaims at most 1,000 globally
expired markers and the current session's expired access tokens before admission.
Capacity failures roll back token replacement and cleanup; the HTTP response is
503 temporarily_unavailable. Existing one-second lock and five-second statement
timeouts apply. PostgreSQL stores the authoritative state with no memory or Redis
fallback. Idle expired state is reclaimed by subsequent admissions, not a timer.
Tests exercise real code issuance followed by rotation, per-access scope storage, replay revocation, independent concurrent connections, source-session deletion and expiry, metadata-time revocation, key replacement/removal, transient lookup failure, storage capacity, bounded cleanup, and the HTTP refresh response. The behavior follows the ATProto token/session profile and OAuth refresh semantics.
DPoP resource reads and session identity#
GET /xrpc/com.atproto.server.getSession accepts Authorization: DPoP <access_token>
and a DPoP proof containing ath for that exact token, htm: GET, and htu for
the configured origin plus request path. It requires a resource-server nonce,
which is distinct from the authorization-server nonce used at /oauth/token.
An old or wrong-role nonce produces HTTP 401 with a fresh DPoP-Nonce and
WWW-Authenticate: DPoP error="use_dpop_nonce", algs="ES256". Retry with a new
proof containing the supplied nonce.
Atoll.OAuth.Resource.read/5 validates the opaque access-token digest, issuer,
proof/key binding, token hash, and shared replay state. Proof admission commits
before the read transaction. It then takes shared row locks on the account,
source password session, OAuth session, and access token, checks their current
expiry/status and scope, and invokes a trusted read callback while holding those
locks. The database clock is read after lock acquisition. A rollback in the
callback cannot restore an admitted proof. Callers may require additional scopes;
atproto and membership in the session's original grant are always required.
This callback is for database reads only, not network requests or writes.
The session response contains DID, handle, and active status. transition:email
or account:email (read or manage) in the access token adds email and confirmation status; a broader OAuth
session cannot restore email access to a narrowed token. OAuth responses omit
emailAuthFactor and never contain password-session JWTs. Inactive accounts,
expired/revoked tokens or source sessions, and unknown issuers cannot read through
this guard. Email management and repository import require explicit account
permissions as described below; other routes retain their authentication requirements.
The resource header plug runs before XRPC rate/method/query guards, so recognized
DPoP attempts receive fresh resource nonces even on early errors. CORS permits
the dpop request header and exposes dpop-nonce and www-authenticate, with
no credentials. Configuration/storage failures return 503, proof/token failures
return 401, and insufficient scopes return 403. Opaque OAuth tokens presented as
Bearer credentials are rejected. Legacy password-JWT getSession continues to
use its existing authentication and response behavior.
HTTP tests cover token issuance through session reads, email scope narrowing, nonce roles, method/target/key/hash checks, proof replay, expiry/revocation, account status, Bearer downgrade rejection, account-management denial, CORS, rate-limit errors, configured-origin binding, and rollback persistence. An independent-connection test verifies all four row locks remain held through the read and that later reads fail after session deletion. Resource challenges follow RFC 9449 sections 7 and 9. Repository writes, blob uploads, service authorization, exports and account inventory are integrated as described below. Other resource routes and other fine-grained permissions still need endpoint-specific OAuth authorization.
Periodic confidential-client key checks#
Atoll.OAuth.KeyCheckWorker starts automatically outside the test environment.
It waits one minute after startup, then visits live confidential OAuth sessions
in (client_id, session_id) order. Configure it with:
export ATOLL_OAUTH_KEY_CHECKS_ENABLED=true
export ATOLL_OAUTH_KEY_CHECKS_INTERVAL_SECONDS=300
The interval accepts 30–3,600 seconds and is the delay after a complete sweep,
not a deadline for every session. One supervised task processes at most 100
sessions for one client, with one second between batches. Each task has a
20-second deadline; a slow or unavailable client cannot block all later clients.
The cursor is kept in memory and resets after each sweep or worker restart.
Session insertions before the current cursor are checked on the next sweep.
Set ATOLL_OAUTH_KEY_CHECKS_ENABLED=false to disable scheduling; refresh-time key
checks still run. Tests disable the application worker and start isolated workers.
Atoll.OAuth.KeyChecks.run(cursor, options) captures a bounded session snapshot,
fetches fresh validated metadata and inline/remote JWKS, and revokes sessions
whose original kid/alg/jkt is absent or replaced. An empty valid key set
revokes all snapshotted sessions for that client. Invalid documents and network
failures preserve the sessions and advance the cursor; they are retried on the
next sweep. No client credentials or account tokens are sent to metadata URLs.
Before deletion, the checker locks affected accounts and source password sessions, then takes the shared OAuth mutation advisory lock and locks current OAuth rows. Only unchanged snapshotted bindings are revoked. New grants or changed bindings created during the fetch are not deleted based on the older observation; refresh token rotation alone does not hide a removed key. Deletion cascades to access tokens and refresh-reuse markers. Database failures roll back the batch, with one-second lock and five-second statement timeouts. Metadata retrieval occurs outside the transaction; nested caller transactions are rejected.
The worker advances past a selected batch even after a task timeout or crash,
and retries it on the next sweep. Failure before batch selection retries from
the same cursor after the interval. Repeated run requests do not overlap tasks;
shutdown cancels the active task. Telemetry [:atoll, :oauth, :key_checks] reports
runs, available checked/revoked/failed counts, and an outcome, without client
IDs, account identifiers, or credentials. Timeout outcomes cannot assert how many
rows committed before task termination.
Multiple application instances may run redundant fetches; database locks make revocation safe, but there is no distributed scheduling lease. For one sweeper per deployment, disable it on other instances. Sweep duration grows with the number and latency of clients; this is not a five-minute revocation SLA. Changes at a client's metadata server cannot be atomic with Atoll's database: decisions use the fetched snapshot, and subsequent sweeps observe later changes.
Tests cover retained/removed/replaced keys, cascades, fetch failures, pagination, public/expired sessions, metadata-time binding changes and deletion, refresh rotation during fetch, one-task scheduling, timeouts, shutdown, and cursor reset. This supplies the periodic retrieval required by the ATProto confidential-client profile.
Localhost OAuth clients#
Atoll accepts the virtual client IDs http://localhost and http://localhost/,
optionally with a query. Atoll.OAuth.LocalhostClient generates a public native
client declaration with none client authentication, mandatory DPoP, authorization
code and refresh grants, and default scope atproto. No DNS or HTTP request is
made to fetch this metadata, including on approval, token exchange, or refresh.
It is supported in production as well as development and is independent of the
did:web:localhost resolver setting.
The client ID must have the literal authority localhost, no explicit port
(including :80), no credentials or fragment, and an absent or root path. Optional
query parameters are one scope and up to 32 distinct redirect_uri values.
For example:
http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto+transition%3Ageneric
That declaration permits a PAR callback such as
http://127.0.0.1:3000/callback. Without a declared callback, the defaults are
http://127.0.0.1/ and http://[::1]/. Matching ignores only the port; host, path,
and query must match. Empty URL paths match /. Callback hosts must be literal
127.0.0.1 or [::1], not localhost, aliases, other loopback representations,
or public/private network addresses. Callback ports must be 1–65,535. Credentials,
fragments, backslashes, malformed percent encodings, and dot path segments are
rejected. Unknown query fields, duplicate scope fields (including encoded names),
invalid scopes, invalid UTF-8, and URLs over 2 KiB are rejected as well.
The full original client ID remains the identifier: changing its query or adding a slash does not preserve an existing grant's client binding. After PAR accepts a callback, code exchange must present that exact callback including its port; registration-time port flexibility cannot redirect an already issued code. Ordinary HTTPS client metadata retains exact callback matching, and client metadata extensions cannot opt it into localhost rules. Localhost clients cannot authenticate with confidential-client assertions.
Tests cover safe defaults, repeated callbacks, scope parsing, ambiguous IDs and
queries, loopback restrictions, and a flow through HTTP PAR, internal account
approval, HTTP code exchange, refresh, and DPoP getSession with all metadata
network access prohibited. Browser login/consent is described below. The behavior
implements the ATProto localhost client profile.
DPoP repository record writes#
com.atproto.repo.createRecord, putRecord, deleteRecord, and applyWrites
accept DPoP access tokens with atproto and either transition:generic or
applicable granular repository permissions. A token narrowed to
atproto or email access cannot borrow the broader session grant. These routes
require the canonical XRPC path and a new POST proof bound to the configured
origin, resource nonce, access-token hash, and original DPoP key. Legacy password
and app-password JWT writes retain their existing behavior.
After the existing peer rate limit, RecordWritePlug admits the proof and checks
current authorization before parsing the bounded JSON body. It creates a
signed internal WriteCredential containing the access-token digest, a fingerprint
of the session binding, the operation, request-process identity, and a 30-second
expiry. The credential is never sent to clients and is redacted when inspected.
Its HMAC key is derived with a separate purpose from ATOLL_OAUTH_NONCE_SECRET;
changing that secret invalidates outstanding credentials. It cannot be used in
another process or for another operation, and HTTP token parsers do not accept it.
The write service uses the same ownership checks, handle resolution, schema
preparation, record validation, and swap constraints for both authentication
schemes. After network schema lookup, it takes the existing event-sequencing lock
and repository write lock, then rechecks the credential and current account,
source session, OAuth session, access expiry, and access-token scope under row
locks. These locks remain held through record/blob-reference changes, signed
commit creation, and event publication. Revocation or scope changes during schema
lookup therefore prevent mutation. All operations in applyWrites retain their
existing transaction atomicity.
Proof admission stays committed when parsing, validation, swaps, or the write transaction fail. Retries need fresh proofs. Work that outlives the internal credential also fails closed and must be retried; the credential is not a cache of permission that can survive session revocation. Authorization failures use DPoP challenges with 401/403, while existing record and validation errors retain their XRPC responses. Fresh resource nonces and CORS headers remain available.
Tests cover all four methods, foreign-repository denial, schema and swap failures, batch rollback, proof replay after failures, body limits, session revocation and scope narrowing during schema lookup, and rejection of altered, expired, wrong-operation, or foreign-process internal credentials. Granular repository permissions are described next; other resource integrations remain tracked above.
Granular repository permissions#
Clients can request repo:com.example.post for create/update/delete access to one
collection, or restrict actions with
repo:com.example.post?action=create&action=delete. repo:* grants all collections;
repo:*?action=delete grants deletion only. Repeated collection query parameters
support several collections in one scope, for example
repo?collection=com.example.post&collection=com.example.profile&action=create.
Partial collection wildcards are rejected. Positional values and query parameters
support percent encoding; a positional collection cannot also appear in the query.
Unknown parameters, malformed encoding, invalid NSIDs/actions and duplicate action
values are rejected. Each scope is bounded by the existing 4 KiB/128-scope budget;
a single permission has at most 128 query parameters.
Client metadata declarations, consent grants and refresh requests use semantic coverage for repository permissions. A broad collection/action grant can cover a narrower request, and grants can combine their action coverage. Narrow collections cannot cover a wildcard and omitted actions mean all three operations. This does not allow a repository scope to become transitional generic access. Other scope types retain exact membership checks.
The consent page names the collections and operations and lets the user uncheck individual requested permissions. Checked fields refer to stored request scopes; clients cannot submit new scope text in the form. Consent accepts up to 131 flat fields within its existing 8 KiB body limit, accommodating all bounded scope choices and CSRF/context fields. Other OAuth forms retain their 13-field limit.
Record requests must have a relevant permission before body parsing. After parsing,
each collection/action is checked before network schema lookup and again under
repository and authorization locks before mutation. createRecord requires
create, deleteRecord requires delete, and putRecord requires both create and
update even if a record already exists. Each applyWrites operation requires its
own action; one denied operation rejects the entire batch. Repository permissions
do not authorize blob uploads, email access or service-token issuance.
HTTP tests cover issuance through PAR/code exchange, browser selection of more than
thirteen permissions, refresh narrowing, collection/action denial, atomic batch
rejection and scope changes during schema lookup. Parsing and coverage tests include
wildcards, multiple collections, combined grants and rejected encodings. The syntax
follows the repository permission specification;
putRecord follows the reference PDS requirement for both create and update.
Granular blob, RPC, account and identity permissions are described below.
include: permission sets are resolved and snapshotted as described below.
OAuth permission sets#
Atoll.OAuth.PermissionSets.resolve/2 resolves an include:<nsid> invocation through
the existing authenticated Lexicon fetcher: DNS namespace delegation, fresh DID/PDS
resolution, record CID verification and a signed CAR inclusion proof under the
resolved repository key. HTTPS public-address checks and fetch size/time bounds
are inherited from that fetcher. Inclusion authenticates the record under that
key; it does not prove that the signed commit is the latest. No network resolution
runs inside a database transaction.
Atoll.OAuth.PermissionSet validates bounded permission-set documents and
expands only understood repository/RPC declarations. Every referenced collection
or method must be in the set's NSID group or a child group. Wildcard resources,
sibling/parent namespaces, fixed RPC audiences, unsupported resources and unknown
permission fields grant nothing. A mixed valid/invalid declaration is ignored in
full. RPC audiences can be * in the document or inherited from an explicit DID
service reference on the include invocation. Missing inherited audiences grant
no permission. Titles, details and localized text are preserved and bounded.
Migration 20260927005341 stores at most 1,000 cached documents, each limited to
256 KiB encoded JSON and 256 declarations (128 resource names per declaration).
Fresh entries avoid resolution for 24 hours. Failed refreshes retain the last
verified document and back off for five minutes without renewing its original
age. New-session lookup expires at 90 days; callers resolving an existing session
may use older cached data. Expired entries can be reclaimed when adding a new
entry. Cache writes serialize briefly and do not overwrite a concurrent update.
include:<nsid> and include?nsid=<nsid> scopes are accepted at PAR, with an
optional concrete DID service aud parameter. Client metadata must declare the
include. Alternate string encodings are equivalent; narrowing may remove an
inherited audience but cannot replace it, add one to an audience-free grant, or
switch namespaces. Direct repository/RPC scopes cannot be manufactured from an
include by changing the refresh request's scope string.
Migration 20260927010301 adds persistent permission-set snapshots to pushed
requests, authorization codes, OAuth sessions and access tokens. PAR verifies and
admits DPoP before resolving any sets. Resolution failures do not persist a pushed
request or consume PKCE, but the admitted proof remains consumed. Unavailable sets
or cache/storage failures return a retryable HTTP 503. Requests are bounded to
16 include invocations, 1 MiB of snapshot JSON, and 256 KiB of expanded permission
strings. A 30-second total resolution budget is checked between fetches and after
resolution; individual network calls retain the existing transport timeouts.
Consent uses the PAR snapshot, with a checkbox for each include and expandable
permission details. Titles and details support bounded Accept-Language
preferences with regional fallback; all schema-provided text is HTML-escaped.
The screen explains that sets may change over time within their namespace. Only
selected includes are copied into the authorization code. Code exchange copies
those same documents into the session and initial token, even if the shared cache
changes between display, approval and exchange.
Resource authorization expands only the access token's own stored snapshot, under the existing authorization locks, and performs no set resolution or shared cache reads. Current raw token scope must remain covered by the session's original grant. All repository, blob, RPC, account and identity checks continue to apply to the resulting permissions; a set may grant only its validated repository/RPC permissions. Changing or evicting the cache cannot change an issued token.
Refresh resolves selected includes outside database locks, falling back to the
session's last verified snapshot during outages or cache eviction. It rechecks
account, source-session, grant, client and refresh-token state before atomically
rotating and storing the new snapshot. Existing access tokens retain their prior
permissions. Narrowed refreshes retain fallback documents for the original grant
without including deselected sets in the new access token. Reuse of an old refresh
token still revokes the session after snapshot changes. This implements the
permission-set token semantics.
Resolution uses the existing :lexicon_resolution_options; the network-record
validation opt-in does not disable explicit OAuth permission-set resolution.
OAuth identity permissions#
identity:handle authorizes com.atproto.identity.updateHandle; identity:*
also authorizes requestPlcOperationSignature, signPlcOperation and
submitPlcOperation. The full grant can be narrowed to handle-only access through
client metadata, consent and refresh. Scalar attr query syntax is accepted;
unknown attributes, extra parameters and repeated attributes are rejected.
Transitional generic access does not grant these operations. Consent distinguishes
handle changes from control over DID keys and migration.
DPoP proof admission precedes body parsing. Each operation receives an endpoint- and process-bound internal credential, and current authorization is checked again inside the existing mutation transactions. Directory and handle-resolution calls run outside those transactions. A token narrowed or revoked during lookup cannot authorize subsequent staging, signing or local completion. Already submitted external operations cannot be rolled back by local revocation; the durable PLC journal remains available for reconciliation and an authorized retry.
Existing identity safeguards still apply: bidirectional custom-handle resolution,
name reservations, verified directory history and head, local signing-key/service
compatibility, and atomic profile/identity-event updates. did:web handle changes
require the owner-updated DID document. PLC signing still requires a confirmed
email and a single-use code delivered by the configured Cloudflare Worker; it
returns a signature without publishing or changing local identity. OAuth requires
an active account, while legacy migration sessions retain their existing policy.
The 30-second credential expiry bounds each request; a retry requires a new proof.
The scope semantics follow the identity permission specification.
GET /xrpc/com.atproto.identity.getRecommendedDidCredentials accepts base atproto
OAuth scope and returns only the authenticated account's public repository signing
key, PDS service and current observed handle (falling back to the profile handle
when no observation exists). Unknown or unverified handles are omitted. It checks
key custody and holds current authorization/account locks while reading; it never
returns private keys, email or password-session credentials. Custody failures
retain their existing domain error responses. PLC rotation-key recommendations
are not currently included.
refreshIdentity also accepts base atproto scope because it only re-resolves and
publishes public identity observations; it cannot change DID keys or account
hosting. The existing owner-only, bounded-resolution and event-deduplication rules
apply. Both endpoints preserve their password-session paths, including the
existing deactivated-account migration policy.
OAuth account permissions#
account:email (or explicit action=read) exposes email and confirmation status
in getSession. account:email?action=manage includes read access and authorizes
requestEmailUpdate, updateEmail, requestEmailConfirmation, and confirmEmail.
Existing confirmation codes, expiry, cooldowns and address checks still apply;
all email delivery uses the configured Cloudflare Worker. OAuth responses omit
emailAuthFactor. Changing an address clears its confirmation and email factor.
account:repo?action=manage authorizes signed CAR import through importRepo.
account:repo alone adds no capability. Neither permission grants record writes
or blob uploads. transition:generic and repo:* do not grant account management.
The parser also accepts scalar attr query parameters, rejects wildcards and
unknown attributes, and permits refresh narrowing from manage to read.
Both paths admit DPoP before reading request bodies and recheck current token, grant and source-session authorization before mutation. Internal credentials are bound to the process and endpoint; imports allow 300 seconds for validation, while other write credentials expire after 30 seconds. OAuth requires an active account; legacy migration authentication retains its existing inactive-account policy. CAR ownership, signature, captured-head and atomic import checks still apply. The semantics follow the account permission specification.
DPoP blob uploads#
POST /xrpc/com.atproto.repo.uploadBlob accepts DPoP-bound OAuth access tokens
with atproto and either transition:generic or applicable granular blob scopes.
Proof admission and declared MIME permission checks run before reading the raw
request body. An admitted proof remains consumed when size, metadata, quota, or
storage checks fail. Missing upload permission returns the OAuth
insufficient_scope error with HTTP 403; invalid or revoked access returns 401.
Uploads use the same signed, process-bound, 30-second internal credential as
record writes, bound specifically to uploadBlob. After reading the body, the
storage path rechecks the credential and then rechecks current authorization
under the repository write lock, holding account/session/access locks through
storage commit. Revocation, scope narrowing, and inactive accounts block uploads.
Record-write credentials cannot authorize blob uploads or vice versa.
The existing raw-body size/time limits, MIME detection, per-account quotas, staged visibility, and PostgreSQL/S3 storage behavior apply to OAuth uploads. Tests cover raw bytes, publication through an OAuth record write, pre-body proof validation, failure replay, revocation and scope changes between the upload plug and controller, quotas, and mocked S3 success/failure. These additions do not change the opt-in MinIO integration tests.
Granular blob permissions#
Request blob:image/* to upload images, blob:image/png for PNG only, or
blob:*/* for all media types. Repeated accept parameters allow a union, such as
blob?accept=image/png&accept=text/plain. MIME patterns support a full subtype
wildcard or */*; suffix globs, parameters and unknown permission fields are
rejected. Positional/query percent encoding follows repository permissions; a
literal + in a query value must be encoded as %2B. MIME matching is
case-insensitive, while resource names remain case-sensitive.
Client declarations and granted scopes can cover narrower MIME permissions in PAR, consent and refresh. Specific types cannot expand into a subtype wildcard, and image permissions cannot expand to all media. The consent page lists the accepted media types and allows each requested scope to be unchecked. Blob scopes do not authorize record writes or other protected resources.
The upload plug checks the normalized declared MIME type before body admission;
a missing Content-Type retains the application/octet-stream default. Under the
storage transaction's repository and authorization locks, Atoll checks current
scope against the declaration, the detected MIME type, and any existing ownership
row's MIME type. The first stored MIME for an account/CID stays authoritative.
A spoofed declaration or duplicate upload therefore cannot refresh staged metadata
or return a descriptor outside the granted types. These checks precede PostgreSQL
byte writes and S3 requests. Scope changes after body admission return HTTP 403;
retries require a new DPoP proof.
Tests cover consent selection, semantic refresh narrowing, accepted/rejected MIME patterns, declared/detected mismatches, duplicate stored metadata, early rejection and proof replay, and scope changes before storage. A mocked S3 transport verifies that a denied MIME upload makes no object-storage request. Signature detection still does not decode complete media files; full media validation remains pending. Scope syntax follows the blob permission specification.
DPoP service-token issuance#
GET /xrpc/com.atproto.server.getServiceAuth accepts DPoP OAuth access tokens with
atproto and either transition:generic or a matching granular RPC permission.
It verifies the resource nonce, original client key,
access-token hash, configured origin and GET proof before examining delegation
parameters. Account, source-session, OAuth-session, and access-token share locks
remain held while checking the requested method, loading the repository key, and
signing. Expired, revoked, or inactive-account access cannot issue a token.
The access token's current scope controls delegation, even if its session has a
broader grant. With transitional grants, explicit chat.bsky.* methods additionally
require transition:chat.bsky; checks include case variants. A matching granular
RPC grant can authorize the requested chat method directly. Protected account-management
methods remain prohibited, and transitional OAuth cannot delegate createAccount
for migration. Insufficient permission returns HTTP 403 insufficient_scope with
a DPoP challenge. Invalid parameters, expiration, and unavailable signing custody
retain the existing XRPC errors. Proofs remain consumed after these failures.
As in the reference transitional permission implementation, generic scope permits a method-less service token, limited to 60 seconds. Explicit methods may use the existing maximum one-hour lifetime. Service tokens carry no OAuth scope or DPoP binding; receiving services must enforce the audience, method, expiry and their own policy. Revoking OAuth access prevents future issuance but does not revoke a JWT already issued. Granular RPC scopes follow the rules below.
Tests verify the issued JWT signature and claims, method-less lifetime, separate chat permission and narrowed access scopes, forbidden migration/protected methods, parameter errors, missing signing custody, proof replay, revocation, inactive accounts and target binding. Existing legacy service-token tests remain enabled.
Granular RPC permissions#
rpc:app.example.getFeed?aud=* permits one method on any service.
rpc:*?aud=did:web:api.example.com%23appview permits all methods for the exact
did:web:api.example.com#appview audience. Repeated lxm query parameters allow
multiple methods, for example
rpc?lxm=app.example.getFeed&lxm=app.example.getProfile&aud=*.
A concrete permission audience must include a nonempty DID service fragment.
Unknown parameters, duplicate audiences, partial method wildcards and the fully
unrestricted rpc:*?aud=* are rejected. Audience and method matching is exact;
a bare DID does not match a grant naming one of its service fragments.
PAR, consent and token refresh support narrowing either axis. Every requested
method/audience pair must be covered by a single original grant: separate grants
for service A/method X and service B/method Y do not authorize service A/method Y.
A finite list of methods cannot be widened to *. The consent page names the
methods and service audience, or explicitly says all methods/any service.
Repository, blob and email rights are not implied by RPC permissions.
Service-token issuance checks the current access token's RPC permissions under
authorization locks before loading the signing key. A method-less request needs
wildcard method permission for its audience and retains the 60-second limit.
Method-bound tokens retain the one-hour maximum. A matching granular grant can
authorize com.atproto.server.createAccount for migration; transitional generic
scope alone still cannot. Explicit protected-method requests remain prohibited
regardless of granted scopes. Already issued JWTs keep the existing revocation
and recipient-validation limitations described above.
Tests cover signed audience/method claims, mismatched audiences and fragments, method-less tokens, refresh attenuation, chat access without transitional grants, explicit migration authorization, protected-method denial and proof replay after permission failures. Browser consent tests exercise RPC selection alongside repository and MIME permissions. The policy follows the RPC permission specification and the reference RPC matcher. Request proxying enforces the same RPC policy and expands permission sets through the frozen authorization snapshots described above.
DPoP public exports#
All currently implemented local XRPC routes have an explicit OAuth policy in
AtollWeb.OAuthPolicyPlug. An inventory test checks the Phoenix routes and the
firehose upgrade route so newly added endpoints must be classified. Unknown
policies deny supplied OAuth credentials by default. Proxy requests are handled
separately with their service-specific RPC permissions.
Public repository reads, sync reads, server description, identity resolution and firehose upgrades remain available without OAuth. If an OAuth credential is supplied, its nonce, proof signature, target/method, token binding, replay status, active account and current session are checked before query/body processing or upgrade. Bad or revoked credentials cannot silently fall back to anonymous access. Successful admission grants only the ordinary public path; existing availability and takedown checks remain in force, without inactive-owner access. Identity resolution runs outside authorization locks. Public subscription admission does not make its public event stream private or bind the stream lifetime to a grant.
Routes with dedicated OAuth grants keep their existing endpoint-specific checks
and final authorization rechecks. App-password/invite management, account
activation/deactivation/deletion, legacy session creation/refresh/deletion,
password reset, signing-key reservation, account creation and operator endpoints
do not accept OAuth as a replacement for their own authentication mechanisms.
A valid supplied OAuth proof is consumed and receives HTTP 403
insufficient_scope before body parsing or side effects; invalid credentials
receive the normal nonce/proof/token errors. Anonymous, password, recovery-code,
service-JWT migration and operator flows retain their existing policies. OAuth
signup still uses the browser prompt=create flow.
This includes the reference PDS exclusions for invite access and deactivation. Full-profile interoperability auditing remains pending; route policy coverage does not establish complete OAuth/client compatibility.
com.atproto.sync.getRepo, listBlobs, and getBlob accept DPoP access tokens.
A supplied OAuth credential is validated against its live account, source session,
OAuth session and access token, including the resource nonce, proof key, access
hash, GET method and requested route. Invalid, expired or revoked credentials
return a DPoP error; an opaque OAuth token cannot be used as a Bearer credential.
The required atproto scope suffices because these responses contain public data.
After authentication, the export uses the same public visibility checks as an anonymous request: only active target repositories and currently referenced blobs are readable. A client may read another active public repository, but cannot use OAuth to export an inactive account or staged/unreferenced blobs. Legacy owner JWT and operator Basic authorization retain their separate export behavior.
OAuth authorization locks are released before the public export starts; no OAuth principal or privileged credential is passed into storage. Revocation after this check does not interrupt an already admitted public download. Repository snapshot locks and the existing streaming timeout still protect CAR consistency, and blob reads retain their repository/reference checks. The download does not hold a client session lock for its duration.
Malformed query schemas can be rejected before proof admission by the existing XRPC query boundary. Once admitted, a proof cannot be replayed, including after a missing-blob error. Tests cover identity-only scope, raw blob responses, CAR roots, publication visibility, foreign active repositories, inactive targets, revocation, replay, target binding and Bearer downgrade rejection. The legacy export tests continue to exercise owner/operator and anonymous access.
DPoP account inventory#
GET /xrpc/com.atproto.repo.listMissingBlobs and
GET /xrpc/com.atproto.server.checkAccountStatus accept OAuth access tokens with
the base atproto scope. The inventory always belongs to the authenticated DID;
query parameters cannot select another account. Both routes validate the DPoP
resource nonce, key, access-token hash, method and target and consume the proof
before inventory reads. Responses retain no-store, CORS and resource nonce headers.
The existing aggregate XRPC and session-query budgets apply.
Missing-blob results use the same account ownership, metadata matching, takedown exclusion, deduplication and exclusive CID pagination as legacy sessions. Account, source session, OAuth grant and access-token locks remain held through the query.
Account status first validates proof and current authorization, resolves the DID
with no authorization locks held, then reacquires all four locks and rechecks
account status, token/grant/source-session expiry, bindings and scopes before
reading inventory. Revocation or expiry during remote resolution prevents the
response; the already admitted proof stays consumed. DID resolution failure still
returns validDid: false, as with legacy sessions. Only active accounts can use
these OAuth routes; legacy migration/status access retains its existing policy.
Tests cover identity-only scopes, cross-account isolation, missing-blob pagination and metadata/takedown filtering, status counts, failed DID resolution, replay, nonce challenges, target binding, Bearer downgrade rejection and revocation. Expiry, account changes and scope changes during resolution are rechecked. An independent-connection test confirms locks are released during resolution and held through the final read. The supported OAuth inventory policy follows the reference PDS implementations of listMissingBlobs and checkAccountStatus.
Owner management of OAuth sessions#
Atoll.OAuth.SessionManagement.list/4 and revoke/3 provide the internal account
UI operations for OAuth grants. Both require a live full-account access JWT;
app-password sessions, refresh JWTs and opaque OAuth credentials cannot manage
grants. Deactivated owners retain access through the existing account-management
authorization policy. The browser routes and interface are described below.
Inventory is scoped to the authenticated owner, excludes expired OAuth grants or expired source sessions, and returns at most 100 entries (default 50). Results are ordered by opaque session ID with an exclusive cursor. Each entry includes only its management ID, client ID, granted scope, expiry, and whether it has a refresh token. No token digests, source-session identifiers, proof keys or client key bindings are exposed. Client metadata is not fetched; client IDs are untrusted data for the future UI to escape when displaying. Pages reflect current state, not a snapshot across requests; new random IDs can sort before a previous cursor.
Revocation reauthenticates the owner inside a transaction, follows the existing
head/source-session/PAR/session lock order, and deletes only the selected owned
grant. Foreign and absent IDs return the same success result. Database cascades
remove access tokens and used refresh markers; the parent password session and
other OAuth grants survive. Expired grants can also be revoked. Lock and database
failures return oauth_session_store_unavailable. Statements and lock acquisition
are bounded by the existing OAuth timeout policy.
Tests cover pagination and field minimization, owner isolation, idempotence, source expiry, management credential restrictions, deactivated accounts, and cascade deletion. A full token-exchange integration test confirms that owner revocation blocks subsequent resource reads and refreshes while preserving the owner's password session. Previously issued service JWTs remain valid until their own expiry, subject to the receiving service's policy.
Browser account session management#
Open /account/login to sign in with your account password and email address
or DID, then view connected applications at /account/sessions. Email-factor
accounts must enter the code sent through the existing configured email Worker.
App passwords cannot open this account-management interface. The page lists the
client URL, granted scope, expiry and a revoke button, with pagination after 50
entries. Client strings are escaped and rendered as text without remote metadata,
images or scripts. Revocation removes only the selected owned OAuth grant.
The browser uses a separate encrypted, signed, HttpOnly SameSite=Lax cookie.
Its one-hour lifetime is also enforced on the server, and each management action
rechecks the underlying live full-account session. Login renews session state and
CSRF tokens. Logout revokes the browser's password session and clears its cookie;
other password sessions and independently created OAuth grants remain intact.
Cookies use Secure when the configured endpoint origin is HTTPS. Production
must serve the account pages over HTTPS with a stable secret key base.
All mutations use CSRF-protected POST forms. The account boundary runs before general parsing/logging, admits only canonical routes/methods, bounds raw forms to 8 KiB, and rejects duplicate fields. Login has a separate 10-request/five-minute peer budget; other browser requests have a 100-request/five-minute budget through the configured memory/PostgreSQL/Redis limiter. Responses disable caching, framing, referrer disclosure, scripts and external resources. Query and body data never choose a redirect destination; redirects stay on fixed account paths.
HTTP tests explicitly enable CSRF protection and cover the complete login/list/ revoke/logout flow, email and DID login, restricted credential rejection, email factor prompts, cookie tampering, expired browser/account sessions, HTML escaping, request limits, and invalid forms/methods. Browser OAuth consent is described below; server discovery is described above.
Browser OAuth authorization and consent#
Clients redirect to GET /oauth/authorize with exactly client_id and
request_uri from the pushed authorization response. Unknown, expired, mismatched,
extra or duplicate front-channel parameters fail locally without redirecting to
an untrusted destination. Pushed requests expire after 90 seconds, or 600 seconds
for prompt=create, measured from admission through consent; an expired flow must
restart from the application.
The account browser stores a compact encrypted context containing the request reference, a client-ID hash and a random form identifier. Login preserves this context across cookie renewal and resumes consent at the fixed local endpoint. The consent page shows the full client URL and current account DID as escaped text, and requires an explicit Allow or Deny action. Clients always receive the required identity scope; users can deselect additional transitional permissions. Chat access depends on general application access. No automatic approval occurs.
A DID login hint must match the displayed account; handle hints use fresh handle
verification before display and again before submission. Approval is also bound
to that DID inside the code-issuance transaction. CSRF protection, a browser-bound
form identifier, strict form fields, live account authorization, current client
metadata/key validation and atomic pushed-request consumption all apply. The
callback, original state and issuer come exclusively from the stored request.
Existing callback query parameters are preserved except OAuth response fields,
which are replaced with the current response. Denial returns access_denied and
never issues a code. Validation failures remain on the PDS.
The consent page permits the registered cross-origin callback navigation by
omitting form-action from its CSP; scripts, frames, external resources and base
URL changes remain blocked. All rendered forms still target fixed local paths.
Responses remain uncached and use Referrer-Policy: no-referrer.
Grants are bound to the browser's password session. Explicit browser sign-out
revokes that session and therefore the grants authorized through it; the consent
and account pages state this behavior. Browser cookie expiry alone does not
revoke grants. Individually revoked applications and independently created grants
retain their existing behavior. Authenticator login enforcement is described below;
its enrollment and recovery screens are available at /account/security. Optional
passkeys are available as described below.
HTTP tests cover login resumption, permission narrowing, code exchange, a resource read, denial, logout cascades, CSRF and form tampering, hint mismatches, expired requests and duplicate/extra query fields. A transaction-level test verifies the displayed account cannot be replaced during code issuance. The shared login shell has been visually checked in desktop, mobile and dark mode; a complete browser OAuth interoperability run remains a separate task.
OAuth account creation (prompt=create)#
Include prompt=create in the pushed request to POST /oauth/par. It is
stored with the validated request; do not add it to the front-channel authorize
URL. This starts the account-creation UI as described by the
Prompt Create extension.
Atoll uses its ATProto authorization-code/DPoP flow, not OpenID ID tokens. Other
prompt values and combinations are currently rejected with invalid_request.
Authorization-server metadata advertises prompt_values_supported: ["create"].
/account/signup uses the shared purple Tailwind account shell. It requires a
live browser-bound creation request, CSRF token and matching form identifier.
The form collects a full handle, password, optional email and invitation code.
Existing signup settings, hosted domains, invitation redemption, password rules,
encrypted signing keys and confirmed PLC registration apply. A supplied login
hint must equal the new handle before creation; consent still verifies the handle
in both directions. Existing-account DID hints cannot be used to create another
account. Client URLs and hints are escaped; credentials never appear in responses.
Even an already signed-in browser starts at account creation. Successful signup renews the encrypted cookie and binds this request to the newly created DID; existing accounts cannot approve it instead. Switching accounts leaves the old account's sessions and connected applications intact. The new account must still explicitly allow or deny permissions. Signup alone issues no authorization code. The browser form shares the login budget of ten POST attempts per IP per five minutes and retains the 8 KiB body bound and duplicate-field rejection.
Creation requests have a ten-minute lifetime without sliding renewal. Expired requests cannot start signup. If directory confirmation finishes after expiry, the account remains created and signed in, but the application must restart sign-in; no expired consent is accepted. Directory failures can resume the existing pending signup using exactly the same credentials, email and invitation, subject to the existing signup recovery policy. Disabled signup returns a local error without redirecting to the client.
Tests exercise HTTP admission (including all thirteen confidential-client fields), invalid/duplicate prompts, complete signup/consent/code exchange, existing-login isolation, CSRF and context tampering, invitation/hint checks, disabled signup, directory retry, and expiry both before and during registration. The rendered signup form has also been checked at desktop/mobile widths and in dark mode, without horizontal overflow.
Authenticator cryptographic primitives#
Atoll.Accounts.TOTP implements the six-digit SHA-1/30-second profile compatible
with common authenticator applications. It generates fresh 160-bit secrets and
builds otpauth://totp provisioning URIs with escaped display labels, an unpadded
Base32 secret and explicit profile parameters. Labels must not contain colons or
control characters. These URIs contain secret material and must stay within the
account's enrollment flow; no external QR-image service should receive them.
Verification takes a trusted timestamp and last-used step, checks the previous,
current and next step, and returns the highest matching step newer than the
persisted value. All candidate code comparisons use Plug.Crypto.secure_compare.
Inputs require exactly six ASCII digits, including leading zeroes, and counters
support the full unsigned 64-bit range. Tests include the RFC 4226 counters and
RFC 6238 SHA-1 vectors (six-digit suffixes), drift boundaries, leading zeroes,
post-2038 timestamps, malformed inputs and counter overflow.
Atoll.Accounts.TOTPSecret seals 160-bit secrets in versioned AES-256-GCM envelopes
with fresh nonces and authenticated data binding the account DID and TOTP purpose.
It uses the existing active master key and bounded decryption-only fallback ring.
rewrap/2 returns a fresh envelope under the active key. The persisted-key
rotation workflow also rewraps enrolled factors atomically and reports a totp
count. Tests cover account binding, tampering, fallback decryption and retirement.
Persistent authenticator enrollment and login#
Atoll.Accounts.Authenticator.begin/2 requires a full account session and fresh
password. It stores an encrypted pending secret for ten minutes; confirm/2
enables the factor only after a valid code and consumes that code. The browser
flow at /account/security displays a manual setup key for Google Authenticator
and compatible apps, then requires a code to finish setup. Accounts without a
confirmed factor retain password login behavior. Passkey login is described below.
Confirmed factors require a six-digit authenticator code or a 26-character
recovery code on browser password login and on
com.atproto.server.createSession, using the Atoll-specific optional totpCode
field. If the email factor is enabled too, both factors are required. Restricted
app passwords retain their existing behavior and cannot open browser management.
PostgreSQL row locks serialize verification and persist the last consumed time
step, preventing concurrent reuse. A factor permits five submitted attempts per
five-minute window, including confirmation and successful codes; restarting
pending enrollment does not reset the limit. Admission runs in its own transaction
so failures and consumed codes survive later login failures. Password login must
therefore be called outside a caller-owned transaction; trusted provisioning may
still use Sessions.create_for_account/2 within a transaction.
Session creation rechecks the factor version and a short-lived internal admission under the account lock. Admission is server state, never an HTTP parameter. Tests cover enrollment expiry, credential changes, replay, parallel connections, attempt persistence, stale admissions, browser/API login and master-key rotation.
Authenticator recovery and management#
Confirmation returns ten random 128-bit recovery codes, displayed once in the
uncached browser response. Only account-bound SHA-256 hashes are persisted.
Enter a recovery code in the authenticator field (or XRPC totpCode) in place of
a current app code; the password and any enabled email factor are still required.
Recovery login consumes that code without disabling TOTP. Failed login steps
cannot restore a consumed recovery code. Recovery attempts share the same
five-attempt/five-minute database budget with ordinary codes and management.
At /account/security, a full account session, fresh password and unused
factor code are required to replace recovery codes or disable TOTP. Replacement
invalidates the complete old set and any outstanding login admissions. Disabling
removes the factor and its recovery codes; a new authenticator can then be enrolled.
An unfinished setup can be restarted with a fresh password. Secrets and recovery
codes are never returned by the status endpoint or stored in browser cookies.
Existing password/OAuth sessions and app passwords remain active; changing the
factor protects future password sign-ins. There is no self-service recovery if both the authenticator and all recovery
codes are lost; no email-only factor reset is provided.
Browser styles and asset builds#
Account and OAuth consent screens share a locally compiled Tailwind CSS 4.3.0
stylesheet. The compact card, neutral surfaces, field sizing and footer follow
the deployed Witchcraft PDS and
selfhosted.social PDS screens, with purple
(#8338ec) actions and automatic light/dark colors. Atoll retains its own name,
account fields and supported actions. mix setup installs the pinned CLI and builds the assets; development
runs a Tailwind watcher. Use mix assets.build after changes to the stylesheet or
screen markup. mix precommit builds assets before tests, so CI checks that the
build succeeds. Before making a production release, run MIX_ENV=prod mix assets.deploy to build and digest the stylesheet. Generated assets are ignored
by Git and must be included in the release. The pages load no third-party styles,
scripts, fonts or QR services. CSP permits same-origin styles; only passkey ceremony
pages additionally permit the local passkey script.
WebAuthn verification foundation#
Atoll.Accounts.WebAuthn verifies user-verified ES256 (P-256/SHA-256) passkey
registration with none attestation and discoverable-credential assertions.
The implementation uses OTP/OpenSSL for point validation and signature checks,
following WebAuthn registration and assertion verification.
It accepts an exact canonical HTTPS origin and uses that origin's full host as the
RP ID; http://localhost with an optional port is available for local development.
Challenges contain 32 random bytes. The verifier checks ceremony type, exact
challenge/origin, RP hash, presence and verification flags, credential ID and
32-byte user handle, immutable backup eligibility, and signatures over the raw
authenticator data and client-data hash. Cross-origin frames are rejected.
Nonzero signature counters must advance; all-zero counters remain supported for synced passkeys. The bounded WebAuthn CBOR decoder is separate from DAG-CBOR and accepts the integer keys needed by COSE. It rejects duplicate map keys, trailing bytes, oversized/deep structures, tags, indefinite lengths and unsupported types. Client-data JSON rejects duplicate top-level keys. This profile does not assert hardware provenance, accept attestation certificates, or offer other algorithms.
This verifier is connected to the persistent lifecycle and browser screens below. Callers must supply trusted context and stored credentials; verification alone cannot prevent challenge replay or authorize a session.
Tests include genuine navigator.credentials.create/get responses captured from
a Chrome CTAP2 virtual authenticator using a fresh temporary profile and no real
account. The checked-in fixture contains public ceremony data, not private keys.
To regenerate it locally with Node 22+ and Chrome/Chromium, run
node scripts/capture_webauthn_fixture.mjs (set CHROME_BIN if needed), then
mix test test/atoll/web_authn_test.exs. Ordinary CI uses the fixture and does not
need a browser. Tests also cover malformed inputs, altered signatures, wrong
origins, user verification, backup flags, extension framing and counter reuse.
Persistent passkey lifecycle#
Atoll.Accounts.Passkeys connects the WebAuthn verifier to PostgreSQL and account
sessions. It is an internal API: browser controllers must supply a random 256-bit
cookie binding, enforce CSRF and rate limits, and never accept trusted admission
state from a request. The browser adapter described below enforces these boundaries.
begin_registration/5 requires a live full-account session, fresh password and,
when enabled, an unused authenticator or recovery code via :totp_code. Its
options request a discoverable, user-verified ES256 key with no attestation.
Each account receives a stable random 32-byte user handle, unrelated to its DID
or email. Accounts can register at most ten passkeys, each named with 1–64 UTF-8
bytes. Existing credentials appear in the authenticator exclusion list.
complete_registration/4 checks the live session, account, browser, ceremony,
origin, password digest and current authenticator enrollment version. It consumes
the challenge and persists only public credential data. Credential IDs are unique
across all accounts and cannot be reassigned by enrolling an existing ID. Logout,
password recovery or operator session revocation invalidates pending enrollment.
Account deletion cascades through its user handle, keys and pending enrollments.
begin_login/1 returns account-independent discoverable options.
complete_login/3 verifies the assertion under the account lock, updates the
counter/backup state and consumes the challenge before issuing a full-account
session. A short-lived internal admission is rechecked for account status,
credential ownership, revocation, password replacement and endpoint origin during
session creation. Consumed proofs remain consumed when issuance hits a session
limit. Hardware or synced passkeys with user verification are an alternative to
the password and its optional email/TOTP factors; enrollment remains opt-in.
Challenges expire after five minutes, use PostgreSQL time, bind the exact endpoint origin, and never slide their expiry. Only digests of request references and browser bindings are persisted. A shared lock caps storage at 10,000 ceremonies; admission reclaims at most 1,000 expired rows. Matched malformed responses consume the challenge; a wrong browser or ceremony cannot consume someone else's request. All lifecycle writes are bounded by lock/statement timeouts. Public ceremony responses are not logged, and sensitive schema fields are redacted from inspection.
list/1 returns only owner-visible management IDs, names, timestamps and backup
flags. revoke/4 requires the live account session, fresh password and any enabled
TOTP proof. Deleting a passkey cascades through sessions created with it, including
their OAuth grants and tokens; other sessions remain intact. Operator credential
revocation also removes passkeys. Password-based sign-in with any configured
factors remains available for recovering from a lost passkey; password resets do
not themselves remove registered keys.
Set config :atoll, :passkeys_enabled, false or ATOLL_PASSKEYS_ENABLED=false to
disable new registration and login ceremonies (default true). The environment
overrides configuration only when supplied. Owner inventory and removal remain
available while disabled; existing sessions are not automatically revoked.
Tests cover persistence, user handles, duplicate ownership, account isolation, password/session/factor changes, counter regression, bad signatures, expiry, challenge/account caps, session-cap failures and cascading OAuth revocation. Independent database connections race both enrollment and zero-counter login: exactly one request succeeds for each shared challenge.
Passkeys in the browser#
Open Account security → Manage passkeys (/account/passkeys) after signing
in. Add a name, re-enter your password and provide an unused authenticator or
recovery code if TOTP is enabled. Choose Continue with passkey and follow the
device prompt. You can use a platform authenticator, a synced passkey provider or
a compatible security key; the authenticator must support discoverable ES256 keys
and user verification. Enrollment is optional and password sign-in remains available.
The login page offers Sign in with a passkey while passkeys are enabled.
The browser asks the authenticator to select a credential without sending an
account identifier. Successful verification renews the account cookie and resumes
any pending OAuth request at explicit consent. A passkey does not bypass
prompt=create, account-hint matching or permission approval. If a passkey is
lost, sign in with the password and any configured email/TOTP factor, remove the
lost key from the management page and enroll a replacement. Removing the key used
for the current browser session signs that browser out too.
All begin, finish and removal actions are CSRF-protected, canonical POST forms sharing the ten-attempt/five-minute login budget. The encrypted cookie holds the ceremony reference, purpose and fresh random browser binding; these are never accepted as form parameters. Finish forms allow up to 48 KiB for WebAuthn data; other account forms retain their 8 KiB limit. Credential JSON must contain exactly the expected fields without duplicate keys. Invalid, expired and wrong-browser responses fail locally. Names and public options are HTML-escaped, and passwords are not reflected into responses.
The local assets/js/passkeys.js calls navigator.credentials.create/get,
serializes the credential and submits a same-origin form. It makes no fetch calls
and requires no npm packages. Unsupported browsers show a password fallback;
cancelled device prompts can be retried within the ceremony lifetime. Only these
ceremony pages permit script-src 'self', with no inline scripts or external
resources. Framing is blocked and responses remain uncached. mix assets.build
copies the script into static assets; mix assets.deploy also fingerprints it.
Run the asset build after editing the script.
HTTP tests cover enrollment, login, password recovery, current-key revocation, CSRF/browser binding, JSON ambiguity, escaping, expiry, disabled policy and request bounds. An OAuth integration test confirms passkey login resumes consent and key removal revokes the resulting grant. A real Chrome test uses a fresh temporary profile and CTAP2 virtual authenticator to exercise the rendered forms and shipped JavaScript, including desktop/mobile/dark rendering checks. No real credentials or browser profiles are used.
Run mix assets.build, then
mix test --include browser test/atoll_web/passkey_browser_e2e_test.exs with Node
22+ and Chrome/Chromium (CHROME_BIN overrides the executable). The ordinary
suite excludes this :browser test; GitHub CI runs it as a separate step using
the runner's installed browser. Screenshots are written as atoll-passkey-*.png
in the system temporary directory. This verifies the passkey browser flow, not
complete ATProto federation or third-party client interoperability.
OAuth client logout#
POST /oauth/revoke accepts form-encoded client_id, token, optional
token_type_hint, and confidential-client assertion fields. Discovery advertises
this endpoint and its none / private_key_jwt authentication methods. As with
PAR/token, send a fresh ES256 DPoP proof with an authorization-server nonce,
targeting the configured /oauth/revoke URL. This endpoint has its own
20-request-per-peer/five-minute budget and shares the strict form, CORS,
no-store and pre-parser protections of the token endpoint.
An access token, current refresh token, or retained rotated refresh token can revoke its entire OAuth grant. All access tokens and refresh history belonging to that grant are deleted atomically; the source password/passkey session and other grants remain. Revocation works even when the source account session has expired or the account is deactivated. Refresh and revocation share the grant mutation lock, so a concurrent refresh cannot leave an orphaned usable token.
Client authentication uses fresh metadata and, for confidential clients, a fresh
assertion validated against the original session key binding. Token lookup also
checks issuer, client ID and DPoP key. Unknown, foreign, mismatched-binding and
already-revoked tokens return the same HTTP 200 {} after proof/client admission.
Invalid proofs and client authentication still fail; replayed proofs/assertions
cannot revoke a grant. Token hints are ignored, including unknown hints, as
permitted by RFC 7009.
The ATProto official OAuth client
uses the discovery endpoint for logout with its existing client credentials and
DPoP key. No tokens or assertions are written to application logs by this route.