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
NodeAuthClientinpackages/atproto_identity: start → open the browser (injected callback:window.openon 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 nopostMessage. Mobile custom-scheme flows are a deliberate TODO. - The
flowid in/auth/resultis an unguessable bearer (exactly like the one-time join token it returns); the auth endpoints are CORS-open for that reason. - Without
format=jsonthe 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 withcreator=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/callbackto 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, likenode.json). - The client metadata document at
/oauth/client.jsonis theclient_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:webwell-knowns, both configurable above. publicBaseUrlmust 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). Ahandle → DIDcache (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_profilesandhandle_cachetables opts a deployment out of profile retention without affecting anything else. avataris the PDS blob URL as-is; clients apply their own remote-image policy when rendering it.
Package layout
packages/atproto_identity(pub namerooms_atproto) — pure identity: desktop loopback OAuth (AtprotoAccountManager+OAuthLoopback, extracted from the original consumer’s accounts stack), the session vault interface, andNodeAuthClient.protocol/clientstay 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_deniedpaths 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.1origins 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).