Packages

A browser-native atproto OAuth + XRPC client for Gleam: WebCrypto DPoP, PKCE, and async transport, for building a public-client SPA with no server-side session custody.

Current section

Files

Jump to
Raw

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](https://atproto.com/) 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`](https://hex.pm/packages/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:

```sh
gleam add atproto_browser
```

## Usage

```gleam
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

```sh
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.