From fe3ec907ae5cb6b388c3e9eb9e6797adb900c139 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 14 Apr 2026 16:54:49 +0000 Subject: [PATCH 01/30] feat(auth-service): preview routes for iterating on client branding CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /preview, /preview/login, /preview/login-otp, /preview/choose-handle, /preview/recovery, /preview/recovery-otp that render each auth-service page with fixture data, so client-app developers can iterate on their branding.css without walking through a real OAuth flow every time. Pass ?client_id= to inject that client's branding.css into the page exactly as it would be injected during a real flow. The trusted-clients check is intentionally skipped on preview routes — so the whole set is gated behind AUTH_PREVIEW_ROUTES=1, documented in .env.example as preview-env-only and never production. Four previously module-private render functions are now exported so the preview router can reuse them; renderOtpForm in recovery.ts is renamed to renderRecoveryOtpForm so it no longer collides with the same name in account-login.ts. pds-core's consent page (from @atproto/oauth-provider-ui) needs a separate treatment because it's a Tailwind SPA driven by a hydration data blob; tracked as follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../epds-login/references/client-metadata.md | 43 ++-- .changeset/auth-preview-routes.md | 13 + docs/configuration.md | 16 ++ docs/tutorial.md | 9 + packages/auth-service/.env.example | 13 + packages/auth-service/src/index.ts | 2 + .../auth-service/src/routes/choose-handle.ts | 2 +- .../auth-service/src/routes/login-page.ts | 2 +- packages/auth-service/src/routes/preview.ts | 225 ++++++++++++++++++ packages/auth-service/src/routes/recovery.ts | 12 +- 10 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 .changeset/auth-preview-routes.md create mode 100644 packages/auth-service/src/routes/preview.ts diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index 024f89cc..d9a57c88 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -98,27 +98,28 @@ key generation and serving details. ## All supported fields -| Field | Required | Description | -| --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `client_id` | Yes | Must match the URL where this file is hosted | -| `client_name` | Yes | Shown on the login page and in OTP emails | -| `redirect_uris` | Yes | Array of allowed callback URLs after login | -| `scope` | Yes | Always `"atproto transition:generic"` | -| `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | -| `response_types` | Yes | Always `["code"]` | -| `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | -| `token_endpoint_auth_signing_alg` | Conditional | Required when `token_endpoint_auth_method` is `"private_key_jwt"`. Must be `"ES256"`. | -| `jwks_uri` | Conditional | Public JWKS URL. Required for `"private_key_jwt"` unless `jwks` is provided. Mutually exclusive with `jwks`. | -| `jwks` | Conditional | Inline JWKS object (`{"keys": [...]}`). Alternative to `jwks_uri`. Mutually exclusive with `jwks_uri`. | -| `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 | -| `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`. | +| Field | Required | Description | +| --------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `client_id` | Yes | Must match the URL where this file is hosted | +| `client_name` | Yes | Shown on the login page and in OTP emails | +| `redirect_uris` | Yes | Array of allowed callback URLs after login | +| `scope` | Yes | Always `"atproto transition:generic"` | +| `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | +| `response_types` | Yes | Always `["code"]` | +| `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | +| `token_endpoint_auth_signing_alg` | Conditional | Required when `token_endpoint_auth_method` is `"private_key_jwt"`. Must be `"ES256"`. | +| `jwks_uri` | Conditional | Public JWKS URL. Required for `"private_key_jwt"` unless `jwks` is provided. Mutually exclusive with `jwks`. | +| `jwks` | Conditional | Inline JWKS object (`{"keys": [...]}`). Alternative to `jwks_uri`. Mutually exclusive with `jwks_uri`. | +| `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 | +| `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`. | +| `branding` | No | ePDS extension. Object containing a `css` string (max 32 KB). ePDS injects this CSS into login, OTP, choose-handle, recovery, and consent pages. Only honoured when the client is in `PDS_OAUTH_TRUSTED_CLIENTS`. Iterate via auth-service `/preview/*` routes. | ## Custom email templates diff --git a/.changeset/auth-preview-routes.md b/.changeset/auth-preview-routes.md new file mode 100644 index 00000000..f45404e8 --- /dev/null +++ b/.changeset/auth-preview-routes.md @@ -0,0 +1,13 @@ +--- +'ePDS': minor +--- + +Add preview routes to auth-service for iterating on client branding CSS. + +**Affects:** Client app developers, Operators + +**Client app developers:** When the auth-service is started with `AUTH_PREVIEW_ROUTES=1`, a set of `/preview/*` URLs becomes available that render each auth-service page (login email step, login OTP step, choose-handle, recovery email step, recovery OTP step) with fixture data. Pass `?client_id=` to inject that client's `branding.css` into the page, exactly as it would be injected during a real OAuth flow — including the same trusted-clients gate, so your `client_id` still needs to be on the operator's `PDS_OAUTH_TRUSTED_CLIENTS` for CSS to be injected. Without a `client_id` query param the preview page renders with no branding, which lets you compare the un-themed baseline against your themed version. Iterating on your CSS becomes: edit `branding.css`, refresh the preview URL — no OTP emails, no walking through the full flow each time. Visit `/preview` on the auth-service for an index of the available pages. + +**Operators:** `AUTH_PREVIEW_ROUTES=1` is safe on preview deployments (Railway PR previews, `pr-base`, dev) and on local development instances. The preview routes have no effect on real auth flows — they short-circuit real state — so they can technically run in production too, but they are a developer-only surface and are best left off outside of preview/dev envs. See `packages/auth-service/.env.example` for the full note. + +The pds-core-hosted consent page (from `@atproto/oauth-provider-ui`) is out of scope for this change; a similar preview for that page needs to construct the SPA's hydration-data blob and will come in a follow-up. diff --git a/docs/configuration.md b/docs/configuration.md index 6cea1ddf..ddac3f50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,6 +78,22 @@ tag closure. The CSP `style-src` directive is updated with a SHA-256 hash of the injected CSS. Untrusted clients never get CSS injection regardless of what their metadata contains. +#### Iterating on `branding.css` via auth-service preview routes + +Set `AUTH_PREVIEW_ROUTES=1` on the auth-service to expose a set of +static `/preview/*` URLs that render each auth-service page with +fixture data (no real OAuth flow, no OTP emails). Pass +`?client_id=` to inject that +client's `branding.css`, subject to the same `PDS_OAUTH_TRUSTED_CLIENTS` +check as real flows. Visit `/preview` on the auth-service for an +index. Intended for preview envs and dev instances — the routes +short-circuit real auth state and have no effect on real flows, but +are a developer-only surface and shouldn't be left on in production. +See `packages/auth-service/.env.example` for details. The pds-core +consent page (from `@atproto/oauth-provider-ui`) is not yet covered +by a preview route — that needs a separate mechanism for the SPA's +hydration data. + Optional PDS email variables: | Variable | Description | diff --git a/docs/tutorial.md b/docs/tutorial.md index 4a692d38..249b1c70 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -269,6 +269,15 @@ You can customise the OTP email and login page colours: } ``` +For full control over the auth-service pages (login, OTP entry, +choose-handle, recovery) and the PDS consent page, trusted clients can +also supply a `branding.css` string in a `branding` object inside their +client metadata. See the +[CSS branding injection](./configuration.md#css-branding-injection) +section for the full reference, including how to iterate on your CSS +without walking through the full OAuth flow each time via the +auth-service's `/preview/*` routes. + The email template must be an HTML file containing at minimum a `{{code}}` placeholder. Supported template variables: diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index e3c55fcf..61fb5a8e 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -125,3 +125,16 @@ DB_LOCATION=/data/epds.sqlite # Values: random | picker | picker-with-random # Defaults to 'picker-with-random' if not set. # EPDS_DEFAULT_HANDLE_MODE=picker-with-random + +# Expose /preview/* routes that render each auth-service page with fixture +# data, so client-app developers can iterate on their branding.css without +# walking through a real OAuth flow each time. Intended for preview envs +# and dev instances; safe but noisy on production (the routes short-circuit +# real auth state, so they have no effect on real flows, but they are a +# developer-only surface and will look out of place on a live PDS). +# +# The trusted-clients gate on CSS injection is preserved: a client_id passed +# via ?client_id=... query param only gets its branding.css injected when +# it's on PDS_OAUTH_TRUSTED_CLIENTS, exactly as in a real OAuth flow. +# Untrusted clients render the page with no branding. +# AUTH_PREVIEW_ROUTES=1 diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 22c41f12..a79749a0 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -15,6 +15,7 @@ import { createAccountLoginRouter } from './routes/account-login.js' import { createAccountSettingsRouter } from './routes/account-settings.js' import { createCompleteRouter } from './routes/complete.js' import { createChooseHandleRouter } from './routes/choose-handle.js' +import { createPreviewRouter } from './routes/preview.js' import { resolveAuthPort } from './lib/resolve-port.js' const logger = createLogger('auth-service') @@ -90,6 +91,7 @@ export function createAuthService(config: AuthServiceConfig): { app.use(createAccountSettingsRouter(ctx, betterAuthInstance)) app.use(createCompleteRouter(ctx, betterAuthInstance)) app.use(createChooseHandleRouter(ctx, betterAuthInstance)) + app.use(createPreviewRouter(ctx)) // Metrics endpoint (protect with admin auth in production) app.get('/metrics', (req, res) => { diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index c47af244..55340bd4 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -431,7 +431,7 @@ export function createChooseHandleRouter( // Template // --------------------------------------------------------------------------- -function renderChooseHandlePage( +export function renderChooseHandlePage( handleDomain: string, error?: string, csrfToken?: string, diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 73ec031f..2e131f9a 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -256,7 +256,7 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { return router } -function renderLoginPage(opts: { +export function renderLoginPage(opts: { flowId: string clientId: string clientName: string diff --git a/packages/auth-service/src/routes/preview.ts b/packages/auth-service/src/routes/preview.ts new file mode 100644 index 00000000..4292ee1a --- /dev/null +++ b/packages/auth-service/src/routes/preview.ts @@ -0,0 +1,225 @@ +/** + * Preview routes for auth-service pages. + * + * Renders each auth-service page with fixture data and real CSS + * injection, so client-app developers can iterate on their + * `branding.css` without walking through the full OAuth flow each + * time they want to see what a colour change looks like. + * + * Gated by `AUTH_PREVIEW_ROUTES=1`. Disabled by default; intended + * for preview envs, dev instances, and `pr-base`. The trusted-clients + * gate on CSS injection is preserved: a `client_id` passed via query + * param gets its CSS injected only if it's on + * `PDS_OAUTH_TRUSTED_CLIENTS`, exactly as in a real flow. Untrusted + * clients still render the page but with no branding, which is what + * real untrusted clients would see in production. + * + * Query params (all optional): + * ?client_id= fetch branding CSS from this client_metadata, + * subject to the trusted-clients check. + * ?error= show error banner (exercises error-state CSS). + */ +import { Router, type Request, type Response } from 'express' +import { randomBytes } from 'node:crypto' +import type { AuthServiceContext } from '../context.js' +import { resolveClientMetadata, getClientCss } from '../lib/client-metadata.js' +import type { ClientMetadata } from '@certified-app/shared' +import { createLogger } from '@certified-app/shared' +import { renderLoginPage } from './login-page.js' +import { renderChooseHandlePage } from './choose-handle.js' +import { renderRecoveryForm, renderRecoveryOtpForm } from './recovery.js' + +const logger = createLogger('auth:preview') + +const FAKE_FLOW_ID = 'preview-flow-000000000000000000000000' +const FAKE_REQUEST_URI = + 'urn:ietf:params:oauth:request_uri:req-preview-0000000000000000' +const FAKE_EMAIL = 'alice@example.com' +const FAKE_HANDLE_DOMAIN = 'preview.example' + +function fakeCsrfToken(): string { + return randomBytes(16).toString('hex') +} + +async function resolvePreviewBranding( + clientId: string | undefined, + trustedClients: string[], +): Promise<{ clientId: string; metadata: ClientMetadata; css: string | null }> { + const defaultClientId = 'https://preview.example/client-metadata.json' + if (!clientId) { + return { clientId: defaultClientId, metadata: {}, css: null } + } + try { + const metadata = await resolveClientMetadata(clientId) + // Preview respects the real trusted-clients gate: CSS is only + // injected when clientId is on PDS_OAUTH_TRUSTED_CLIENTS, exactly + // as it is during a real OAuth flow. This keeps preview useful as + // a pre-production check ("does my CSS actually load once I'm + // added to the trusted list?") without letting arbitrary clients + // inject CSS onto a preview instance just by being typed into a + // URL. + const css = getClientCss(clientId, metadata, trustedClients) + return { clientId, metadata, css } + } catch (err) { + logger.warn({ err, clientId }, 'Preview: failed to resolve client metadata') + return { clientId, metadata: {}, css: null } + } +} + +function renderIndex(): string { + return ` + + + + auth-service previews + + + +

