diff --git a/.changeset/another-account-reaches-email-form.md b/.changeset/another-account-reaches-email-form.md new file mode 100644 index 00000000..182147d2 --- /dev/null +++ b/.changeset/another-account-reaches-email-form.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +"Use a different account" on the chooser now reliably takes you to the email form, not the code step for the previous account. + +**Affects:** End users + +**End users:** when you click "Another account" on the account chooser to sign in as someone else, you now always land on a fresh email entry form. Previously, if the app that started the sign-in had pre-filled an account hint, the page jumped straight to the verification-code step for the _previous_ account — leaving you stuck typing a code for an account you were trying to leave. diff --git a/e2e/step-definitions/common.steps.ts b/e2e/step-definitions/common.steps.ts index b2b835d0..a1d41884 100644 --- a/e2e/step-definitions/common.steps.ts +++ b/e2e/step-definitions/common.steps.ts @@ -11,13 +11,45 @@ import type { EpdsWorld } from '../support/world.js' import { testEnv } from '../support/env.js' import { getPage } from '../support/utils.js' +/** + * Health-check the PDS with a short retry budget. The CI workflow already + * polls /health for 5 minutes before invoking the e2e suite, so any + * failure here is *post* deploy-readiness — typically a transient Railway + * edge / undici DNS blip that resolves in seconds. Without retries, a + * single such blip on the first scenario fails an entire 9-minute e2e + * run; with retries, the rest of the suite runs unimpaired. + * + * Each attempt has its own AbortSignal.timeout(2s) so a hung connection + * cannot blow past the documented wall-clock budget of 6 × (2s fetch + + * 2s backoff) = ~24s. Real outages still surface — the suite cannot make + * meaningful progress past this Given without a live PDS, so a hard + * failure is the right outcome once the budget is spent. + */ Given('the ePDS test environment is running', async function (this: EpdsWorld) { - const res = await fetch(`${testEnv.pdsUrl}/health`) - if (!res.ok) { - throw new Error( - `PDS health check failed: ${res.status} at ${testEnv.pdsUrl}/health`, - ) + const url = `${testEnv.pdsUrl}/health` + const maxAttempts = 6 + const perAttemptTimeoutMs = 2000 + const backoffMs = 2000 + let lastErr: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(perAttemptTimeoutMs), + }) + if (res.ok) return + lastErr = new Error(`PDS health check failed: ${res.status} at ${url}`) + } catch (err) { + lastErr = err + } + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } } + throw new Error( + `PDS health check failed after ${maxAttempts} attempts at ${url}: ${ + lastErr instanceof Error ? lastErr.message : String(lastErr) + }`, + ) }) /** diff --git a/e2e/step-definitions/session-reuse.steps.ts b/e2e/step-definitions/session-reuse.steps.ts index 3de2152a..53d62b2e 100644 --- a/e2e/step-definitions/session-reuse.steps.ts +++ b/e2e/step-definitions/session-reuse.steps.ts @@ -418,6 +418,25 @@ Then( }, ) +/** + * Strict-form assertions used by the "Another account + login_hint" repro + * for issue #138: the email form must not carry the previous account's + * email pre-fill, and the OTP step must not be the active step (the + * regression rendered the page with both #email and #step-otp.active). + */ +Then('the email input is empty', async function (this: EpdsWorld) { + const page = getPage(this) + await expect(page.locator('#email')).toHaveValue('') +}) + +Then( + 'the OTP verification step is not active', + async function (this: EpdsWorld) { + const page = getPage(this) + await expect(page.locator('#step-otp.active')).toBeHidden() + }, +) + /** * Negative assertion for the auto-approve path: the second OAuth flow * must complete without the consent screen ever appearing. Checked by diff --git a/features/session-reuse-bugs.feature b/features/session-reuse-bugs.feature index e8410010..c35d3a4a 100644 --- a/features/session-reuse-bugs.feature +++ b/features/session-reuse-bugs.feature @@ -138,6 +138,23 @@ Feature: Welcome-page guard suppresses upstream's authentication UI When the demo client starts a new OAuth flow with the test user's handle as login_hint Then the browser lands on the ePDS enriched account picker + # GitHub issue #138: when the OAuth flow that reached the chooser carried + # a login_hint, the "Another account" rebind preserves it on the + # auth-service URL and adds prompt=login. shouldReuseSession honours + # prompt=login and falls through to the login page, but the initialStep + # selector at routes/login-page.ts only looks at hint presence — so the + # resolved email forces initialStep='otp' and the user lands on the OTP + # form for the *previous* account instead of the email entry form. The + # email form is the only correct outcome: prompt=login is the user's + # explicit "start over" signal. + Scenario: "Another account" with login_hint must reach the email form + When the demo client starts a new OAuth flow with the test user's handle as login_hint + Then the browser lands on the ePDS enriched account picker + When the user clicks "Another account" on the enriched account picker + Then the browser is on the auth service email form + And the email input is empty + And the OTP verification step is not active + Scenario: Login hint resolves to an unbound account — skip chooser Given another user has a separate PDS account When the demo client starts a new OAuth flow with the other user's handle as login_hint diff --git a/packages/auth-service/src/__tests__/login-page-prompt-login.test.ts b/packages/auth-service/src/__tests__/login-page-prompt-login.test.ts new file mode 100644 index 00000000..28561c03 --- /dev/null +++ b/packages/auth-service/src/__tests__/login-page-prompt-login.test.ts @@ -0,0 +1,222 @@ +/** + * Route-level coverage for the "Another account" rebind branch of + * GET /oauth/authorize. + * + * GitHub issue #138: pds-core's "Another account" rebind navigates back + * to auth-service with `prompt=login` AND `epds_skip_par_hint=1` + * appended (and any URL `login_hint` stripped). `epds_skip_par_hint=1` + * tells auth-service to ignore any login_hint stored in the PAR — the + * user clicked an opt-out, so the RP's hint must not influence rendering. + * With no hint resolving, `resolvedEmail` is null and the email step + * falls out from the standard rendering decision. + * + * `prompt=login` ALONE (without the skip flag) must NOT suppress hint + * resolution — pds-core's auth-ui-guard sign-in-view bounce appends + * prompt=login while still expecting the hint to be honoured (the user + * wants that account; upstream's password sign-in form is just + * unreachable in a passwordless deployment). The third test below pins + * this so a future "simplification" that conflates the two signals is + * caught. + * + * Lives in its own file because the `vi.mock` calls below replace the + * shared resolver modules wholesale, and we don't want that bleed into + * the existing unit tests in login-page.test.ts. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import express from 'express' +import cookieParser from 'cookie-parser' +import { randomBytes } from 'node:crypto' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import type { AddressInfo } from 'node:net' +import { _seedClientMetadataCacheForTest } from '@certified-app/shared' +import { csrfProtection } from '../middleware/csrf.js' +import { createLoginPageRouter } from '../routes/login-page.js' +import { AuthServiceContext, type AuthServiceConfig } from '../context.js' + +// Hoisted spies — `vi.mock` runs before module imports, so the mock factory +// must capture them via `vi.hoisted` rather than referencing top-level +// `const`s that haven't been initialised yet. +const mocks = vi.hoisted(() => ({ + fetchParLoginHint: vi.fn(), + resolveLoginHint: vi.fn(), + fetchDeviceAccountEmails: vi.fn(), +})) + +vi.mock('../lib/resolve-login-hint.js', () => ({ + fetchParLoginHint: mocks.fetchParLoginHint, + resolveLoginHint: mocks.resolveLoginHint, +})) + +vi.mock('../lib/fetch-device-accounts.js', () => ({ + fetchDeviceAccountEmails: mocks.fetchDeviceAccountEmails, +})) + +function makeConfig(dbPath: string): AuthServiceConfig { + return { + hostname: 'auth.test.local', + port: 0, + sessionSecret: 'test-session-secret', + csrfSecret: 'test-csrf-secret', + epdsCallbackSecret: 'test-callback-secret', + pdsHostname: 'test.local', + pdsPublicUrl: 'https://test.local', + email: { + provider: 'smtp', + smtpHost: 'localhost', + smtpPort: 1025, + from: 'noreply@test.local', + fromName: 'ePDS Test', + }, + dbLocation: dbPath, + otpLength: 6, + otpCharset: 'numeric', + trustedClients: [], + } +} + +async function startApp(ctx: AuthServiceContext): Promise<{ + baseUrl: string + close: () => Promise +}> { + const app = express() + // Silence sonar's "framework version disclosure" hotspot that fires + // on any vanilla express() instance. This is an in-process test + // server bound to 127.0.0.1 on an ephemeral port — the header is + // only visible to the test runner — but disabling it keeps the + // signal clean. + app.disable('x-powered-by') + app.use(cookieParser()) + app.use(csrfProtection(ctx.config.csrfSecret)) + app.use(createLoginPageRouter(ctx)) + const server = app.listen(0) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.once('listening', () => { + resolve() + }) + }) + server.unref() + const port = (server.address() as AddressInfo).port + return { + baseUrl: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolve) => { + server.close(() => { + resolve() + }) + }), + } +} + +describe('GET /oauth/authorize prompt=login handling (issue #138)', () => { + let dbPath: string + let ctx: AuthServiceContext + let app: { baseUrl: string; close: () => Promise } + + beforeEach(async () => { + dbPath = path.join( + os.tmpdir(), + `prompt-login-${Date.now()}-${randomBytes(4).toString('hex')}.db`, + ) + // Avoid an outbound fetch when the handler resolves client metadata. + _seedClientMetadataCacheForTest('https://app.example.com', { + client_name: 'Test App', + }) + // AuthServiceContext opens its own EpdsDb against config.dbLocation; + // we use ctx.db throughout (rather than constructing a parallel + // instance and overwriting) so there's exactly one open SQLite handle + // per test — `ctx.destroy()` in afterEach closes it cleanly and + // releases the WAL/SHM companion files for the unlink to remove. + ctx = new AuthServiceContext(makeConfig(dbPath)) + app = await startApp(ctx) + mocks.fetchParLoginHint.mockReset() + mocks.resolveLoginHint.mockReset() + mocks.fetchDeviceAccountEmails.mockReset() + }) + + afterEach(async () => { + await app.close() + ctx.destroy() + // Best-effort cleanup of the temp DB and its WAL/SHM companions. + // SQLite in WAL mode writes alongside the main file; rmSync(force) + // tolerates the missing companions when WAL was checkpointed before + // close, and avoids the empty try/catch antipattern. + for (const suffix of ['', '-wal', '-shm']) { + fs.rmSync(dbPath + suffix, { force: true }) + } + }) + + it('renders the email step on the "Another account" rebind path', async () => { + // pds-core's "Another account" rebind sets epds_skip_par_hint=1 + // and strips URL login_hint. Mock fetchParLoginHint to return a + // value anyway so a regression that ignored the skip flag would + // visibly pre-fill the email box with the previous user. + mocks.fetchParLoginHint.mockResolvedValue('previous@example.com') + mocks.resolveLoginHint.mockResolvedValue('previous@example.com') + + const url = + app.baseUrl + + '/oauth/authorize?request_uri=urn:ietf:params:oauth:request_uri:rebind' + + '&client_id=' + + encodeURIComponent('https://app.example.com') + + '&prompt=login' + + '&epds_skip_par_hint=1' + const res = await fetch(url) + expect(res.status).toBe(200) + const html = await res.text() + + // PAR hint resolution is skipped: fetchParLoginHint must NOT be called. + expect(mocks.fetchParLoginHint).not.toHaveBeenCalled() + // Email step rendered, OTP step not active, input empty. + expect(html).toMatch(/
/) + expect(html).not.toMatch(/class="step-otp active"/) + expect(html).toMatch(/]*value=""[^>]*>/) + }) + + it('honours PAR login_hint when prompt=login arrives without the skip flag', async () => { + // pds-core's auth-ui-guard sign-in-view bounce appends prompt=login + // (no epds_skip_par_hint) and expects auth-service to resolve any + // PAR login_hint and serve the OTP step. A regression that + // conflated prompt=login with the rebind semantics would re-break + // the @session-reuse e2e scenario "login_hint narrows to a stale + // binding on a multi-account device". + mocks.fetchParLoginHint.mockResolvedValue('hinted@example.com') + mocks.resolveLoginHint.mockResolvedValue('hinted@example.com') + + const url = + app.baseUrl + + '/oauth/authorize?request_uri=urn:ietf:params:oauth:request_uri:guardbounce' + + '&prompt=login' + const res = await fetch(url) + expect(res.status).toBe(200) + const html = await res.text() + + expect(mocks.fetchParLoginHint).toHaveBeenCalled() + expect(mocks.resolveLoginHint).toHaveBeenCalled() + expect(html).toMatch(/class="step-otp active"/) + }) + + it('still resolves login_hint when prompt=login is absent (regression guard)', async () => { + // Without prompt=login at all, the hint must resolve normally and + // the OTP step pre-fills with the email. + mocks.resolveLoginHint.mockResolvedValue('user@example.com') + + const url = + app.baseUrl + + '/oauth/authorize?request_uri=urn:ietf:params:oauth:request_uri:nopromptlogin' + + '&login_hint=user%40example.com' + const res = await fetch(url) + expect(res.status).toBe(200) + const html = await res.text() + + expect(mocks.resolveLoginHint).toHaveBeenCalledWith( + 'user@example.com', + expect.any(String), + expect.any(String), + ) + // OTP step is the active step (hasLoginHint true). + expect(html).toMatch(/class="step-otp active"/) + }) +}) diff --git a/packages/auth-service/src/__tests__/session-reuse.test.ts b/packages/auth-service/src/__tests__/session-reuse.test.ts index 8630473f..baa03af6 100644 --- a/packages/auth-service/src/__tests__/session-reuse.test.ts +++ b/packages/auth-service/src/__tests__/session-reuse.test.ts @@ -180,10 +180,39 @@ describe('isForceLoginPrompt (HYPER-268)', () => { expect(isForceLoginPrompt(makeReq())).toBe(false) }) - it('returns false when prompt is an array', () => { + // OIDC Core 1.0 §3.1.2.1: `prompt` is a space-delimited list. The + // single-token cases above must continue to work, AND multi-token / + // repeated-key forms that include `login` must also be honoured — + // otherwise a client passing `prompt=login consent` (or two `prompt=` + // query keys) would re-trigger #138 even after the per-step gate + // landed. + it('returns true when prompt is a space-delimited list containing login', () => { + expect( + isForceLoginPrompt(makeReq({ query: { prompt: 'login consent' } })), + ).toBe(true) + expect( + isForceLoginPrompt(makeReq({ query: { prompt: 'consent login' } })), + ).toBe(true) + }) + + it('returns true when prompt is an array containing login', () => { + // Express's req.query parser surfaces repeated query keys as arrays: + // `?prompt=login&prompt=consent` becomes `['login', 'consent']`. expect(isForceLoginPrompt(makeReq({ query: { prompt: ['login'] } }))).toBe( - false, + true, ) + expect( + isForceLoginPrompt(makeReq({ query: { prompt: ['consent', 'login'] } })), + ).toBe(true) + }) + + it('returns false when prompt is a list of other tokens', () => { + expect( + isForceLoginPrompt(makeReq({ query: { prompt: 'consent none' } })), + ).toBe(false) + expect( + isForceLoginPrompt(makeReq({ query: { prompt: ['consent', 'none'] } })), + ).toBe(false) }) }) diff --git a/packages/auth-service/src/context.ts b/packages/auth-service/src/context.ts index a2b02f38..65f6d787 100644 --- a/packages/auth-service/src/context.ts +++ b/packages/auth-service/src/context.ts @@ -51,14 +51,18 @@ export class AuthServiceContext { public readonly db: EpdsDb public readonly emailSender: EmailSender public readonly config: AuthServiceConfig + private readonly cleanupInterval: ReturnType constructor(config: AuthServiceConfig) { this.config = config this.db = new EpdsDb(config.dbLocation) this.emailSender = new EmailSender(config.email, config.trustedClients) - // Cleanup expired tokens every 5 minutes - setInterval( + // Cleanup expired tokens every 5 minutes. unref() so a process that + // owns this context (e.g. a vitest worker constructing a context per + // test) isn't held alive by the timer alone — destroy() still + // explicitly clears it for prompt teardown of the cleanup callback. + this.cleanupInterval = setInterval( () => { const flows = this.db.cleanupExpiredAuthFlows() if (flows > 0) { @@ -69,9 +73,11 @@ export class AuthServiceContext { }, 5 * 60 * 1000, ) + this.cleanupInterval.unref() } destroy(): void { + clearInterval(this.cleanupInterval) this.db.close() } } diff --git a/packages/auth-service/src/lib/session-reuse.ts b/packages/auth-service/src/lib/session-reuse.ts index 6f3d5961..4b25c75f 100644 --- a/packages/auth-service/src/lib/session-reuse.ts +++ b/packages/auth-service/src/lib/session-reuse.ts @@ -10,6 +10,7 @@ * then either auto-selects a matching session (flow 1 with login_hint) * or renders the account chooser (flow 2). */ +import { promptHasLogin } from '@certified-app/shared' /** Shape of the request data the helpers consume. Intentionally narrow * so we can unit-test without an Express instance. Mirrors the fields @@ -117,10 +118,15 @@ export function hasOrphanDeviceCookie(req: SessionReuseRequest): { * #AuthRequest): "The Authorization Server SHOULD prompt the End-User * for reauthentication". The capture-phase rebind of upstream's * "Another account" button injected into the chooser in pds-core uses - * this to opt out of session reuse. */ + * this to opt out of session reuse. + * + * Delegates to the shared `promptHasLogin` so this check matches + * pds-core's auth-ui-guard exactly: both the space-delimited token + * case (`prompt=login consent`) and the array-of-strings case that + * Express produces from repeated query keys (`?prompt=login&prompt=consent`) + * are honoured. */ export function isForceLoginPrompt(req: SessionReuseRequest): boolean { - const p = req.query.prompt - return typeof p === 'string' && p === 'login' + return promptHasLogin(req.query.prompt) } /** Optional context for the hint-vs-bindings check that gates Flow 1 diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 62a3e959..53503472 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -175,6 +175,16 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { // Resolve the login_hint up-front so we can decide whether the // device session is a match before redirecting to pds-core. The // resolution result is also reused below for the email/OTP form. + // + // pds-core's "Another account" rebind sets epds_skip_par_hint=1 + // (issue #138) — the user clicked an opt-out, so any login_hint + // stored in the PAR by the RP at OAuth init must be ignored. The + // rebind also strips URL login_hint, so on that path effectiveLoginHint + // ends up null and the spec-correct decision below renders the email + // form. This skip flag does NOT affect prompt=login alone: pds-core's + // sign-in-view bounce also sets prompt=login (without the skip flag) + // and expects the hint to resolve normally so the OTP step renders. + const skipParHint = req.query.epds_skip_par_hint === '1' const pdsInternalUrl = ensurePdsUrl( process.env.PDS_INTERNAL_URL, ctx.config.pdsPublicUrl, @@ -182,7 +192,7 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { const internalSecret = process.env.EPDS_INTERNAL_SECRET ?? '' let effectiveLoginHint = loginHint ?? null - if (!effectiveLoginHint && requestUri) { + if (!skipParHint && !effectiveLoginHint && requestUri) { effectiveLoginHint = await fetchParLoginHint( pdsInternalUrl, requestUri, @@ -368,7 +378,10 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { // c) Only in the stored PAR request (third-party apps that put the // handle in the PAR body but don't duplicate it on the redirect URL) // The hint was already resolved above for the session-reuse decision; we - // reuse `resolvedEmail` here rather than re-fetching. + // reuse `resolvedEmail` here rather than re-fetching. On the "Another + // account" rebind path (issue #138) `epds_skip_par_hint=1` upstream + // suppresses both URL and PAR hint resolution, so resolvedEmail is null + // and the email step falls out without a special case here. const hasLoginHint = !!resolvedEmail const initialStep = hasLoginHint ? 'otp' : 'email' diff --git a/packages/pds-core/src/auth-ui-guard.ts b/packages/pds-core/src/auth-ui-guard.ts index 6aa1abde..a053b24b 100644 --- a/packages/pds-core/src/auth-ui-guard.ts +++ b/packages/pds-core/src/auth-ui-guard.ts @@ -41,8 +41,25 @@ import { SESSION_ID_PREFIX, } from '@atproto/oauth-provider' import type { Logger } from 'pino' +import { + parsePromptTokens as parsePromptTokensShared, + promptHasLogin as promptHasLoginShared, +} from '@certified-app/shared' import { loadDeviceBindings } from './lib/device-accounts.js' +/** Re-export of the shared implementation. The canonical home is + * `@certified-app/shared`; this re-export exists so internal callers + * in pds-core (and the existing `auth-ui-guard.test.ts` import site) + * don't have to be touched on every refactor. New callers should + * import from `@certified-app/shared` directly. */ +export const parsePromptTokens = parsePromptTokensShared +/** Re-export of the shared implementation. The canonical home is + * `@certified-app/shared`; this re-export exists so internal callers + * in pds-core (and the existing `auth-ui-guard.test.ts` import site) + * don't have to be touched on every refactor. New callers should + * import from `@certified-app/shared` directly. */ +export const promptHasLogin = promptHasLoginShared + const DEVICE_ID_RE = new RegExp( `^${DEVICE_ID_PREFIX}[0-9a-f]{${DEVICE_ID_BYTES_LENGTH * 2}}$`, ) @@ -368,21 +385,3 @@ function filterCandidateBindings( ) return matched.length === 1 ? matched : bindings } - -/** Tokenise an OIDC `prompt` parameter value into its space-delimited set. - * Per OpenID Connect Core 1.0 §3.1.2.1, `prompt` is a space-delimited list - * of values (e.g. `"login consent"`), not a single literal — so an exact - * string check would miss `login` when it appears alongside other tokens. - * Returns an empty Set for null/undefined/non-string input. */ -export function parsePromptTokens(value: unknown): Set { - if (typeof value !== 'string') return new Set() - return new Set(value.split(/\s+/).filter(Boolean)) -} - -/** True when the given OIDC prompt value contains the `login` token. Both - * the auth-ui-guard and epds-callback's PAR mutation use this — they must - * agree on what counts as "forced login" so the guard's bounce condition - * and the callback's prompt-strip apply to exactly the same requests. */ -export function promptHasLogin(value: unknown): boolean { - return parsePromptTokens(value).has('login') -} diff --git a/packages/pds-core/src/chooser-enrichment.ts b/packages/pds-core/src/chooser-enrichment.ts index dd6935ee..6a38778f 100644 --- a/packages/pds-core/src/chooser-enrichment.ts +++ b/packages/pds-core/src/chooser-enrichment.ts @@ -109,6 +109,14 @@ export function buildChooserEnrichmentScript(): string { // request_uri / client_id / scope etc. so the OAuth flow resumes // after the new account signs in. // + // Adds epds_skip_par_hint=1 — an ePDS-private signal that tells + // auth-service "ignore the login_hint stored in the PAR for this + // request" (issue #138). The user clicked "Another account", so + // they're overriding any hint the RP supplied at OAuth init: with + // no hint resolved, the spec-correct rendering decision is the + // email form. URL login_hint is also dropped; the PAR-body hint + // can't be mutated from here, hence the explicit skip flag. + // // Returns '' when there is no request_uri in the current URL // (standalone /account navigation, bookmark, direct URL) — auth-service // rejects /oauth/authorize without request_uri with a 400, so letting @@ -118,6 +126,8 @@ export function buildChooserEnrichmentScript(): string { var params = new URLSearchParams(window.location.search || ''); if (!params.has('request_uri')) return ''; params.set('prompt', 'login'); + params.set('epds_skip_par_hint', '1'); + params.delete('login_hint'); return authOrigin + '/oauth/authorize?' + params.toString(); } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8b906e15..120fb4f0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -71,6 +71,7 @@ export type { PreviewCheck, PreviewValidationResult, } from './preview-validation.js' +export { parsePromptTokens, promptHasLogin } from './oidc-prompt.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/oidc-prompt.ts b/packages/shared/src/oidc-prompt.ts new file mode 100644 index 00000000..81107aca --- /dev/null +++ b/packages/shared/src/oidc-prompt.ts @@ -0,0 +1,44 @@ +/** + * Helpers for parsing the OIDC `prompt` parameter. + * + * Per OpenID Connect Core 1.0 §3.1.2.1, `prompt` is a space-delimited + * list of values (e.g. `"login consent"`), not a single literal. An + * exact string check (`p === 'login'`) misses every multi-token value + * that includes `login`. Both pds-core's auth-ui-guard and + * auth-service's session-reuse layer must agree on what counts as + * "the client asked for forced re-authentication", so the parsing + * lives here in shared rather than being duplicated per package. + * + * Express's `req.query` parser also surfaces repeated query keys as + * arrays (`?prompt=login&prompt=consent` → `['login', 'consent']`), so + * we accept both string and string-array shapes. + */ + +/** Tokenise an OIDC `prompt` parameter value into its space-delimited + * set. Returns an empty Set for null/undefined/non-string-or-array + * input. Array shapes (from repeated query keys) are joined with a + * space before tokenising so `?prompt=login&prompt=consent` produces + * the same set as `prompt=login%20consent`. */ +export function parsePromptTokens(value: unknown): Set { + let raw: string + if (typeof value === 'string') { + raw = value + } else if ( + Array.isArray(value) && + value.every((v): v is string => typeof v === 'string') + ) { + raw = value.join(' ') + } else { + return new Set() + } + return new Set(raw.split(/\s+/).filter(Boolean)) +} + +/** True when the given OIDC prompt value contains the `login` token. + * Both the auth-ui-guard and the auth-service login-page route use + * this — they must agree on what counts as "forced login" so the + * guard's bounce condition and the auth-service's session-reuse / + * initial-step decisions apply to exactly the same requests. */ +export function promptHasLogin(value: unknown): boolean { + return parsePromptTokens(value).has('login') +}