diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index 024f89cc..5a6c7a06 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -98,27 +98,38 @@ 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 (see below). | + +## Iterating on `branding.css` + +Both auth-service and pds-core expose static `/preview/*` routes (when the +operator sets `AUTH_PREVIEW_ROUTES=1` / `PDS_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. auth-service covers +login / OTP / choose-handle / recovery; pds-core covers the consent page. 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/.changeset/auth-preview-routes.md b/.changeset/auth-preview-routes.md new file mode 100644 index 00000000..a32a00d2 --- /dev/null +++ b/.changeset/auth-preview-routes.md @@ -0,0 +1,11 @@ +--- +'ePDS': minor +--- + +Add preview routes on auth-service and pds-core for iterating on client branding CSS. + +**Affects:** Client app developers, Operators + +**Client app developers:** Two new sets of preview routes render the ePDS sign-in pages against fixture data, so you can iterate on your `branding.css` without walking through a real OAuth flow each time. The auth-service exposes `/preview`, `/preview/login`, `/preview/login-otp`, `/preview/choose-handle` (picker-with-random mode), `/preview/choose-handle-picker` (picker-only mode), `/preview/recovery`, and `/preview/recovery-otp`. pds-core exposes `/preview` and `/preview/consent` — the consent route renders the same `@atproto/oauth-provider-ui` SPA as the real `/oauth/authorize` page, hydrated with fixture data. Pass `?client_id=` on any of them to inject that client's `branding.css` into the page, subject to the same `PDS_OAUTH_TRUSTED_CLIENTS` check as a real OAuth flow. Without `client_id` the page renders unbranded (baseline). Iterating becomes: edit `branding.css`, refresh the preview URL — no OTP emails, no walking through the full flow. Visit `/preview` on either service for an index that lists every preview route from both services in one place (cross-service links are absolute). Landing on `/preview?client_id=` pre-fills the client-metadata URL input from the query string, so shareable preview links work. The demo app also links directly to the auth-service preview index with its own `client_id` pre-selected. + +**Operators:** Two new env vars gate the preview routes, one per service: `AUTH_PREVIEW_ROUTES=1` on auth-service, `PDS_PREVIEW_ROUTES=1` on pds-core. Both are independent and safe to enable 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. **Privacy:** enabling previews exposes `/preview/cache-status`, which returns the list of `client_id` URLs currently in the shared client-metadata cache — i.e. apps that have recently started an OAuth flow against this PDS. That partially leaks which third-party clients are using the instance, so **keep previews disabled in production** unless you're comfortable with that. See `packages/auth-service/.env.example` and `packages/pds-core/.env.example` for the full notes. diff --git a/.env.example b/.env.example index a88dbe13..5ee3768c 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,23 @@ 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. +# +# Privacy: enabling this also exposes /preview/cache-status, which +# returns the list of client_id URLs currently in the shared +# client-metadata cache — i.e. apps that have recently started an +# OAuth flow against this PDS. That partially leaks which +# third-party clients are using the instance, so keep this off in +# production unless you're OK with that disclosure. +# 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,6 +161,24 @@ SESSION_UPDATE_AGE=86400 # Defaults to 'picker-with-random' if not set. EPDS_DEFAULT_HANDLE_MODE=picker-with-random +# 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. +# +# Privacy: enabling this also exposes /preview/cache-status, which +# returns the list of client_id URLs currently in this service's +# client-metadata cache — i.e. apps that have recently started an +# OAuth flow against this PDS. That partially leaks which +# third-party clients are using the instance, so keep this off in +# production unless you're OK with that disclosure. +# AUTH_PREVIEW_ROUTES=1 + # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= # GITHUB_CLIENT_ID= diff --git a/AGENTS.md b/AGENTS.md index 33deb265..66498c1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,24 @@ pnpm lint # lint all files with ESLint pnpm lint:fix # lint and auto-fix where possible ``` +## Before Pushing + +**Run every check CI runs — locally — before every push.** CI failures +that prettier / eslint / tsc would have caught locally waste review +cycles. The full set CI runs is: + +```bash +pnpm format:check # prettier — CI fails if any file is unformatted +pnpm lint # eslint +pnpm typecheck # tsc --noEmit +pnpm test # vitest run +pnpm test:coverage # v8 thresholds in vitest.config.ts +``` + +These are cheap. Run them all before `git push` — not just the ones you +think are relevant to the change. In particular, `pnpm format:check` +catches prettier drift that `pnpm format` fixes in-place. + ## Documentation **Always update documentation when your changes would render existing docs diff --git a/docs/configuration.md b/docs/configuration.md index 6cea1ddf..074a510c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,6 +78,15 @@ 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. +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 (covers login / OTP / +choose-handle / recovery) and `PDS_PREVIEW_ROUTES=1` on pds-core +(covers the consent page). 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: | Variable | Description | diff --git a/docs/tutorial.md b/docs/tutorial.md index 4a692d38..3264417d 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -339,6 +339,104 @@ 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 + `') + expect(res.body).toContain(String.raw`\"clientTrusted\":true`) + expect(res.body).toContain('trusted.example') + }) + + it('marks clientTrusted=false when client_id is not in trustedClients', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: ['https://other.example/client-metadata.json'], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler( + { + query: { + client_id: 'https://untrusted.example/client-metadata.json', + }, + }, + res, + ) + expect(res.body).toContain(String.raw`\"clientTrusted\":false`) + }) + + it('logs a warning and still renders when metadata resolution fails', async () => { + const logger = mockLogger() + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.reject(new Error('fetch failed')), + getClientCss: () => null, + logger, + })! + const res = mockRes() + await handler( + { query: { client_id: 'https://broken.example/client-metadata.json' } }, + res, + ) + expect(logger.warn).toHaveBeenCalledOnce() + const [ctx, msg] = logger.warn.mock.calls[0] + expect(msg).toMatch(/Preview consent/i) + expect(ctx).toMatchObject({ + clientId: 'https://broken.example/client-metadata.json', + }) + // Still responds with valid HTML shell: + expect(res.body).toMatch(//i) + }) + + it('ignores non-string client_id and falls back to fixture default', async () => { + const resolveClientMetadata = vi.fn(() => Promise.resolve({})) + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata, + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: { client_id: ['array', 'value'] } }, res) + // Default fixture client: no resolution attempted + expect(resolveClientMetadata).not.toHaveBeenCalled() + 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 = + /`, `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 { + escapeHtml, + renderPreviewIndexPage, + type ClientMetadata, +} from '@certified-app/shared' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import serialize from 'serialize-javascript' + +// pds-core compiles to CommonJS, so createRequire(__filename) gives us a +// require that can resolve the sibling package without tripping the +// no-require-imports lint rule (we never call the local `require` — only +// require.resolve, which is a function lookup not a syntactic require). +const nodeRequire = createRequire(__filename) + +// Use structural request/response types rather than importing from +// express — pds-core doesn't depend on express's types directly and +// pulling them in would be a heavier change than the handler warrants. +type RequestLike = { + query: Record +} +type ResponseLike = { + setHeader: (name: string, value: string) => unknown + send: (body: string) => unknown +} + +type LoggerLike = { + info: (obj: object, msg: string) => void + warn: (obj: object, msg: string) => void + debug: (obj: object, msg: string) => void +} + +// Deep read into the provider-ui package. Its exports map explicitly lists +// `./bundle-manifest.json`, so this is a supported entry point. If the +// package's layout changes, the preview route will fail loudly — fine for +// a dev tool. Loaded lazily (not at import-time) to avoid paying the cost +// on instances that never enable the preview. +type BundleManifest = Record< + string, + { type: string; mime?: string; name?: string; isEntry?: boolean } +> + +let cachedAssets: { scripts: string[]; styles: string[] } | null = null + +async function loadAssetRefs(): Promise<{ + scripts: string[] + styles: string[] +}> { + if (cachedAssets) return cachedAssets + // Read the manifest as a plain file rather than via `import(..., { with: + // { type: 'json' } })`. Import-attributes are only stable in Node 22+ + // and the repo allows Node >=20.0.0; require.resolve + fs.readFile + // works on every Node 20.x without attributes. + const manifestPath = nodeRequire.resolve( + '@atproto/oauth-provider-ui/bundle-manifest.json', + ) + const manifest = JSON.parse( + await readFile(manifestPath, 'utf8'), + ) as BundleManifest + const scripts = Object.entries(manifest) + .filter( + ([, a]) => + a.type === 'chunk' && a.isEntry && a.name === 'authorization-page', + ) + .map(([filename]) => filename) + const styles = Object.entries(manifest) + .filter(([, a]) => a.mime === 'text/css') + .map(([filename]) => filename) + cachedAssets = { scripts, styles } + return cachedAssets +} + +const ASSETS_URL_PREFIX = '/@atproto/oauth-provider/~assets/' + +function assetUrl(filename: string): string { + return `${ASSETS_URL_PREFIX}${encodeURIComponent(filename)}` +} + +function renderHydration(values: Record): string { + // 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 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('') +} + +interface PreviewFixtureOptions { + clientId: string + clientMetadata: ClientMetadata + isTrusted: boolean +} + +function buildAuthorizeData(opts: PreviewFixtureOptions): unknown { + // Fixture matching the AuthorizeData type in + // @atproto/oauth-provider-ui/hydration-data.d.ts. The SPA tolerates + // missing optional fields and empty permissionSets, so this is the + // minimal viable shape for the consent page to render. + // + // Intentionally no `loginHint`: setting it flips AuthorizeView into + // `forceSignIn` mode and shows the sign-in form instead of the consent + // screen. The SPA's consent view is only reachable when a session is + // already selected and `consentRequired` is true — see the fixture + // session declared in buildSessions() below. + return { + requestUri: + 'urn:ietf:params:oauth:request_uri:req-preview-0000000000000000', + clientId: opts.clientId, + clientMetadata: opts.clientMetadata, + clientTrusted: opts.isTrusted, + clientFirstParty: false, + scope: 'atproto transition:generic', + uiLocales: undefined, + promptMode: undefined, + permissionSets: {}, + } +} + +function buildSessions(): unknown { + // Fixture session that drives the SPA straight to the consent screen: + // `selected && !loginRequired && consentRequired` is the exact gate in + // authorize-view.tsx that mounts . + return [ + { + account: { + sub: 'did:web:preview.example', + aud: 'https://preview.example', + preferred_username: 'alice.preview.example', + name: 'Alice Preview', + email: 'alice@preview.example', + }, + selected: true, + loginRequired: false, + consentRequired: true, + }, + ] +} + +/** Build the HTML page that the real oauth-provider SPA boots from. */ +async function renderConsentHtml(opts: { + fixture: PreviewFixtureOptions + injectedCss: string | null +}): Promise { + const { scripts, styles } = await loadAssetRefs() + + const hydration = renderHydration({ + __authorizeData: buildAuthorizeData(opts.fixture), + __sessions: buildSessions(), + // No customization data: pds-core's provider isn't configured with + // `branding.colors`, so the SPA falls back to its defaults. + __customizationData: {}, + }) + + const styleLinks = styles + .map((f) => ``) + .join('') + const scriptTags = scripts + .map((f) => ``) + .join('') + + const injectedStyle = opts.injectedCss + ? `` + : '' + + return ` + + + + + + Consent preview — ${escapeHtml(opts.fixture.clientId)} + ${styleLinks} + ${injectedStyle} + + +
+ + ${scriptTags} + +` +} + +interface PreviewConsentDeps { + trustedClients: string[] + resolveClientMetadata: ( + clientId: string, + options?: { noCache?: boolean }, + ) => 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 { + // 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( + { 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 preview routes from both services. */ +export function renderPreviewIndex(opts: { + authPublicUrl: string + pdsPublicUrl: string +}): string { + return renderPreviewIndexPage({ + currentService: 'pds', + authPublicUrl: opts.authPublicUrl, + pdsPublicUrl: opts.pdsPublicUrl, + }) +} diff --git a/packages/shared/src/__tests__/preview-ui.test.ts b/packages/shared/src/__tests__/preview-ui.test.ts new file mode 100644 index 00000000..d25a443b --- /dev/null +++ b/packages/shared/src/__tests__/preview-ui.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' + +import { + AUTH_PREVIEW_ROUTES, + PDS_PREVIEW_ROUTES, + renderPreviewIndexPage, + renderPreviewLinksSections, +} from '../preview-ui.js' + +const AUTH_URL = 'https://auth.example' +const PDS_URL = 'https://pds.example' + +describe('renderPreviewLinksSections', () => { + it('renders same-origin links as path-relative when on the current service', () => { + const html = renderPreviewLinksSections({ + currentService: 'auth', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + // First auth route is /preview/login — must appear as a relative href. + expect(html).toContain('href="/preview/login"') + // Sibling service's routes must be absolute on the other origin. + expect(html).toContain(`href="${PDS_URL}/preview/consent"`) + }) + + it('swaps relative/absolute when rendered for the other service', () => { + const html = renderPreviewLinksSections({ + currentService: 'pds', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + expect(html).toContain(`href="${AUTH_URL}/preview/login"`) + expect(html).toContain('href="/preview/consent"') + }) + + it("marks the sibling service's heading as a link to its /preview index", () => { + const authHtml = renderPreviewLinksSections({ + currentService: 'auth', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + // The sibling heading carries data-preview-link so the wire-up + // script carries the current client_id across to the other service. + expect(authHtml).toContain( + `pds-core`, + ) + expect(authHtml).toContain('

auth-service

') + + const pdsHtml = renderPreviewLinksSections({ + currentService: 'pds', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + expect(pdsHtml).toContain( + `auth-service`, + ) + expect(pdsHtml).toContain('

pds-core

') + }) + + it('appends ?query=... to routes that declare one', () => { + const html = renderPreviewLinksSections({ + currentService: 'auth', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + expect(html).toContain( + 'href="/preview/choose-handle?error=Handle+already+taken"', + ) + }) + + it('tags every link with data-preview-link so the wire-up script finds it', () => { + const html = renderPreviewLinksSections({ + currentService: 'auth', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + const count = (html.match(/data-preview-link/g) || []).length + // One per route, plus one for the sibling service's heading link. + expect(count).toBe( + AUTH_PREVIEW_ROUTES.length + PDS_PREVIEW_ROUTES.length + 1, + ) + }) +}) + +describe('renderPreviewIndexPage', () => { + it('uses the service-specific title, heading, and sibling blurb', () => { + const authHtml = renderPreviewIndexPage({ + currentService: 'auth', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + expect(authHtml).toContain('auth-service previews') + expect(authHtml).toContain('

auth-service preview routes

') + expect(authHtml).toContain('pds-core') + + const pdsHtml = renderPreviewIndexPage({ + currentService: 'pds', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + expect(pdsHtml).toContain('pds-core previews') + expect(pdsHtml).toContain('

pds-core preview routes

') + expect(pdsHtml).toContain('auth-service') + }) + + it('embeds the client_id input, cache-status block, and wire-up script', () => { + const html = renderPreviewIndexPage({ + currentService: 'auth', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + expect(html).toContain('id="client-id-input"') + expect(html).toContain('id="cache-status"') + expect(html).toContain("var STORAGE_KEY = 'epds:preview:client_id';") + }) + + it('nests the shared links section inside the page', () => { + const html = renderPreviewIndexPage({ + currentService: 'pds', + authPublicUrl: AUTH_URL, + pdsPublicUrl: PDS_URL, + }) + // Relative pds-core link and absolute auth-service link should both + // appear, proving the links section is wired in. + expect(html).toContain('href="/preview/consent"') + expect(html).toContain(`href="${AUTH_URL}/preview/login"`) + }) +}) diff --git a/packages/shared/src/__tests__/preview-validation.test.ts b/packages/shared/src/__tests__/preview-validation.test.ts new file mode 100644 index 00000000..7792b068 --- /dev/null +++ b/packages/shared/src/__tests__/preview-validation.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Intercept the SSRF-hardened fetch used by preview-validation.ts — +// safeFetchWrap speaks via an undici dispatcher, not globalThis.fetch, +// so spying on globalThis.fetch doesn't help. Replace the factory +// instead and return a plain async fn we control per-test. vi.hoisted +// lets the spy exist by the time vi.mock's factory runs (mocks are +// hoisted above imports). +const { mockSafeFetch } = vi.hoisted(() => ({ mockSafeFetch: vi.fn() })) +vi.mock('../safe-fetch.js', () => ({ + makeSafeFetch: () => mockSafeFetch, +})) + +// Import after the mock so preview-validation picks up the stub. +import { validateClientMetadataForPreview } from '../preview-validation.js' + +beforeEach(() => { + mockSafeFetch.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function mockFetchOnce(body: unknown, status = 200): void { + mockSafeFetch.mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as unknown as Response) +} + +describe('validateClientMetadataForPreview', () => { + it('returns a single error-severity check for a non-URL string', async () => { + const result = await validateClientMetadataForPreview('not a url', null) + expect(result.fetched).toBe(false) + expect(result.checks).toHaveLength(1) + expect(result.checks[0]).toMatchObject({ + id: 'url-parseable', + severity: 'error', + }) + }) + + it('errors on http:// URLs and short-circuits before fetching', async () => { + const result = await validateClientMetadataForPreview( + 'http://insecure.example/client-metadata.json', + null, + ) + // The https check fires first; we deliberately return early so + // safeFetch isn't invoked and the operator isn't shown a second + // overlapping `fetch` error for the same root cause. + expect(result.fetched).toBe(false) + expect(result.checks).toHaveLength(1) + expect(result.checks[0]).toMatchObject({ + id: 'url-https', + severity: 'error', + }) + expect(mockSafeFetch).not.toHaveBeenCalled() + }) + + it('flags ok for all ok fields on a well-formed metadata', async () => { + const url = 'https://good.example/client-metadata.json' + mockFetchOnce({ + client_id: url, + redirect_uris: [`${url.replace('/client-metadata.json', '')}/cb`], + brand_color: '#f59e0b', + background_color: '#1a1208', + branding: { css: 'body { color: red; }' }, + tos_uri: 'https://good.example/terms', + policy_uri: 'https://good.example/privacy', + }) + const result = await validateClientMetadataForPreview(url, [url]) + expect(result.fetched).toBe(true) + const byId = Object.fromEntries(result.checks.map((c) => [c.id, c])) + expect(byId.fetch.severity).toBe('ok') + expect(byId['client-id-match'].severity).toBe('ok') + expect(byId['redirect-uris'].severity).toBe('ok') + expect(byId['brand-color'].severity).toBe('ok') + expect(byId['background-color'].severity).toBe('ok') + expect(byId['branding-css'].severity).toBe('ok') + expect(byId['tos-uri'].severity).toBe('ok') + expect(byId['policy-uri'].severity).toBe('ok') + expect(byId['trusted-client'].severity).toBe('ok') + }) + + it('warns (not errors) when optional branding fields are missing', async () => { + const url = 'https://plain.example/client-metadata.json' + mockFetchOnce({ + client_id: url, + redirect_uris: ['https://plain.example/cb'], + // no brand_color, background_color, or branding.css + }) + const result = await validateClientMetadataForPreview(url, []) + const byId = Object.fromEntries(result.checks.map((c) => [c.id, c])) + expect(byId['brand-color'].severity).toBe('warn') + expect(byId['background-color'].severity).toBe('warn') + expect(byId['branding-css'].severity).toBe('warn') + expect(byId['tos-uri'].severity).toBe('warn') + expect(byId['policy-uri'].severity).toBe('warn') + // trust check also warn, not error + expect(byId['trusted-client'].severity).toBe('warn') + // No error-level checks on an otherwise-valid metadata: + expect(result.checks.every((c) => c.severity !== 'error')).toBe(true) + }) + + it('errors when tos_uri / policy_uri are not valid https URLs', async () => { + const url = 'https://d.example/client-metadata.json' + mockFetchOnce({ + client_id: url, + redirect_uris: ['https://d.example/cb'], + tos_uri: 'not a url', + policy_uri: 'http://insecure.example/privacy', + }) + const result = await validateClientMetadataForPreview(url, null) + const byId = Object.fromEntries(result.checks.map((c) => [c.id, c])) + expect(byId['tos-uri'].severity).toBe('error') + expect(byId['policy-uri'].severity).toBe('error') + }) + + it('errors when client_id field does not match the URL', async () => { + const url = 'https://a.example/client-metadata.json' + mockFetchOnce({ + client_id: 'https://different.example/client-metadata.json', + redirect_uris: ['https://a.example/cb'], + }) + const result = await validateClientMetadataForPreview(url, null) + const check = result.checks.find((c) => c.id === 'client-id-match') + expect(check?.severity).toBe('error') + }) + + it('errors on missing redirect_uris', async () => { + const url = 'https://b.example/client-metadata.json' + mockFetchOnce({ + client_id: url, + // no redirect_uris + }) + const result = await validateClientMetadataForPreview(url, null) + const check = result.checks.find((c) => c.id === 'redirect-uris') + expect(check?.severity).toBe('error') + }) + + it('errors when the upstream returns 404', async () => { + const url = 'https://missing.example/client-metadata.json' + mockFetchOnce({}, 404) + const result = await validateClientMetadataForPreview(url, null) + expect(result.fetched).toBe(false) + expect(result.checks.find((c) => c.id === 'fetch')?.severity).toBe('error') + }) + + it('skips the trust check when trustedClients is null', async () => { + const url = 'https://c.example/client-metadata.json' + mockFetchOnce({ + client_id: url, + redirect_uris: ['https://c.example/cb'], + }) + const result = await validateClientMetadataForPreview(url, null) + expect(result.checks.find((c) => c.id === 'trusted-client')).toBeUndefined() + }) +}) diff --git a/packages/shared/src/client-metadata.ts b/packages/shared/src/client-metadata.ts index ba7659c6..861c87e7 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 } @@ -69,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 { @@ -76,8 +102,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 +127,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 +142,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 +158,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..990bce0f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -42,9 +42,30 @@ export { escapeCss, getClientCss, clearClientMetadataCache, + getClientMetadataCacheStatus, _seedClientMetadataCacheForTest, } from './client-metadata.js' -export type { ClientMetadata, ClientBranding } from './client-metadata.js' +export type { + ClientMetadata, + ClientBranding, + ResolveClientMetadataOptions, +} from './client-metadata.js' +export { + PREVIEW_CACHE_STATUS_HTML, + PREVIEW_CLIENT_ID_INPUT_HTML, + PREVIEW_CLIENT_ID_SCRIPT_HTML, + AUTH_PREVIEW_ROUTES, + PDS_PREVIEW_ROUTES, + renderPreviewLinksSections, + renderPreviewIndexPage, +} from './preview-ui.js' +export type { PreviewRoute } from './preview-ui.js' +export { validateClientMetadataForPreview } from './preview-validation.js' +export type { + CheckSeverity, + PreviewCheck, + PreviewValidationResult, +} from './preview-validation.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/logger.ts b/packages/shared/src/logger.ts index 99d9d072..0a9c9d72 100644 --- a/packages/shared/src/logger.ts +++ b/packages/shared/src/logger.ts @@ -1,8 +1,17 @@ import pino from 'pino' -const LOG_LEVEL = - process.env.LOG_LEVEL || - (process.env.NODE_ENV === 'development' ? 'debug' : 'info') +function defaultLogLevel(): string { + switch (process.env.NODE_ENV) { + case 'development': + return 'debug' + case 'test': + return 'silent' + default: + return 'info' + } +} + +const LOG_LEVEL = process.env.LOG_LEVEL || defaultLogLevel() export function createLogger(name: string): pino.Logger { return pino({ diff --git a/packages/shared/src/preview-ui.ts b/packages/shared/src/preview-ui.ts new file mode 100644 index 00000000..57b6db00 --- /dev/null +++ b/packages/shared/src/preview-ui.ts @@ -0,0 +1,466 @@ +/** + * 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. + */ + +/** + *