auth-service preview routes

+

Each link below renders one of the auth-service pages with fixture data, so you can iterate on your client's branding.css without going through a real OAuth flow.

+

Pass ?client_id=<URL-of-your-client-metadata.json> to inject that client's CSS. The trusted-clients check still applies: your client_id must be on PDS_OAUTH_TRUSTED_CLIENTS for its CSS to be injected, exactly as in a real OAuth flow. Untrusted clients still render the page but with no branding.

+ + +` +} + +export function createPreviewRouter(ctx: AuthServiceContext): Router { + const router = Router() + + // Single gate: every route 404s unless the env flag is on. Checked + // at each request rather than at mount time so the flag can flip + // without a restart. + router.use('/preview', (req, res, next) => { + if (process.env.AUTH_PREVIEW_ROUTES !== '1') { + res.status(404).send('Not Found') + return + } + next() + }) + + router.get('/preview', (_req: Request, res: Response) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.send(renderIndex()) + }) + + router.get('/preview/login', async (req: Request, res: Response) => { + const { clientId, metadata, css } = await resolvePreviewBranding( + req.query.client_id as string | undefined, + ctx.config.trustedClients, + ) + const html = renderLoginPage({ + flowId: FAKE_FLOW_ID, + clientId, + clientName: metadata.client_name || 'Preview Client', + branding: metadata, + customCss: css, + loginHint: '', + initialStep: 'email', + otpAlreadySent: false, + csrfToken: fakeCsrfToken(), + authBasePath: '/api/auth', + pdsPublicUrl: ctx.config.pdsPublicUrl, + otpLength: ctx.config.otpLength, + otpCharset: ctx.config.otpCharset, + }) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.send(html) + }) + + router.get('/preview/login-otp', async (req: Request, res: Response) => { + const { clientId, metadata, css } = await resolvePreviewBranding( + req.query.client_id as string | undefined, + ctx.config.trustedClients, + ) + const html = renderLoginPage({ + flowId: FAKE_FLOW_ID, + clientId, + clientName: metadata.client_name || 'Preview Client', + branding: metadata, + customCss: css, + loginHint: FAKE_EMAIL, + initialStep: 'otp', + otpAlreadySent: true, + csrfToken: fakeCsrfToken(), + authBasePath: '/api/auth', + pdsPublicUrl: ctx.config.pdsPublicUrl, + otpLength: ctx.config.otpLength, + otpCharset: ctx.config.otpCharset, + }) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.send(html) + }) + + router.get('/preview/choose-handle', async (req: Request, res: Response) => { + const { css } = await resolvePreviewBranding( + req.query.client_id as string | undefined, + ctx.config.trustedClients, + ) + const error = + typeof req.query.error === 'string' ? req.query.error : undefined + const html = renderChooseHandlePage( + FAKE_HANDLE_DOMAIN, + error, + fakeCsrfToken(), + true, + css, + ) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.send(html) + }) + + router.get('/preview/recovery', async (req: Request, res: Response) => { + const { css } = await resolvePreviewBranding( + req.query.client_id as string | undefined, + ctx.config.trustedClients, + ) + const error = + typeof req.query.error === 'string' ? req.query.error : undefined + const html = renderRecoveryForm({ + requestUri: FAKE_REQUEST_URI, + csrfToken: fakeCsrfToken(), + error, + customCss: css, + backUri: FAKE_REQUEST_URI, + }) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.send(html) + }) + + router.get('/preview/recovery-otp', async (req: Request, res: Response) => { + const { css } = await resolvePreviewBranding( + req.query.client_id as string | undefined, + ctx.config.trustedClients, + ) + const error = + typeof req.query.error === 'string' ? req.query.error : undefined + const html = renderRecoveryOtpForm({ + email: FAKE_EMAIL, + csrfToken: fakeCsrfToken(), + requestUri: FAKE_REQUEST_URI, + otpLength: ctx.config.otpLength, + otpCharset: ctx.config.otpCharset, + error, + customCss: css, + backUri: FAKE_REQUEST_URI, + }) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.send(html) + }) + + return router +} diff --git a/packages/auth-service/src/routes/recovery.ts b/packages/auth-service/src/routes/recovery.ts index dc6df4dc..786a26fd 100644 --- a/packages/auth-service/src/routes/recovery.ts +++ b/packages/auth-service/src/routes/recovery.ts @@ -141,7 +141,7 @@ export function createRecoveryRouter( logger.info({ email }, 'Recovery OTP sent via better-auth') res.send( - renderOtpForm({ + renderRecoveryOtpForm({ email, csrfToken: res.locals.csrfToken, requestUri, @@ -154,7 +154,7 @@ export function createRecoveryRouter( } catch (err) { logger.error({ err }, 'Failed to send recovery OTP') res.status(500).send( - renderOtpForm({ + renderRecoveryOtpForm({ email, csrfToken: res.locals.csrfToken, requestUri, @@ -169,7 +169,7 @@ export function createRecoveryRouter( } else { // No backup email found, but show OTP form anyway (anti-enumeration) res.send( - renderOtpForm({ + renderRecoveryOtpForm({ email, csrfToken: res.locals.csrfToken, requestUri, @@ -227,7 +227,7 @@ export function createRecoveryRouter( : 'Verification failed. Please try again.' const { customCss, backUri } = await getFlowCss(req) res.send( - renderOtpForm({ + renderRecoveryOtpForm({ email, csrfToken: res.locals.csrfToken, requestUri, @@ -244,7 +244,7 @@ export function createRecoveryRouter( return router } -function renderRecoveryForm(opts: { +export function renderRecoveryForm(opts: { requestUri: string csrfToken: string error?: string @@ -284,7 +284,7 @@ function renderRecoveryForm(opts: { ` } -function renderOtpForm(opts: { +export function renderRecoveryOtpForm(opts: { email: string csrfToken: string requestUri: string From 8298d257477a6f069aaf4340ebf1aae220c1de2a Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 14 Apr 2026 17:06:08 +0000 Subject: [PATCH 02/30] docs(env): add AUTH_PREVIEW_ROUTES to top-level .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level .env is loaded by docker-compose and pnpm dev. Without a mention in the top-level .env.example, devs running in those modes wouldn't know AUTH_PREVIEW_ROUTES exists — only Railway users get the auth-service-specific .env.example in front of them. setup.sh's inject_shared_vars correctly leaves this var per-package (it's not in the shared list, and packages/auth-service/.env.example already carries it), so no changes there. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.env.example b/.env.example index a88dbe13..aa17f04d 100644 --- a/.env.example +++ b/.env.example @@ -144,6 +144,16 @@ SESSION_UPDATE_AGE=86400 # Defaults to 'picker-with-random' if not set. EPDS_DEFAULT_HANDLE_MODE=picker-with-random +# Expose /preview/* routes on the auth-service that render each auth-service +# page with fixture data, so client-app developers can iterate on their +# branding.css without walking through a real OAuth flow each time. The +# trusted-clients gate on CSS injection is preserved: a client_id passed via +# ?client_id=... only gets its branding.css injected when it's on +# PDS_OAUTH_TRUSTED_CLIENTS, exactly as in a real OAuth flow. Intended for +# preview envs and dev instances — the routes have no effect on real flows +# but are a developer-only surface that shouldn't be left on in production. +# AUTH_PREVIEW_ROUTES=1 + # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= # GITHUB_CLIENT_ID= From c93c891c9e3f2de1bedfaeb25594e41e5066cfc4 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 14 Apr 2026 17:09:59 +0000 Subject: [PATCH 03/30] docs: put preview-routes guide in the client tutorial, not operator config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-app devs iterating on branding.css are the audience for this content, and they'll be reading tutorial.md — not configuration.md, which is a reference for operators. Add a new "Optional: custom CSS for ePDS pages" subsection to tutorial.md's "Register your app" flow, with an explicit route table, example URL, and a note on the consent-page gap. Leave a pointer in configuration.md so operators know it exists. Also fix the skill reference's tutorial links — they used three-dot paths that didn't resolve from the file's location on GitHub. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../epds-login/references/client-metadata.md | 53 ++++++++------ docs/configuration.md | 21 ++---- docs/tutorial.md | 71 ++++++++++++++++--- 3 files changed, 99 insertions(+), 46 deletions(-) diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index d9a57c88..21442630 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -98,28 +98,37 @@ key generation and serving details. ## All supported fields -| Field | Required | Description | -| --------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `client_id` | Yes | Must match the URL where this file is hosted | -| `client_name` | Yes | Shown on the login page and in OTP emails | -| `redirect_uris` | Yes | Array of allowed callback URLs after login | -| `scope` | Yes | Always `"atproto transition:generic"` | -| `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | -| `response_types` | Yes | Always `["code"]` | -| `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | -| `token_endpoint_auth_signing_alg` | Conditional | Required when `token_endpoint_auth_method` is `"private_key_jwt"`. Must be `"ES256"`. | -| `jwks_uri` | Conditional | Public JWKS URL. Required for `"private_key_jwt"` unless `jwks` is provided. Mutually exclusive with `jwks`. | -| `jwks` | Conditional | Inline JWKS object (`{"keys": [...]}`). Alternative to `jwks_uri`. Mutually exclusive with `jwks_uri`. | -| `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 | -| `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`. | -| `branding` | No | ePDS extension. Object containing a `css` string (max 32 KB). ePDS injects this CSS into login, OTP, choose-handle, recovery, and consent pages. Only honoured when the client is in `PDS_OAUTH_TRUSTED_CLIENTS`. Iterate via auth-service `/preview/*` routes. | +| Field | Required | Description | +| --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `client_id` | Yes | Must match the URL where this file is hosted | +| `client_name` | Yes | Shown on the login page and in OTP emails | +| `redirect_uris` | Yes | Array of allowed callback URLs after login | +| `scope` | Yes | Always `"atproto transition:generic"` | +| `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | +| `response_types` | Yes | Always `["code"]` | +| `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | +| `token_endpoint_auth_signing_alg` | Conditional | Required when `token_endpoint_auth_method` is `"private_key_jwt"`. Must be `"ES256"`. | +| `jwks_uri` | Conditional | Public JWKS URL. Required for `"private_key_jwt"` unless `jwks` is provided. Mutually exclusive with `jwks`. | +| `jwks` | Conditional | Inline JWKS object (`{"keys": [...]}`). Alternative to `jwks_uri`. Mutually exclusive with `jwks_uri`. | +| `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 | +| `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`. | +| `branding` | No | ePDS extension. Object containing a `css` string (max 32 KB). ePDS injects this CSS into login, OTP, choose-handle, recovery, and consent pages. Only honoured when the client is in `PDS_OAUTH_TRUSTED_CLIENTS`. Iterate via auth-service preview routes (see below). | + +## Iterating on `branding.css` + +The auth-service exposes static `/preview/*` routes (when the operator sets +`AUTH_PREVIEW_ROUTES=1`, typically on preview envs and `pr-base`) that render +each page with fixture data so client devs can iterate without going through +a real OAuth flow. See +[the client tutorial's "Iterating on `branding.css`" section](../../../../docs/tutorial.md#iterating-on-brandingcss) +for the route list and example URLs. ## Custom email templates diff --git a/docs/configuration.md b/docs/configuration.md index ddac3f50..d73ae582 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,21 +78,12 @@ tag closure. The CSP `style-src` directive is updated with a SHA-256 hash of the injected CSS. Untrusted clients never get CSS injection regardless of what their metadata contains. -#### Iterating on `branding.css` via auth-service preview routes - -Set `AUTH_PREVIEW_ROUTES=1` on the auth-service to expose a set of -static `/preview/*` URLs that render each auth-service page with -fixture data (no real OAuth flow, no OTP emails). Pass -`?client_id=` to inject that -client's `branding.css`, subject to the same `PDS_OAUTH_TRUSTED_CLIENTS` -check as real flows. Visit `/preview` on the auth-service for an -index. Intended for preview envs and dev instances — the routes -short-circuit real auth state and have no effect on real flows, but -are a developer-only surface and shouldn't be left on in production. -See `packages/auth-service/.env.example` for details. The pds-core -consent page (from `@atproto/oauth-provider-ui`) is not yet covered -by a preview route — that needs a separate mechanism for the SPA's -hydration data. +Client-app developers can iterate on their `branding.css` without +walking through a real OAuth flow each time by setting +`AUTH_PREVIEW_ROUTES=1` on the auth-service — see the +["Iterating on `branding.css`" section of the client tutorial](./tutorial.md#iterating-on-brandingcss) +for the list of preview routes and example URLs. Intended for preview +envs and dev instances only, not production. Optional PDS email variables: diff --git a/docs/tutorial.md b/docs/tutorial.md index 249b1c70..f4f5d16b 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -269,15 +269,6 @@ You can customise the OTP email and login page colours: } ``` -For full control over the auth-service pages (login, OTP entry, -choose-handle, recovery) and the PDS consent page, trusted clients can -also supply a `branding.css` string in a `branding` object inside their -client metadata. See the -[CSS branding injection](./configuration.md#css-branding-injection) -section for the full reference, including how to iterate on your CSS -without walking through the full OAuth flow each time via the -auth-service's `/preview/*` routes. - The email template must be an HTML file containing at minimum a `{{code}}` placeholder. Supported template variables: @@ -348,6 +339,68 @@ The skip only applies to initial sign-up — returning users go through normal consent handling (which may still be auto-approved if they have already granted the requested scopes). +#### Optional: custom CSS for ePDS pages (trusted clients) + +If your app is in the PDS operator's `PDS_OAUTH_TRUSTED_CLIENTS`, you can +supply a `branding.css` string in your client metadata and ePDS will inject +it into every page it renders during sign-in — login, OTP entry, +choose-handle, account recovery, and the consent screen. This gives +trusted clients full control over the look of those pages, not just the +two hex colours in `brand_color` / `background_color`. + +```json +{ + "branding": { + "css": "body { background: #1a1208; color: #fef3c7; } .btn-primary { background: #f59e0b; color: #1a1208; } /* ... */" + } +} +``` + +Constraints: + +- CSS is size-capped at 32 KB (measured in escaped UTF-8 bytes). +- `` sequences are escaped so the CSS can't break out of its + `` + : '' + + return ` + + + + + + Consent preview — ${escapeHtml(opts.fixture.clientId)} + ${styleLinks} + ${injectedStyle} + + +
+ + ${scriptTags} + +` +} + +interface PreviewConsentDeps { + trustedClients: string[] + resolveClientMetadata: (clientId: string) => Promise + getClientCss: ( + clientId: string, + metadata: ClientMetadata, + trustedClients: string[], + ) => string | null + logger: LoggerLike +} + +const FIXTURE_DEFAULT_CLIENT_ID = 'https://preview.example/client-metadata.json' + +/** + * Express handler factory: creates a GET /preview/consent handler if the + * env var is on, returns null otherwise so the caller can skip wiring. + */ +export function createPreviewConsentHandler( + deps: PreviewConsentDeps, +): ((req: RequestLike, res: ResponseLike) => Promise) | null { + if (process.env.PDS_PREVIEW_ROUTES !== '1') return null + + return async function previewConsent(req: RequestLike, res: ResponseLike) { + const rawClientId = req.query.client_id + const clientId = + typeof rawClientId === 'string' && rawClientId + ? rawClientId + : FIXTURE_DEFAULT_CLIENT_ID + + let metadata: ClientMetadata = {} + let injectedCss: string | null = null + + if (clientId !== FIXTURE_DEFAULT_CLIENT_ID) { + try { + metadata = await deps.resolveClientMetadata(clientId) + injectedCss = deps.getClientCss(clientId, metadata, deps.trustedClients) + } catch (err) { + deps.logger.warn( + { err, clientId }, + 'Preview consent: failed to resolve client metadata', + ) + } + } + + const html = await renderConsentHtml({ + fixture: { + clientId, + clientMetadata: metadata, + isTrusted: deps.trustedClients.includes(clientId), + }, + injectedCss, + }) + + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + // Relaxed CSP to match the auth-service preview routes: the hydration + // block is an inline script, and pinning its sha256 would fight every + // time the fixture changes. This is a dev-only surface. + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'none'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "frame-ancestors 'none'", + "base-uri 'self'", + ].join('; '), + ) + res.send(html) + } +} + +/** Static index page listing the preview route. */ +export function renderPreviewIndex(): string { + return ` + + + + pds-core previews + + + +

pds-core preview routes

+

Renders the OAuth consent page with fixture hydration data, so you can iterate on your client's branding.css without walking through the full OAuth flow.

+

Pass ?client_id=<URL-of-your-client-metadata.json> to inject that client's CSS. The trusted-clients check still applies: your client_id must be on PDS_OAUTH_TRUSTED_CLIENTS for its CSS to be injected. Without client_id the page renders unbranded (baseline).

+ + +` +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ffa87c..91fd1a61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: '@atproto/oauth-provider': specifier: ^0.15.9 version: 0.15.10 + '@atproto/oauth-provider-ui': + specifier: ^0.4.3 + version: 0.4.3 '@atproto/pds': specifier: ^0.4.209 version: 0.4.211 From 294842dfdfcee36f8a8b1da9a7f1a7f6eb8bca69 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 16 Apr 2026 22:01:04 +0000 Subject: [PATCH 05/30] fix(pds-core): drive preview consent SPA to the consent view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture hydration we were emitting pushed the SPA into its sign-in view instead of the consent view: `loginHint` triggered `forceSignIn`, and the empty `__sessions` array meant no session had `selected && !loginRequired && consentRequired`, which is the exact gate `authorize-view.tsx` uses to mount ``. Fix by dropping `loginHint` and emitting a single fixture session with `selected`, `!loginRequired`, `consentRequired` — minimum viable shape to land directly on the consent screen. Also covers `preview-consent.ts` with unit tests (previously 0%) and ratchets coverage thresholds per AGENTS.md policy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/preview-consent.test.ts | 221 ++++++++++++++++++ packages/pds-core/src/lib/preview-consent.ts | 29 ++- vitest.config.ts | 8 +- 3 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 packages/pds-core/src/__tests__/preview-consent.test.ts diff --git a/packages/pds-core/src/__tests__/preview-consent.test.ts b/packages/pds-core/src/__tests__/preview-consent.test.ts new file mode 100644 index 00000000..6a2c94e1 --- /dev/null +++ b/packages/pds-core/src/__tests__/preview-consent.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createPreviewConsentHandler, + renderPreviewIndex, +} from '../lib/preview-consent.js' + +function mockLogger() { + return { info: vi.fn(), warn: vi.fn(), debug: vi.fn() } +} + +type CapturedRes = { + headers: Record + body: string | null + setHeader: (name: string, value: string) => void + send: (body: string) => void +} + +function mockRes(): CapturedRes { + const res: CapturedRes = { + headers: {}, + body: null, + setHeader(name, value) { + this.headers[name] = value + }, + send(body) { + this.body = body + }, + } + return res +} + +describe('createPreviewConsentHandler', () => { + const originalEnv = process.env.PDS_PREVIEW_ROUTES + + afterEach(() => { + if (originalEnv === undefined) delete process.env.PDS_PREVIEW_ROUTES + else process.env.PDS_PREVIEW_ROUTES = originalEnv + vi.restoreAllMocks() + }) + + it('returns null when PDS_PREVIEW_ROUTES is unset', () => { + delete process.env.PDS_PREVIEW_ROUTES + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + }) + expect(handler).toBeNull() + }) + + it('returns null when PDS_PREVIEW_ROUTES is not "1"', () => { + process.env.PDS_PREVIEW_ROUTES = '0' + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + }) + expect(handler).toBeNull() + }) + + describe('when enabled', () => { + beforeEach(() => { + process.env.PDS_PREVIEW_ROUTES = '1' + }) + + it('renders fixture consent HTML with default client id when none provided', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: {} }, res) + + expect(res.headers['Content-Type']).toBe('text/html; charset=utf-8') + expect(res.headers['Cache-Control']).toBe('no-store') + expect(res.headers['Content-Security-Policy']).toContain( + "script-src 'self' 'unsafe-inline'", + ) + expect(res.body).toContain('preview.example/client-metadata.json') + // Drives the SPA to the consent view, not sign-in. The hydration + // data is JSON-stringified twice (once for the value, once for the + // script-literal), so field names appear with escaped quotes. + expect(res.body).toContain('\\"consentRequired\\":true') + expect(res.body).toContain('\\"selected\\":true') + // No loginHint — would force sign-in mode in authorize-view.tsx: + expect(res.body).not.toContain('\\"loginHint\\"') + // Hydration script + entry bundle present: + expect(res.body).toMatch( + /` or U+2028/2029 in the value would break out. Delegate escaping to serialize-javascript so the hygiene lives in a well-vetted library, not a hand-rolled escape routine. Added regression test. - Drop the local escapeHtml and use the shared `escapeHtml` from @certified-app/shared, matching the project convention. UX — metadata cache: - resolveClientMetadata silently swallowed fetch failures and cached a branding-less fallback for 60s, so a transient failure looked identical to "client has no branding.css". Log the failure (warn). - Add `{ noCache?: boolean }` option to resolveClientMetadata and wire `?no_cache=1` on /preview/consent and auth-service preview routes so devs editing branding.css don't have to wait out the 10-min TTL. Docs / env: - Move PDS_PREVIEW_ROUTES to the pds-core section of .env.example. - Add `text` language to the preview-URLs fenced block for MD040. - Document `?no_cache=1` in tutorial.md and both preview index pages. Tests: - Per-test env snapshot/restore to prevent leak on mid-test throws. - Added coverage for XSS escape + no_cache wiring. Coverage thresholds ratcheted (main → this PR): 33→35 / 27→30 / 51→54 / 32→34. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 31 ++++--- docs/tutorial.md | 7 +- packages/auth-service/src/routes/preview.ts | 9 +- packages/pds-core/package.json | 4 +- .../src/__tests__/preview-consent.test.ts | 90 ++++++++++++++++++- packages/pds-core/src/lib/preview-consent.ts | 50 ++++++----- packages/shared/src/client-metadata.ts | 37 ++++++-- packages/shared/src/index.ts | 6 +- pnpm-lock.yaml | 17 ++++ vitest.config.ts | 2 +- 10 files changed, 207 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 31b4c42b..35f7f905 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,16 @@ PDS_BLOBSTORE_DISK_LOCATION=/data/blobs # "epds_skip_consent_on_signup": true. Default: false. # PDS_SIGNUP_ALLOW_CONSENT_SKIP=false +# Expose /preview and /preview/consent on pds-core, rendering the OAuth +# consent page with fixture hydration data so client-app developers can +# iterate on their branding.css without walking through a real OAuth +# flow. ?client_id=... injects that client's branding.css (subject to +# PDS_OAUTH_TRUSTED_CLIENTS, same as a real flow). Intended for preview +# envs and dev instances — leave off in production. The matching +# auth-service flag is AUTH_PREVIEW_ROUTES (see auth-service section +# below); both flags are independent. +# PDS_PREVIEW_ROUTES=1 + # Invite code for automated account creation (ePDS creates accounts on first login). # Required when PDS_INVITE_REQUIRED is true (the default). # Generate with: @@ -144,19 +154,16 @@ SESSION_UPDATE_AGE=86400 # Defaults to 'picker-with-random' if not set. EPDS_DEFAULT_HANDLE_MODE=picker-with-random -# Expose /preview/* routes that render each auth-service / pds-core page -# with fixture data, so client-app developers can iterate on their -# branding.css without walking through a real OAuth flow each time. The -# trusted-clients gate on CSS injection is preserved: a client_id passed via -# ?client_id=... only gets its branding.css injected when it's on -# PDS_OAUTH_TRUSTED_CLIENTS, exactly as in a real OAuth flow. Intended for -# preview envs and dev instances — the routes have no effect on real flows -# but are a developer-only surface that shouldn't be left on in production. -# AUTH_PREVIEW_ROUTES covers login / OTP / choose-handle / recovery pages -# (auth-service); PDS_PREVIEW_ROUTES covers the consent page (pds-core). -# Both flags are independent — enable whichever pages you need. +# Expose /preview/* routes on auth-service that render each page +# (login / OTP / choose-handle / recovery) with fixture data, so +# client-app developers can iterate on their branding.css without +# walking through a real OAuth flow each time. The trusted-clients +# gate on CSS injection is preserved: ?client_id=... only gets its +# branding.css injected when on PDS_OAUTH_TRUSTED_CLIENTS. Intended +# for preview envs and dev instances — leave off in production. +# The matching pds-core flag (PDS_PREVIEW_ROUTES, see pds-core section +# above) covers the consent page. Both flags are independent. # AUTH_PREVIEW_ROUTES=1 -# PDS_PREVIEW_ROUTES=1 # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= diff --git a/docs/tutorial.md b/docs/tutorial.md index 2e4b6162..b36b6b21 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -402,7 +402,7 @@ enabled in production. Typical URLs: -``` +```text https:///preview/login?client_id= https:///preview/consent?client_id= ``` @@ -412,6 +412,11 @@ OTP emails, no walking through the full flow. Browser devtools work normally so you can inspect, tweak in the Styles panel, and copy the winning rules back into your `branding.css`. +Metadata is cached for 10 minutes per `client_id`. Append `&no_cache=1` +to bypass the cache and force a re-fetch — handy when you've just edited +and re-hosted `branding.css` and want to see the change on the next +refresh. + ### Using `@atproto/oauth-client-node` (recommended for Flow 2) If your app does **not** need to pass a raw email as `login_hint` (i.e. diff --git a/packages/auth-service/src/routes/preview.ts b/packages/auth-service/src/routes/preview.ts index 4292ee1a..63be8fc8 100644 --- a/packages/auth-service/src/routes/preview.ts +++ b/packages/auth-service/src/routes/preview.ts @@ -44,13 +44,14 @@ function fakeCsrfToken(): string { async function resolvePreviewBranding( clientId: string | undefined, trustedClients: string[], + noCache: boolean, ): Promise<{ clientId: string; metadata: ClientMetadata; css: string | null }> { const defaultClientId = 'https://preview.example/client-metadata.json' if (!clientId) { return { clientId: defaultClientId, metadata: {}, css: null } } try { - const metadata = await resolveClientMetadata(clientId) + const metadata = await resolveClientMetadata(clientId, { noCache }) // Preview respects the real trusted-clients gate: CSS is only // injected when clientId is on PDS_OAUTH_TRUSTED_CLIENTS, exactly // as it is during a real OAuth flow. This keeps preview useful as @@ -85,6 +86,7 @@ function renderIndex(): string {

auth-service preview routes

Each link below renders one of the auth-service pages with fixture data, so you can iterate on your client's branding.css without going through a real OAuth flow.

Pass ?client_id=<URL-of-your-client-metadata.json> to inject that client's CSS. The trusted-clients check still applies: your client_id must be on PDS_OAUTH_TRUSTED_CLIENTS for its CSS to be injected, exactly as in a real OAuth flow. Untrusted clients still render the page but with no branding.

+

Append &no_cache=1 to bypass the 10-minute metadata cache — useful when you've just edited branding.css on the upstream client and want to see the change immediately.

  • Login — email step
  • Login — OTP step
  • @@ -120,6 +122,7 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { clientId, metadata, css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, + req.query.no_cache === '1', ) const html = renderLoginPage({ flowId: FAKE_FLOW_ID, @@ -144,6 +147,7 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { clientId, metadata, css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, + req.query.no_cache === '1', ) const html = renderLoginPage({ flowId: FAKE_FLOW_ID, @@ -168,6 +172,7 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, + req.query.no_cache === '1', ) const error = typeof req.query.error === 'string' ? req.query.error : undefined @@ -186,6 +191,7 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, + req.query.no_cache === '1', ) const error = typeof req.query.error === 'string' ? req.query.error : undefined @@ -204,6 +210,7 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, + req.query.no_cache === '1', ) const error = typeof req.query.error === 'string' ? req.query.error : undefined diff --git a/packages/pds-core/package.json b/packages/pds-core/package.json index dc7d71ec..7cb3ef6b 100644 --- a/packages/pds-core/package.json +++ b/packages/pds-core/package.json @@ -15,10 +15,12 @@ "@atproto/pds": "^0.4.209", "@certified-app/shared": "workspace:*", "@did-plc/lib": "^0.0.4", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "serialize-javascript": "^7.0.5" }, "devDependencies": { "@types/node": "^20.11.0", + "@types/serialize-javascript": "^5.0.4", "tsx": "^4.7.0", "typescript": "^5.3.3" } diff --git a/packages/pds-core/src/__tests__/preview-consent.test.ts b/packages/pds-core/src/__tests__/preview-consent.test.ts index 6a2c94e1..b0429270 100644 --- a/packages/pds-core/src/__tests__/preview-consent.test.ts +++ b/packages/pds-core/src/__tests__/preview-consent.test.ts @@ -31,7 +31,15 @@ function mockRes(): CapturedRes { } describe('createPreviewConsentHandler', () => { - const originalEnv = process.env.PDS_PREVIEW_ROUTES + // Snapshot + restore per-test so a mid-test throw cannot leak env state + // between tests (process.env is process-global, unlike Vitest's module + // isolation). + let originalEnv: string | undefined + + beforeEach(() => { + originalEnv = process.env.PDS_PREVIEW_ROUTES + delete process.env.PDS_PREVIEW_ROUTES + }) afterEach(() => { if (originalEnv === undefined) delete process.env.PDS_PREVIEW_ROUTES @@ -40,7 +48,7 @@ describe('createPreviewConsentHandler', () => { }) it('returns null when PDS_PREVIEW_ROUTES is unset', () => { - delete process.env.PDS_PREVIEW_ROUTES + // beforeEach already deleted it; explicit here is redundant but harmless const handler = createPreviewConsentHandler({ trustedClients: [], resolveClientMetadata: () => Promise.resolve({}), @@ -113,7 +121,9 @@ describe('createPreviewConsentHandler', () => { const res = mockRes() await handler({ query: { client_id: trusted } }, res) - expect(resolveClientMetadata).toHaveBeenCalledWith(trusted) + expect(resolveClientMetadata).toHaveBeenCalledWith(trusted, { + noCache: false, + }) expect(getClientCss).toHaveBeenCalledWith( trusted, { client_name: 'Trusted App' }, @@ -181,6 +191,80 @@ describe('createPreviewConsentHandler', () => { expect(res.body).toContain('preview.example/client-metadata.json') }) + it('escapes `` in attacker-controlled clientId so it cannot break out of the hydration ', + }, + }, + res, + ) + // Pull out the hydration script and assert the breakout payload is escaped. + // The browser only terminates `; as long as + // the unescaped sequence never appears inside the script block we're safe. + const body = res.body! + const scriptMatch = body.match( + /`, `U+2028`, `U+2029`, and other JS-string-literal hazards + * in attacker-controllable fields (e.g. `clientId`) cannot break out + * of the inline script. * * - CSP: we use `script-src 'self' 'unsafe-inline'` rather than sha256- * pinning the hydration script, matching the auth-service preview * routes' relaxed CSP. */ -import type { ClientMetadata } from '@certified-app/shared' +import { escapeHtml, type ClientMetadata } from '@certified-app/shared' +import serialize from 'serialize-javascript' // Use structural request/response types rather than importing from // express — pds-core doesn't depend on express's types directly and @@ -88,28 +92,22 @@ async function loadAssetRefs(): Promise<{ const ASSETS_URL_PREFIX = '/@atproto/oauth-provider/~assets/' -function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - function assetUrl(filename: string): string { return `${ASSETS_URL_PREFIX}${encodeURIComponent(filename)}` } function renderHydration(values: Record): string { - // Mirrors @atproto/oauth-provider's declareHydrationData: each value is - // stringified once (to JSON), then that string is JSON-stringified again - // to produce a safely-embedded JS string literal. The script removes - // itself so subsequent scripts can't read the globals off window. + // Mirrors @atproto/oauth-provider's declareHydrationData. We delegate the + // actual escaping to serialize-javascript so ``, U+2028/2029, + // and other inline-script hazards in attacker-controllable values (e.g. + // `clientId`) can't break out. `isJSON: true` tells serialize-javascript + // the value is plain JSON-safe data (no Date/Function/RegExp round-trip + // needed), which makes the output a drop-in for the SPA's JSON.parse. const lines: string[] = [] for (const [key, val] of Object.entries(values)) { - const payload = JSON.stringify(JSON.stringify(val)) - lines.push(`window[${JSON.stringify(key)}]=JSON.parse(${payload});`) + const keyLit = serialize(key, { isJSON: true }) + const valLit = serialize(JSON.stringify(val), { isJSON: true }) + lines.push(`window[${keyLit}]=JSON.parse(${valLit});`) } lines.push('document.currentScript.remove();') return lines.join('') @@ -212,7 +210,10 @@ async function renderConsentHtml(opts: { interface PreviewConsentDeps { trustedClients: string[] - resolveClientMetadata: (clientId: string) => Promise + resolveClientMetadata: ( + clientId: string, + options?: { noCache?: boolean }, + ) => Promise getClientCss: ( clientId: string, metadata: ClientMetadata, @@ -239,12 +240,18 @@ export function createPreviewConsentHandler( ? rawClientId : FIXTURE_DEFAULT_CLIENT_ID + // `?no_cache=1` bypasses the 10-minute `resolveClientMetadata` cache + // so CSS edits on the client's metadata JSON show up on the next + // refresh. Without it, devs can spend 10 minutes staring at a stale + // branding.css wondering why their change didn't land. + const noCache = req.query.no_cache === '1' + let metadata: ClientMetadata = {} let injectedCss: string | null = null if (clientId !== FIXTURE_DEFAULT_CLIENT_ID) { try { - metadata = await deps.resolveClientMetadata(clientId) + metadata = await deps.resolveClientMetadata(clientId, { noCache }) injectedCss = deps.getClientCss(clientId, metadata, deps.trustedClients) } catch (err) { deps.logger.warn( @@ -305,6 +312,7 @@ export function renderPreviewIndex(): string {

    pds-core preview routes

    Renders the OAuth consent page with fixture hydration data, so you can iterate on your client's branding.css without walking through the full OAuth flow.

    Pass ?client_id=<URL-of-your-client-metadata.json> to inject that client's CSS. The trusted-clients check still applies: your client_id must be on PDS_OAUTH_TRUSTED_CLIENTS for its CSS to be injected. Without client_id the page renders unbranded (baseline).

    +

    Append &no_cache=1 to bypass the 10-minute metadata cache — useful when you've just edited branding.css on the upstream client and want to see the change immediately.

    diff --git a/packages/shared/src/client-metadata.ts b/packages/shared/src/client-metadata.ts index ba7659c6..2bdfbb19 100644 --- a/packages/shared/src/client-metadata.ts +++ b/packages/shared/src/client-metadata.ts @@ -14,8 +14,11 @@ */ import type { HandleMode } from './handle.js' +import { createLogger } from './logger.js' import { makeSafeFetch } from './safe-fetch.js' +const logger = createLogger('shared:client-metadata') + export interface ClientBranding { css?: string } @@ -76,8 +79,19 @@ export async function resolveClientName(clientId: string): Promise { return metadata.client_name || extractDomain(clientId) || 'an application' } +export interface ResolveClientMetadataOptions { + /** + * When true, ignore any existing cache entry for this clientId and + * refetch from the network. A successful fetch still populates the + * cache. Intended for preview/dev loops where the upstream metadata + * JSON is being edited live. + */ + noCache?: boolean +} + export async function resolveClientMetadata( clientId: string, + options: ResolveClientMetadataOptions = {}, ): Promise { // Only attempt a fetch for URL-shaped client IDs let parsedUrl: URL @@ -90,10 +104,11 @@ export async function resolveClientMetadata( return { client_name: clientId } } - // Check cache - const cached = cache.get(clientId) - if (cached && cached.expiresAt > Date.now()) { - return cached.metadata + if (!options.noCache) { + const cached = cache.get(clientId) + if (cached && cached.expiresAt > Date.now()) { + return cached.metadata + } } try { @@ -104,6 +119,10 @@ export async function resolveClientMetadata( }) if (!res.ok) { + logger.warn( + { clientId, status: res.status }, + 'Client metadata fetch returned non-OK status; using fallback', + ) return fallback(clientId) } @@ -116,7 +135,15 @@ export async function resolveClientMetadata( }) return metadata - } catch { + } catch (err) { + // Previously swallowed silently — meant a transient boot-time fetch + // failure would cache a branding-less fallback for 60s with no audit + // trail. Logging keeps the "don't throw" ergonomics for callers while + // making the negative cache diagnosable. + logger.warn( + { err, clientId }, + 'Client metadata fetch failed; using fallback', + ) return fallback(clientId) } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b5f7ca79..e099ab04 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -44,7 +44,11 @@ export { clearClientMetadataCache, _seedClientMetadataCacheForTest, } from './client-metadata.js' -export type { ClientMetadata, ClientBranding } from './client-metadata.js' +export type { + ClientMetadata, + ClientBranding, + ResolveClientMetadataOptions, +} from './client-metadata.js' export { getEpdsVersion } from './version.js' export { makeSafeFetch } from './safe-fetch.js' export type { SafeFetchOptions } from './safe-fetch.js' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91fd1a61..4ed353b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,10 +158,16 @@ importers: dotenv: specifier: ^16.3.1 version: 16.6.1 + serialize-javascript: + specifier: ^7.0.5 + version: 7.0.5 devDependencies: '@types/node': specifier: ^20.11.0 version: 20.19.33 + '@types/serialize-javascript': + specifier: ^5.0.4 + version: 5.0.4 tsx: specifier: ^4.7.0 version: 4.21.0 @@ -1644,6 +1650,9 @@ packages: '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + '@types/serialize-javascript@5.0.4': + resolution: {integrity: sha512-Z2R7UKFuNWCP8eoa2o9e5rkD3hmWxx/1L0CYz0k2BZzGh0PhEVMp9kfGiqEml/0IglwNERXZ2hwNzIrSz/KHTA==} + '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} @@ -3806,6 +3815,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -6550,6 +6563,8 @@ snapshots: dependencies: '@types/node': 20.19.33 + '@types/serialize-javascript@5.0.4': {} + '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 @@ -8866,6 +8881,8 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-javascript@7.0.5: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 diff --git a/vitest.config.ts b/vitest.config.ts index 86476096..2a580937 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ thresholds: { statements: 35, branches: 30, - functions: 55, + functions: 54, lines: 34, }, }, From f1cab6ee142db057441f917c34353aeacbc5d5ab Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Fri, 17 Apr 2026 00:08:33 +0000 Subject: [PATCH 07/30] feat(preview): persisted client_id input + live cache-status panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On both auth-service and pds-core /preview index pages: - Text field for a client_metadata URL. Typing in it rewrites every `a[data-preview-link]` on the page with `?client_id=...` live (no save button). Value persisted to localStorage under `epds:preview:client_id`, so the field is pre-filled on return visits and all preview links come up already carrying the chosen client_id. - /preview routes now unconditionally bypass the 10-minute client-metadata cache. Editing branding.css and refreshing the preview URL always picks up the new CSS immediately — no more "wait for the cache to expire" loop. The `?no_cache=1` query param is dropped (it's always on now). - New /preview/cache-status JSON endpoint on both services exposing the real-flow cache: `{ now, entries: [{ clientId, expiresAt }] }`. The preview index polls it every 15s and shows a live countdown per entry, so devs can see "has my branding.css change reached real users yet?" without restarting the service. - Shared HTML/JS snippets live in `@certified-app/shared/preview-ui` so both index pages stay byte-identical. Docs in docs/tutorial.md updated to cover all three affordances. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/tutorial.md | 27 +++- packages/auth-service/src/routes/preview.ts | 53 +++++-- .../src/__tests__/preview-consent.test.ts | 45 +++--- packages/pds-core/src/index.ts | 10 +- packages/pds-core/src/lib/preview-consent.ts | 37 +++-- packages/shared/src/client-metadata.ts | 23 +++ packages/shared/src/index.ts | 6 + packages/shared/src/preview-ui.ts | 149 ++++++++++++++++++ 8 files changed, 288 insertions(+), 62 deletions(-) create mode 100644 packages/shared/src/preview-ui.ts diff --git a/docs/tutorial.md b/docs/tutorial.md index b36b6b21..583e1973 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -395,10 +395,13 @@ enabled in production. **pds-core** (consent): -| Route | Page it renders | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `GET /preview` | Index page for the pds-core preview | -| `GET /preview/consent` | OAuth consent page (the same `@atproto/oauth-provider-ui` SPA used by `/oauth/authorize`, rendered against fixture hydration data) | +| Route | Page it renders | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `GET /preview` | Index page for the pds-core preview | +| `GET /preview/consent` | OAuth consent page (the same `@atproto/oauth-provider-ui` SPA used by `/oauth/authorize`, rendered against fixture hydration data) | +| `GET /preview/cache-status` | JSON: live state of the shared client-metadata cache as seen by real OAuth flows | + +The auth-service has the same `/preview/cache-status` endpoint. Typical URLs: @@ -412,10 +415,18 @@ OTP emails, no walking through the full flow. Browser devtools work normally so you can inspect, tweak in the Styles panel, and copy the winning rules back into your `branding.css`. -Metadata is cached for 10 minutes per `client_id`. Append `&no_cache=1` -to bypass the cache and force a re-fetch — handy when you've just edited -and re-hosted `branding.css` and want to see the change on the next -refresh. +**Persistent client_id.** The `/preview` index pages have a text field +for your client metadata URL. Paste it once and every preview link on +the page gets `?client_id=...` appended live as you type. The value is +saved in `localStorage` under `epds:preview:client_id`, so it's +pre-filled on your next visit. + +**Cache bypass.** Preview routes always re-fetch your client metadata — +the 10-minute cache that real OAuth flows use is bypassed, so your +`branding.css` edits show up on the next refresh with no waiting. The +`/preview` index also surfaces the current real-flow cache state +(entries and TTLs) via the `/preview/cache-status` JSON endpoint so you +can tell when a real user's next request will see the new version. ### Using `@atproto/oauth-client-node` (recommended for Flow 2) diff --git a/packages/auth-service/src/routes/preview.ts b/packages/auth-service/src/routes/preview.ts index 63be8fc8..edfaced0 100644 --- a/packages/auth-service/src/routes/preview.ts +++ b/packages/auth-service/src/routes/preview.ts @@ -23,8 +23,14 @@ import { Router, type Request, type Response } from 'express' import { randomBytes } from 'node:crypto' import type { AuthServiceContext } from '../context.js' import { resolveClientMetadata, getClientCss } from '../lib/client-metadata.js' -import type { ClientMetadata } from '@certified-app/shared' -import { createLogger } from '@certified-app/shared' +import { + createLogger, + getClientMetadataCacheStatus, + PREVIEW_CACHE_STATUS_HTML, + PREVIEW_CLIENT_ID_INPUT_HTML, + PREVIEW_CLIENT_ID_SCRIPT_HTML, + type ClientMetadata, +} from '@certified-app/shared' import { renderLoginPage } from './login-page.js' import { renderChooseHandlePage } from './choose-handle.js' import { renderRecoveryForm, renderRecoveryOtpForm } from './recovery.js' @@ -44,14 +50,15 @@ function fakeCsrfToken(): string { async function resolvePreviewBranding( clientId: string | undefined, trustedClients: string[], - noCache: boolean, ): Promise<{ clientId: string; metadata: ClientMetadata; css: string | null }> { const defaultClientId = 'https://preview.example/client-metadata.json' if (!clientId) { return { clientId: defaultClientId, metadata: {}, css: null } } try { - const metadata = await resolveClientMetadata(clientId, { noCache }) + // Preview routes always bypass the 10-minute cache so devs see + // branding.css edits on the next refresh. + const metadata = await resolveClientMetadata(clientId, { noCache: true }) // Preview respects the real trusted-clients gate: CSS is only // injected when clientId is on PDS_OAUTH_TRUSTED_CLIENTS, exactly // as it is during a real OAuth flow. This keeps preview useful as @@ -80,21 +87,32 @@ function renderIndex(): string { code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-size: 14px; } ul { line-height: 2; } a { color: #0b5ed7; } + label { display: block; margin: 16px 0 6px; font-weight: 500; } + input[type="url"] { width: 100%; padding: 8px 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + input[type="url"]:focus { outline: 2px solid #0b5ed7; outline-offset: -1px; border-color: transparent; } + .cache-status { margin-top: 32px; padding: 12px 16px; background: #f8f9fa; border: 1px solid #e5e7eb; border-radius: 8px; } + .cache-status h2 { font-size: 15px; margin: 0 0 4px; } + .cache-status-hint { font-size: 13px; color: #555; margin: 0 0 8px; } + .cache-entries { list-style: none; padding: 0; margin: 0; line-height: 1.8; } + .cache-entries code { word-break: break-all; }

    auth-service preview routes

    Each link below renders one of the auth-service pages with fixture data, so you can iterate on your client's branding.css without going through a real OAuth flow.

    Pass ?client_id=<URL-of-your-client-metadata.json> to inject that client's CSS. The trusted-clients check still applies: your client_id must be on PDS_OAUTH_TRUSTED_CLIENTS for its CSS to be injected, exactly as in a real OAuth flow. Untrusted clients still render the page but with no branding.

    -

    Append &no_cache=1 to bypass the 10-minute metadata cache — useful when you've just edited branding.css on the upstream client and want to see the change immediately.

    +

    Preview routes always re-fetch client metadata — the 10-minute cache used by real flows is bypassed here, so edits to your branding.css show up on the next refresh.

    + ${PREVIEW_CLIENT_ID_INPUT_HTML} + ${PREVIEW_CACHE_STATUS_HTML} + ${PREVIEW_CLIENT_ID_SCRIPT_HTML} ` } @@ -118,11 +136,18 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { res.send(renderIndex()) }) + router.get('/preview/cache-status', (_req: Request, res: Response) => { + res.setHeader('Cache-Control', 'no-store') + res.json({ + now: Date.now(), + entries: getClientMetadataCacheStatus(), + }) + }) + router.get('/preview/login', async (req: Request, res: Response) => { const { clientId, metadata, css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, - req.query.no_cache === '1', ) const html = renderLoginPage({ flowId: FAKE_FLOW_ID, @@ -147,7 +172,6 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { clientId, metadata, css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, - req.query.no_cache === '1', ) const html = renderLoginPage({ flowId: FAKE_FLOW_ID, @@ -172,7 +196,6 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, - req.query.no_cache === '1', ) const error = typeof req.query.error === 'string' ? req.query.error : undefined @@ -191,7 +214,6 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, - req.query.no_cache === '1', ) const error = typeof req.query.error === 'string' ? req.query.error : undefined @@ -210,7 +232,6 @@ export function createPreviewRouter(ctx: AuthServiceContext): Router { const { css } = await resolvePreviewBranding( req.query.client_id as string | undefined, ctx.config.trustedClients, - req.query.no_cache === '1', ) const error = typeof req.query.error === 'string' ? req.query.error : undefined diff --git a/packages/pds-core/src/__tests__/preview-consent.test.ts b/packages/pds-core/src/__tests__/preview-consent.test.ts index b0429270..e697c200 100644 --- a/packages/pds-core/src/__tests__/preview-consent.test.ts +++ b/packages/pds-core/src/__tests__/preview-consent.test.ts @@ -122,7 +122,7 @@ describe('createPreviewConsentHandler', () => { await handler({ query: { client_id: trusted } }, res) expect(resolveClientMetadata).toHaveBeenCalledWith(trusted, { - noCache: false, + noCache: true, }) expect(getClientCss).toHaveBeenCalledWith( trusted, @@ -222,31 +222,7 @@ describe('createPreviewConsentHandler', () => { expect(scriptBody).toContain('\\u003C\\u002Fscript') }) - it('passes noCache=true to resolveClientMetadata when ?no_cache=1', async () => { - const resolveClientMetadata = vi.fn(() => Promise.resolve({})) - const handler = createPreviewConsentHandler({ - trustedClients: [], - resolveClientMetadata, - getClientCss: () => null, - logger: mockLogger(), - })! - const res = mockRes() - await handler( - { - query: { - client_id: 'https://x.example/client-metadata.json', - no_cache: '1', - }, - }, - res, - ) - expect(resolveClientMetadata).toHaveBeenCalledWith( - 'https://x.example/client-metadata.json', - { noCache: true }, - ) - }) - - it('passes noCache=false by default', async () => { + it('always bypasses the metadata cache (preview routes never serve stale branding)', async () => { const resolveClientMetadata = vi.fn(() => Promise.resolve({})) const handler = createPreviewConsentHandler({ trustedClients: [], @@ -261,7 +237,7 @@ describe('createPreviewConsentHandler', () => { ) expect(resolveClientMetadata).toHaveBeenCalledWith( 'https://x.example/client-metadata.json', - { noCache: false }, + { noCache: true }, ) }) @@ -302,4 +278,19 @@ describe('renderPreviewIndex', () => { expect(html).toContain('href="/preview/consent"') expect(html).toContain('PDS_OAUTH_TRUSTED_CLIENTS') }) + + it('includes the persisted client_id input with data-preview-link anchors', () => { + const html = renderPreviewIndex() + expect(html).toContain('id="client-id-input"') + expect(html).toContain('data-preview-link') + // Inline script wires input → links and persists via localStorage: + expect(html).toContain("'epds:preview:client_id'") + expect(html).toContain('localStorage.getItem') + }) + + it('includes the live metadata-cache status block', () => { + const html = renderPreviewIndex() + expect(html).toContain('id="cache-status-body"') + expect(html).toContain('/preview/cache-status') + }) }) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 6137c33b..a81e37c8 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -39,6 +39,7 @@ import { validateLocalPart, resolveClientMetadata, getClientCss, + getClientMetadataCacheStatus, getEpdsVersion, } from '@certified-app/shared' import { shouldRewriteSecFetchSite } from './lib/sec-fetch-site-rewrite.js' @@ -612,8 +613,15 @@ async function main() { res.send(renderPreviewIndex()) }) pds.app.get('/preview/consent', previewConsentHandler) + pds.app.get('/preview/cache-status', (_req, res) => { + res.setHeader('Cache-Control', 'no-store') + res.json({ + now: Date.now(), + entries: getClientMetadataCacheStatus(), + }) + }) logger.info( - 'Preview routes installed (PDS_PREVIEW_ROUTES=1): /preview, /preview/consent', + 'Preview routes installed (PDS_PREVIEW_ROUTES=1): /preview, /preview/consent, /preview/cache-status', ) } diff --git a/packages/pds-core/src/lib/preview-consent.ts b/packages/pds-core/src/lib/preview-consent.ts index f25f44f4..edd9b23e 100644 --- a/packages/pds-core/src/lib/preview-consent.ts +++ b/packages/pds-core/src/lib/preview-consent.ts @@ -36,7 +36,13 @@ * pinning the hydration script, matching the auth-service preview * routes' relaxed CSP. */ -import { escapeHtml, type ClientMetadata } from '@certified-app/shared' +import { + escapeHtml, + PREVIEW_CACHE_STATUS_HTML, + PREVIEW_CLIENT_ID_INPUT_HTML, + PREVIEW_CLIENT_ID_SCRIPT_HTML, + type ClientMetadata, +} from '@certified-app/shared' import serialize from 'serialize-javascript' // Use structural request/response types rather than importing from @@ -240,18 +246,18 @@ export function createPreviewConsentHandler( ? rawClientId : FIXTURE_DEFAULT_CLIENT_ID - // `?no_cache=1` bypasses the 10-minute `resolveClientMetadata` cache - // so CSS edits on the client's metadata JSON show up on the next - // refresh. Without it, devs can spend 10 minutes staring at a stale - // branding.css wondering why their change didn't land. - const noCache = req.query.no_cache === '1' - let metadata: ClientMetadata = {} let injectedCss: string | null = null if (clientId !== FIXTURE_DEFAULT_CLIENT_ID) { try { - metadata = await deps.resolveClientMetadata(clientId, { noCache }) + // Preview routes always bypass the 10-minute client-metadata + // cache — the whole point of /preview is to iterate on + // branding.css and see the change on the next refresh without + // waiting for cache expiry. + metadata = await deps.resolveClientMetadata(clientId, { + noCache: true, + }) injectedCss = deps.getClientCss(clientId, metadata, deps.trustedClients) } catch (err) { deps.logger.warn( @@ -306,16 +312,27 @@ export function renderPreviewIndex(): string { code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-size: 14px; } ul { line-height: 2; } a { color: #0b5ed7; } + label { display: block; margin: 16px 0 6px; font-weight: 500; } + input[type="url"] { width: 100%; padding: 8px 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + input[type="url"]:focus { outline: 2px solid #0b5ed7; outline-offset: -1px; border-color: transparent; } + .cache-status { margin-top: 32px; padding: 12px 16px; background: #f8f9fa; border: 1px solid #e5e7eb; border-radius: 8px; } + .cache-status h2 { font-size: 15px; margin: 0 0 4px; } + .cache-status-hint { font-size: 13px; color: #555; margin: 0 0 8px; } + .cache-entries { list-style: none; padding: 0; margin: 0; line-height: 1.8; } + .cache-entries code { word-break: break-all; }

    pds-core preview routes

    Renders the OAuth consent page with fixture hydration data, so you can iterate on your client's branding.css without walking through the full OAuth flow.

    Pass ?client_id=<URL-of-your-client-metadata.json> to inject that client's CSS. The trusted-clients check still applies: your client_id must be on PDS_OAUTH_TRUSTED_CLIENTS for its CSS to be injected. Without client_id the page renders unbranded (baseline).

    -

    Append &no_cache=1 to bypass the 10-minute metadata cache — useful when you've just edited branding.css on the upstream client and want to see the change immediately.

    +

    Preview routes always re-fetch client metadata — the 10-minute cache used by real flows is bypassed here, so edits to your branding.css show up on the next refresh.

    + ${PREVIEW_CLIENT_ID_INPUT_HTML} + ${PREVIEW_CACHE_STATUS_HTML} + ${PREVIEW_CLIENT_ID_SCRIPT_HTML} ` } diff --git a/packages/shared/src/client-metadata.ts b/packages/shared/src/client-metadata.ts index 2bdfbb19..861c87e7 100644 --- a/packages/shared/src/client-metadata.ts +++ b/packages/shared/src/client-metadata.ts @@ -72,6 +72,29 @@ export function _seedClientMetadataCacheForTest( cache.set(clientId, { metadata, expiresAt: Date.now() + CACHE_TTL_MS }) } +/** + * Inspect the in-memory client-metadata cache. Returns one entry per + * cached clientId with its expiry timestamp (ms since epoch). Expired + * entries are skipped. Read-only; does not mutate the cache. + * + * Intended for operators/devs to see "how long until the next real + * OAuth flow for this client re-fetches its metadata" — exposed by the + * /preview/cache-status endpoint. + */ +export function getClientMetadataCacheStatus(): Array<{ + clientId: string + expiresAt: number +}> { + const now = Date.now() + const entries: Array<{ clientId: string; expiresAt: number }> = [] + for (const [clientId, entry] of cache) { + if (entry.expiresAt > now) { + entries.push({ clientId, expiresAt: entry.expiresAt }) + } + } + return entries +} + const safeFetch = makeSafeFetch({ timeoutMs: 5_000 }) export async function resolveClientName(clientId: string): Promise { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e099ab04..875ed480 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -42,6 +42,7 @@ export { escapeCss, getClientCss, clearClientMetadataCache, + getClientMetadataCacheStatus, _seedClientMetadataCacheForTest, } from './client-metadata.js' export type { @@ -49,6 +50,11 @@ export type { ClientBranding, ResolveClientMetadataOptions, } from './client-metadata.js' +export { + PREVIEW_CACHE_STATUS_HTML, + PREVIEW_CLIENT_ID_INPUT_HTML, + PREVIEW_CLIENT_ID_SCRIPT_HTML, +} from './preview-ui.js' export { getEpdsVersion } from './version.js' export { makeSafeFetch } from './safe-fetch.js' export type { SafeFetchOptions } from './safe-fetch.js' diff --git a/packages/shared/src/preview-ui.ts b/packages/shared/src/preview-ui.ts new file mode 100644 index 00000000..5a99708b --- /dev/null +++ b/packages/shared/src/preview-ui.ts @@ -0,0 +1,149 @@ +/** + * Shared HTML snippets for the preview route index pages served by + * pds-core and auth-service. Keeping these here avoids two copies that + * would drift apart: the behaviour (live-bound client_id input, links + * updated on input, localStorage persistence) should be identical on + * both services. + */ + +/** + *