Skip to content

Identity

Users can join with plain guest tokens (the control plane mints one-time join tokens), or sign in with their AT Protocol identity. The two providers compose; the quick shape:

Guest AT Protocol sign-in
Credential one-time jt_ token from the control plane the same token, minted by the node after OAuth, carrying the verified DID
Bans per-connection only bind to the DID (new handle, new device — still banned)
Resume this device the same DID resumes anywhere; guests cannot resume a DID session
Profile none displayName/avatar prefetched from the user’s PDS on the roster

Identity: guests, AT Protocol sign-in, and DID-bound rooms

Two join-credential providers ship with the node (auth.providers in node.json), and they compose:

Provider Credential What the hub binds
guest a control-plane-minted one-time join token (POST /v1/rooms / …/tokens) nothing — the peer has no verified identity
atproto the same join tokens, minted by the node after an AT Protocol (Bluesky &c.) OAuth sign-in, carrying the verified DID bans, moderation eligibility and resume key on the DID

guest alone is the v0 default; adding atproto changes nothing for existing rooms except that the creator-claim seam now verifies signed creator tokens (the opaque string comparison remains for embedders that construct authorities directly).

The atproto flow (web/server shape — the node is the OAuth client)

game node AT Protocol world
| | |
|-- GET /auth/start -->| |
| ?handle&room |-- resolveHandle --> DID --> DID doc --> PDS |
| [&creator=true] |-- AS metadata --> PAR (PKCE + DPoP) ------> |
|<-- {authorizationUrl,|-- |
| flow} ------------| |
| |
|== user's browser goes to authorizationUrl, signs in, approves ======|
| |
| |<-- redirect /auth/callback?code&state&iss --|
| |-- token exchange: PKCE + DPoP + |
| | private_key_jwt (ES256, JWKS at |
| | /oauth/jwks.json) --> sub = DID -------->|
| |-- mint jt_ (claims: rm, exp, jti, did, hd) |
|<-- poll /auth/result?flow=... -- {joinToken, did, roomKey, url, |
| [creatorToken]} -------------------|
| | |
|== WebSocket /play/<roomKey>?token=<joinToken> ===================>|
  • The client half of the flow is NodeAuthClient in packages/atproto_identity: start → open the browser (injected callback: window.open on the web, the system browser on desktop) → poll /auth/result. Polling is the completion mechanism — one mechanism that works unchanged for web and desktop; no loopback listener and no postMessage. Mobile custom-scheme flows are a deliberate TODO.
  • The flow id in /auth/result is an unguessable bearer (exactly like the one-time join token it returns); the auth endpoints are CORS-open for that reason.
  • Without format=json the node 302s the browser straight to the authorization URL (popup-friendly); with it, the JSON above comes back for programmatic clients.

What the DID unlocks

  • Bans bind to the DID (RoomHub’s adapter-verified identity): a banned user is refused at the join handshake on every future DID token — new handles, new devices — for that room’s lifetime.
  • Resume across devices: the resume token is bound to the identity; a fresh DID token keeps the same peer id, roster row and (relay) published state. A guest token cannot resume a DID-bound session (Session identity changed), and a DID token cannot resume a guest session.
  • Moderation eligibility: only identity-bearing peers can be banned (unchanged rule — now it means “only DID-bearing peers”).
  • Creator claims (ct_ tokens minted with creator=true): the relay and movement authorities verify them as signed node tokens (cr: true, optionally DID-bearing) bound to the room key, instead of comparing opaque strings. The DID binding of the creator claim is delivery-channel only in v0 (the token is minted at /auth/callback to the verified holder); the authority cannot yet compare the claim’s DID with the joiner’s adapter identity — see Deferred below.

Node configuration

