atproto_browser#
Alpha, pre-release. Proven via the manual smoke scripts below, not yet used in a real browser tab or backed by persistent key storage. Expect breaking changes.
A browser-native atproto OAuth + XRPC client for
Gleam: WebCrypto DPoP and PKCE, async fetch transport, and the full
PAR/token-exchange/authenticated-request flow for a public client. Separate
from atproto_client because real browser fetch is
async and DPoP/PKCE need browser WebCrypto; this package is
javascript-target-only and owns the atproto/browser/* namespace.
Installation#
JavaScript target only:
gleam add atproto_browser
Usage#
import atproto/browser/oauth/flow
import atproto/browser/xrpc
import atproto_core/oauth/transport
// handle -> DID -> DID document -> PDS -> verified auth server -> PAR;
// returns the authorization URL to redirect the user to, plus the Flow.
flow.start(
xrpc.fetch_client(),
resolver: "https://slingshot.microcosm.blue",
identifier: handle,
client_id: client_id,
redirect_uri: redirect_uri,
scope: "atproto transition:generic",
client_auth: transport.PublicClient,
)
Before redirecting, persist the flow in two halves: flow.to_json(pending)
in sessionStorage under flow.state(pending), and flow.dpop_key(pending)
in IndexedDB. The key is a non-extractable WebCrypto CryptoKeyPair: it
cannot go into sessionStorage or JSON, but IndexedDB's structured clone
keeps it usable and still non-extractable. On the callback page, take (read
and delete) both by the returned state, flow.resume them, and hand the
whole query to flow.callback. It checks state and iss, exchanges the
code, and returns Authenticated(session, dpop_key) only when the token's
sub is the DID the flow started for and resolves back to the same
authorization server. Wrap a client with oauth/resource so requests to the
session's PDS (and only there) are DPoP-signed.
xrpc.fetch_client() refuses redirects (redirect: "error"), so a 3xx on
any OAuth hop fails instead of landing on a host nobody validated.
Architecture#
| Module | What it does |
|---|---|
atproto/browser/xrpc |
Async Client, XrpcError, get/post_json/etc., fetch_client() |
atproto/browser/dpop |
WebCrypto DPoP (RFC 9449) key generation + proof signing |
atproto/browser/identity |
resolve_pds via Slingshot resolveMiniDoc (a hint, not trusted for auth) |
atproto/browser/oauth/flow |
start, callback, and to_json/resume across the redirect |
atproto/browser/oauth/tokens |
refresh/revoke of a VerifiedSession |
atproto/browser/oauth/resource |
Wraps a Client so requests to the session's PDS carry the DPoP token |
The rest (pkce, webcrypto, oauth/runner) is internal plumbing behind
these six: PKCE and WebCrypto primitives and the effect-kernel runner that
flow, tokens, and resource interpret against. The package does not
depend on gleam_crypto, whose JavaScript FFI imports node:crypto.
Development#
gleam test # pure, synchronous pieces
node test/manual/dpop_smoke.ts # real WebCrypto DPoP sign + verify
node test/manual/oauth_flow_smoke.ts # full flow, attacks, redirect round trip
The smoke tests run the actual compiled output against real WebCrypto and a
fake-but-realistic PDS/authorization server, parameterized over proof shapes
and server behaviors (including the use_dpop_nonce retry); gleeunit has no
Promise-test support, so they are the regression check for the async paths
and CI runs them on every push. They are TypeScript, run via node's type
stripping (node 23.6+, or --experimental-strip-types on 22.6+), with
editor types from the typescript_declarations build output.