diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 1d5fadc3..33c619c3 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -1,61 +1,56 @@ --- name: epds-login -description: Implement AT Protocol OAuth login against an ePDS instance. Covers two flows — Flow 1 (email-first, hand-rolled PAR/DPoP) and Flow 2 (via @atproto/oauth-client-node, accepting no hint / handle / DID). Use when building passwordless OTP login, configuring client metadata (confidential vs public), or integrating NodeOAuthClient. +description: Implement AT Protocol OAuth login against an ePDS instance with @atproto/oauth-client-node. Covers email-first OTP, hosted email entry, handle/DID login, client metadata, callbacks, and debugging. Use when building passwordless login against ePDS, configuring confidential/public OAuth clients, or integrating NodeOAuthClient. --- # Implementing ePDS Login -ePDS lets your users sign in to [AT Protocol](https://atproto.com/) apps — like -[Bluesky](https://bsky.app/) — using familiar login methods: **email OTP**, **Google**, -**GitHub**, or any other provider [Better Auth](https://www.better-auth.com/) supports. -Under the hood it is a standard AT Protocol PDS wrapped with a pluggable authentication -layer. Users just sign in with their email or social account and get a presence in the -AT Protocol universe (a DID, a handle, a data repository) automatically provisioned. +ePDS lets users sign in to AT Protocol apps using email OTP. New users receive +a DID, handle, and data repository automatically. -From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP). -The reference implementation is `packages/demo` in the [ePDS repository](https://github.com/hypercerts-org/ePDS). +Earlier versions of this skill recommended a hand-rolled OAuth flow. That +guidance is deprecated in favor of `@atproto/oauth-client-node`, which handles +PAR, PKCE, DPoP, token exchange, and OAuth state. The repository's +`packages/demo` client still contains the earlier implementation and is not the +current integration reference. -For protocol-level detail beyond ePDS specifics — DPoP proof mechanics, granular -scope design (`repo:`/`rpc:`/`blob:`/`account:`), identity verification after token -exchange, and refresh-token race handling — see the `atproto-oauth` skill. This skill -covers only what is ePDS-specific. +For protocol-level guidance beyond ePDS specifics—granular scope design, +identity verification after token exchange, session storage, and refresh-token +race handling—read the `atproto-oauth` skill. -## Two Flows +## Choose the Login Input -| Flow | App provides | How user starts | Implementation | -| ---- | ----------------------- | ---------------------------- | -------------------- | -| 1 | Email address | OTP screen immediately | Hand-rolled PAR/DPoP | -| 2 | Nothing, handle, or DID | Depends on input (see below) | `NodeOAuthClient` | +All variants use one `NodeOAuthClient` and converge on the same callback and +`session.did`. -**Why the split?** `@atproto/oauth-client-node`'s `authorize()` method accepts -a handle or DID as input but explicitly omits `login_hint` from its options — -the library resolves the identity itself and overrides the hint. Flow 1 needs -to pass a raw email as `login_hint` on the auth redirect URL (not in the PAR -body), which the library cannot do. Flow 1 must therefore use hand-rolled -PAR + DPoP requests. +| App provides | Call | User experience | +| ------------ | -------------------------------------------------- | --------------------------------------- | +| Email | `authorize(epdsUrl)`, then append URL `login_hint` | OTP step immediately | +| Nothing | `authorize(epdsUrl)` | Hosted ePDS login form | +| Handle | `authorize(handle)` | SDK resolves account; ePDS starts login | +| DID | `authorize(did)` | SDK resolves account; ePDS starts login | -Flow 2 covers three input variants — all use the same `NodeOAuthClient` code: +`NodeOAuthClient.authorize()` deliberately does not accept `login_hint` in its +options. For email-first login, call `authorize(epdsUrl)` first, then append the +email to the returned authorization URL. This preserves SDK ownership of PAR, +PKCE, DPoP, state, nonce retry, token exchange, and OAuth session storage. -- **No identifier** — pass the PDS URL; auth server shows its own email form -- **Handle** — pass an AT Protocol handle (e.g. `alice.pds.example.com`); auth server resolves it and sends OTP directly -- **DID** — pass a DID (e.g. `did:plc:abc123...`); auth server resolves it and sends OTP directly +> **ePDS-specific behavior:** Put an email `login_hint` only on the returned +> authorization URL. Never put it in the PAR body. PDS core validates a PAR +> `login_hint` as an AT Protocol handle or DID and rejects an email with +> `Invalid login_hint`; ePDS auth-service accepts email hints from the browser +> authorization request. +> +> Email in a URL may appear in browser history, infrastructure logs, and error +> reports. Do not log authorization URLs. Prefer hosted email entry when that +> exposure is unacceptable, and configure query-string redaction where possible. -> **Important:** `login_hint` must **never** go in the PAR body when the value -> is an email address. The PDS core validates `login_hint` as an ATProto -> identity (handle or DID) and rejects emails with `Invalid login_hint`. Put -> email `login_hint` only on the **auth redirect URL** — that request goes to -> the ePDS auth service (Better Auth layer), which accepts emails. +## Quick Start -## Quick Start — Flow 2 (recommended) +### 1. Client Metadata -Use `@atproto/oauth-client-node` for any flow that does not require passing a -raw email as `login_hint`. - -### 1. Client Metadata (confidential client) - -Host at your `client_id` URL (must be HTTPS in production). Provide the -public key via `jwks_uri` (remote endpoint) or inline `jwks` — the two -are mutually exclusive: +Host metadata at the HTTPS URL used as `client_id`. A confidential client is +recommended for server-rendered web applications: ```json { @@ -72,23 +67,22 @@ are mutually exclusive: } ``` -> **On the `scope` value:** `atproto` is mandatory and must be listed first; the -> remaining entries request only the permissions your app needs. The examples here -> reference two hypercerts specific permission sets via the `include:` prefix — -> `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`; -> substitute the permission sets your own app defines. A -> permission set that bundles `rpc:` service calls also needs an -> `?aud=` parameter (with `#` percent-encoded as `%23`); these are -> write-only sets, so no `aud` is required. Avoid the legacy `transition:generic` -> catch-all. See the `atproto-oauth` skill's "Scopes and Permission Sets" for the -> grammar. +`atproto` is mandatory and must come first. Replace example Hypercerts +permission sets with only those your app needs. A permission set containing +`rpc:` service calls also needs `?aud=`; percent-encode `#` as +`%23`. Avoid legacy `transition:generic`. + +You may replace `jwks_uri` with inline `jwks`; never publish the private `d` +parameter. Read [client-metadata.md](references/client-metadata.md) for public +clients, JWKS generation and rotation, consent behavior, branding, and email +templates. -Alternatively, replace `jwks_uri` with an inline `jwks` object containing -the public key directly — see -[client-metadata.md](references/client-metadata.md) for both forms, the -force-consent gotcha with public clients, and key generation instructions. +### 2. Create One OAuth Client -### 2. Create the OAuth client +Create a singleton. Its state and session stores must persist across requests; +in-memory stores are unsafe for serverless or multi-instance deployments. +Multi-instance deployments also need a distributed `requestLock` so concurrent +token refreshes for one DID cannot revoke each other. ```typescript import { NodeOAuthClient } from '@atproto/oauth-client-node' @@ -97,6 +91,8 @@ import { JoseKey } from '@atproto/jwk-jose' const privateJwk = JSON.parse(process.env.OAUTH_PRIVATE_KEY!) const client = new NodeOAuthClient({ + // Required across multiple processes. Use a distributed lock implementation. + requestLock, clientMetadata: { client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', @@ -111,10 +107,9 @@ const client = new NodeOAuthClient({ dpop_bound_access_tokens: true, }, keyset: [await JoseKey.fromImportable(privateJwk, privateJwk.kid)], - stateStore: { async set(key, value) { - /* store in DB/Redis */ + /* persist in DB/Redis */ }, async get(key) { /* retrieve */ @@ -125,7 +120,7 @@ const client = new NodeOAuthClient({ }, sessionStore: { async set(key, value) { - /* store in DB/Redis */ + /* persist in DB/Redis */ }, async get(key) { /* retrieve */ @@ -137,133 +132,205 @@ const client = new NodeOAuthClient({ }) ``` -### 3. Login handler +A single-process deployment may omit `requestLock`; the SDK then uses only a +process-local fallback and warns that credentials might be revoked. A public +client omits `OAUTH_PRIVATE_KEY`, `keyset`, and confidential-client metadata +fields; read [client-metadata.md](references/client-metadata.md). -```typescript -// No identifier — auth server shows email form -const authUrl = await client.authorize('https://pds.example.com') +For local HTTP-only ePDS development, pass `allowHttp: true`. Never enable it in +production. -// With a handle — auth server resolves and sends OTP -const authUrl = await client.authorize('alice.pds.example.com') +### 3. Login Handler -// With a DID — same behaviour as handle -const authUrl = await client.authorize('did:plc:abc123...') +```typescript +const EPDS_URL = process.env.EPDS_URL! + +export async function beginLogin(input?: { + email?: string + identifier?: string + forceLogin?: boolean +}): Promise { + // identifier may be a handle or DID. Without one, use the configured ePDS. + // Keep prompt in PAR for pds-core, then duplicate it on the returned URL for + // auth-service's browser-session decision. + const authorizationUrl = await client.authorize( + input?.identifier ?? EPDS_URL, + input?.forceLogin ? { prompt: 'login' } : undefined, + ) + + // Email hints belong on the browser authorization URL, not in PAR. + if (input?.email) { + authorizationUrl.searchParams.set('login_hint', input.email) + } + + // ePDS reads this URL parameter when deciding whether to reuse a browser + // session. A prompt stored only in PAR does not engage that behavior. + if (input?.forceLogin) { + authorizationUrl.searchParams.set('prompt', 'login') + } + + return authorizationUrl +} ``` -Redirect the user's browser to `authUrl`. +Validate and normalize user input before calling this helper. Redirect the +browser to the returned URL. + +### 4. Use One Shared Callback -### 4. Callback handler +Email, hosted-form, handle, and DID login should normally use one callback: + +```text +/api/oauth/callback +``` + +`client.callback()` matches `state` to context created by `authorize()`, then +restores issuer, redirect URI, PKCE verifier, and DPoP key before exchanging the +code. Login method does not require a separate callback. ```typescript const { session, state } = await client.callback( new URLSearchParams(callbackQueryString), ) -// session.did — the user's DID (e.g. "did:plc:abc123...") -// session.fetchHandler() — authenticated fetch for AT Protocol API calls + +const userDid = session.did ``` -### 5. Restore a session +Use a separate callback only when application behavior genuinely differs. In +that case, register both URIs in client metadata and pass the selected URI to +`authorize()` explicitly: ```typescript -const session = await client.restore(userDid) -// Use session.fetchHandler() for API calls +const epdsCallbackUri = 'https://yourapp.example.com/api/oauth/epds/callback' + +await client.authorize(EPDS_URL, { + redirect_uri: epdsCallbackUri, +}) + +// Pass the same non-default URI during code exchange. +await client.callback(callbackParams, { + redirect_uri: epdsCallbackUri, +}) ``` -### 6. Serve library endpoints +Application-session management is a generic AT Protocol OAuth concern; follow +the `atproto-oauth` skill for that guidance. -Your `client_id` URL must be publicly reachable. If you use `jwks_uri` -(rather than inline `jwks`), that endpoint must also be reachable. You -can serve both from the `NodeOAuthClient` instance: +### 5. Serve Metadata and JWKS ```typescript -app.get('/client-metadata.json', (req, res) => { - res.json(client.clientMetadata) +app.get('/client-metadata.json', (_request, response) => { + response.json(publishedClientMetadata) }) -// Only needed when using jwks_uri (not inline jwks) -app.get('/jwks.json', (req, res) => { - res.json(client.jwks) +// Only needed when metadata uses jwks_uri instead of inline jwks. +app.get('/jwks.json', (_request, response) => { + response.json(client.jwks) }) ``` -## Quick Start — Flow 1 (hand-rolled) +Serve the original `publishedClientMetadata` object when using ePDS extension +fields such as `branding` or `epds_handle_mode`. SDK validation may omit unknown +extension fields from `client.clientMetadata`. -Flow 1 requires hand-rolled PAR and token exchange because the library -cannot pass a raw email as `login_hint`. See -[references/flows.md](references/flows.md) for the full walkthrough and -[references/dpop-pkce.md](references/dpop-pkce.md) for the helper -functions. +The ePDS must be able to reach discoverable client metadata and remote JWKS. A +remote ePDS cannot fetch an endpoint bound only to your local machine; use an +HTTPS tunnel or deployed development URL. Loopback clients follow separate AT +Protocol metadata rules and are not confidential clients. -The abbreviated version: +## Forcing Fresh Sign-In -1. Generate DPoP key pair and PKCE verifier -2. POST to `/oauth/par` (with DPoP nonce retry) -3. Redirect browser to `/oauth/authorize?...&login_hint=` -4. Handle callback: verify state, exchange code for tokens (with DPoP nonce retry) +Existing ePDS cookies may lead to account reuse or an account chooser. Put +`prompt=login` in both PAR and the returned authorization URL: -## Forcing a Fresh Sign-In (`prompt=login`) +```typescript +const url = await client.authorize(EPDS_URL, { prompt: 'login' }) +url.searchParams.set('prompt', 'login') +``` -When a previous sign-in's cookies are present in the browser, ePDS skips -the email code form and lands the user on the account chooser to confirm -which identity to reuse. To force the email code form instead, use the -standard OIDC `prompt=login` parameter. +For email-first login, append the email too: -**Important — where to put it:** ePDS's auth service decides whether to -engage session reuse by inspecting the **query string** of the -`/oauth/authorize` redirect. PAR-body `prompt=login` is ignored. +```typescript +const url = await client.authorize(EPDS_URL, { prompt: 'login' }) +url.searchParams.set('login_hint', email) +url.searchParams.set('prompt', 'login') +``` -If your OAuth library (e.g. `NodeOAuthClient`) only supports passing -`prompt` via the PAR body, you must also append `&prompt=login` to the -authorization URL the library returns before redirecting the user. +The PAR value informs pds-core's authentication guard. ePDS auth-service also +inspects the browser authorization URL when deciding whether to reuse a browser +session, so the URL value is required too. -**Hand-rolled (Flow 1):** +## Verification -```typescript -const authUrl = - `${authEndpoint}?client_id=${encodeURIComponent(clientId)}` + - `&request_uri=${encodeURIComponent(parData.request_uri)}` + - (forceLogin ? '&prompt=login' : '') +Check authorization-server discovery: + +```bash +curl -fsS "$EPDS_URL/.well-known/oauth-authorization-server" \ + | python3 -m json.tool ``` -**With `NodeOAuthClient` (Flow 2):** +Confirm metadata contains exact callback URI and expected scope: -```typescript -const url = await client.authorize(input, { prompt: 'login' }) -// Library puts prompt in PAR; also append it to the URL query string -// so ePDS's session-reuse short-circuit fires. -url.searchParams.set('prompt', 'login') +```bash +curl -fsS "https://yourapp.example.com/client-metadata.json" \ + | python3 -m json.tool +``` + +When metadata uses `jwks_uri`, confirm that endpoint exposes no private `d` +value: + +```bash +curl -fsS "https://yourapp.example.com/jwks.json" \ + | python3 -m json.tool +``` + +Skip this endpoint check when metadata contains inline `jwks`; inspect those +inline keys instead. + +Test login endpoint without following redirects. Expect a `3xx` response whose +`Location` points to ePDS authorization endpoint and contains `request_uri`: + +```bash +curl -sS -o /dev/null -D - \ + "https://yourapp.example.com/api/oauth/login?email=user%40example.com" ``` -## Common Pitfalls +For email-first login, confirm `Location` also contains encoded `login_hint`. +Never print OAuth tokens, private JWKs, cookies, or store contents while +debugging. -| Pitfall | Fix | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| Consent screen on every login | Switch to `private_key_jwt` — public clients force consent unless in the PDS trusted list | -| Flash of email form (Flow 1) | Include `login_hint` on the **auth redirect URL only** (never in the PAR body) | -| `Invalid login_hint` from PAR | Remove `login_hint` from the PAR body — PDS core only accepts handles/DIDs, not emails | -| `auth_failed` immediately | Check Caddy logs — likely a DNS/upstream name mismatch | -| DPoP rejected (hand-rolled only) | Always implement the nonce retry loop (ePDS always demands a nonce) | -| Token exchange fails (hand-rolled) | Restore the DPoP key pair from the session cookie, don't generate a new one | -| `Cannot find package` in tests | Run `pnpm build` before `pnpm test` — vitest needs `dist/` | -| `NodeOAuthClient` callback 401 | Ensure `stateStore` and `sessionStore` persist across requests (not in-memory for serverless) | -| `prompt=login` ignored, chooser still shown | Append `&prompt=login` to the **authorize URL** query string — PAR body alone doesn't engage ePDS's short-circuit | +## Common Failures + +| Failure | Likely cause | Fix | +| --------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------- | +| `Invalid login_hint` during PAR | Email included in PAR body | Append email only after `authorize()` returns | +| Fresh-login request still shows chooser | `prompt=login` exists only in PAR | Append it to returned authorization URL | +| `Invalid redirect_uri` | Callback missing from metadata, or wrong URI selected | Register exact URI; pass `redirect_uri` when not using first entry | +| `client_id not found` | ePDS cannot fetch metadata URL | Use reachable HTTPS metadata URL or supported loopback client metadata | +| Callback returns 401 or loses state | In-memory store lost or request reached another instance | Use shared persistent state and session stores | +| Consent appears every login | Public untrusted client | Use `private_key_jwt` or operator trust configuration | +| New account has no profile | Profile record has not been created | Use DID as display fallback; resolve handle separately when needed | +| OTP never arrives | Unknown email, delivery delay, or ePDS mail configuration | Verify address and inspect ePDS operator logs without exposing secrets | ## Handles -New users choose their own handle during signup (e.g. `alice.pds.example.com`). -The local part must be 5–20 characters, alphanumeric with hyphens. Handles are -not derived from the user's email address, for privacy. +New users receive a handle during signup. With `epds_handle_mode` set to +`picker` or `picker-with-random`, they can choose a local part of 5–20 +characters using letters, numbers, and hyphens. With `random`, ePDS assigns the +handle without showing a picker. Handles are not derived from email addresses. -## ePDS Endpoints (defaults) +Do not expect `OAuthSession` to expose `session.handle`; its stable identity is +`session.did`. Resolve current handle through an AT Protocol identity resolver +when needed. -``` -PAR: https:///oauth/par -Auth: https://auth./oauth/authorize -Token: https:///oauth/token -``` +## ePDS Endpoint Discovery + +Do not hard-code PAR, authorization, or token endpoints. `NodeOAuthClient` +discovers them through OAuth authorization-server metadata. ePDS commonly uses +separate PDS and auth-service hostnames; that is normal. ## Reference Files -- [Client metadata fields](references/client-metadata.md) — confidential vs public, JWKS, all fields, email branding -- [Full flow walkthrough](references/flows.md) — sequence diagrams, Flow 1 hand-rolled code, Flow 2 library code -- [PKCE and DPoP helpers](references/dpop-pkce.md) — Flow 1 only; Flow 2 should use `NodeOAuthClient` instead +- [Client metadata](references/client-metadata.md): confidential/public clients, JWKS, branding, email templates +- [Flow walkthrough](references/flows.md): hosted-form and email-first login examples +- [PKCE and DPoP](references/dpop-pkce.md): responsibilities handled by `NodeOAuthClient` diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index 130aceee..8aab47bf 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -113,10 +113,9 @@ key generation and serving details. | `dpop_bound_access_tokens` | Yes | Always `true` | | `client_uri` | No | Your app's homepage URL | | `logo_uri` | No | URL to your app logo (shown on login page) | -| `email_template_uri` | No | URL to a custom OTP email HTML template | -| `email_subject_template` | No | Custom email subject line with `{{code}}` placeholder | +| `email_template_uri` | No | URL to a custom OTP email HTML template. Trusted clients only. Must use HTTPS; HTTP is permitted only when `EPDS_ALLOW_PRIVATE_IPS=true` enables `allowHttp` for local development. Keep that setting disabled for internet-facing deployments. | +| `email_subject_template` | No | Custom email subject line with `{{code}}` placeholder. Trusted clients only. | | `brand_color` | No | Hex colour for buttons and input focus rings (default: `#1A130F`) | -| `background_color` | No | Hex colour for the login page background (default: `#F2EBE4`) | | `epds_handle_mode` | No | ePDS extension. Handle picker variant for new users: `"picker"`, `"random"`, or `"picker-with-random"` (default). See [tutorial](../../../../docs/tutorial.md#optional-control-the-handle-picker). | | `epds_skip_consent_on_signup` | No | ePDS extension. When `true`, skip the consent screen on initial sign-up. Only honoured when the PDS has `PDS_SIGNUP_ALLOW_CONSENT_SKIP=true` AND the client is in `PDS_OAUTH_TRUSTED_CLIENTS`. | | `epds_handle_login_url` | No | ePDS extension. Absolute http(s) URL on your client's origin. When set, the auth-service login page renders an "Or sign in with ATProto/Bluesky" button; submitting a handle redirects the browser to this URL with `?handle=` appended. See [tutorial](../../../../docs/tutorial.md#optional-offer-atprotobluesky-handle-sign-in). | @@ -134,18 +133,20 @@ for the route list and example URLs. ## Custom email templates -If you provide `email_template_uri`, the auth service fetches that URL and -uses it as the OTP email body instead of the default Certified template. +Custom email HTML and subject fields are honored only when the client is in +`PDS_OAUTH_TRUSTED_CLIENTS`. When trusted metadata provides +`email_template_uri`, auth-service fetches that HTML and uses it for the HTML +part of the OTP email. The server retains control of the plain-text part. Your template must be an HTML file. Supported placeholders: -| Placeholder | Description | -| ------------------------------------- | ----------------------------------------- | -| `{{code}}` | The 8-digit OTP code — **required** | -| `{{app_name}}` | Value of `client_name` from your metadata | -| `{{logo_uri}}` | Value of `logo_uri` from your metadata | -| `{{#is_new_user}}...{{/is_new_user}}` | Block shown only on first sign-up | -| `{{^is_new_user}}...{{/is_new_user}}` | Block shown only on subsequent sign-ins | +| Placeholder | Description | +| ------------------------------------- | ------------------------------------------------- | +| `{{code}}` | Configured 4–12-character OTP code — **required** | +| `{{app_name}}` | Value of `client_name` from your metadata | +| `{{logo_uri}}` | Value of `logo_uri` from your metadata | +| `{{#is_new_user}}...{{/is_new_user}}` | Block shown only on first sign-up | +| `{{^is_new_user}}...{{/is_new_user}}` | Block shown only on subsequent sign-ins | Minimal template example: @@ -281,6 +282,12 @@ allow-list. ## Local development -During local development you can use `http://localhost` client IDs. The -`client_id` must still be a reachable URL — ePDS fetches it at runtime. -Use a local server or `ngrok` to expose your metadata endpoint. +A remote ePDS must be able to fetch a discoverable client metadata URL and any +remote `jwks_uri`. It cannot reach a server bound only to your localhost. Every +ordinary web client, including a public client, must use an HTTPS tunnel or +deployed HTTPS development URL. + +AT Protocol loopback clients are the only exception. They use a separate +metadata encoding and public-client rules. Do not assume an ordinary +`http://localhost/.../client-metadata.json` URL will be fetched successfully by +a remote ePDS. diff --git a/.agents/skills/epds-login/references/dpop-pkce.md b/.agents/skills/epds-login/references/dpop-pkce.md index 58255eb3..c73585db 100644 --- a/.agents/skills/epds-login/references/dpop-pkce.md +++ b/.agents/skills/epds-login/references/dpop-pkce.md @@ -1,185 +1,14 @@ -# PKCE and DPoP Helper Implementations +# PKCE and DPoP -> **Flow 2 does not need these helpers.** If your app uses -> `@atproto/oauth-client-node` (recommended for any flow that does not pass -> a raw email as `login_hint`), the library handles PKCE, DPoP, and nonce -> retry internally. These helpers are only needed for **Flow 1** (hand-rolled -> PAR/DPoP with email `login_hint`). - -Copy these into your project. They have no dependencies beyond Node's built-in -`node:crypto` module. +`NodeOAuthClient` handles PKCE, DPoP, and the associated OAuth state, including +for email-first login: ```typescript -import * as crypto from 'node:crypto' - -// --------------------------------------------------------------------------- -// PKCE helpers -// --------------------------------------------------------------------------- - -/** Generate a random code verifier for PKCE. Store this in your session. */ -export function generateCodeVerifier(): string { - return crypto.randomBytes(32).toString('base64url') -} - -/** Derive the code challenge to send to the auth server. */ -export function generateCodeChallenge(verifier: string): string { - return crypto.createHash('sha256').update(verifier).digest('base64url') -} - -/** Generate a random state value. Store this in your session. */ -export function generateState(): string { - return crypto.randomBytes(16).toString('base64url') -} - -// --------------------------------------------------------------------------- -// DPoP helpers -// --------------------------------------------------------------------------- - -/** - * Generate a fresh DPoP key pair. - * - * Call this once per login attempt. Store `privateJwk` in your session cookie - * so the callback handler can restore the key pair for token exchange. - * Never reuse a key pair across different login flows. - */ -export function generateDpopKeyPair() { - const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { - namedCurve: 'P-256', - }) - return { - privateKey, - publicJwk: publicKey.export({ format: 'jwk' }), - privateJwk: privateKey.export({ format: 'jwk' }), - } -} - -/** - * Restore a DPoP key pair from a serialized private JWK. - * - * Use this in the callback handler to recover the key pair from the session - * cookie that was set during the login handler. - */ -export function restoreDpopKeyPair(privateJwk: crypto.JsonWebKey) { - const privateKey = crypto.createPrivateKey({ key: privateJwk, format: 'jwk' }) - const publicKey = crypto.createPublicKey(privateKey) - return { - privateKey, - publicJwk: publicKey.export({ format: 'jwk' }), - } -} - -/** - * Create a DPoP proof JWT for a single HTTP request. - * - * Create a new proof for every request — they are single-use by design. - * - * @param opts.nonce - Include when the server returned a `dpop-nonce` header. - * @param opts.accessToken - Include when making API calls with an access token. - */ -export function createDpopProof(opts: { - privateKey: crypto.KeyObject - jwk: object - method: string - url: string - nonce?: string - accessToken?: string -}): string { - const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: opts.jwk } - - const payload: Record = { - jti: crypto.randomUUID(), - htm: opts.method, - htu: opts.url, - iat: Math.floor(Date.now() / 1000), - } - if (opts.nonce) payload.nonce = opts.nonce - if (opts.accessToken) { - payload.ath = crypto - .createHash('sha256') - .update(opts.accessToken) - .digest('base64url') - } - - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url') - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') - const signingInput = `${headerB64}.${payloadB64}` - const sig = crypto.sign('sha256', Buffer.from(signingInput), opts.privateKey) - return `${signingInput}.${derToRaw(sig).toString('base64url')}` -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** - * Convert a DER-encoded ECDSA signature to raw r||s format. - * Required because Node's crypto.sign() outputs DER, but JWTs expect raw. - */ -function derToRaw(der: Buffer): Buffer { - // DER: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s] - let offset = 2 - if (der[1]! > 0x80) offset += der[1]! - 0x80 // long-form length - - offset++ // skip 0x02 tag - const rLen = der[offset++]! - let r = der.subarray(offset, offset + rLen) - offset += rLen - - offset++ // skip 0x02 tag - const sLen = der[offset++]! - let s = der.subarray(offset, offset + sLen) - - // DER may pad with a leading zero byte to indicate positive — strip it - if (r.length > 32) r = r.subarray(r.length - 32) - if (s.length > 32) s = s.subarray(s.length - 32) - - const raw = Buffer.alloc(64) - r.copy(raw, 32 - r.length) - s.copy(raw, 64 - s.length) - return raw -} +const url = await client.authorize(epdsUrl) +url.searchParams.set('login_hint', email) ``` -## Nonce retry pattern (Flow 1 only) - -ePDS always rejects the first DPoP proof with a `400` and a `dpop-nonce` -header. This is standard behaviour. For Flow 1 (hand-rolled), wrap every -PAR and token request in this retry loop. Flow 2 does not need this — -`NodeOAuthClient` handles nonce retry internally. +The SDK handles PKCE, DPoP key generation and proof signing, nonce retry, PAR, +token exchange, client assertions, and persisted OAuth state. -```typescript -async function fetchWithDpopRetry( - url: string, - body: URLSearchParams, - privateKey: crypto.KeyObject, - publicJwk: object, -): Promise { - const makeProof = (nonce?: string) => - createDpopProof({ privateKey, jwk: publicJwk, method: 'POST', url, nonce }) - - let res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(), - }, - body: body.toString(), - }) - - if (!res.ok) { - const nonce = res.headers.get('dpop-nonce') - if (nonce && res.status === 400) { - res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(nonce), - }, - body: body.toString(), - }) - } - } - - return res -} -``` +See [`../SKILL.md`](../SKILL.md) for maintained integration guidance. diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index 4b6a9a15..23266383 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -1,352 +1,36 @@ -# Flow Walkthroughs +# Flow Walkthrough -## Which flow should I use? +Use `NodeOAuthClient` for email, hosted-form, handle, and DID login. -| Flow | App provides | User experience | Implementation | -| ---- | ----------------------- | ---------------------------- | -------------------- | -| 1 | Email address | OTP screen immediately | Hand-rolled PAR/DPoP | -| 2 | Nothing, handle, or DID | Depends on input (see below) | `NodeOAuthClient` | - -**Flow 1** is the only flow that requires hand-rolled PAR and DPoP code. -`@atproto/oauth-client-node`'s `authorize()` method explicitly omits -`login_hint` from its options — the library resolves handles and DIDs -itself and overrides the hint. Since Flow 1 needs to pass a raw email -as `login_hint` on the auth redirect URL (not in the PAR body), it -cannot use the library. - -**Flow 2** should use `NodeOAuthClient`, which handles PAR, PKCE, DPoP, -nonce retry, token exchange, and session management automatically. It -covers three input variants — all use the same code path: - -- **No identifier** — pass the PDS URL; auth server shows its own email form -- **Handle** — pass `alice.pds.example.com`; auth server resolves it, sends OTP directly -- **DID** — pass `did:plc:abc123...`; same as handle - -Both flows end the same way: the user enters an OTP, ePDS redirects -back to your app, and your callback receives an authorization code to -exchange for tokens. - ---- - -## Flow 2 — Using `NodeOAuthClient` - -### Setup - -See the [SKILL.md quick start](../SKILL.md) for `NodeOAuthClient` -construction (client metadata, keyset, stores). - -### Login handler +For a hosted-form flow, where the client does not collect the user's email: ```typescript -// No identifier — auth server shows email form -const authUrl = await client.authorize('https://pds.example.com') - -// With a handle — auth server resolves and sends OTP -const authUrl = await client.authorize('alice.pds.example.com') - -// With a DID — same behaviour as handle -const authUrl = await client.authorize('did:plc:abc123...') +const url = await client.authorize(epdsUrl) +// Redirect without adding login_hint. ``` -The `authorize()` method: - -1. Resolves the input (handle → DID → PDS endpoint, or uses the PDS URL directly) -2. Sends a PAR request with PKCE and DPoP (including nonce retry) -3. Stores the OAuth state in your `stateStore` -4. Returns the authorization URL to redirect the user to +The ePDS presents its hosted login form, where the user supplies their email. +“No email” means the client does not collect the email before redirecting; it +does not mean the account has no email. -Redirect the user's browser to the returned URL. - -### Callback handler +For an email-first flow: ```typescript -// GET /api/oauth/callback?code=...&state=...&iss=... -const { session, state } = await client.callback( - new URLSearchParams(callbackQueryString), -) - -const userDid = session.did // e.g. "did:plc:abc123..." -// session.fetchHandler() returns an authenticated fetch for AT Protocol API calls +const url = await client.authorize(epdsUrl) +url.searchParams.set('login_hint', email) ``` -The `callback()` method: - -1. Validates the state against your `stateStore` -2. Exchanges the authorization code for tokens (with DPoP) -3. Stores the session in your `sessionStore` -4. Returns the `OAuthSession` and original state - -### Restoring a session +When fresh authentication is required, also pass `prompt=login` to +`authorize()` so it enters PAR, then append it to returned URL for ePDS +auth-service session handling: ```typescript -const session = await client.restore(userDid) -// Use session.fetchHandler() for API calls -// session.signOut() to end the session +const url = await client.authorize(epdsUrl, { prompt: 'login' }) +url.searchParams.set('login_hint', email) +url.searchParams.set('prompt', 'login') ``` -### Step-by-step (no identifier) - -1. User clicks "Sign in" in your app -2. Your login handler calls `client.authorize('https://pds.example.com')` -3. Library sends PAR request, gets `request_uri`, stores state -4. Your app redirects browser to the returned auth URL -5. Auth server shows email form -6. User enters email, receives OTP, enters it -7. **New users only**: ePDS shows a handle picker -8. Auth server redirects to your `redirect_uri` with `?code=&state=&iss=` -9. Your callback calls `client.callback(params)` — library handles token exchange -10. User is logged in - -When passing a handle or DID instead of the PDS URL, the flow is -identical except the user skips the email form (the auth server resolves -the handle/DID to an email and sends the OTP directly). - ---- - -## Flow 1 — Hand-rolled (email `login_hint`) - -Flow 1 requires hand-rolled PAR and token exchange because the library -cannot pass a raw email as `login_hint`. - -### Step-by-step - -1. User enters their email in your app and clicks "Sign in" -2. Your login handler: - - a. Generates a DPoP key pair and PKCE verifier (see [dpop-pkce.md](dpop-pkce.md)) - - b. POSTs to `/oauth/par` (with DPoP nonce retry) - - c. Stores DPoP private key, code verifier, and state in a signed session cookie - - d. Redirects the browser to `/oauth/authorize?...&login_hint=` - -3. The auth server sees the email, immediately sends the OTP, and shows the - code entry screen (no email form shown) -4. User reads OTP from email and submits it -5. Auth server verifies the code -6. **New users only**: ePDS shows a handle picker -7. ePDS redirects back to your app's callback URL -8. Your callback handler exchanges the code for tokens (with DPoP nonce retry) -9. User is logged in - -### Login handler code - -```typescript -import { - generateDpopKeyPair, - generateCodeVerifier, - generateCodeChallenge, - generateState, - createDpopProof, -} from './auth-helpers' - -const PAR_ENDPOINT = 'https://pds.example.com/oauth/par' -const AUTH_ENDPOINT = 'https://auth.pds.example.com/oauth/authorize' -const CLIENT_ID = 'https://yourapp.example.com/client-metadata.json' -const REDIRECT_URI = 'https://yourapp.example.com/api/oauth/callback' - -export async function handleLogin(email: string) { - const { privateKey, publicJwk, privateJwk } = generateDpopKeyPair() - const codeVerifier = generateCodeVerifier() - const codeChallenge = generateCodeChallenge(codeVerifier) - const state = generateState() - - const parBody = new URLSearchParams({ - client_id: CLIENT_ID, - redirect_uri: REDIRECT_URI, - response_type: 'code', - scope: - 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', - state, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - }) - - // ePDS always requires a nonce on the first attempt — retry automatically - const makeProof = (nonce?: string) => - createDpopProof({ - privateKey, - jwk: publicJwk, - method: 'POST', - url: PAR_ENDPOINT, - nonce, - }) - - let parRes = await fetch(PAR_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(), - }, - body: parBody.toString(), - }) - if (!parRes.ok) { - const nonce = parRes.headers.get('dpop-nonce') - if (nonce && parRes.status === 400) { - parRes = await fetch(PAR_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(nonce), - }, - body: parBody.toString(), - }) - } - } - if (!parRes.ok) throw new Error(`PAR failed: ${parRes.status}`) - const { request_uri } = await parRes.json() - - // Save session data in a signed cookie for the callback - setSessionCookie({ state, codeVerifier, dpopPrivateJwk: privateJwk }) - - // Redirect user to auth server — include login_hint so OTP screen shows immediately - const authUrl = new URL(AUTH_ENDPOINT) - authUrl.searchParams.set('client_id', CLIENT_ID) - authUrl.searchParams.set('request_uri', request_uri) - authUrl.searchParams.set('login_hint', email) - return redirect(authUrl.toString()) -} -``` - -### Callback handler code - -```typescript -import { restoreDpopKeyPair, createDpopProof } from './auth-helpers' - -const TOKEN_ENDPOINT = 'https://pds.example.com/oauth/token' - -export async function handleCallback(params: { code: string; state: string }) { - const session = getSessionFromCookie() - if (params.state !== session.state) throw new Error('state mismatch') - - const { privateKey, publicJwk } = restoreDpopKeyPair(session.dpopPrivateJwk) - - const tokenBody = new URLSearchParams({ - grant_type: 'authorization_code', - code: params.code, - redirect_uri: REDIRECT_URI, - client_id: CLIENT_ID, - code_verifier: session.codeVerifier, - }) - - const makeProof = (nonce?: string) => - createDpopProof({ - privateKey, - jwk: publicJwk, - method: 'POST', - url: TOKEN_ENDPOINT, - nonce, - }) - - let tokenRes = await fetch(TOKEN_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(), - }, - body: tokenBody.toString(), - }) - if (!tokenRes.ok) { - const nonce = tokenRes.headers.get('dpop-nonce') - if (nonce) { - tokenRes = await fetch(TOKEN_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(nonce), - }, - body: tokenBody.toString(), - }) - } - } - if (!tokenRes.ok) throw new Error(`Token exchange failed: ${tokenRes.status}`) - - const { access_token, sub: userDid } = await tokenRes.json() - - // userDid is e.g. "did:plc:abc123..." — resolve to handle via PLC directory - const plcRes = await fetch(`https://plc.directory/${userDid}`) - const { alsoKnownAs } = await plcRes.json() - const handle = alsoKnownAs - ?.find((u: string) => u.startsWith('at://')) - ?.replace('at://', '') - - // Store access_token and userDid in your session — user is now logged in -} -``` - ---- - -## Sequence diagrams - -### Flow 1 — App passes email as `login_hint` - -```mermaid -sequenceDiagram - actor User - participant App as Your App - participant PDS as ePDS - participant Auth as Auth Server - participant Inbox as User's Inbox - - User->>App: Enters email, clicks Sign in - App->>PDS: POST /oauth/par (DPoP + PKCE) - PDS-->>App: { request_uri } - App-->>User: Redirect to /oauth/authorize?...&login_hint=email - - User->>Auth: GET /oauth/authorize - Auth->>Inbox: Sends 8-digit OTP - Auth-->>User: Shows OTP entry screen - - User->>Auth: Submits OTP code - Auth-->>User: Redirect to your callback URL - - User->>App: GET /api/oauth/callback?code=... - App->>PDS: POST /oauth/token (DPoP + code_verifier) - PDS-->>App: { access_token, user DID } - App-->>User: Logged in -``` - -### Flow 2 — No identifier (via `NodeOAuthClient`) - -```mermaid -sequenceDiagram - actor User - participant App as Your App - participant Lib as NodeOAuthClient - participant PDS as ePDS - participant Auth as Auth Server - participant Inbox as User's Inbox - - User->>App: Clicks Sign in - App->>Lib: authorize('https://pds.example.com') - Lib->>PDS: POST /oauth/par (auto DPoP + PKCE) - PDS-->>Lib: { request_uri } - Lib-->>App: auth URL - App-->>User: Redirect to auth URL - - User->>Auth: GET /oauth/authorize - Auth-->>User: Shows email input form - - User->>Auth: Submits email - Auth->>Inbox: Sends 8-digit OTP - Auth-->>User: Shows OTP entry screen - - User->>Auth: Submits OTP code - Auth-->>User: Redirect to callback URL - - User->>App: GET /api/oauth/callback?code=...&state=... - App->>Lib: callback(params) - Lib->>PDS: POST /oauth/token (auto DPoP) - PDS-->>Lib: { tokens } - Lib-->>App: { session, state } - App-->>User: Logged in -``` - -### Flow 2 with handle or DID - -Same as the diagram above except: +Email `login_hint` belongs only on the returned browser authorization URL, +never in the PAR body. -- `authorize('alice.pds.example.com')` or `authorize('did:plc:abc123...')` -- Library resolves the identity to the user's PDS -- Auth server skips the email form and sends OTP directly +See [`../SKILL.md`](../SKILL.md) for maintained flow and callback guidance. diff --git a/.agents/skills/writing-changesets/SKILL.md b/.agents/skills/writing-changesets/SKILL.md index 2f513d95..c7444cb5 100644 --- a/.agents/skills/writing-changesets/SKILL.md +++ b/.agents/skills/writing-changesets/SKILL.md @@ -179,6 +179,25 @@ Every non-trivial changeset **must** contain, in this order: will recognise (env var names, field names, function names) — the plain-language rule only applies to the summary line above. + **Capitalize the first word after the bold audience label.** + A label such as `**End users:**` or `**Operators:**` is a + lead-in, but the text following it is a full sentence and must + start with a capital letter — the colon does not make the + following word mid-sentence. This applies even when the first + word is an identifier that is lowercase in code: capitalize the + surrounding prose, or reword so the identifier isn't + sentence-initial (e.g. start with "The `email_verified` claim + …", not "`email_verified` is now …"). + - ✅ **End users:** Apps no longer ask you to verify an address you have already confirmed. + - ✅ **Client app developers:** The `email_verified` claim is now `true` … + - ✅ **Operators:** Deploy the auth service and the PDS together … + - ❌ **End users:** apps no longer ask you to verify … (lowercase "apps") + - ❌ **Operators:** deploy the auth service … (lowercase "deploy") + + The same capitalization rule applies to each bullet when a + section is laid out as a bullet list (see below): every bullet + is a sentence and starts capitalized. + Each paragraph in the changeset body must be **unwrapped**: one paragraph per physical line. Do not manually hard-wrap prose at 72 or 80 columns inside the changeset file. Wrapped source lines @@ -381,7 +400,7 @@ Longer sign-in codes, optionally mixing letters and numbers. **End users:** OTP codes may now be longer than 6 digits and may include uppercase letters if the operator has opted into the `alphanumeric` charset. Codes of length 8 or more are displayed with a space in the middle (e.g. `1234 5678`) for readability; copy-paste still yields the flat code. -**Operators:** two new environment variables on the auth service — `OTP_LENGTH` (integer, range 4–12, default 8) and `OTP_CHARSET` (`numeric` (default) or `alphanumeric`). Values outside the range cause the service to fail on startup. The OTP input form fields adapt automatically; no template changes required. +**Operators:** Two new environment variables on the auth service — `OTP_LENGTH` (integer, range 4–12, default 8) and `OTP_CHARSET` (`numeric` (default) or `alphanumeric`). Values outside the range cause the service to fail on startup. The OTP input form fields adapt automatically; no template changes required. ``` A denser change with several distinct points per audience is @@ -402,12 +421,12 @@ A second app sign-in in your browser now skips the email code step. - You either land on the "approve this app" screen or on a chooser where you confirm which identity to reuse. - A "Use a different account" link on the chooser takes you back to the email form. -**Client app developers:** no client-side changes required. +**Client app developers:** No client-side changes required. - When a previous sign-in's cookies are present, the user lands on the account chooser to confirm which identity to reuse. - To force the email code form instead, set the standard OIDC `prompt` parameter to `login` on the authorization request your OAuth library sends. -**Operators:** no new required configuration. +**Operators:** No new required configuration. - ePDS auto-detects whether the auth service shares a parent domain with the PDS (`AUTH_HOSTNAME` ends with `.`) and broadens the device-session cookies to that parent. On unrelated hostnames the feature self-disables. ``` diff --git a/.env.example b/.env.example index b25b42a7..ccfdea00 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,7 @@ PDS_HOSTNAME=pds.example PDS_PUBLIC_URL=https://pds.example # HMAC secret for signing /oauth/epds-callback redirects. +# REQUIRED — both services refuse to start without it. # Generate with: openssl rand -hex 32 EPDS_CALLBACK_SECRET= @@ -158,9 +159,9 @@ EPDS_INVITE_CODE= AUTH_HOSTNAME=auth.pds.example AUTH_PORT=3001 +# REQUIRED — auth service refuses to start without it. # Generate with: openssl rand -hex 32 AUTH_SESSION_SECRET= -AUTH_CSRF_SECRET= EPDS_LINK_EXPIRY_MINUTES=10 EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8eb2dd2..97827a8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm format:check @@ -31,7 +31,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint @@ -43,7 +43,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck @@ -55,7 +55,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index eca24cf9..db82518d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -115,7 +115,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b538a76..e9197079 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - run: pnpm install --frozen-lockfile diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..e2228113 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.19.0 diff --git a/AGENTS.md b/AGENTS.md index 11c28450..9e7b83c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,30 @@ thresholds: { **Never lower thresholds.** If a change removes tested code (e.g. deleting a feature), add tests for other code to compensate. +### Coveralls Thresholds (Not Version-Controlled) + +Coverage is gated twice: by `vitest.config.ts` above, and by Coveralls. +The two measure different things, so a PR can pass one and fail the other. + +Coveralls' thresholds **cannot** be set from a file in this repo. They live +only on the Coveralls repo Settings page, under "Status Updates": + +| Setting | Value | Rationale | +| --------------------------------------- | ----- | ------------------------------------------------------------ | +| Coverage threshold for failure | 55% | Backstop below the vitest floors, so vitest trips first | +| Coverage decrease threshold for failure | 0.5% | Catches drift; tolerates rounding noise and honest refactors | + +The decrease threshold is the one thing vitest cannot express: vitest checks +absolute floors only, so nothing else catches a series of small drops. + +Do **not** add a `.coveralls.yml` with keys such as +`coverage_threshold_for_failure` or `coverage_decreased_threshold`. They are +silently ignored — the docs list no such keys, `coveralls-ruby` parses only +`repo_token` / `repo_secret_token` / `service_name`, and the +`coverallsapp/github-action@v2` used in `.github/workflows/ci.yml` neither +reads the file nor exposes a threshold input. Such a file looks like +version-controlled policy while configuring nothing. + ## Docker ```bash @@ -359,6 +383,14 @@ import { AuthServiceContext } from './context.js' schema and crashes. Leave unused tables/columns in place; they're harmless. - Do **not** directly read or modify `@atproto/pds` database tables — use `pds.ctx.accountManager.*` methods. + - **One documented exception:** enumerating every account in + `packages/pds-core/src/backfill-email-confirmed.ts`, because + `AccountManager` has no "list all accounts" call. Confined to that + operator-invoked script; the request path must never do it. See item 19 + of [`docs/design/pds-white-boxing.md`](docs/design/pds-white-boxing.md) + for the rationale and the breakage scenario — that document is the + catalogue of upstream-internal dependencies, and is the list to check + on every `@atproto/pds` bump. ## Security diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fe1351..8f8f0a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,202 @@ # ePDS +## 0.9.0 + +### Who should read this release + +- **End users:** + - [Update sign-in and approval screens while keeping account switching in email sign-in and preserving ePDS branding.](#v0.9.0-update-sign-in-and-approval-screens-while-keeping-account) +- **Client app developers:** + - [Update sign-in and approval screens while keeping account switching in email sign-in and preserving ePDS branding.](#v0.9.0-update-sign-in-and-approval-screens-while-keeping-account) +- **Operators:** + - [Update sign-in and approval screens while keeping account switching in email sign-in and preserving ePDS branding.](#v0.9.0-update-sign-in-and-approval-screens-while-keeping-account) + +### Minor Changes + +- [#251](https://github.com/hypercerts-org/ePDS/pull/251) [`afe6be7`](https://github.com/hypercerts-org/ePDS/commit/afe6be7daac04193a2a59fe3cf934ee659e74768) Thanks [@Kzoeps](https://github.com/Kzoeps)! - Update sign-in and approval screens while keeping account switching in email sign-in and preserving ePDS branding. + + **Affects:** End users, Client app developers, Operators + + **End users:** Selecting “Another account” continues to open the email sign-in form instead of the server's password form. Authorization and account-selection screens now use consistent ePDS colors, surfaces, and responsive controls instead of partially falling back to upstream styling. + + **Client app developers:** Review consent and account-picker branding against the updated screens. Update custom `branding.css` to target provider semantic variables and `data-slot` component attributes; CSS targeting old gray/slate utility classes may no longer match. The demo themes illustrate the supported provider UI 0.10 approach. Unresolved supported handles now return `HandleNotFound`, and OAuth/account-management request bodies over 100 KiB return HTTP 413. + + **Operators:** This upgrades PDS to 0.5.34 and shared safe-fetch to 0.4.0. Rebuild both core and auth images, verify startup with their bundled Node/Undici versions, and validate outbound identity/service resolution against the stricter URL policy before rollout. No new upstream database migrations were found in the assessed release range; rehearse rollback against copied data rather than assuming runtime compatibility. + +## 0.8.0 + +### Who should read this release + +- **End users:** + - [Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer.](#v0.8.0-signing-in-and-managing-your-account-now-run-on-the-latest) + - [Catch likely email-domain typos before sending a sign-in code.](#v0.8.0-catch-likely-email-domain-typos-before-sending-a-sign-in) + - [Sign-in, recovery and handle-choice feedback is now announced automatically by screen readers.](#v0.8.0-sign-in-recovery-and-handle-choice-feedback-is-now) + - [Sign-in code boxes now ignore stray characters pasted or typed alongside your code.](#v0.8.0-sign-in-code-boxes-now-ignore-stray-characters-pasted-or) + - [Asking for a new sign-in code now clears the boxes and tells you the old code has stopped working.](#v0.8.0-asking-for-a-new-sign-in-code-now-clears-the-boxes-and) + - [Sign-in and recovery email fields now work more reliably with browser autofill and password managers.](#v0.8.0-sign-in-and-recovery-email-fields-now-work-more-reliably) + - [Sign-in pages no longer leak a malformed client_id as the displayed app name.](#v0.8.0-sign-in-pages-no-longer-leak-a-malformed-client-id-as-the) + - [Handle-picker error wording is now accurate when the handle is reserved.](#v0.8.0-handle-picker-error-wording-is-now-accurate-when-the-handle) + - [Sign-in errors are written in plain English instead of showing the raw failure code.](#v0.8.0-sign-in-errors-are-written-in-plain-english-instead-of) + - [Sign-in buttons now show a visible keyboard focus ring.](#v0.8.0-sign-in-buttons-now-show-a-visible-keyboard-focus-ring) + - [Small grey text on the sign-in page is darker and easier to read.](#v0.8.0-small-grey-text-on-the-sign-in-page-is-darker-and-easier-to) + - [Apps you sign in to are now told that your email address is confirmed.](#v0.8.0-apps-you-sign-in-to-are-now-told-that-your-email-address-is) + - [The sign-in button that emails you another code now says "Send a new code" instead of "Resend code".](#v0.8.0-the-sign-in-button-that-emails-you-another-code-now-says) + - [The email sign-in button now waits until the page is ready before accepting clicks.](#v0.8.0-the-email-sign-in-button-now-waits-until-the-page-is-ready) + - [Sign-in error messages now appear next to the field that caused them, instead of at the top of the page.](#v0.8.0-sign-in-error-messages-now-appear-next-to-the-field-that) + - [Clickable text on the sign-in screens now looks the same wherever it appears.](#v0.8.0-clickable-text-on-the-sign-in-screens-now-looks-the-same) + - ["Use different email" on the sign-in code page now clears the email field.](#v0.8.0-use-different-email-on-the-sign-in-code-page-now-clears-the) +- **Client app developers:** + - [The approval and account-chooser screens now honour the handle mode your app asked for, instead of falling back to the server default.](#v0.8.0-the-approval-and-account-chooser-screens-now-honour-the) + - [Small grey text on the sign-in page is darker and easier to read.](#v0.8.0-small-grey-text-on-the-sign-in-page-is-darker-and-easier-to) + - [Apps you sign in to are now told that your email address is confirmed.](#v0.8.0-apps-you-sign-in-to-are-now-told-that-your-email-address-is) + - [Clickable text on the sign-in screens now looks the same wherever it appears.](#v0.8.0-clickable-text-on-the-sign-in-screens-now-looks-the-same) +- **Operators:** + - [Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer.](#v0.8.0-signing-in-and-managing-your-account-now-run-on-the-latest) + - [Apps you sign in to are now told that your email address is confirmed.](#v0.8.0-apps-you-sign-in-to-are-now-told-that-your-email-address-is) + - [Both services now refuse to start unless their secret environment variables are set, instead of silently falling back to well-known development defaults.](#v0.8.0-both-services-now-refuse-to-start-unless-their-secret) + - [Resend webhook logs now identify the email event type and provider in their message text.](#v0.8.0-resend-webhook-logs-now-identify-the-email-event-type-and) + +### Minor Changes + +- [#233](https://github.com/hypercerts-org/ePDS/pull/233) [`ebd214e`](https://github.com/hypercerts-org/ePDS/commit/ebd214effb0072b5280f2902083f8d0ab1366e4d) Thanks [@aspiers](https://github.com/aspiers)! - Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer. + + **Affects:** End users, Operators + + **End users:** the account and sign-in screens now come from a newer version of the underlying AT Protocol software, which adds changing your handle and deactivating or deleting your account through the account-management interface. The passwordless email-code sign-in and email verification you already use through ePDS are unchanged. + + **Operators:** + - ePDS now requires **Node.js 22.19.0 or newer** (previously 20). Deployments pinned to Node 20 must upgrade; the Docker images already use `node:22-alpine`. `engines.node` is now `>=22.19.0` (the floor is set by a transitive `undici@8` runtime dependency, which fails on Node 22.0–22.18). + - The bundled AT Protocol packages moved to their latest releases: `@atproto/pds` 0.5.23, `@atproto/oauth-provider` 0.21.1, `@atproto/oauth-provider-ui` 0.8.9. + - No configuration changes are required, but the native `better-sqlite3` module must be built for the Node 22 ABI — a clean `pnpm install` on Node 22 handles this; an in-place Node version switch needs `pnpm rebuild better-sqlite3`. + +- [#231](https://github.com/hypercerts-org/ePDS/pull/231) [`fe3b15c`](https://github.com/hypercerts-org/ePDS/commit/fe3b15c17b85319e7d7c73b036bafe90ade8c06b) Thanks [@aspiers](https://github.com/aspiers)! - Catch likely email-domain typos before sending a sign-in code. + + **Affects:** End users + + **End users:** addresses one edit away from Gmail, Hotmail, Outlook, Yahoo, or iCloud now show a “Did you mean…?” prompt as soon as they are typed. Accepting replaces the address in the email field; dismissing keeps the original. The prompt never changes or blocks an address silently. + +### Patch Changes + +- [#224](https://github.com/hypercerts-org/ePDS/pull/224) [`610b5e6`](https://github.com/hypercerts-org/ePDS/commit/610b5e6318e363f9987adaf7846393e66cd988a0) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in, recovery and handle-choice feedback is now announced automatically by screen readers. + + **Affects:** End users + + **End users:** authentication errors now use standard alert and live-region semantics across sign-in, recovery, handle selection, and the demo client, so screen-reader users hear failures without having to search the page for changed text. Repeated failures — such as entering a second incorrect one-time code — announce each time rather than falling silent, and handle availability ("Available!" / "Already taken.") is now announced as you type. + +- [#223](https://github.com/hypercerts-org/ePDS/pull/223) [`0637fa7`](https://github.com/hypercerts-org/ePDS/commit/0637fa77e71fc425647a67ced188372311500ef3) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in code boxes now ignore stray characters pasted or typed alongside your code. + + **Affects:** End users + + **End users:** every screen that asks for a sign-in code — the main sign-in screen, Account Settings sign-in, and account recovery — now drops characters your code can't contain, so a code pasted with surrounding quotes, brackets, or a trailing full stop is accepted instead of rejected. The main sign-in screen previously ignored only spaces and line breaks. Where the operator has turned on codes that mix letters and numbers, typing them in lowercase now works too. + +- [#220](https://github.com/hypercerts-org/ePDS/pull/220) [`68b2104`](https://github.com/hypercerts-org/ePDS/commit/68b21040c6e823b038c2e30410288a225c680f6c) Thanks [@aspiers](https://github.com/aspiers)! - Asking for a new sign-in code now clears the boxes and tells you the old code has stopped working. + + **Affects:** End users + + **End users:** the boxes reset on **Send a new code**, so a half-typed old code no longer has to be deleted by hand before you can type the new one. The confirmation also warns that only the newest code will be accepted, and points at the spam folder — the two things most likely to be going wrong for anyone who got that far. + +- [#222](https://github.com/hypercerts-org/ePDS/pull/222) [`9e12e30`](https://github.com/hypercerts-org/ePDS/commit/9e12e30edf8a9259fec8d261f8587d90d24a2e37) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in and recovery email fields now work more reliably with browser autofill and password managers. + + **Affects:** End users + + **End users:** email fields now identify themselves to browsers as email addresses, while the same sign-in field identifies itself as a username after switching to handle entry. This helps browsers and password managers offer the right saved value in each mode. + +- [#229](https://github.com/hypercerts-org/ePDS/pull/229) [`6f4fa2d`](https://github.com/hypercerts-org/ePDS/commit/6f4fa2dd43b32720154d0b8cc80e272f32568b7b) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in pages no longer leak a malformed client_id as the displayed app name. + + **Affects:** End users + + **End users:** if you arrived at a sign-in page with a broken `client_id` parameter on the URL (e.g. from a misconfigured app or a tampered link), the page would display that raw broken value as the app's name, e.g. "Sign in to not-a-url" or "Sign in to my-local-app". The page now falls back to "an application" in that case, so a malformed value in the URL doesn't leak into the visible page text or browser tab title. + +- [#243](https://github.com/hypercerts-org/ePDS/pull/243) [`534647d`](https://github.com/hypercerts-org/ePDS/commit/534647da664abe07cf543759f94b8286dc7b81a6) Thanks [@aspiers](https://github.com/aspiers)! - The approval and account-chooser screens now honour the handle mode your app asked for, instead of falling back to the server default. + + **Affects:** Client app developers + + **Client app developers:** no action needed — `epds_handle_mode` is resolved exactly as before (the `/oauth/authorize` query parameter, then your OAuth client metadata, then the server default). Previously the resolved mode was lost on the way to the approval step, so a flow that asked for a chosen handle could still show a generated one there. The mode is now carried through `/oauth/epds-callback` and applied on the approval and chooser screens. Values that are not one of the recognised modes are ignored rather than forwarded. + +- [#227](https://github.com/hypercerts-org/ePDS/pull/227) [`68b0760`](https://github.com/hypercerts-org/ePDS/commit/68b0760afa923ca2a3f3b4997137eca1a5ca0b77) Thanks [@aspiers](https://github.com/aspiers)! - Handle-picker error wording is now accurate when the handle is reserved. + + **Affects:** End users + + **End users:** if you tried to claim a handle that was on the reserved list (admin, www, …) the picker said "That handle was just taken — please choose another." That's misleading — it wasn't just taken, it's permanently unavailable. The wording is now "That handle is not available — please choose another." which doesn't imply you can wait it out. + +- [#237](https://github.com/hypercerts-org/ePDS/pull/237) [`ddde1b2`](https://github.com/hypercerts-org/ePDS/commit/ddde1b22f89b45dfd90a38a6f34915dce9b013d3) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in errors are written in plain English instead of showing the raw failure code. + + **Affects:** End users + + **End users:** a rejected code used to report "Invalid OTP", an unexplained acronym that ran straight into the recovery link beside it — "Invalid OTP Send a new code". It now reads "That code didn't work." followed by the link, and the two other common failures are similarly rewritten: an aged-out code says "That code has expired.", and one rejected after repeated wrong attempts says "Too many tries — that code is no longer usable.". Any failure outside those three is still shown as-is rather than hidden behind a generic apology, so an unexpected problem can still be reported accurately. + +- [#225](https://github.com/hypercerts-org/ePDS/pull/225) [`23dcd0f`](https://github.com/hypercerts-org/ePDS/commit/23dcd0f0b88ca5e87b4b465523be19d14864ea04) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in buttons now show a visible keyboard focus ring. + + **Affects:** End users + + **End users:** keyboard users tabbing through the sign-in pages couldn't tell where focus was — the buttons styled out the browser default focus ring without replacing it. Verify, Resend, Use different email, Recover, Send recovery code, Continue with email, Update handle, etc. all now show a clear outline when focused via the keyboard, while still hiding the ring on a mouse click. + +- [#232](https://github.com/hypercerts-org/ePDS/pull/232) [`87ef832`](https://github.com/hypercerts-org/ePDS/commit/87ef8320a169e970474747ca0e9d65b26a3984da) Thanks [@aspiers](https://github.com/aspiers)! - Small grey text on the sign-in page is darker and easier to read. + + **Affects:** End users, Client app developers + + **End users:** the 13px grey text — the terms line, the "Recover with backup email" link, the "or continue with" / "or use email" separators, and the "powered by Certified" footer — was too light to meet the WCAG AA contrast minimum against either the card or the page background behind it. It is now dark enough to pass on both. + + **Client app developers:** the `--muted-foreground` custom property on the sign-in page changes from `[#999](https://github.com/hypercerts-org/ePDS/issues/999)` to `[#666](https://github.com/hypercerts-org/ePDS/issues/666)`. If your `branding.css` sets it, check your value clears 4.5:1 against both `#F8F8F8` (the card) and `#E8E8E8` (the page) — the old default reached only 2.68:1 and 2.33:1 respectively, so a value chosen to sit alongside it may be equally low. The separator text between the social and email sign-in options now follows `--muted-foreground` too, where it previously ignored your override and stayed grey. + +- [#234](https://github.com/hypercerts-org/ePDS/pull/234) [`3924855`](https://github.com/hypercerts-org/ePDS/commit/39248551ccaa1097cdfd569bbe4d6617ad0fed7f) Thanks [@aspiers](https://github.com/aspiers)! - Apps you sign in to are now told that your email address is confirmed. + + **Affects:** End users, Client app developers, Operators + + **End users:** Apps no longer ask you to verify an address you have already confirmed with an emailed code. + + **Client app developers:** The `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. + + **Operators:** Deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. An older auth service is rejected with an explicit `Missing or invalid email_verified parameter` rather than a generic signature error, so a mixed-version rollout is recognisable in the logs. Accounts predating this release are repaired the next time their owner signs in **through the emailed-code flow**; a sign-in that does not prove control of the address leaves them unconfirmed. To fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. + +- [#41](https://github.com/hypercerts-org/ePDS/pull/41) [`d4256ea`](https://github.com/hypercerts-org/ePDS/commit/d4256ea03a0982386662c3a9adb1ff7d9f347a38) Thanks [@aspiers](https://github.com/aspiers)! - Both services now refuse to start unless their secret environment variables are set, instead of silently falling back to well-known development defaults. + + **Affects:** Operators + + **Operators:** `AUTH_SESSION_SECRET` and `EPDS_CALLBACK_SECRET` are now mandatory. Previously, a deployment that omitted them would boot successfully using hardcoded placeholders such as `dev-session-secret-change-me`, leaving session cookies and the signed `/oauth/epds-callback` redirect verifiable by anyone who has read the source. The auth service and PDS Core now throw at startup with a message naming the missing variable and the `openssl rand -hex 32` command to generate one. Check that both variables are set in your deployment before upgrading, and note that `EPDS_CALLBACK_SECRET` must be identical across the two services. + + `AUTH_CSRF_SECRET` has been removed entirely. CSRF protection derives its tokens from `crypto.randomBytes` and never consumed the value, so the variable was inert; it is no longer read, no longer generated by `scripts/setup.sh`, and can be deleted from your environment. + +- [#209](https://github.com/hypercerts-org/ePDS/pull/209) [`0af6d7f`](https://github.com/hypercerts-org/ePDS/commit/0af6d7f690f848517269e181d18a9a6229828e79) Thanks [@aspiers](https://github.com/aspiers)! - Resend webhook logs now identify the email event type and provider in their message text. + + **Affects:** Operators + + **Operators:** Log messages use text such as `Received email event 'delivered' from Resend`, making events legible in log viewers that de-emphasize structured fields. + +- [#236](https://github.com/hypercerts-org/ePDS/pull/236) [`e238c38`](https://github.com/hypercerts-org/ePDS/commit/e238c3830566b5e2960d4bd5bf40e474ac6db0e4) Thanks [@aspiers](https://github.com/aspiers)! - The sign-in button that emails you another code now says "Send a new code" instead of "Resend code". + + **Affects:** End users + + **End users:** each request emails a genuinely new code and stops the previous one working, so "Resend" was misleading — it suggested the code already in your inbox was still the one to type. The confirmation after clicking now starts "Sent!" rather than "Resent!", and still tells you that earlier codes no longer work. The same wording appears on the recovery and account sign-in pages, and on the link offered beside a rejected or expired code. + +- [#242](https://github.com/hypercerts-org/ePDS/pull/242) [`a0a819a`](https://github.com/hypercerts-org/ePDS/commit/a0a819af2d39288351ea17f4f4f9de62af026be8) Thanks [@aspiers](https://github.com/aspiers)! - The email sign-in button now waits until the page is ready before accepting clicks. + + **Affects:** End users + + **End users:** tapping Continue before the sign-in page finished loading could start a broken sign-in attempt that silently did nothing. The button is now disabled until the page is ready. Browsers with JavaScript disabled see a message explaining that JavaScript is required to sign in with email, instead of a button that never responds. + +- [#220](https://github.com/hypercerts-org/ePDS/pull/220) [`50c8620`](https://github.com/hypercerts-org/ePDS/commit/50c8620896d8c797ab629467e09701d983426468) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in error messages now appear next to the field that caused them, instead of at the top of the page. + + **Affects:** End users + + **End users:** a rejected sign-in code used to report the problem above the page heading, several elements away from the boxes you had just typed into — easy to miss, and it left the **Verify** button looking like the thing to press again. The message now sits directly between the code boxes and **Verify**, and a failed email submission likewise reads under the email field rather than above it. + + A rejected code now also carries a **Send a new code** link beside the message. The standalone button below the form was easy to overlook, and "that code didn't work" often means there is no usable code left at all rather than that you mistyped it — after too many wrong attempts, after signing in from another tab, or once an old code has aged out. Retyping cannot recover any of those. The boxes still clear and refocus, so retyping remains one keystroke away when that is what you need. + +- [#237](https://github.com/hypercerts-org/ePDS/pull/237) [`43baeb5`](https://github.com/hypercerts-org/ePDS/commit/43baeb54c60402ab97050b5e4af5b143b8901640) Thanks [@aspiers](https://github.com/aspiers)! - Clickable text on the sign-in screens now looks the same wherever it appears. + + **Affects:** End users, Client app developers + + **End users:** the same action no longer renders differently depending on where it sits. "Resend code", "Use different email" and "Recover with backup email" are now all plain text that darkens when you point at it, while actions embedded in a sentence — "Send a new code" in an error message, and the Terms of Use and Privacy Policy links — stay underlined so they remain visible against the text around them. Pointing at an underlined action no longer makes its underline vanish, and every one of these actions now shows a focus outline when reached with the keyboard. + + **Client app developers:** `--muted-foreground` now also sets the colour of the `.btn-secondary` actions on the sign-in page, which previously hardcoded `#6b6b6b`. If you override that custom property in your `branding.css`, it will now recolour "Resend code" and "Use different email" alongside "Recover with backup email" and the other muted text. Choose a value that clears 4.5:1 contrast against your card background. `--recovery-link-display` is unchanged. + +- [#215](https://github.com/hypercerts-org/ePDS/pull/215) [`1a90062`](https://github.com/hypercerts-org/ePDS/commit/1a900621936941066372228b423d26a6808cb21b) Thanks [@aspiers](https://github.com/aspiers)! - "Use different email" on the sign-in code page now clears the email field. + + **Affects:** End users + + **End users:** clicking **Use different email** on the code-entry page used to take you back to the email form with your previous address still filled in — exactly the opposite of what the button suggests, and forcing you to clear the field manually before you could type a new address. The form now starts empty, with the cursor in the field, so you can just type the new address and continue. + ## 0.7.0 ### Who should read this release diff --git a/Dockerfile.auth b/Dockerfile.auth index cb058177..27b07f9f 100644 --- a/Dockerfile.auth +++ b/Dockerfile.auth @@ -4,7 +4,7 @@ # so each service has its own Dockerfile. # -- Base -- -FROM node:20-alpine AS base +FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.9 --activate RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser WORKDIR /app @@ -24,7 +24,7 @@ COPY tsconfig.json ./ RUN pnpm --filter @certified-app/auth-service... build # -- Auth Service -- -FROM node:20-alpine +FROM node:22-alpine RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser RUN apk add --no-cache wget su-exec WORKDIR /app diff --git a/Dockerfile.demo b/Dockerfile.demo index 4dbde82e..9cc44d32 100644 --- a/Dockerfile.demo +++ b/Dockerfile.demo @@ -3,7 +3,7 @@ # with output: "standalone" and then runs the self-contained server. # -- Base -- -FROM node:20-alpine AS base +FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.9 --activate WORKDIR /app @@ -35,7 +35,7 @@ RUN /tmp/resolve-version.sh && \ pnpm --filter @certified-app/demo build # -- Production -- -FROM node:20-alpine +FROM node:22-alpine RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser WORKDIR /app diff --git a/Dockerfile.pds b/Dockerfile.pds index 9dd798e9..7cecbab4 100644 --- a/Dockerfile.pds +++ b/Dockerfile.pds @@ -4,7 +4,7 @@ # so each service has its own Dockerfile. # -- Base -- -FROM node:20-alpine AS base +FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.9 --activate RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser WORKDIR /app @@ -24,7 +24,7 @@ COPY tsconfig.json ./ RUN pnpm --filter @certified-app/pds-core... build # -- PDS Core -- -FROM node:20-alpine +FROM node:22-alpine RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser RUN apk add --no-cache wget su-exec WORKDIR /app diff --git a/docs/configuration.md b/docs/configuration.md index 642ec3ac..94427468 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,7 +30,7 @@ marked `[shared]` in the per-package `.env.example` files. | `EPDS_VERSION` | Override the version string returned by `/health`. In Docker/Railway builds this is set automatically to `+<8-char commit SHA>`. In dev it falls back to the root `package.json` version. Only set this if you need a custom override. | | `PDS_HOSTNAME` | Your PDS domain — handles will be `.PDS_HOSTNAME` | | `PDS_PUBLIC_URL` | Full public URL of the PDS, used as OAuth issuer (e.g. `https://pds.example.com`) | -| `EPDS_CALLBACK_SECRET` | HMAC-SHA256 secret signing the `/oauth/epds-callback` redirect — generate with `openssl rand -hex 32` | +| `EPDS_CALLBACK_SECRET` | **Required.** HMAC-SHA256 secret signing the `/oauth/epds-callback` redirect — generate with `openssl rand -hex 32`. Both pds-core and auth-service refuse to start if unset. | | `EPDS_INTERNAL_SECRET` | Shared secret for internal service-to-service calls (auth → PDS) — generate with `openssl rand -hex 32` | | `PDS_ADMIN_PASSWORD` | PDS admin API password (auth-service uses it for account provisioning) | | `NODE_ENV` | Set to `development` for dev mode (disables secure cookies) | @@ -126,8 +126,7 @@ Optional PDS email variables: | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AUTH_HOSTNAME` | Auth subdomain (e.g. `auth.pds.example.com`) — must be a subdomain of `PDS_HOSTNAME` | | `AUTH_PORT` | Port for Auth Service. Resolved as `AUTH_PORT` → `PORT` → `3001`, so platforms that inject `PORT` (e.g. Railway) work without a service-specific override. | -| `AUTH_SESSION_SECRET` | Session secret — generate with `openssl rand -hex 32` | -| `AUTH_CSRF_SECRET` | CSRF secret — generate with `openssl rand -hex 32` | +| `AUTH_SESSION_SECRET` | **Required.** Session secret — generate with `openssl rand -hex 32`. Auth service refuses to start if unset. | | `PDS_INTERNAL_URL` | **Required.** Internal URL for auth→PDS calls. Docker: `http://core:3000`; Railway: `http://.railway.internal:3000`; local dev: `http://localhost:3000`. Auth service crashes at startup if unset. | ### Verification link settings diff --git a/docs/deployment.md b/docs/deployment.md index 42939a72..d71afc85 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -246,3 +246,52 @@ For test or development environments, you can disable the invite code requirement entirely by setting `PDS_INVITE_REQUIRED=false` on the pds-core service. This allows anyone who can reach the PDS to create accounts, so it is **not recommended for production**. + +## Backfilling Email Confirmation + +Accounts created before email confirmation was recorded at sign-up report +`email_verified: false` to relying parties. New accounts are recorded as +confirmed automatically, and existing accounts are repaired the next time +their owner signs in **through the emailed-code flow** — repair requires a +sign-in that proves control of the address, so an authentication method that +does not (a passkey, say) leaves the account unconfirmed. This backfill is +therefore needed for accounts whose owners have not signed in since the +upgrade, and for any that sign in only by other means. + +Preview first: + +```bash +pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run +``` + +Then run it for real: + +```bash +pnpm --filter @certified-app/pds-core backfill:email-confirmed +``` + +Run it with the same environment as the PDS service, so it targets that +deployment's account database. It is idempotent — already-confirmed accounts +are skipped, so re-running is safe. + +A trailing argument scopes the run to addresses containing it +(case-insensitive), which lets you work through a large deployment in batches +or repair a single account: + +```bash +pnpm --filter @certified-app/pds-core backfill:email-confirmed @gmail.com +pnpm --filter @certified-app/pds-core backfill:email-confirmed my.account@yahoo.com +``` + +With no argument, every account is considered. + +Accounts that cannot be confirmed are listed by DID at the end and the command +exits non-zero, so a scripted run does not report success after a partial +failure. + +**This is deliberately not automatic.** It marks every account that has an +email address but no confirmation timestamp. ePDS does not block upstream's +`com.atproto.server.createAccount` XRPC route, so if you provisioned any +accounts outside the normal sign-in flow, their addresses would be marked +confirmed without having been verified. Check the dry-run count before +committing. diff --git a/docs/design/pds-0.5.34-upgrade.md b/docs/design/pds-0.5.34-upgrade.md new file mode 100644 index 00000000..b447a4a9 --- /dev/null +++ b/docs/design/pds-0.5.34-upgrade.md @@ -0,0 +1,536 @@ +# PDS 0.5.34 Upgrade Assessment + +## Status + +This document records the read-only assessment of upgrading ePDS from +`@atproto/pds` 0.5.23 to 0.5.34. It is an implementation guide, not evidence +that the upgrade has been completed. + +The assessment compared local upstream release tags and ePDS source. No package +installation, build, test, database operation, deployment, or production +configuration inspection was performed. + +## Source versions + +| Package | Current ePDS version | Target companion version | +| ----------------------------- | -------------------- | --------------------------------------- | +| `@atproto/pds` | 0.5.23 | 0.5.34 | +| `@atproto/oauth-provider` | 0.21.1 | 0.22.8 | +| `@atproto/oauth-provider-ui` | 0.8.9 | 0.10.3 | +| `@atproto/oauth-provider-api` | 0.7.7 | 0.8.3 through the target provider graph | + +Local upstream release commits used by the assessment: + +- PDS 0.5.23: `5dc34f6846bad7c6f649e0a733186f4a6a467fcb` +- PDS 0.5.34: `7ca16cc6989f8247637615aca17c5abb911b8fb1` +- OAuth provider UI 0.10.3: + `1f2604f0546754aef1cf93b7ac3855cf31751b1c` + +The committed ePDS lockfile is the authority for the current dependency graph. +Local `node_modules` links were stale during the assessment and pointed to +older package versions, so runtime observations from that installation are not +trustworthy until dependencies are installed cleanly. + +## Overall conclusion + +The upgrade appears manageable, but it is not a PDS-only version bump. + +Required work: + +1. Align PDS, OAuth provider, and OAuth provider UI versions. +2. Fix the `Another account` control lookup for the redesigned provider UI. +3. Repair and validate the consent preview against the redesigned provider UI. +4. Update generic ePDS branding selectors for the provider's semantic CSS + targets. +5. Build a temporary Ma Earth-branded MVP of the new consent layout for team + review; do not recreate the old layout or permanently own Ma Earth CSS in + ePDS. +6. Run full OAuth, PDS, container, and rollback validation. + +No new upstream database migration was found in this release range. Core OAuth +provider methods used by ePDS remain compatible in the inspected source, but +many are private upstream interfaces and still require runtime validation. + +## Required dependency alignment + +Upgrade these direct dependencies together: + +```json +{ + "@atproto/pds": "0.5.34", + "@atproto/oauth-provider": "0.22.8", + "@atproto/oauth-provider-ui": "0.10.3" +} +``` + +The current `0.x` caret ranges for provider and provider UI do not accept the +target companion versions. Updating only PDS can create a mixed package graph. + +### `HandleUnavailableError` constructor mismatch + +ePDS imports `HandleUnavailableError` and uses `instanceof` while handling +account creation. If PDS creates the error through provider 0.22.8 but ePDS +checks it against provider 0.21.1, the constructors differ. A taken or reserved +handle can fall through to the generic account-creation error instead of +redirecting to handle selection. + +Alignment of the direct provider version is required. This is separate from the +upstream `HandleNotFound` response change for handle resolution. + +### Provider UI manifest mismatch + +ePDS preview routes resolve the provider UI bundle manifest through ePDS's +direct dependency. The provider resolves its own manifest separately. Mixed UI +versions can make preview HTML reference old asset filenames while the provider +serves new assets. + +Align the direct provider UI dependency and verify chooser and consent preview +assets after lockfile regeneration. + +### Shared safe-fetch dependency + +Also upgrade the direct `@atproto-labs/fetch-node` dependency in +`packages/shared/package.json` from `^0.2.0` to `0.4.0`. The target PDS 0.5.34 +release changelog lists fetch-node 0.4.0; 0.3.6 belongs to the current dependency +graph and is not the target companion version. Updating PDS alone does not +upgrade shared's independent `^0.2.0` dependency. + +Source comparison found no incompatible use of the wrapper options in ePDS. +The shared wrapper retains HTTPS-only/private-IP protection by default, a +5-second timeout, a 100 KB response limit, and unconditional redirect rejection. +It serves client metadata, remote email templates, and preview validation. +Auth-service requests directly to `PDS_INTERNAL_URL` use ordinary fetch and are +not intercepted by this wrapper. + +Fetch-node 0.4.0 applies URL policy at dispatch time, including every redirect +hop, and keeps the dispatcher guard when private-IP checks are relaxed. Because +ePDS already rejects redirects, hop validation is defense-in-depth for the +shared wrapper. Custom request dispatchers are rejected even in relaxed mode; +no current ePDS caller supplies one. The removed +`dangerouslyForceKeepAliveAgent` option is also unused. Current callers catch +fetch failures generically, so changes to error wrapping do not establish a +functional break. + +Validate metadata, template and preview success/fallback behavior, HTTP and +private-IP rejection, redirect rejection, timeout and streamed response limits, +and local `EPDS_ALLOW_PRIVATE_IPS=true` behavior. This is source assessment, +not runtime validation under the new dependency graph. + +### Optional dependency cleanup + +Multiple `@atproto/syntax` and `@atproto/crypto` versions exist in the current +graph. No concrete constructor, singleton, or branded-type failure was found for +these packages. Deduplication or removal of an unused direct crypto dependency +is optional unless typechecking or runtime tests prove otherwise. + +## Confirmed provider UI break + +### `Another account` lookup + +Provider UI 0.8.9 renders the control through an element with +`role="button"`. UI 0.10.3 renders a native ` +

