From b5a3f34fc3e4e992507abec4ac6c13c0cc13fd20 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 3 Sep 2026 15:07:21 +0600 Subject: [PATCH 1/2] docs: simplify ePDS OAuth login skill --- .agents/skills/epds-login/SKILL.md | 412 ++++++++++++------ .../epds-login/references/client-metadata.md | 35 +- .../skills/epds-login/references/dpop-pkce.md | 189 +------- .agents/skills/epds-login/references/flows.md | 359 +-------------- 4 files changed, 323 insertions(+), 672 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 1d5fadc3..547b7c34 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -1,61 +1,59 @@ --- 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, application sessions, callbacks, and debugging. Use when building passwordless or social 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, Google, GitHub, +or another provider supported by Better Auth. 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). +From a client's perspective, ePDS uses standard AT Protocol OAuth with PAR, +PKCE, and DPoP. Use `@atproto/oauth-client-node` for every login variant. Do +not implement those protocol mechanisms yourself. -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. +The ePDS repository demonstrates server behavior and UI. Its `packages/demo` +OAuth client still contains a legacy hand-rolled flow; do not copy that client +implementation. -## Two Flows +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. -| 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` | +## Choose the Login Input -**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. +All variants use one `NodeOAuthClient` and converge on the same callback and +`session.did`. -Flow 2 covers three input variants — all use the same `NodeOAuthClient` code: +| 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 | -- **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 +`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. -> **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. +> **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. -## Quick Start — Flow 2 (recommended) +## Quick Start -Use `@atproto/oauth-client-node` for any flow that does not require passing a -raw email as `login_hint`. +### 1. Client Metadata -### 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 +70,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 +94,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 +110,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 +123,7 @@ const client = new NodeOAuthClient({ }, sessionStore: { async set(key, value) { - /* store in DB/Redis */ + /* persist in DB/Redis */ }, async get(key) { /* retrieve */ @@ -137,133 +135,271 @@ 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 +### 5. Create an Application Session -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: +OAuth session storage and browser application sessions have different jobs: + +- `NodeOAuthClient.sessionStore` stores OAuth tokens and DPoP material by DID. +- Application session store maps a random ID to the signed-in DID. +- Browser cookie contains only that random ID, never tokens, email, or DID. ```typescript -app.get('/client-metadata.json', (req, res) => { - res.json(client.clientMetadata) -}) +interface AppSession { + did: string +} + +const APP_SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 +const { session: oauthSession } = await client.callback(callbackParams) +const appSessionId = crypto.randomUUID() -// Only needed when using jwks_uri (not inline jwks) -app.get('/jwks.json', (req, res) => { - res.json(client.jwks) +await appSessionStore.set( + appSessionId, + { did: oauthSession.did }, + { ttlSeconds: APP_SESSION_TTL_SECONDS }, +) + +setCookie('app_session', appSessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: APP_SESSION_TTL_SECONDS, }) ``` -## Quick Start — Flow 1 (hand-rolled) +The cookie is opaque: its random value reveals no identity or credentials. +`httpOnly` prevents browser JavaScript from reading it. On later requests, load +the DID server-side and restore the OAuth session: + +```typescript +const appSession = await appSessionStore.get(appSessionId) +if (!appSession) throw new Error('Application session expired; sign in again') + +const oauthSession = await client.restore(appSession.did) +``` + +Generate session IDs with a cryptographically secure random source. Rotate the +ID after authentication, delete it during logout, and protect state-changing +routes against CSRF. Do not treat `httpOnly` alone as complete session security. -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. +`NodeOAuthClient` stores OAuth credentials by DID. Multiple opaque application +sessions for one DID therefore share one underlying OAuth session; they are not +independent device credentials. A later login may replace that stored OAuth +session. Deleting one application session should normally delete only its random +ID. Calling OAuth `signOut()` revokes shared credentials and may affect other +application sessions for that DID. Choose and document intended multi-device +logout behavior. -The abbreviated version: +### 6. Serve Metadata and JWKS -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) +```typescript +app.get('/client-metadata.json', (_request, response) => { + response.json(publishedClientMetadata) +}) + +// Only needed when metadata uses jwks_uri instead of inline jwks. +app.get('/jwks.json', (_request, response) => { + response.json(client.jwks) +}) +``` + +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`. + +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. -## Forcing a Fresh Sign-In (`prompt=login`) +## Do Not Hand-Roll OAuth Primitives -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. +Do not manually implement: -**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. +- PKCE verifier or challenge generation +- DPoP key generation, proof JWTs, or nonce retry +- PAR requests +- authorization-code token exchange +- `private_key_jwt` client assertions +- authorization-server discovery +- OAuth state/session object construction -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. +`NodeOAuthClient` already coordinates these operations and their persisted +state. Reimplementing only part of that lifecycle risks mismatched keys, +incorrect issuer or audience values, replay vulnerabilities, and broken refresh. -**Hand-rolled (Flow 1):** +## Forcing Fresh Sign-In + +Existing ePDS cookies may lead to account reuse or an account chooser. Put +`prompt=login` in both PAR and the returned authorization URL: ```typescript -const authUrl = - `${authEndpoint}?client_id=${encodeURIComponent(clientId)}` + - `&request_uri=${encodeURIComponent(parData.request_uri)}` + - (forceLogin ? '&prompt=login' : '') +const url = await client.authorize(EPDS_URL, { prompt: 'login' }) +url.searchParams.set('prompt', 'login') ``` -**With `NodeOAuthClient` (Flow 2):** +For email-first login, append the email too: ```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. +const url = await client.authorize(EPDS_URL, { prompt: 'login' }) +url.searchParams.set('login_hint', email) url.searchParams.set('prompt', 'login') ``` -## Common Pitfalls +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. -| 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 | +## Verification -## Handles +Check authorization-server discovery: + +```bash +curl -fsS "$EPDS_URL/.well-known/oauth-authorization-server" \ + | python3 -m json.tool +``` + +Confirm metadata contains exact callback URI and expected scope: -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. +```bash +curl -fsS "https://yourapp.example.com/client-metadata.json" \ + | python3 -m json.tool +``` -## ePDS Endpoints (defaults) +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 ``` -PAR: https:///oauth/par -Auth: https://auth./oauth/authorize -Token: https:///oauth/token + +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" ``` +For email-first login, confirm `Location` also contains encoded `login_hint`. +Never print OAuth tokens, private JWKs, cookies, or store contents while +debugging. + +## 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 a handle during signup. Local part must be 5–20 characters, +alphanumeric with hyphens. Handles are not derived from email addresses. + +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. + +## 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 +- [Legacy flow walkthrough](references/flows.md): historical hand-rolled flow; do not use for new integrations +- [Legacy PKCE/DPoP helpers](references/dpop-pkce.md): historical reference only; prefer `NodeOAuthClient` diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index 130aceee..afb94f39 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. | +| `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,10 @@ 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. Use an +HTTPS tunnel or deployed development URL for a confidential client. + +AT Protocol loopback clients 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..3c27fe3c 100644 --- a/.agents/skills/epds-login/references/dpop-pkce.md +++ b/.agents/skills/epds-login/references/dpop-pkce.md @@ -1,185 +1,18 @@ -# PKCE and DPoP Helper Implementations +# Legacy PKCE and DPoP Helpers -> **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`). +This reference previously contained hand-rolled PKCE and DPoP helpers. They were +removed because partial OAuth implementations risk mismatched keys, invalid +issuer or audience values, replay vulnerabilities, broken nonce handling, and +refresh races. -Copy these into your project. They have no dependencies beyond Node's built-in -`node:crypto` module. +Use `NodeOAuthClient`, 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..fcdc55a4 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -1,352 +1,29 @@ -# Flow Walkthroughs +# Legacy Flow Walkthrough -## Which flow should I use? +This reference previously documented a hand-rolled email-first OAuth flow. +Do not use it for new integrations. -| 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 - -```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...') -``` - -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 - -Redirect the user's browser to the returned URL. - -### Callback handler - -```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 -``` - -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 - -```typescript -const session = await client.restore(userDid) -// Use session.fetchHandler() for API calls -// session.signOut() to end the session -``` - -### 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 +Use `NodeOAuthClient` for email, hosted-form, handle, and DID login. For an +email-first flow: ```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()) -} +const url = await client.authorize(epdsUrl) +url.searchParams.set('login_hint', email) ``` -### Callback handler code +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 -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 +const url = await client.authorize(epdsUrl, { prompt: 'login' }) +url.searchParams.set('login_hint', email) +url.searchParams.set('prompt', 'login') ``` -### 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. `NodeOAuthClient` must retain ownership of PAR, PKCE, +DPoP, nonce retry, token exchange, and OAuth state/session storage. -- `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, callback, and application +session guidance. From 84253202c30886c94eaa60908650099275c552fc Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 4 Sep 2026 17:12:56 +0600 Subject: [PATCH 2/2] epds-login: correct OAuth integration guidance --- .agents/skills/epds-login/SKILL.md | 103 +++--------------- .../epds-login/references/client-metadata.md | 14 ++- .../skills/epds-login/references/dpop-pkce.md | 10 +- .agents/skills/epds-login/references/flows.md | 25 +++-- 4 files changed, 44 insertions(+), 108 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 547b7c34..33c619c3 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -1,21 +1,18 @@ --- name: epds-login -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, application sessions, callbacks, and debugging. Use when building passwordless or social login against ePDS, configuring confidential/public OAuth clients, 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 users sign in to AT Protocol apps using email OTP, Google, GitHub, -or another provider supported by Better Auth. New users receive a DID, handle, -and data repository automatically. +ePDS lets users sign in to AT Protocol apps using email OTP. New users receive +a DID, handle, and data repository automatically. -From a client's perspective, ePDS uses standard AT Protocol OAuth with PAR, -PKCE, and DPoP. Use `@atproto/oauth-client-node` for every login variant. Do -not implement those protocol mechanisms yourself. - -The ePDS repository demonstrates server behavior and UI. Its `packages/demo` -OAuth client still contains a legacy hand-rolled flow; do not copy that client -implementation. +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 guidance beyond ePDS specifics—granular scope design, identity verification after token exchange, session storage, and refresh-token @@ -216,62 +213,10 @@ await client.callback(callbackParams, { }) ``` -### 5. Create an Application Session - -OAuth session storage and browser application sessions have different jobs: - -- `NodeOAuthClient.sessionStore` stores OAuth tokens and DPoP material by DID. -- Application session store maps a random ID to the signed-in DID. -- Browser cookie contains only that random ID, never tokens, email, or DID. - -```typescript -interface AppSession { - did: string -} - -const APP_SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 -const { session: oauthSession } = await client.callback(callbackParams) -const appSessionId = crypto.randomUUID() - -await appSessionStore.set( - appSessionId, - { did: oauthSession.did }, - { ttlSeconds: APP_SESSION_TTL_SECONDS }, -) - -setCookie('app_session', appSessionId, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: APP_SESSION_TTL_SECONDS, -}) -``` +Application-session management is a generic AT Protocol OAuth concern; follow +the `atproto-oauth` skill for that guidance. -The cookie is opaque: its random value reveals no identity or credentials. -`httpOnly` prevents browser JavaScript from reading it. On later requests, load -the DID server-side and restore the OAuth session: - -```typescript -const appSession = await appSessionStore.get(appSessionId) -if (!appSession) throw new Error('Application session expired; sign in again') - -const oauthSession = await client.restore(appSession.did) -``` - -Generate session IDs with a cryptographically secure random source. Rotate the -ID after authentication, delete it during logout, and protect state-changing -routes against CSRF. Do not treat `httpOnly` alone as complete session security. - -`NodeOAuthClient` stores OAuth credentials by DID. Multiple opaque application -sessions for one DID therefore share one underlying OAuth session; they are not -independent device credentials. A later login may replace that stored OAuth -session. Deleting one application session should normally delete only its random -ID. Calling OAuth `signOut()` revokes shared credentials and may affect other -application sessions for that DID. Choose and document intended multi-device -logout behavior. - -### 6. Serve Metadata and JWKS +### 5. Serve Metadata and JWKS ```typescript app.get('/client-metadata.json', (_request, response) => { @@ -293,22 +238,6 @@ 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. -## Do Not Hand-Roll OAuth Primitives - -Do not manually implement: - -- PKCE verifier or challenge generation -- DPoP key generation, proof JWTs, or nonce retry -- PAR requests -- authorization-code token exchange -- `private_key_jwt` client assertions -- authorization-server discovery -- OAuth state/session object construction - -`NodeOAuthClient` already coordinates these operations and their persisted -state. Reimplementing only part of that lifecycle risks mismatched keys, -incorrect issuer or audience values, replay vulnerabilities, and broken refresh. - ## Forcing Fresh Sign-In Existing ePDS cookies may lead to account reuse or an account chooser. Put @@ -385,8 +314,10 @@ debugging. ## Handles -New users choose a handle during signup. Local part must be 5–20 characters, -alphanumeric with hyphens. Handles are not derived from email addresses. +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. Do not expect `OAuthSession` to expose `session.handle`; its stable identity is `session.did`. Resolve current handle through an AT Protocol identity resolver @@ -401,5 +332,5 @@ separate PDS and auth-service hostnames; that is normal. ## Reference Files - [Client metadata](references/client-metadata.md): confidential/public clients, JWKS, branding, email templates -- [Legacy flow walkthrough](references/flows.md): historical hand-rolled flow; do not use for new integrations -- [Legacy PKCE/DPoP helpers](references/dpop-pkce.md): historical reference only; prefer `NodeOAuthClient` +- [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 afb94f39..ab258af9 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -283,9 +283,11 @@ allow-list. ## Local development 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. Use an -HTTPS tunnel or deployed development URL for a confidential client. - -AT Protocol loopback clients 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. +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 3c27fe3c..c73585db 100644 --- a/.agents/skills/epds-login/references/dpop-pkce.md +++ b/.agents/skills/epds-login/references/dpop-pkce.md @@ -1,11 +1,7 @@ -# Legacy PKCE and DPoP Helpers +# PKCE and DPoP -This reference previously contained hand-rolled PKCE and DPoP helpers. They were -removed because partial OAuth implementations risk mismatched keys, invalid -issuer or audience values, replay vulnerabilities, broken nonce handling, and -refresh races. - -Use `NodeOAuthClient`, including for email-first login: +`NodeOAuthClient` handles PKCE, DPoP, and the associated OAuth state, including +for email-first login: ```typescript const url = await client.authorize(epdsUrl) diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index fcdc55a4..23266383 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -1,10 +1,19 @@ -# Legacy Flow Walkthrough +# Flow Walkthrough -This reference previously documented a hand-rolled email-first OAuth flow. -Do not use it for new integrations. +Use `NodeOAuthClient` for email, hosted-form, handle, and DID login. -Use `NodeOAuthClient` for email, hosted-form, handle, and DID login. For an -email-first flow: +For a hosted-form flow, where the client does not collect the user's email: + +```typescript +const url = await client.authorize(epdsUrl) +// Redirect without adding login_hint. +``` + +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. + +For an email-first flow: ```typescript const url = await client.authorize(epdsUrl) @@ -22,8 +31,6 @@ url.searchParams.set('prompt', 'login') ``` Email `login_hint` belongs only on the returned browser authorization URL, -never in the PAR body. `NodeOAuthClient` must retain ownership of PAR, PKCE, -DPoP, nonce retry, token exchange, and OAuth state/session storage. +never in the PAR body. -See [`../SKILL.md`](../SKILL.md) for maintained flow, callback, and application -session guidance. +See [`../SKILL.md`](../SKILL.md) for maintained flow and callback guidance.