ATProto-based AI Assistant Chat interface.
scn-chat specs auth.md
11 kB · 136 lines
Markdown

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_URL must be HTTPS.
  • Development. When PUBLIC_URL is a loopback address, the server uses buildAtprotoLoopbackClientMetadata instead. Loopback clients are always public, so development 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 #

  1. The web UI sends the user to GET /oauth/login?identifier=<handle, DID, or PDS URL>.
  2. The server calls client.authorize(identifier, { scope }) and redirects to the returned URL.
  3. GET /oauth/callback calls client.callback(params) and gets an OAuthSession.
  4. The server reads the granted scope with session.getTokenInfo(). Using ScopePermissions from @atproto/oauth-scopes, it checks whether the scope allows creating conversation spaces for the user's own DID.
  5. It upserts the account row 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.

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 gets local.
  • An existing local account stays local even if its PDS gains spaces support. Moving data to the PDS is a future migration feature.
  • An existing space account 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
POST /api/logout Sign out

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.

Scope Boundaries #

  • No accounts outside atproto, and no passwords.
  • No migration of local accounts to spaces.
  • No revoking the OAuth session on ordinary logout.

Edge Cases and Decisions #

  • The permission set still lists a blob permission, which PDSs ignore. It stays because published lexicons are never tightened, and the separate blob:*/* 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 with invalid_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 self authorities in the granted scope to the user's DID, since ScopePermissions does not resolve them itself.
  • Login calls an onLogin hook, 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.
  • JoseKey comes from @atproto/oauth-client-node's re-export, so key types cannot come from two package versions.
  • In development PUBLIC_URL is the Vite dev server, which proxies the server routes, so the OAuth callback returns to the web app.

Acceptance Criteria #