Auth #
Summary #
Users sign in with their atproto account through atproto OAuth, using @atproto/oauth-client-node. The server is a confidential OAuth client, so it holds long-lived sessions and can write replies to the user's PDS in the background. After login, the server checks which permissions the PDS actually granted. If the space permissions are there, the account uses spaces storage. If not, because the PDS does not support spaces, the account falls back to local storage. The browser holds only an opaque session cookie, and all PDS calls happen on the server.
Motivation #
An atproto account is the only identity SCN Chat uses, for spaces and fallback users alike. OAuth is the standard way for an app to act for a user. The server needs sessions that survive for months, because a reply can be triggered by a record written from another client while the user is away. atproto only grants long sessions (up to two years, refreshed every three months) to confidential clients. Public clients are capped at two weeks.
Design #
OAuth client #
apps/server/src/auth/client.ts creates one NodeOAuthClient:
- Production. A confidential client with
token_endpoint_auth_method: "private_key_jwt",dpop_bound_access_tokens: true, and a keyset of ES256 keys from@atproto/jwk-jose. The client ID is${PUBLIC_URL}/oauth-client-metadata.json. The server publishes the metadata at that path and the public keys at/oauth/jwks.json.PUBLIC_URLmust be HTTPS. - Loopback. When
PUBLIC_URLis a loopback address, in development or a local production build, the server usesbuildAtprotoLoopbackClientMetadatainstead. Loopback clients are always public, so their sessions last two weeks at most.
The OAuth state and session stores are oauth_state and oauth_session tables, each with a key, a JSON value, and an updated timestamp. Sessions are keyed by DID.
Scope #
The requested scope is atproto include:network.sharedcomputer.chat.permissions blob:*/*, with the permission set NSID taken from the lexicon module, never typed as a literal. The permission set grants the conversation, shared-read, and settings permissions. Blob access is requested as its own blob:*/* scope, because PDSs only take space, repo, and rpc permissions from a permission set and drop the rest. The PDS resolves it through the lexicon's DNS record, so the lexicons must be published under sharedcomputer.network before anyone can log in. A fork publishes its own lexicons under its own domain.
Before the lexicons are published, setting OAUTH_SCOPE_MODE to raw lets a PDS without spaces sign users in, for testing the local fallback. It requests the equivalent raw scopes instead, built from the same lexicon module: space: scopes for the conversation, shared-read, and settings permissions with every collection listed, plus blob:*/*. The default is permission-set, and production startup refuses raw.
Login #
- The web UI sends the user to
GET /oauth/login?identifier=<handle, DID, or PDS URL>. - The server drops a leading
@, since people often type a handle as it is shown, then callsclient.authorize(identifier, { scope })and redirects to the returned URL. GET /oauth/callbackcallsclient.callback(params)and gets anOAuthSession.- The server reads the granted scope with
session.getTokenInfo(). UsingScopePermissionsfrom@atproto/oauth-scopes, it checks whether the scope allows creating conversation spaces for the user's own DID. - It upserts the
accountrow and creates a web session (below), then redirects to/.
A PDS without spaces drops space scopes silently instead of refusing them, so login always succeeds. The check in step 4 is how the server tells the two kinds of PDS apart.
Handle search #
The sign-in field suggests accounts as the user types a handle, from Bluesky's public app.bsky.actor.searchActorsTypeahead at public.api.bsky.app, which the browser calls itself, without a referrer, after a 150 ms pause in typing. It asks for five accounts matching what was typed, without a leading @, and skips anything with a : or /, such as a DID or a PDS URL. Like the profile, the method is Bluesky's, so features/auth/queries.ts names it itself.
- Each suggestion shows the account's avatar thumbnail, display name, and handle. Picking one, by click or with the arrow keys and Enter, puts its handle in the field without signing in, and its avatar takes the @'s place until the field is edited. Enter with nothing picked signs in with what was typed.
- The panel under the field shows skeleton rows once a first search has taken the theme's quiet wait, 400 ms, so a quick answer never flashes them. A later search keeps the last accounts in view, with a spinner in the field after the same wait.
- No match says "No accounts found. You can still type your full handle.", and a failed search says it couldn't search, with the same hint. Screen readers hear how many accounts were found.
- Accounts with the handle
handle.invalidare left out. Only accounts Bluesky has indexed appear, so a handle Bluesky doesn't know still signs in when typed in full.
Storage mode #
The account table holds the DID, current handle, PDS URL, storage mode (space or local), the background_sync setting from the chat-storage spec (default true), and creation, last-login, and last-activity times.
- A new account whose granted scope allows spaces gets
space. Otherwise it getslocal. - An existing
localaccount stayslocaleven if its PDS gains spaces support. Moving data to the PDS is a future migration feature. - An existing
spaceaccount whose granted scope no longer allows spaces fails login with a message explaining that its chats live in spaces the PDS no longer supports. It does not switch to local silently, which would hide the user's existing chats.
Web sessions #
The browser gets an scn_session cookie holding 32 random bytes, base64url encoded. It is HttpOnly, SameSite=Lax, Path=/, and Secure whenever PUBLIC_URL is HTTPS. The web_session table stores the token's SHA-256 hash, the DID, and creation and expiry times. Sessions last sessions.ttlDays days, an admin setting with a default of 30, and are extended when used in the last half of their life.
Hono middleware resolves the cookie to the account on every /api request. Routes that need a user return 401 without one.
Every non-GET /api request must carry an Origin header matching PUBLIC_URL, or it is refused with 403. Together with SameSite=Lax, that blocks cross-site request forgery.
Acting for a user #
Other specs get a PDS client for a user through one function:
getPdsClient(did): Promise<Client>
It calls client.restore(did) and wraps the session in @atproto/lex-client's Client. Token refresh happens inside the OAuth library. If the session is gone or refresh fails, it throws SessionExpired. The web UI answers that by sending the user to log in again, and a background turn records it as the reply's error.
Roles and access #
Roles, access, and viewers are described in the admin spec. Roles live in the database, and the session middleware works out a user's roles and access level on every request. The callback checks access before it creates the account or a web session.
Logout #
POST /api/logout deletes the web session and clears the cookie. It does not revoke the OAuth session, since background replies still need it. POST /api/logout?everywhere=1 also calls client.revoke(did) and deletes all of that user's web sessions.
Endpoints #
| Route | Purpose |
|---|---|
GET /oauth-client-metadata.json |
OAuth client metadata |
GET /oauth/jwks.json |
Public signing keys |
GET /oauth/login |
Start login |
GET /oauth/callback |
Finish login |
GET /api/me |
The signed-in user's DID, handle, storage mode, roles, whether they are an admin, and their access level |
GET /api/me/profile |
The user's display name, and their avatar's thumbnail URL, from their public profile |
POST /api/logout |
Sign out |
Profile #
The sidebar shows the user's avatar, display name, and handle. The handle comes from /api/me. The display name and avatar come from the user's public app.bsky.actor.profile record, key self, which most atproto accounts carry. The server reads it from the user's PDS with the public com.atproto.repo.getRecord on each request to /api/me/profile, keeping nothing, so the PDS stays the only copy. Fetches go through the same private-network guard as other PDS calls. The avatar's blob is the full upload, often far larger than the sidebar needs, so the server answers with its thumbnail on Bluesky's image CDN, https://cdn.bsky.app/img/avatar_thumbnail/plain/{did}/{cid}@jpeg, for a PNG or JPEG avatar only. The browser loads it from there without a referrer, and shows the initial when it fails, as for an account Bluesky hasn't indexed. These NSIDs are Bluesky's and public PDS methods, not this app's lexicons, so auth/profile.ts names them itself rather than the generated module.
Configuration #
In the environment:
| Variable | Purpose |
|---|---|
OAUTH_SCOPE_MODE |
permission-set (default) or raw, for local development only. |
PLC_URL |
The PLC directory. Default https://plc.directory. |
OAUTH_PRIVATE_KEYS |
JSON array of ES256 private JWKs, each with a kid. Required in production. |
The web session lifetime is the sessions.ttlDays admin setting, default 30. A change applies to sessions created or extended after it.
A pnpm keys script prints a fresh OAUTH_PRIVATE_KEYS value and a SECRET_KEY for the providers spec. pnpm setup-env runs it with --write, which creates .env from .env.example if it is missing and fills in whichever of the two is empty, never replacing one already set.
Scope Boundaries #
- No accounts outside atproto, and no passwords.
- No migration of
localaccounts to spaces. - No revoking the OAuth session on ordinary logout.
Edge Cases and Decisions #
- The permission set still lists a
blobpermission, which PDSs ignore. It stays because published lexicons are never tightened, and the separateblob:*/*scope is what grants uploads. - Login depends on the published permission set. An unpublished or unresolvable permission set makes every login fail with
invalid_scope, and the login page shows that error. - A handle that doesn't resolve to an account shows "We couldn't find an account for that handle. Check the spelling, or sign in with your DID instead." Some PDSs serve handles that can't be resolved, such as a wildcard domain whose certificate doesn't cover it, and the DID works for those. A DID that doesn't resolve says the DID wasn't found. The sign-in form itself only asks for a handle.
- A spaces account that loses spaces support fails login loudly instead of falling back to local storage.
- Loopback development uses a public client, because atproto OAuth does not allow confidential loopback clients.
- To verify during implementation: that a PDS without spaces accepts the permission set and skips its space permissions, as the research into the alpha source suggests.
- Verified against the alpha PDS: its consent screen resolves the lexicon of every space type in the scope, for raw
space:scopes as well as the permission set, and refuses the request withinvalid_scope("Unable to retrieve space declarations") when it cannot. A spaces PDS therefore needs the lexicons published in either scope mode. - Spaces support is checked after resolving
selfauthorities in the granted scope to the user's DID, sinceScopePermissionsdoes not resolve them itself. - Login calls an
onLoginhook, which creates the settings space and, for spaces users, registers for notifications, runs discovery, and syncs the index in the background. A failure is logged and does not block the login. - A handle is stored only when it resolves back to the same DID.
JoseKeycomes from@atproto/oauth-client-node's re-export, so key types cannot come from two package versions.- In development
PUBLIC_URLis the server, which also serves the web app through Vite, so the OAuth callback returns to the web app.