"auth": {
"providers": ["guest", "atproto"],
"atproto": {
"clientName": "rooms example node",
"entrywayUrl": "https://bsky.social",
"plcDirectoryUrl": "https://plc.directory",
"jwksPath": "/etc/rooms/oauth-client-jwks.json"
}
}
  • Generate the client key once per node: cd packages/node && dart run tool/gen_client_jwks.dart /etc/rooms/oauth-client-jwks.json — an EC P-256 private JWK. The node serves only its public half at /oauth/jwks.json; the private file is a deployment secret (mount it read-only, like node.json).
  • The client metadata document at /oauth/client.json is the client_id (atproto OAuth profile, application_type: web, token_endpoint_auth_method: private_key_jwt, redirect {publicBaseUrl}/auth/callback).
  • Identity resolution is entryway XRPC (com.atproto.identity.resolveHandle) plus the PLC directory / did:web well-knowns, both configurable above.
  • publicBaseUrl must be https for real authorization servers (a production AS rejects non-confidential or non-https web clients).

Portable profiles (prefilled from the user’s PDS)

On first join with a DID the node fetches app.bsky.actor.getProfile from the user’s PDS unauthenticated (public profiles), caches displayName/avatar/handle keyed by DID, and exposes the profile on the room social roster path clients already consume: members[].profile = {did, handle?, displayName?, avatar?} (absent for guests and until the fetch lands — it never blocks the join; the roster updates when the profile arrives).

  • Cache layers: memory → SQLite (did_profiles) → PDS, with a TTL (default 24 h; stale entries are refreshed in the background and served meanwhile). A handle → DID cache (10 min TTL) front-loads future sign-ins.
  • Privacy: a DID is a persistent identifier. Profiles are stored node-scoped (one row per DID in the node’s SQLite) and exposed room-scoped (only to peers of rooms the DID actually joined). Hosts deploying the atproto provider should say so in their privacy policy; wiping the did_profiles and handle_cache tables opts a deployment out of profile retention without affecting anything else.
  • avatar is the PDS blob URL as-is; clients apply their own remote-image policy when rendering it.

Package layout

  • packages/atproto_identity (pub name rooms_atproto) — pure identity: desktop loopback OAuth (AtprotoAccountManager + OAuthLoopback, extracted from the original consumer’s accounts stack), the session vault interface, and NodeAuthClient. protocol/client stay dependency-clean — no atproto imports outside these two packages.
  • packages/server_sdk — the node-side provider (AtprotoAuthProvider, NodeClientKeys, ConfidentialClient, NodeAtprotoResolver, ProfileService), DID-bearing join tokens, the signed creator-claim seam, and the roster profile plumbing (RoomMember.profile, RoomHub.profileOf/refreshRoster).
  • packages/node — config (auth.providers), wiring, the JWKS generator tool.

Real-world caveats (and what tests fake)

The test suites run against an in-test authorization server and PDS (plain HttpServer over real TLS with a per-run self-signed certificate — atproto_oauth hardcodes https for AS metadata/PAR/token). No real PDS is ever contacted. Places where reality differs from the fakes:

  • The fake AS approves automatically — no consent screen, no account chooser, no access_denied paths in the node tests (the extracted desktop manager does map denial to cancellation).
  • The fake AS verifies the client assertion (ES256 against the served JWKS), PKCE (S256) and DPoP presence, but not the DPoP proof itself.
  • Handle resolution goes through the entryway XRPC, not DNS TXT / /.well-known/atproto-did, and without bidirectional handle verification.
  • Profile fetch is unauthenticated v0 — private-profile behavior depends on the PDS (authenticated fetch with the sign-in session is deferred; the node does not retain OAuth sessions).
  • In-flight auth flows live in process memory: a node restart drops them (the user signs in again). Join-token consumption still persists.
  • http://127.0.0.1 origins are accepted in dev/test paths only (allowPrivateNetwork); production requires https everywhere.

Deferred

  • Creator-claim DID binding enforced against the joiner’s adapter identity (needs the authority to see the hub’s verified identity).
  • Authenticated (session-carrying) profile fetch; avatar blob proxying.
  • Per-room profile visibility controls; a room-creation-time “DID-only” room flag.
  • Mobile custom-scheme completion for NodeAuthClient.
  • Session persistence node-side (refresh-token vaulting for service-style calls on the user’s behalf).

Generated from docs/identity.md (embedded above via markdown import).