atproto_core

The shared sans-io core of the gleam-atproto client packages: the XRPC error vocabulary and response handling, plus the OAuth effect kernel that atproto_client (sync, erlang) and atproto_browser (async, javascript) both interpret with their own runners.

This package performs no IO and depends on no HTTP backend. Unless you are building a runner for a new target, you want atproto_client or atproto_browser instead; they re-surface everything here behind their own entry points.

Modules

Module What it does
atproto_core/xrpc TransportError, XrpcError, describe, parse, check_ok
atproto_core/remote Origin/Url: public https hosts; opt-in AllowLocalDevelopment
atproto_core/did Did and native DID document resolution (PLC, did:web)
atproto_core/identity Slingshot resolveMiniDoc (a hint, never trusted for auth)
atproto_core/oauth/effect The Effect type: Fetch/FetchBits/DpopProof/Done steps
atproto_core/oauth/transport Form POST with DPoP, client auth, and the use_dpop_nonce retry
atproto_core/oauth/metadata Verified protected-resource and authorization-server metadata
atproto_core/oauth/flow start (resolve, discover, PAR) and callback (verify, exchange)
atproto_core/oauth/session VerifiedSession: tokens bound to a verified DID and issuer
atproto_core/oauth/tokens Refresh and revocation of a VerifiedSession
atproto_core/oauth/resource DPoP-signed requests, pinned to the session's PDS origin

The effect kernel in one paragraph

Every OAuth operation is a pure Effect value: a step asking for a fetch, a bit-array fetch, or a DPoP proof, with a continuation for the answer. A runner walks the value, performing real IO per step; the kernel never sees a socket or a key. That is what lets one implementation of the nonce-retry and token-exchange logic serve both a sync erlang client and an async browser client, and it is why the whole kernel is unit-testable by stepping effects by hand (see test/oauth_effect_test.gleam).

What the OAuth kernel verifies

Every hop of discovery is attacker-influenced, so each is parsed and checked before the next request is made:

  1. The account is a DID. A handle goes through the resolver for its DID only; the DID document (fetched natively from the PLC directory or the did:web host) must list the handle in alsoKnownAs.
  2. The PDS is the DID document's #atproto_pds service, parsed as a remote.Origin.
  3. The PDS's protected-resource metadata must name that PDS as resource.
  4. The authorization server's metadata issuer must be exactly the issuer it was fetched from, and it must advertise the atproto profile capabilities.
  5. flow.callback requires state and iss, then exchanges the code. The token sub must be the flow's DID, token_type DPoP, the scope must include atproto, and the DID must resolve back to the same issuer.
  6. The access token is only ever sent to the session's PDS origin.

What parsing cannot decide is the transport's job: no redirects, bounded response size and request time, and on a server, no connections to private, loopback, link-local, or unique-local addresses after DNS resolution.