infra stuff in gleam
gleam atproto lexicon codegen oauth
10

Configure Feed

Select the types of activity you want to include in your feed.

README.md

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.