Did you mean ?

+ +` +} + +/** + * Render a dependency-free browser guard for one email form. + * + * Input changes reveal likely corrections immediately. The capture-phase + * submit listener remains as defence in depth, so a typo cannot fire an OTP + * request until the user accepts or dismisses the suggestion. + */ +export function renderEmailTypoGuardScript( + formId: string, + inputId: string, +): string { + return `` +} diff --git a/packages/auth-service/src/middleware/csrf.ts b/packages/auth-service/src/middleware/csrf.ts index 2eae05af..3f3f1c23 100644 --- a/packages/auth-service/src/middleware/csrf.ts +++ b/packages/auth-service/src/middleware/csrf.ts @@ -4,7 +4,7 @@ import * as crypto from 'node:crypto' const CSRF_COOKIE = 'epds_csrf' const CSRF_HEADER = 'x-csrf-token' -export function csrfProtection(_secret: string) { +export function csrfProtection() { return (req: Request, res: Response, next: NextFunction): void => { // GET requests: set CSRF cookie if not present if (req.method === 'GET') { diff --git a/packages/auth-service/src/otp-input.ts b/packages/auth-service/src/otp-input.ts index 63a1edc6..7642151a 100644 --- a/packages/auth-service/src/otp-input.ts +++ b/packages/auth-service/src/otp-input.ts @@ -5,6 +5,8 @@ * logic, and so the logic can be unit-tested without rendering or parsing HTML. */ +export type OtpCharset = 'numeric' | 'alphanumeric' + export interface OtpInputProps { pattern: string placeholder: string @@ -14,7 +16,7 @@ export interface OtpInputProps { export function buildOtpInputProps( otpLength: number, - otpCharset: 'numeric' | 'alphanumeric', + otpCharset: OtpCharset, ): OtpInputProps { if (otpCharset === 'alphanumeric') { return { @@ -31,3 +33,18 @@ export function buildOtpInputProps( autocapitalize: 'off', } } + +/** Build the character-removal pattern shared by server-rendered OTP inputs. */ +export function buildOtpInputFilter(otpCharset: OtpCharset): RegExp { + return otpCharset === 'alphanumeric' ? /[^A-Za-z0-9]/g : /\D/g +} + +/** Accept a supported preview override, otherwise preserve the configured policy. */ +export function resolvePreviewOtpCharset( + requested: string | undefined, + configured: OtpCharset, +): OtpCharset { + return requested === 'numeric' || requested === 'alphanumeric' + ? requested + : configured +} diff --git a/packages/auth-service/src/routes/account-login.ts b/packages/auth-service/src/routes/account-login.ts index 7b044ab2..786b795f 100644 --- a/packages/auth-service/src/routes/account-login.ts +++ b/packages/auth-service/src/routes/account-login.ts @@ -17,9 +17,14 @@ import { Router, type Request, type Response } from 'express' import { escapeHtml, maskEmail, createLogger } from '@certified-app/shared' import { fromNodeHeaders } from 'better-auth/node' import type { AuthServiceContext } from '../context.js' -import { buildOtpInputProps } from '../otp-input.js' +import { buildOtpInputFilter, buildOtpInputProps } from '../otp-input.js' import type { BetterAuthInstance } from '../better-auth.js' import { POWERED_BY_CSS, POWERED_BY_HTML } from '../lib/page-helpers.js' +import { + EMAIL_TYPO_GUARD_CSS, + renderEmailTypoGuardMarkup, + renderEmailTypoGuardScript, +} from '../lib/email-typo-guard.js' const logger = createLogger('auth:account-login') @@ -151,19 +156,22 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string {

Account Settings

Sign in to manage your account

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} - + ${opts.error ? '' : ''} +
+ ${renderEmailTypoGuardMarkup()}
${POWERED_BY_HTML} + ${renderEmailTypoGuardScript('form-account-send-otp', 'email')} ` } @@ -177,6 +185,7 @@ function renderOtpForm(opts: { }): string { const maskedEmail = maskEmail(opts.email) const inputProps = buildOtpInputProps(opts.otpLength, opts.otpCharset) + const inputFilter = buildOtpInputFilter(opts.otpCharset) return ` @@ -193,7 +202,7 @@ function renderOtpForm(opts: {

Enter your code

We sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to ${escapeHtml(maskedEmail)}

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''}
@@ -208,6 +217,7 @@ function renderOtpForm(opts: { autocapitalize="${inputProps.autocapitalize}" placeholder="${inputProps.placeholder}" class="otp-input" + oninput="this.value=this.value.replace(${inputFilter.toString()},'')${opts.otpCharset === 'alphanumeric' ? '.toUpperCase()' : ''}" style="letter-spacing: ${Math.max(2, Math.round(32 / opts.otpLength))}px">
@@ -215,7 +225,7 @@ function renderOtpForm(opts: { - + ${POWERED_BY_HTML} @@ -236,9 +246,16 @@ const CSS = ` .field label { display: block; font-size: 14px; font-weight: 500; color: #333; margin-bottom: 6px; } .field input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; outline: none; } .field input:focus { border-color: #0f1828; } + ${EMAIL_TYPO_GUARD_CSS} .otp-input { font-size: 28px !important; text-align: center; font-family: 'SF Mono', Menlo, Consolas, monospace !important; padding: 14px !important; } .btn-primary { width: 100%; padding: 12px; background: #0f1828; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 500; cursor: pointer; } .btn-primary:hover { background: #1a2a40; } - .btn-secondary { display: inline-block; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: underline; } + .btn-primary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } + /* Standalone action in its own row — see the link-affordance convention + documented in login-page.ts. No underline, darkens on hover. "Resend + code" here and on the sign-in page must not render differently. */ + .btn-secondary { display: inline-block; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: none; border-radius: 4px; } + .btn-secondary:hover { color: #000; } + .btn-secondary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .error { color: #dc3545; background: #fdf0f0; padding: 12px; border-radius: 8px; margin: 12px 0; } ` diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index 555a912f..a98a8d59 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -67,6 +67,9 @@ export function createChooseHandleRouter( clientId: string | null } email: string + /** Whether this sign-in proved control of `email`; see + * CallbackParams.email_verified. */ + emailVerified: boolean } | null> { // Guard 1: auth_flow cookie const flowId = req.cookies[AUTH_FLOW_COOKIE] as string | undefined @@ -144,7 +147,16 @@ export function createChooseHandleRouter( return null } - return { flowId, flow, email: session.user.email.toLowerCase() } + // Carried alongside the email so the signed callback can state + // whether this sign-in actually proved control of that address. + // better-auth sets emailVerified when the emailed one-time code is + // verified. See CallbackParams.email_verified. + return { + flowId, + flow, + email: session.user.email.toLowerCase(), + emailVerified: session.user.emailVerified === true, + } } // --------------------------------------------------------------------------- @@ -209,7 +221,7 @@ export function createChooseHandleRouter( } const KNOWN_ERROR_MESSAGES: Record = { - handle_taken: 'That handle was just taken — please choose another.', + handle_taken: 'That handle is not available — please choose another.', } const rawError = req.query.error as string | undefined const error = rawError @@ -245,7 +257,7 @@ export function createChooseHandleRouter( const result = await getFlowAndSession(req, res) if (!result) return - const { flowId, flow, email } = result + const { flowId, flow, email, emailVerified } = result // Guard: reject flows with handleMode='random' — they should skip the picker entirely if (flow.handleMode === 'random') { @@ -380,7 +392,7 @@ export function createChooseHandleRouter( .send( renderChooseHandlePage( handleDomain, - 'That handle is already taken.', + 'That handle is not available.', res.locals.csrfToken, showRandomButton, branding.customCss, @@ -404,6 +416,8 @@ export function createChooseHandleRouter( approved: '1', new_account: '1', handle: normalizedLocal, + epds_handle_mode: flow.handleMode ?? '', + email_verified: emailVerified ? '1' : '0', } if (flow.clientId) callbackParams.client_id = flow.clientId const { sig, ts } = signCallback( @@ -497,8 +511,12 @@ export function renderChooseHandlePage( customFaviconUrl?: string | null, customFaviconUrlDark?: string | null, ): string { + // role=alert only on the populated branch: it is static at render + // time, so the default assertive announcement is what we want. The + // empty placeholder is never written to by this page's script, so + // it needs no live-region semantics. const errorHtml = error - ? `
${escapeHtml(error)}
` + ? `` : `` return ` @@ -530,15 +548,17 @@ export function renderChooseHandlePage( } .status { min-height: 20px; font-size: 14px; margin-top: 6px; } .status.available { color: #28a745; } - .status.taken { color: #dc3545; } + .status.unavailable { color: #dc3545; } .status.checking { color: #888; } .status.format-error { color: #dc3545; } .error { color: #dc3545; background: #fdf0f0; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; } .btn-primary { width: 100%; padding: 12px; background: #0f1828; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 500; cursor: pointer; margin-top: 8px; } .btn-primary:hover:not(:disabled) { background: #1a2a40; } + .btn-primary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-secondary { width: 100%; padding: 10px; background: white; color: #0f1828; border: 1px solid #0f1828; border-radius: 8px; font-size: 15px; font-weight: 500; cursor: pointer; margin-top: 8px; } .btn-secondary:hover:not(:disabled) { background: #f0f2f5; } + .btn-secondary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; } ${renderOptionalStyleTag(customCss)} @@ -565,10 +585,15 @@ export function renderChooseHandlePage( spellcheck="false" minlength="5" maxlength="20" + aria-describedby="handle-status" > .${escapeHtml(handleDomain)} -
+ +
${showRandomButton ? `` : ''} @@ -587,8 +612,8 @@ export function renderChooseHandlePage( var debounceTimer = null; var currentAbort = null; - // isAvailable: null = unknown, true = confirmed available, false = confirmed taken. - // submitBtn is disabled only when handle is confirmed taken or unavailable. + // isAvailable: null = unknown, true = confirmed available, false = confirmed unavailable. + // submitBtn is disabled only when the handle is confirmed unavailable. var isAvailable = null; function setStatus(text, cls) { @@ -626,14 +651,14 @@ export function renderChooseHandlePage( setStatus('\u2713 Available!', 'available'); } else { isAvailable = false; - setStatus('\u2717 Already taken.', 'taken'); + setStatus('\u2717 Not available.', 'unavailable'); } updateSubmit(); }) .catch(function(err) { if (err.name === 'AbortError') return; // silently ignore cancelled requests currentAbort = null; - // Network/timeout error: unknown state — don't block if handle isn't confirmed taken + // Network/timeout error: unknown state — don't block if the handle isn't confirmed unavailable isAvailable = null; setStatus('Could not check availability.', 'format-error'); updateSubmit(); @@ -714,7 +739,7 @@ export function renderChooseHandlePage( setStatus('Could not check availability.', 'format-error'); randomBtn.disabled = false; } else { - // Genuinely taken — retry with a new random value + // Genuinely unavailable — retry with a new random value tryRandomHandle(attemptsLeft - 1); } }) diff --git a/packages/auth-service/src/routes/complete.ts b/packages/auth-service/src/routes/complete.ts index 3c9d9cab..5309c00a 100644 --- a/packages/auth-service/src/routes/complete.ts +++ b/packages/auth-service/src/routes/complete.ts @@ -57,6 +57,10 @@ const AUTH_FLOW_COOKIE = 'epds_auth_flow' * the same `params.handle ?? ''` shape; the sentinel is pinned by * tests in packages/shared/src/__tests__/crypto.test.ts. * + * `epds_handle_mode` is signed too rather than appended afterwards, so + * the browser cannot flip the chooser/consent presentation mode on the + * hop to pds-core without invalidating the signature. + * * Exported so it can be unit-tested without standing up the full * /auth/complete route. */ @@ -65,6 +69,16 @@ export function buildEpdsCallbackUrl(args: { flowClientId: string | null email: string isNewAccount: boolean + flowHandleMode?: string | null + /** + * Whether this sign-in actually proved control of `email` — read + * from the better-auth session, which sets it when the emailed + * one-time code is verified. pds-core records email confirmation + * from this and nothing else, so it must reflect what the + * authenticating flow really established, not what the current + * flow happens to be. See CallbackParams.email_verified. + */ + emailVerified: boolean pdsPublicUrl: string epdsCallbackSecret: string }): string { @@ -73,13 +87,47 @@ export function buildEpdsCallbackUrl(args: { email: args.email, approved: '1', new_account: args.isNewAccount ? '1' : '0', + email_verified: args.emailVerified ? '1' : '0', } if (args.flowClientId) callbackParams.client_id = args.flowClientId + if (args.flowHandleMode) callbackParams.epds_handle_mode = args.flowHandleMode const { sig, ts } = signCallback(callbackParams, args.epdsCallbackSecret) const params = new URLSearchParams({ ...callbackParams, ts, sig }) return `${args.pdsPublicUrl}/oauth/epds-callback?${params.toString()}` } +async function resolveCompleteIdentity( + email: string, + flowId: string, + ctx: AuthServiceContext, + pdsUrl: string, + internalSecret: string, +): Promise<{ email: string; did: string | null; viaRecovery: boolean }> { + const did = await getDidByEmail(email, pdsUrl, internalSecret) + if (did) return { email, did, viaRecovery: false } + + // Recovery path: session email is a backup email, not a primary. Resolve + // the backup-email -> DID mapping (auth-service-owned) and then DID -> + // primary email via pds-core's internal API, so the downstream callback + // signs the user's real account email, not the recovery address. + const recovered = await resolveRecoveryEmail( + email, + ctx, + pdsUrl, + internalSecret, + ) + if (!recovered) return { email, did: null, viaRecovery: false } + + logger.info( + { flowId, did: recovered.did }, + 'Recovery: translated backup email to primary email via DID', + ) + // Reported so the caller can withhold the email-verified claim: the + // returned address is the account's primary, which this sign-in proved + // nothing about. + return { email: recovered.email, did: recovered.did, viaRecovery: true } +} + export function createCompleteRouter( ctx: AuthServiceContext, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- better-auth instance has no exported type @@ -127,9 +175,14 @@ export function createCompleteRouter( */ async function redirectNewUserRandomMode( res: Response, - flow: { requestUri: string; clientId: string | null }, + flow: { + requestUri: string + clientId: string | null + handleMode: string | null + }, email: string, flowId: string, + emailVerified: boolean, ): Promise { const ping = await pingParRequest(flow.requestUri, pdsUrl, internalSecret) if (!ping.ok) { @@ -143,6 +196,8 @@ export function createCompleteRouter( flowClientId: flow.clientId, email, isNewAccount: true, + flowHandleMode: flow.handleMode, + emailVerified, pdsPublicUrl: ctx.config.pdsPublicUrl, epdsCallbackSecret: ctx.config.epdsCallbackSecret, }) @@ -204,31 +259,28 @@ export function createCompleteRouter( return } - let email = session.user.email.toLowerCase() + const sessionEmail = session.user.email.toLowerCase() + + // Whether this sign-in proved control of the address the user + // actually authenticated with. better-auth sets emailVerified when + // the emailed one-time code is verified, so it is the authoritative + // answer for `sessionEmail`. + const sessionEmailVerified: boolean = session.user.emailVerified === true // Step 4: Check whether this is a new user (no PDS account for email). - let did = await getDidByEmail(email, pdsUrl, internalSecret) + const { email, did, viaRecovery } = await resolveCompleteIdentity( + sessionEmail, + flowId, + ctx, + pdsUrl, + internalSecret, + ) - // Recovery path: session email is a backup email, not a primary. Resolve - // the backup-email → DID mapping (auth-service-owned) and then DID → - // primary email via pds-core's internal API, so the downstream callback - // signs the user's real account email, not the recovery address. - if (!did) { - const recovered = await resolveRecoveryEmail( - email, - ctx, - pdsUrl, - internalSecret, - ) - if (recovered) { - logger.info( - { flowId, did: recovered.did }, - 'Recovery: translated backup email to primary email via DID', - ) - email = recovered.email - did = recovered.did - } - } + // Recovery rebinds `email` from the proved backup address to the + // account's primary. The user proved control of the backup, not the + // primary, so recovering access is not evidence about the address + // being signed here — withhold the claim. + const emailVerified = sessionEmailVerified && !viaRecovery const isNewAccount = !did @@ -236,7 +288,7 @@ export function createCompleteRouter( // Step 5a: skip the handle picker, let pds-core call // generateRandomHandle() (signalled by the absent `handle` // field in the signed callback). - await redirectNewUserRandomMode(res, flow, email, flowId) + await redirectNewUserRandomMode(res, flow, email, flowId, emailVerified) return } @@ -264,6 +316,8 @@ export function createCompleteRouter( flowClientId: flow.clientId, email, isNewAccount: false, + flowHandleMode: flow.handleMode, + emailVerified, pdsPublicUrl: ctx.config.pdsPublicUrl, epdsCallbackSecret: ctx.config.epdsCallbackSecret, }) diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 376b6343..8a9858dc 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -42,7 +42,7 @@ import { type HandleMode, } from '@certified-app/shared' import { socialProviders } from '../better-auth.js' -import { buildOtpInputProps } from '../otp-input.js' +import { buildOtpInputFilter, buildOtpInputProps } from '../otp-input.js' import { resolveLoginHint, fetchParLoginHint, @@ -53,6 +53,11 @@ import { renderFaviconTag, } from '../lib/page-helpers.js' import { renderError } from '../lib/render-error.js' +import { + EMAIL_TYPO_GUARD_CSS, + renderEmailTypoGuardMarkup, + renderEmailTypoGuardScript, +} from '../lib/email-typo-guard.js' import { appendOrphanDeviceCookieClearHeaders, buildPdsAuthorizeRedirect, @@ -509,6 +514,7 @@ export function renderLoginPage(opts: { : `` const inputProps = buildOtpInputProps(opts.otpLength, opts.otpCharset) + const inputFilter = buildOtpInputFilter(opts.otpCharset) // ATProto/Bluesky handle login button. // @@ -554,6 +560,14 @@ export function renderLoginPage(opts: { const hasGithub = 'github' in socialProviders const hasSocialProviders = hasGoogle || hasGithub + // The flash region is a single element shared by both steps, so there + // is only ever one live region for assistive tech to track. It is + // server-rendered into whichever step is initially visible and moved + // between the two slots on step transitions; both transitions call + // clearError() first, so it is always empty when it moves. + const flashRegionHtml = + '' + // Social login buttons — redirect to better-auth provider endpoints const socialButtonsHtml = hasSocialProviders ? ` @@ -597,7 +611,7 @@ export function renderLoginPage(opts: { ${renderFaviconTag(opts.customFaviconUrl, opts.customFaviconUrlDark)} Sign in to ${escapeHtml(appName)} ${renderOptionalStyleTag(opts.customCss)} @@ -654,8 +690,6 @@ export function renderLoginPage(opts: { ${logoHtml}

${opts.initialStep === 'otp' ? 'Enter your code' : 'Sign in'}

- - ${socialButtonsHtml} @@ -664,14 +698,23 @@ export function renderLoginPage(opts: {
- + ${renderEmailTypoGuardMarkup()} + +
${opts.initialStep === 'otp' ? '' : flashRegionHtml}
+ ${handleLoginButtonHtml} + +

${ @@ -694,10 +737,16 @@ export function renderLoginPage(opts: { ) .join('\n ')}

+ +
${opts.initialStep === 'otp' ? flashRegionHtml : ''}
- +
+ ${renderEmailTypoGuardScript('form-send-otp', 'email')}