# 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. - **Loopback.** When `PUBLIC_URL` is a loopback address, in development or a local production build, the server uses `buildAtprotoLoopbackClientMetadata` instead. 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 1. The web UI sends the user to `GET /oauth/login?identifier=`. 2. The server drops a leading `@`, since people often type a handle as it is shown, then 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. ### 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.invalid` are 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 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: ```ts getPdsClient(did): Promise ``` 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. ## 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 server, which also serves the web app through Vite, so the OAuth callback returns to the web app. ## Acceptance Criteria - [ ] Client metadata is served at the client ID URL, with `private_key_jwt` and the public keys at `/oauth/jwks.json`. - [ ] A loopback `PUBLIC_URL` uses loopback client metadata. - [ ] Production startup fails without valid `OAUTH_PRIVATE_KEYS` or with a non-HTTPS `PUBLIC_URL`. - [ ] The requested scope includes the permission set, with its NSID taken from the lexicon module. - [ ] An `invalid_scope` error from the PDS is shown on the login page. - [ ] A handle that doesn't resolve shows an error suggesting the DID, and a DID that doesn't resolve says it wasn't found. - [ ] `OAUTH_SCOPE_MODE=raw` requests raw space and blob scopes equivalent to the permission set, and production startup refuses it. - [ ] A new account whose granted scope allows spaces gets storage mode `space`. - [ ] A new account whose granted scope lacks spaces gets storage mode `local`. - [ ] A `space` account whose granted scope lacks spaces fails login with an explanation. - [ ] A `local` account stays `local` when its scope later allows spaces. - [ ] The session cookie is HttpOnly and SameSite=Lax, and only its hash is stored. - [ ] `/api` routes return 401 without a valid session and 403 for a non-GET request with a foreign `Origin`. - [ ] Sessions expire after the TTL and are extended when used in the second half of it. - [ ] `getPdsClient` throws `SessionExpired` when the OAuth session cannot be restored. - [ ] Logout clears the web session and keeps the OAuth session, and logout everywhere revokes it. - [ ] `GET /api/me` includes the user's roles. - [x] The sign-in field suggests accounts from Bluesky's typeahead for a typed handle, never for a DID or URL, and picking one fills in its handle and avatar without submitting. - [x] A slow search shows skeletons after the quiet wait, and an empty or failed one says so with a hint to type the full handle. - [x] `GET /api/me/profile` reads the display name and avatar thumbnail from the user's profile record, and answers with nulls when there is none or their PDS can't be reached.