diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 552a3d89..b7c1c108 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -261,6 +261,7 @@ {"id":"ePDS-ipm.7","title":"Update recovery.test.ts to use dynamic OTP length instead of hardcoded 8","description":"## Files\n- packages/auth-service/src/__tests__/recovery.test.ts (modify)\n\n## What to do\n\nThere are two test blocks that hardcode OTP length to 8:\n\n### Block 1 (lines 122-128)\n```ts\ndescribe('Recovery flow: OTP pattern (8 digits)', () =\u003e {\n it('OTP sent by better-auth is 8 digits (configured in emailOTP plugin)', () =\u003e {\n const OTP_LENGTH = 8\n const pattern = new RegExp(`^[0-9]{${OTP_LENGTH}}$`)\n // ...\n })\n})\n```\n\n### Block 2 (lines 136-142)\n```ts\nit('OTP entry form uses maxlength=8 and pattern [0-9]{8}', () =\u003e {\n const maxlength = 8\n const pattern = '[0-9]{8}'\n expect(maxlength).toBe(8)\n expect(pattern).toContain('8')\n})\n```\n\n### Changes needed\n\nFor both blocks, read the OTP length from `process.env.OTP_LENGTH` with default 8:\n```ts\nconst OTP_LENGTH = parseInt(process.env.OTP_LENGTH ?? '8', 10)\n```\n\nUpdate the describe/it descriptions to not hardcode '8':\n- `'Recovery flow: OTP pattern'` (drop '8 digits')\n- `'OTP sent by better-auth matches configured length'`\n- `'OTP entry form uses configured maxlength and pattern'`\n\nUpdate Block 2 assertions to use the dynamic value:\n```ts\nconst maxlength = OTP_LENGTH\nconst pattern = `[0-9]{${OTP_LENGTH}}`\nexpect(maxlength).toBe(OTP_LENGTH)\nexpect(pattern).toContain(String(OTP_LENGTH))\n```\n\n## Don't\n- Don't change any other test files\n- Don't import from better-auth internals — just read process.env","acceptance_criteria":"1. No hardcoded 8 remains in OTP-related test assertions or descriptions\n2. Both test blocks read OTP_LENGTH from process.env with default 8\n3. Test descriptions are updated to not mention '8 digits'\n4. pnpm test passes with default OTP_LENGTH (unset = 8)","status":"closed","priority":2,"issue_type":"task","assignee":"karma.gainforest.id","owner":"karma.gainforest.id","estimated_minutes":20,"created_at":"2026-03-11T22:24:40.027924118+06:00","created_by":"karma.gainforest.id","updated_at":"2026-03-11T22:26:23.690810039+06:00","closed_at":"2026-03-11T22:26:23.690810039+06:00","close_reason":"627028c Use dynamic OTP_LENGTH in recovery tests instead of hardcoded 8","labels":["scope:trivial"],"dependencies":[{"issue_id":"ePDS-ipm.7","depends_on_id":"ePDS-ipm","type":"parent-child","created_at":"2026-03-11T22:24:40.029555266+06:00","created_by":"karma.gainforest.id"}]} {"id":"ePDS-ipm.8","title":"Document OTP_LENGTH env var in .env.example","description":"## Files\n- packages/auth-service/.env.example (modify)\n\n## What to do\n\nAdd the OTP_LENGTH env var documentation. Place it in the '-- Auth-service only --' section, after the session settings block (after line 61, before the social login section):\n\n```\n# OTP code length — number of digits in the email verification code (default: 8)\n# Must be between 4 and 12. Applies to login, recovery, and account settings OTP flows.\n# OTP_LENGTH=8\n```\n\nThe variable is commented out (like other optional vars in this file) since the default of 8 is sensible.\n\n## Don't\n- Don't modify any other .env.example files (the root one, pds-core, or demo)\n- Don't change any existing lines","acceptance_criteria":"1. OTP_LENGTH is documented in packages/auth-service/.env.example\n2. It's in the auth-service-only section, near the session settings\n3. Comment explains the valid range (4-12) and default (8)\n4. The variable line is commented out (# OTP_LENGTH=8)\n5. No existing lines are modified","status":"closed","priority":3,"issue_type":"task","assignee":"karma.gainforest.id","owner":"karma.gainforest.id","estimated_minutes":10,"created_at":"2026-03-11T22:24:48.428248126+06:00","created_by":"karma.gainforest.id","updated_at":"2026-03-11T22:26:17.022688894+06:00","closed_at":"2026-03-11T22:26:17.022688894+06:00","close_reason":"b8d6f58 docs: document OTP_LENGTH env var in auth-service .env.example","labels":["scope:trivial"],"dependencies":[{"issue_id":"ePDS-ipm.8","depends_on_id":"ePDS-ipm","type":"parent-child","created_at":"2026-03-11T22:24:48.42974985+06:00","created_by":"karma.gainforest.id"}]} {"id":"ePDS-ix2","title":"Fix: recovery .link-btn has padding:0 — touch target too small on mobile (from ePDS-aq2.5)","description":"Review of ePDS-aq2.5 found: The .link-btn CSS rule in recovery.ts (line 636) sets padding: 0, which means the touch target for 'Back to sign in' and 'Resend Code' links is only the text height (~20px). WCAG 2.5.5 recommends a minimum 44×44px touch target.\n\nEvidence:\n- recovery.ts line 636: padding: 0; (inside .link-btn rule)\n- The same issue exists in login-page.ts (line 565: padding: 0)\n\nThe .link-btn elements appear in:\n1. .form-actions row: 'Back to sign in' link (line 246)\n2. .otp-links row: 'Resend Code' button and 'Back to sign in' link (lines 319, 321)\n\nThese are the only interactive elements in those rows, so small touch targets are a real usability issue on mobile.\n\nFix: Add padding: 8px 0 (or similar) to .link-btn to increase the touch target height to at least 36px, while keeping the visual appearance the same. This fix should be applied to both recovery.ts and login-page.ts for consistency. Note: this is a pre-existing issue in login-page.ts that was copied into recovery.ts.","status":"open","priority":3,"issue_type":"bug","owner":"karma.gainforest.id","created_at":"2026-03-04T15:59:41.944209123+06:00","created_by":"karma.gainforest.id","updated_at":"2026-03-04T15:59:41.944209123+06:00","dependencies":[{"issue_id":"ePDS-ix2","depends_on_id":"ePDS-aq2.5","type":"discovered-from","created_at":"2026-03-04T15:59:42.002820695+06:00","created_by":"karma.gainforest.id"}]} +{"id":"ePDS-j448","title":"Epic: E2E coverage for consent and chooser enrichment","description":"Add end-to-end coverage for the user-facing enrichment introduced on this branch. Success means real OAuth consent screens assert identity enrichment in random and picker modes, and the session-reuse chooser profile asserts non-random chooser rows keep handle and email visible. Constraints: do not add preview-route e2e coverage; do not add multi-account/exact-matching e2e coverage in this scope; avoid coupling random consent coverage to random account provisioning risk. Known risks: consent DOM is upstream-owned and can be fragile; keep assertions anchored on visible role/text and ePDS stable classes.","status":"open","priority":2,"issue_type":"epic","owner":"kzoepa@gmail.com","created_at":"2026-05-04T17:17:28.800992481+06:00","created_by":"kzoeps","updated_at":"2026-05-04T17:17:28.800992481+06:00","labels":["scope:medium"]} {"id":"ePDS-j825","title":"Fix: renderNoAccountPage() response missing HTTP status code (from ePDS-01p.3)","description":"Review of ePDS-01p.3 found: In account-settings.ts line 69, when no PDS account is found for the session email, the response is sent as 'res.type(\"html\").send(renderNoAccountPage())' without setting an HTTP status code. This defaults to 200 OK, which is semantically incorrect for an error page and may confuse clients, monitoring tools, or browser caching. The 503 branch (PDS unavailable) correctly uses res.status(503), but the no-account branch silently returns 200. Fix: add an appropriate status code — either 403 Forbidden (the session is valid but the user has no account) or 404 Not Found. Suggested: res.status(403).type(\"html\").send(renderNoAccountPage()). Evidence: account-settings.ts line 69 — no .status() call before .type('html').send(...).","status":"open","priority":2,"issue_type":"bug","owner":"kzoepa@gmail.com","created_at":"2026-03-19T16:12:47.885869077+06:00","created_by":"kzoeps","updated_at":"2026-03-19T16:12:47.885869077+06:00","dependencies":[{"issue_id":"ePDS-j825","depends_on_id":"ePDS-01p.3","type":"discovered-from","created_at":"2026-03-19T16:13:23.934385443+06:00","created_by":"kzoeps"}]} {"id":"ePDS-jbs","title":"Fix: dark mode body background overridden by light-mode rule ordering (from ePDS-wae.2)","description":"The CSS has two body background rules: (1) body { background: var(--color-contrast-0); } at line ~364 and (2) @media (prefers-color-scheme: light) { body { background: white; } } at line ~371. In dark mode, rule (1) correctly uses --color-contrast-0 which resolves to hsl(265 20% 6%) — a near-black. However, when AUTH_BACKGROUND_COLOR is set, the bgColorStyle injects a \u003cstyle\u003ebody { background: ... !important; }\u003c/style\u003e block. The !important correctly overrides both rules. But if a client's background_color is set via OAuth metadata (b.background_color), it is NOT injected into the bgColorStyle block — it is only set via --color-panel (which only affects the panel, not body). The body background_color from client OAuth metadata is silently ignored. Document this limitation or implement it.","status":"open","priority":2,"issue_type":"bug","owner":"karma.gainforest.id","created_at":"2026-03-03T20:16:17.449655765+06:00","created_by":"karma.gainforest.id","updated_at":"2026-03-03T20:16:17.449655765+06:00","dependencies":[{"issue_id":"ePDS-jbs","depends_on_id":"ePDS-wae.2","type":"discovered-from","created_at":"2026-03-03T20:17:56.359797404+06:00","created_by":"karma.gainforest.id"}]} {"id":"ePDS-jk0","title":"Bug: recovery GET requires request_uri query param but plan says cookie should be sufficient","description":"## Finding\n\n`packages/auth-service/src/routes/recovery.ts` lines 42-44 return HTTP 400 if `request_uri` is absent from the query string:\n\n```ts\nif (!requestUri) {\n res.status(400).send(renderError('Missing request_uri parameter'))\n return\n}\n```\n\nThe login page recovery link at `login-page.ts:368` currently passes `request_uri` and `client_id` as query params to work around this:\n\n```ts\n\u003ca href=\"/auth/recover?request_uri=${...}\\\u0026client_id=${...}\" ...\u003e\n```\n\nThis is the current working state, but it means:\n\n1. The recovery page is reachable only via the login page link (which includes the params). Direct navigation or a stale link without params returns 400.\n2. The plan to simplify the recovery link to just `/auth/recover` (change #5) would break the GET handler unless change #4 (read from cookie instead) is implemented first. These two changes are tightly coupled and must be implemented atomically.\n3. The fallback at lines 47-51 (`getAuthFlowByRequestUri`) only runs when `clientId` is missing but `requestUri` is present — it never fires when `requestUri` itself is absent.\n\n## Not a regression — this is the current design\n\nThis is not a regression from any recent change. It is a pre-existing coupling that the refactoring plan must address carefully. The plan correctly identifies this (change #4), but the implementation must ensure the GET handler is updated before or simultaneously with simplifying the login page link (change #5). If done in two separate commits, the intermediate state would break recovery.","status":"open","priority":2,"issue_type":"bug","owner":"karma.gainforest.id","created_at":"2026-03-18T17:11:00.440621594+06:00","created_by":"karma.gainforest.id","updated_at":"2026-03-18T17:11:00.440621594+06:00"} diff --git a/.changeset/sign-in-account-presentation.md b/.changeset/sign-in-account-presentation.md new file mode 100644 index 00000000..8a5cd4a7 --- /dev/null +++ b/.changeset/sign-in-account-presentation.md @@ -0,0 +1,15 @@ +--- +'ePDS': patch +--- + +Sign-in screens now show your email more consistently and wait until they are ready before accepting clicks. + +**Affects:** End users, Client app developers + +**End users:** + +- Sign-in, app approval, account chooser, and account-management screens now use email as the primary identifier for accounts with generated handles, while still showing the public handle where it helps explain the account. +- The final approval step no longer briefly exposes a generated random handle when an app asked to show email first. +- The email sign-in button now waits until the page is ready before accepting clicks, so an early tap does not start a broken sign-in attempt. + +**Client app developers:** `epds_handle_mode` is now applied consistently to approval and account-chooser pages from either the current `/oauth/authorize` query parameter or OAuth client metadata, including pushed authorization requests where the browser URL only contains `request_uri`. Explicit query parameters still take precedence over client metadata. Valid modes stored for an auth flow are preserved through `/oauth/epds-callback`; invalid callback values are ignored rather than forwarded; metadata lookup failures fall back to the existing default behavior. diff --git a/e2e/step-definitions/consent.steps.ts b/e2e/step-definitions/consent.steps.ts index 5982b7a3..094b5f99 100644 --- a/e2e/step-definitions/consent.steps.ts +++ b/e2e/step-definitions/consent.steps.ts @@ -9,6 +9,7 @@ import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import type { EpdsWorld } from '../support/world.js' import { testEnv } from '../support/env.js' import { @@ -35,6 +36,59 @@ import { fillOtp } from '../support/otp.js' // Note: When('the user clicks {string}') lives in common.steps.ts — it is a // generic UI interaction step used here for "Authorize" and "Deny access" buttons. +function requireScenarioEmail(world: EpdsWorld): string { + if (!world.testEmail) { + throw new Error( + 'No test email set — "a returning user has a PDS account" step must run first', + ) + } + return world.testEmail +} + +function requireScenarioHandle(world: EpdsWorld): string { + if (!world.userHandle) { + throw new Error( + 'No user handle set — "a returning user has a PDS account" step must run first', + ) + } + return world.userHandle +} + +function formatPublicHandle(handle: string): string { + return handle.startsWith('@') ? handle : `@${handle}` +} + +function formatRawPublicHandle(handle: string): string { + return handle.startsWith('@') ? handle.slice(1) : handle +} + +function escapeRegex(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`) +} + +async function openIdentityTooltip(page: Page): Promise { + const tooltipControl = page + .getByRole('main') + .getByRole('button', { name: 'Identity information' }) + + await expect(tooltipControl).toHaveAttribute('type', 'button') + await expect(tooltipControl).toHaveAttribute('aria-expanded', 'false') + const describedBy = await tooltipControl.getAttribute('aria-describedby') + expect(describedBy?.trim()).toBeTruthy() + if (!describedBy?.trim()) { + throw new Error('Expected aria-describedby to reference a tooltip') + } + const [tooltipId] = describedBy.trim().split(/\s+/) + + await tooltipControl.click() + await expect(tooltipControl).toHaveAttribute('aria-expanded', 'true') + + const tooltip = page.locator(`#${tooltipId}`) + await expect(tooltip).toHaveAttribute('role', 'tooltip') + await expect(tooltip).toBeVisible() + return tooltip +} + Then('a consent screen is displayed', async function (this: EpdsWorld) { const page = getPage(this) @@ -99,6 +153,80 @@ When( }, ) +When( + 'the untrusted demo client starts a new OAuth flow with random handle mode', + async function (this: EpdsWorld) { + if (!testEnv.demoUntrustedUrl) return 'pending' + const page = getPage(this) + const base = testEnv.demoUntrustedUrl.replace(/\/$/, '') + await page.goto(`${base}/flow3`) + await page.click('button[type=submit]') + }, +) + +Then( + 'the consent page shows the email as the primary account identifier', + async function (this: EpdsWorld) { + const page = getPage(this) + const scenarioEmail = requireScenarioEmail(this) + const grantAccessText = /\bGrant\s+access\s+to\s+your\b/ + const accountCardText = /\bwants\s+to\s+access\s+your\b/ + const grantAccessParagraph = page.getByText(grantAccessText).first() + const accountCard = page + .getByRole('main') + .getByText(accountCardText) + .first() + + await expect(grantAccessParagraph).toBeVisible() + await expect(grantAccessParagraph).toContainText(scenarioEmail) + await expect(accountCard).toBeVisible() + await expect(accountCard).toContainText(scenarioEmail) + }, +) + +Then( + 'the consent identity tooltip exposes the public AT Protocol handle', + async function (this: EpdsWorld) { + const page = getPage(this) + const publicHandle = formatPublicHandle(requireScenarioHandle(this)) + const tooltip = await openIdentityTooltip(page) + await expect(tooltip).toContainText('Public AT Protocol handle:') + await expect(tooltip).toContainText(publicHandle) + }, +) + +Then( + 'the consent identity tooltip exposes the account email', + async function (this: EpdsWorld) { + const page = getPage(this) + const scenarioEmail = requireScenarioEmail(this) + const tooltip = await openIdentityTooltip(page) + await expect(tooltip).toContainText('This handle is associated with') + await expect(tooltip).toContainText(scenarioEmail) + }, +) + +Then( + 'the public handle is not shown as the primary consent identifier', + async function (this: EpdsWorld) { + const page = getPage(this) + const scenarioHandle = requireScenarioHandle(this) + const primaryHandlePatterns = [ + formatPublicHandle(scenarioHandle), + formatRawPublicHandle(scenarioHandle), + ].map( + (publicHandle) => + new RegExp( + String.raw`\byour\s+${escapeRegex(publicHandle)}\s+account\b`, + ), + ) + + for (const primaryHandlePattern of primaryHandlePatterns) { + await expect(page.getByText(primaryHandlePattern)).toHaveCount(0) + } + }, +) + Then( 'the browser is redirected back to the untrusted demo client with an auth error', async function (this: EpdsWorld) { diff --git a/e2e/step-definitions/session-reuse-bugs.steps.ts b/e2e/step-definitions/session-reuse-bugs.steps.ts index 23cc5bdf..d946a9ff 100644 --- a/e2e/step-definitions/session-reuse-bugs.steps.ts +++ b/e2e/step-definitions/session-reuse-bugs.steps.ts @@ -320,8 +320,8 @@ When( // pds-core's chooser middleware reads to inject // into the chooser's // . The enrichment script reads that meta and hides the handle - // span (display:none on .epds-handle-label, title= on - // .epds-email-label) without touching the DB or the account's actual + // span (display:none on .epds-handle-label) and describes it through + // aria-describedby without touching the DB or the account's actual // stored handle. const page = getPage(this) const base = testEnv.demoTrustedUrl.replace(/\/$/, '') @@ -417,25 +417,100 @@ Then( }, ) +type HiddenHandleDescriptionRow = { + describedBy: string | null + descriptions: { + id: string + isHiddenHandleDescription: boolean + text: string + }[] + emailTitle: string | null + hiddenHandleText: string + rowIndex: number +} + Then( - 'each row exposes the handle only via a title tooltip', + 'each row exposes the hidden handle through an accessible description', async function (this: EpdsWorld) { const page = getPage(this) - // The script copies the hidden handle span's text into a title= - // attribute on the adjacent .epds-email-label so power-users can - // still inspect which account maps to which DID without the - // gibberish random handle cluttering the visual hierarchy. - const emailLabels = page.locator('.epds-email-label') - const count = await emailLabels.count() - expect(count).toBeGreaterThan(0) - for (let i = 0; i < count; i++) { - const title = await emailLabels.nth(i).getAttribute('title') - const titleRepr = title === null ? 'null' : `"${title}"` + await expect(page.locator('.epds-email-label').first()).toBeVisible({ + timeout: 10_000, + }) + + const rows = await page + .locator('.epds-email-label') + .evaluateAll((emailLabels): HiddenHandleDescriptionRow[] => { + return emailLabels + .map((emailLabel, rowIndex) => { + const row = emailLabel.closest('[aria-label]') + const handleLabel = row?.querySelector('.epds-handle-label') + if (!row || !handleLabel) return null + + const handleIsHidden = + globalThis.getComputedStyle(handleLabel).display === 'none' + if (!handleIsHidden) return null + + const hiddenHandleText = handleLabel.textContent?.trim() ?? '' + const describedBy = row.getAttribute('aria-describedby') + const descriptionIds = + describedBy?.trim().split(/\s+/).filter(Boolean) ?? [] + const descriptions = descriptionIds.map((id) => { + const describedElement = document.getElementById(id) + return { + id, + isHiddenHandleDescription: + describedElement?.classList.contains( + 'epds-hidden-handle-description', + ) ?? false, + text: describedElement?.textContent?.trim() ?? '', + } + }) + + return { + describedBy, + descriptions, + emailTitle: emailLabel.getAttribute('title'), + hiddenHandleText, + rowIndex, + } + }) + .filter((row): row is HiddenHandleDescriptionRow => row !== null) + }) + + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) { expect( - title, - `Row ${i}: expected .epds-email-label to carry the hidden handle as title=, got ${titleRepr}`, + row.describedBy, + `Row ${row.rowIndex}: expected chooser row to reference the hidden handle with aria-describedby`, ).toBeTruthy() - expect(title!.trim().length).toBeGreaterThan(0) + + const description = row.descriptions.find( + (candidate) => candidate.isHiddenHandleDescription, + ) + expect( + description, + `Row ${row.rowIndex}: expected aria-describedby to reference an .epds-hidden-handle-description element`, + ).toBeDefined() + + const descriptionText = description?.text ?? '' + const prefix = 'Underlying handle:' + const prefixIndex = descriptionText.indexOf(prefix) + expect( + prefixIndex, + `Row ${row.rowIndex}: expected hidden-handle description to contain "${prefix}", got "${descriptionText}"`, + ).toBeGreaterThanOrEqual(0) + const describedHiddenHandle = descriptionText + .slice(prefixIndex + prefix.length) + .trim() + expect( + describedHiddenHandle, + `Row ${row.rowIndex}: expected hidden-handle description suffix to match the hidden handle text`, + ).toBe(row.hiddenHandleText) + + expect( + row.emailTitle, + `Row ${row.rowIndex}: .epds-email-label should not expose the hidden handle through title=`, + ).toBeNull() } }, ) diff --git a/features/consent-screen.feature b/features/consent-screen.feature index 69871b57..e7dbe3c4 100644 --- a/features/consent-screen.feature +++ b/features/consent-screen.feature @@ -72,6 +72,29 @@ Feature: OAuth consent screen When the user later initiates an OAuth login via the untrusted demo client Then a consent screen is displayed + @untrusted-client @email + Scenario: Default picker consent tooltip shows email associated with the public handle + Given a returning user has a PDS account + When the untrusted demo client initiates an OAuth login + And the user enters the test email on the login page + And an OTP email arrives in the mail trap + And the user enters the OTP code + Then a consent screen is displayed + And it identifies the untrusted demo client by its URL host + And the consent identity tooltip exposes the account email + + @untrusted-client @email + Scenario: Random-handle consent shows email with public handle in identity tooltip + Given a returning user has a PDS account + When the untrusted demo client starts a new OAuth flow with random handle mode + And the user enters the test email on the login page + And an OTP email arrives in the mail trap + And the user enters the OTP code + Then a consent screen is displayed + And the consent page shows the email as the primary account identifier + And the consent identity tooltip exposes the public AT Protocol handle + And the public handle is not shown as the primary consent identifier + # TODO: automate once custom CSS injection is merged into the consent route # (renderConsent() needs to accept and apply clientBrandingCss from client metadata) @manual diff --git a/features/session-reuse-bugs.feature b/features/session-reuse-bugs.feature index c35d3a4a..1548de87 100644 --- a/features/session-reuse-bugs.feature +++ b/features/session-reuse-bugs.feature @@ -79,7 +79,7 @@ Feature: Welcome-page guard suppresses upstream's authentication UI When the demo client starts a new OAuth flow with random handle mode Then the browser lands on the ePDS enriched account picker And the enriched account picker renders without the handle visible - And each row exposes the handle only via a title tooltip + And each row exposes the hidden handle through an accessible description And the email remains visible as the primary identifier @pending diff --git a/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts b/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts index faf25ab1..ae4ed0f9 100644 --- a/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts +++ b/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts @@ -56,6 +56,7 @@ describe('buildEpdsCallbackUrl', () => { const url = buildEpdsCallbackUrl({ flowRequestUri: REQUEST_URI, flowClientId: CLIENT_ID, + flowHandleMode: null, email: EMAIL, isNewAccount: false, pdsPublicUrl: PDS_PUBLIC_URL, @@ -70,6 +71,7 @@ describe('buildEpdsCallbackUrl', () => { const url = buildEpdsCallbackUrl({ flowRequestUri: REQUEST_URI, flowClientId: CLIENT_ID, + flowHandleMode: null, email: EMAIL, isNewAccount: false, pdsPublicUrl: PDS_PUBLIC_URL, @@ -98,6 +100,7 @@ describe('buildEpdsCallbackUrl', () => { const url = buildEpdsCallbackUrl({ flowRequestUri: REQUEST_URI, flowClientId: CLIENT_ID, + flowHandleMode: null, email: EMAIL, isNewAccount: true, pdsPublicUrl: PDS_PUBLIC_URL, @@ -126,6 +129,7 @@ describe('buildEpdsCallbackUrl', () => { const url = buildEpdsCallbackUrl({ flowRequestUri: REQUEST_URI, flowClientId: null, + flowHandleMode: null, email: EMAIL, isNewAccount: false, pdsPublicUrl: PDS_PUBLIC_URL, @@ -158,6 +162,7 @@ describe('buildEpdsCallbackUrl', () => { const url = buildEpdsCallbackUrl({ flowRequestUri: REQUEST_URI, flowClientId: CLIENT_ID, + flowHandleMode: 'random', email: EMAIL, isNewAccount: true, pdsPublicUrl: PDS_PUBLIC_URL, @@ -165,6 +170,7 @@ describe('buildEpdsCallbackUrl', () => { }) const q = paramsFromUrl(url) expect(q.has('handle')).toBe(false) + expect(q.get('epds_handle_mode')).toBe('random') // verifyCallback with absent handle accepts the signature, mirroring // the sentinel test in shared/src/__tests__/crypto.test.ts. const params: CallbackParams = { @@ -173,6 +179,7 @@ describe('buildEpdsCallbackUrl', () => { approved: '1', new_account: '1', client_id: CLIENT_ID, + epds_handle_mode: 'random', } expect( verifyCallback( @@ -188,6 +195,7 @@ describe('buildEpdsCallbackUrl', () => { const url = buildEpdsCallbackUrl({ flowRequestUri: REQUEST_URI, flowClientId: CLIENT_ID, + flowHandleMode: null, email: EMAIL, isNewAccount: false, pdsPublicUrl: PDS_PUBLIC_URL, diff --git a/packages/auth-service/src/__tests__/callback-handle-mode.test.ts b/packages/auth-service/src/__tests__/callback-handle-mode.test.ts new file mode 100644 index 00000000..cccd75da --- /dev/null +++ b/packages/auth-service/src/__tests__/callback-handle-mode.test.ts @@ -0,0 +1,237 @@ +import { randomBytes } from 'node:crypto' +import type { AddressInfo } from 'node:net' +import express from 'express' +import cookieParser from 'cookie-parser' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + verifyCallback, + type CallbackParams, + type HandleMode, +} from '@certified-app/shared' +import type { AuthServiceContext } from '../context.js' +import { createChooseHandleRouter } from '../routes/choose-handle.js' +import { createCompleteRouter } from '../routes/complete.js' + +const mocks = vi.hoisted(() => ({ + getDidByEmail: vi.fn(), + pingParRequest: vi.fn(), + resolveRecoveryEmail: vi.fn(), + resolveClientBranding: vi.fn(), +})) + +vi.mock('../lib/get-did-by-email.js', () => ({ + getDidByEmail: mocks.getDidByEmail, +})) + +vi.mock('../lib/ping-par-request.js', () => ({ + pingParRequest: mocks.pingParRequest, +})) + +vi.mock('../lib/resolve-recovery-email.js', () => ({ + resolveRecoveryEmail: mocks.resolveRecoveryEmail, +})) + +vi.mock('../lib/client-metadata.js', () => ({ + resolveClientBranding: mocks.resolveClientBranding, +})) + +const AUTH_FLOW_COOKIE = 'epds_auth_flow' +const realFetch = globalThis.fetch.bind(globalThis) + +function makeCtx(handleMode: HandleMode | null): AuthServiceContext { + return { + config: { + pdsPublicUrl: 'https://pds.example', + pdsHostname: 'pds.example', + epdsCallbackSecret: 'test-callback-secret', + trustedClients: [], + }, + db: { + getAuthFlow: vi.fn(() => ({ + flowId: 'flow-1', + requestUri: 'urn:ietf:params:oauth:request_uri:req-123', + clientId: 'https://app.example/client.json', + handleMode, + })), + deleteAuthFlow: vi.fn(), + }, + } as unknown as AuthServiceContext +} + +function makeAuth() { + return { + api: { + getSession: vi.fn(() => + Promise.resolve({ user: { email: 'Alice@example.com' } }), + ), + }, + } +} + +async function startApp( + ctx: AuthServiceContext, + auth: ReturnType, +): Promise<{ baseUrl: string; close: () => Promise }> { + const app = express() + app.disable('x-powered-by') + app.use(cookieParser()) + app.use(express.urlencoded({ extended: false })) + app.use(createCompleteRouter(ctx, auth)) + app.use(createChooseHandleRouter(ctx, auth)) + + 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() + }) + }), + } +} + +function parseRedirect(res: globalThis.Response): URL { + const location = res.headers.get('location') + if (!location) throw new Error('Missing redirect location') + return new URL(location) +} + +function normalizeFetchUrl(input: Parameters[0]): URL { + if (input instanceof URL) return input + if (typeof input === 'string') return new URL(input) + return new URL(input.url) +} + +function verifySignedCallbackUrl(url: URL): boolean { + const callbackParams: CallbackParams = { + request_uri: url.searchParams.get('request_uri') ?? '', + email: url.searchParams.get('email') ?? '', + approved: url.searchParams.get('approved') ?? '', + new_account: url.searchParams.get('new_account') ?? '', + ...(url.searchParams.has('handle') + ? { handle: url.searchParams.get('handle') ?? '' } + : {}), + ...(url.searchParams.has('client_id') + ? { client_id: url.searchParams.get('client_id') ?? '' } + : {}), + ...(url.searchParams.has('epds_handle_mode') + ? { epds_handle_mode: url.searchParams.get('epds_handle_mode') ?? '' } + : {}), + } + + return verifyCallback( + callbackParams, + url.searchParams.get('ts') ?? '', + url.searchParams.get('sig') ?? '', + 'test-callback-secret', + ) +} + +async function fetchCompleteRedirect(handleMode: HandleMode | null) { + const app = await startApp(makeCtx(handleMode), makeAuth()) + try { + const res = await fetch(`${app.baseUrl}/auth/complete`, { + redirect: 'manual', + headers: { cookie: `${AUTH_FLOW_COOKIE}=flow-1` }, + }) + + expect(res.status).toBe(303) + const url = parseRedirect(res) + expect(url.pathname).toBe('/oauth/epds-callback') + return url + } finally { + await app.close() + } +} + +describe('auth-service epds-callback handle mode threading', () => { + let priorEnv: { pdsInternalUrl?: string; internalSecret?: string } + + beforeEach(() => { + priorEnv = { + pdsInternalUrl: process.env.PDS_INTERNAL_URL, + internalSecret: process.env.EPDS_INTERNAL_SECRET, + } + process.env.PDS_INTERNAL_URL = 'http://pds.internal' // NOSONAR test-only internal mocked URL + process.env.EPDS_INTERNAL_SECRET = 'test-internal-secret' + mocks.getDidByEmail.mockReset() + mocks.pingParRequest.mockReset() + mocks.resolveRecoveryEmail.mockReset() + mocks.resolveClientBranding.mockReset() + mocks.pingParRequest.mockResolvedValue({ ok: true }) + mocks.resolveRecoveryEmail.mockResolvedValue(null) + mocks.resolveClientBranding.mockResolvedValue({ + customCss: null, + customFaviconUrl: null, + customFaviconUrlDark: null, + }) + vi.stubGlobal( + 'fetch', + vi.fn((input: Parameters[0], init?: RequestInit) => { + const url = normalizeFetchUrl(input) + if (url.hostname === '127.0.0.1') return realFetch(input, init) + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ exists: false }), + }) + }), + ) + }) + + afterEach(() => { + if (priorEnv.pdsInternalUrl === undefined) + delete process.env.PDS_INTERNAL_URL + else process.env.PDS_INTERNAL_URL = priorEnv.pdsInternalUrl + if (priorEnv.internalSecret === undefined) + delete process.env.EPDS_INTERNAL_SECRET + else process.env.EPDS_INTERNAL_SECRET = priorEnv.internalSecret + vi.unstubAllGlobals() + }) + + it('includes the stored canonical handle mode for random-mode new users', async () => { + mocks.getDidByEmail.mockResolvedValue(null) + const url = await fetchCompleteRedirect('random') + expect(url.searchParams.get('epds_handle_mode')).toBe('random') + expect(url.searchParams.has('handle')).toBe(false) + expect(verifySignedCallbackUrl(url)).toBe(true) + }) + + it('includes the stored canonical handle mode for existing users', async () => { + mocks.getDidByEmail.mockResolvedValue(randomBytes(16).toString('hex')) + const url = await fetchCompleteRedirect('picker-with-random') + expect(url.searchParams.get('epds_handle_mode')).toBe('picker-with-random') + }) + + it('includes the stored canonical handle mode for chosen-handle callbacks', async () => { + mocks.getDidByEmail.mockResolvedValue(null) + const app = await startApp(makeCtx('picker'), makeAuth()) + try { + const res = await fetch(`${app.baseUrl}/auth/choose-handle`, { + method: 'POST', + redirect: 'manual', + headers: { + cookie: `${AUTH_FLOW_COOKIE}=flow-1`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ handle: 'Alice1' }), + }) + + expect(res.status).toBe(303) + const url = parseRedirect(res) + expect(url.pathname).toBe('/oauth/epds-callback') + expect(url.searchParams.get('epds_handle_mode')).toBe('picker') + expect(url.searchParams.get('handle')).toBe('alice1') + } finally { + await app.close() + } + }) +}) diff --git a/packages/auth-service/src/__tests__/login-page.test.ts b/packages/auth-service/src/__tests__/login-page.test.ts index 5376f713..f940c2fc 100644 --- a/packages/auth-service/src/__tests__/login-page.test.ts +++ b/packages/auth-service/src/__tests__/login-page.test.ts @@ -521,6 +521,36 @@ function renderDefault(): string { }) } +describe('renderLoginPage email form readiness gate', () => { + it('renders the email OTP form without a readiness dataset marker', () => { + const html = renderDefault() + expect(html).toContain('
') + expect(html).not.toContain('data-epds-login-ready') + expect(html).not.toContain('epdsLoginReady') + }) + + it('renders the email submit button disabled with the existing label', () => { + const html = renderDefault() + expect(html).toMatch( + / +
${handleLoginButtonHtml} + +

${ @@ -727,6 +729,7 @@ export function renderLoginPage(opts: { var otpSubtitle = document.getElementById('otp-subtitle'); var otpEmailInput = document.getElementById('otp-email'); var atprotoBtn = document.querySelector('.btn-atproto'); + var sendOtpForm = document.getElementById('form-send-otp'); var emailInput = document.getElementById('email'); var emailLabel = document.querySelector('label[for="email"]'); var sendOtpBtn = document.querySelector('#form-send-otp button[type=submit]'); @@ -1033,7 +1036,7 @@ export function renderLoginPage(opts: { } // Form: send OTP (email mode) or hand off to client (handle mode) - document.getElementById('form-send-otp').addEventListener('submit', async function(e) { + sendOtpForm.addEventListener('submit', async function(e) { e.preventDefault(); clearError(); var raw = emailInput.value.trim(); @@ -1162,6 +1165,10 @@ export function renderLoginPage(opts: { clearOtpBoxes(); }); + // Enable only after the submit handler is installed. This avoids a race mostly + // seen in fast e2e runs where the button is clicked before JS is ready. + sendOtpBtn.disabled = false; + // Pillar 1: If login_hint was provided, the OTP step is already visible // server-side — no DOM transition needed. // Pillar 2: Auto-fire the OTP send as a client-side POST. diff --git a/packages/pds-core/src/__tests__/chooser-enrichment.test.ts b/packages/pds-core/src/__tests__/chooser-enrichment.test.ts index 53ae36bf..c7244c02 100644 --- a/packages/pds-core/src/__tests__/chooser-enrichment.test.ts +++ b/packages/pds-core/src/__tests__/chooser-enrichment.test.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it, vi } from 'vitest' import { appendScriptHashToCsp, @@ -30,6 +31,1243 @@ describe('buildChooserEnrichmentScript (HYPER-268)', () => { }) }) +class FakeTextNode { + readonly nodeType = 3 + + constructor(readonly data: string) {} +} + +class FakeClassList { + private readonly classes = new Set() + + add(className: string): void { + this.classes.add(className) + } + + contains(className: string): boolean { + return this.classes.has(className) + } + + set(value: string): void { + this.classes.clear() + for (const className of value.split(/\s+/)) { + if (className) this.classes.add(className) + } + } +} + +class FakeElement { + readonly nodeType = 1 + readonly childNodes: Array = [] + readonly dataset: Record = {} + readonly classList = new FakeClassList() + readonly style: Record = {} + id = '' + hidden = false + type = '' + parentElement: FakeElement | null = null + textContentOverride: string | null = null + private readonly eventListeners = new Map< + string, + Array<(event: FakeEvent) => void> + >() + + constructor( + readonly tagName: string, + private readonly attributes: Record = {}, + ) {} + + appendChild(child: FakeElement | FakeTextNode): void { + if (child instanceof FakeElement) { + child.parentElement = this + } + this.childNodes.push(child) + } + + insertAdjacentElement(position: string, element: FakeElement): void { + if (position !== 'afterend' || !this.parentElement) return + const siblings = this.parentElement.childNodes + const index = siblings.indexOf(this) + if (index === -1) return + element.parentElement = this.parentElement + siblings.splice(index + 1, 0, element) + } + + set textContent(value: string) { + this.textContentOverride = value + } + + set className(value: string) { + this.classList.set(value) + } + + setAttribute(name: string, value: string): void { + this.attributes[name] = value + if (name === 'id') this.id = value + } + + getAttribute(name: string): string | null { + return this.attributes[name] ?? null + } + + get textContent(): string { + if (this.textContentOverride !== null) return this.textContentOverride + return this.childNodes + .map((child) => + child instanceof FakeTextNode ? child.data : child.textContent, + ) + .join('') + } + + closest(selector: string): FakeElement | null { + if (selector === 'a') { + if (this.tagName === 'a') return this + + let current = this.parentElement + while (current) { + if (current.tagName === 'a') return current + current = current.parentElement + } + return null + } + + if (selector !== '[role="button"][tabindex="0"]') return null + + if (this.attributes.role === 'button' && this.attributes.tabindex === '0') { + return this + } + + let current = this.parentElement + while (current) { + if ( + current.attributes.role === 'button' && + current.attributes.tabindex === '0' + ) { + return current + } + current = current.parentElement + } + return null + } + + addEventListener(event: string, listener: (event: FakeEvent) => void): void { + const listeners = this.eventListeners.get(event) ?? [] + listeners.push(listener) + this.eventListeners.set(event, listeners) + } + + dispatchEvent(eventName: string): FakeEvent { + const event = new FakeEvent() + for (const listener of this.eventListeners.get(eventName) ?? []) { + listener(event) + } + return event + } + + querySelectorAll(selector: string): FakeElement[] { + const descendants = this.descendants() + if (selector === 'button, a') { + return descendants.filter( + (el) => el.tagName === 'button' || el.tagName === 'a', + ) + } + if (selector === '[role="button"]') { + return descendants.filter((el) => el.attributes.role === 'button') + } + if (selector === 'h2') { + return descendants.filter((el) => el.tagName === 'h2') + } + return [] + } + + querySelector(selector: string): FakeElement | null { + if ( + selector === + '[role="button"][aria-label="Login to account that is not listed"]' + ) { + return ( + this.descendants().find( + (el) => + el.attributes.role === 'button' && + el.attributes['aria-label'] === + 'Login to account that is not listed', + ) ?? null + ) + } + if (selector === 'meta[name="epds-handle-mode"]') { + return ( + this.descendants().find( + (el) => + el.tagName === 'meta' && el.attributes.name === 'epds-handle-mode', + ) ?? null + ) + } + if (selector === 'meta[name="epds-auth-origin"]') { + return ( + this.descendants().find( + (el) => + el.tagName === 'meta' && el.attributes.name === 'epds-auth-origin', + ) ?? null + ) + } + return null + } + + descendants(): FakeElement[] { + const result: FakeElement[] = [] + const visit = (node: FakeElement): void => { + for (const child of node.childNodes) { + if (child instanceof FakeElement) { + result.push(child) + visit(child) + } + } + } + visit(this) + return result + } +} + +class FakeEvent { + defaultPrevented = false + propagationStopped = false + + preventDefault(): void { + this.defaultPrevented = true + } + + stopPropagation(): void { + this.propagationStopped = true + } +} + +class FakeDocument { + readonly root = new FakeElement('div', { id: 'root' }) + readyState = 'loading' + private domContentLoadedListener: (() => void) | null = null + + get documentElement(): FakeElement { + return this.root + } + + getElementById(id: string): FakeElement | null { + return id === 'root' ? this.root : null + } + + createTreeWalker(root: FakeElement): { nextNode: () => FakeElement | null } { + const elements = root.descendants() + let index = 0 + return { + nextNode: () => elements[index++] ?? null, + } + } + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName) + } + + querySelector(selector: string): FakeElement | null { + return this.root.querySelector(selector) + } + + addEventListener(event: string, listener: () => void): void { + if (event === 'DOMContentLoaded') this.domContentLoadedListener = listener + } + + dispatchDOMContentLoaded(): void { + this.readyState = 'complete' + this.domContentLoadedListener?.() + } +} + +function appendText(parent: FakeElement, text: string): void { + parent.appendChild(new FakeTextNode(text)) +} + +const DEFAULT_CHOOSER_LOCATION = { + pathname: '/oauth/authorize', + search: '', +} + +const ALICE_ASSOCIATED_TOOLTIP = + 'This handle is associated with alice@example.test.' +const ASSOCIATED_TOOLTIP_PREFIX = 'This handle is associated' +const ALICE_PUBLIC_HANDLE_TOOLTIP = + 'Public AT Protocol handle: @alice.test. Handles are public account names used by AT Protocol apps.' +const EMAIL_LABEL_CLASS = 'epds-email-label' +const HIDDEN_HANDLE_DESCRIPTION_CLASS = 'epds-hidden-handle-description' +const IDENTITY_INFO_ICON_CLASS = 'epds-identity-info-icon' +const IDENTITY_TOOLTIP_CLASS = 'epds-identity-tooltip' + +function findChildWithClass( + parent: FakeElement, + className: string, +): FakeElement | undefined { + return parent.childNodes.find( + (child) => + child instanceof FakeElement && child.classList.contains(className), + ) as FakeElement | undefined +} + +function findEmailLabel(parent: FakeElement): FakeElement | undefined { + return findChildWithClass(parent, EMAIL_LABEL_CLASS) +} + +function findHiddenHandleDescription( + parent: FakeElement, +): FakeElement | undefined { + return findChildWithClass(parent, HIDDEN_HANDLE_DESCRIPTION_CLASS) +} + +function findDescendantsWithClass( + parent: FakeElement, + className: string, +): FakeElement[] { + return parent.descendants().filter((el) => el.classList.contains(className)) +} + +function expectConsentTooltip( + container: FakeElement, + expectedText: string, +): void { + const icon = findChildWithClass(container, IDENTITY_INFO_ICON_CLASS) + const tooltip = findChildWithClass(container, IDENTITY_TOOLTIP_CLASS) + + expect(icon).toBeInstanceOf(FakeElement) + expect(icon?.tagName).toBe('button') + expect(icon?.getAttribute('aria-describedby')).toBe(tooltip?.id) + expect(tooltip?.textContent).toBe(expectedText) +} + +function expectConsentTooltipTexts( + document: FakeDocument, + expectedTexts: string[], +): void { + const icons = findDescendantsWithClass( + document.root, + IDENTITY_INFO_ICON_CLASS, + ) + const tooltips = findDescendantsWithClass( + document.root, + IDENTITY_TOOLTIP_CLASS, + ) + + expect(icons).toHaveLength(expectedTexts.length) + expect(tooltips.map((tooltip) => tooltip.textContent)).toEqual(expectedTexts) +} + +function runChooserEnrichmentScript( + document: FakeDocument, + globals: { + __sessions?: unknown[] + __deviceSessions?: unknown[] + } = {}, + location: { pathname: string; search?: string } = DEFAULT_CHOOSER_LOCATION, +): void { + const fakeWindow: Record = { + location, + } + const sandbox = { + document, + MutationObserver: class { + observed = false + + observe(): void { + this.observed = true + } + }, + Node: { TEXT_NODE: 3 }, + NodeFilter: { SHOW_ELEMENT: 1 }, + URLSearchParams, + window: fakeWindow, + } + + runInNewContext(buildChooserEnrichmentScript(), sandbox) // NOSONAR — test executes only the deterministic script generated in this repository. + fakeWindow.__sessions = globals.__sessions ?? [ + { + selected: true, + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + preferred_username: 'alice.test', + selected: true, + }, + }, + { + account: { + sub: 'did:plc:bob', + email: 'bob@example.test', + preferred_username: 'bob.test', + }, + }, + ] + if (globals.__deviceSessions) { + fakeWindow.__deviceSessions = globals.__deviceSessions + } + document.dispatchDOMContentLoaded() +} + +function createChooserRow( + document: FakeDocument, + identifierText: string, +): { row: FakeElement; wrap: FakeElement; identifier: FakeElement } { + const row = new FakeElement('div', { role: 'button', tabindex: '0' }) + const wrap = new FakeElement('span') + const identifier = new FakeElement('span') + appendText(identifier, identifierText) + wrap.appendChild(identifier) + row.appendChild(wrap) + document.root.appendChild(row) + return { row, wrap, identifier } +} + +function createAccountListRow( + document: FakeDocument, + identifierText: string, + { emptyTitle = false }: { emptyTitle?: boolean } = {}, +): { + anchor: FakeElement + title?: FakeElement + wrap: FakeElement + identifier: FakeElement +} { + const anchor = new FakeElement('a', { + href: '/account/did:plc:alice', + 'aria-label': 'View and manage account for alice.test', + }) + const wrap = new FakeElement('span') + const identifier = new FakeElement('span') + const title = emptyTitle ? new FakeElement('h2') : undefined + + if (title) anchor.appendChild(title) + appendText(identifier, identifierText) + wrap.appendChild(identifier) + anchor.appendChild(wrap) + document.root.appendChild(anchor) + return { anchor, title, wrap, identifier } +} + +function createAccountSelector( + document: FakeDocument, + identifierTexts: string[], +): { button: FakeElement; identifiers: FakeElement[]; wrap: FakeElement } { + const button = new FakeElement('button', { + 'aria-label': 'Select an account', + }) + const wrap = new FakeElement('span') + const identifiers = identifierTexts.map((identifierText) => { + const identifier = new FakeElement('p') + appendText(identifier, identifierText) + wrap.appendChild(identifier) + return identifier + }) + button.appendChild(wrap) + document.root.appendChild(button) + return { button, identifiers, wrap } +} + +function createConsentIdentity( + document: FakeDocument, + textBefore: string, + identifierText: string, + textAfter: string, + tagName = 'b', +): { container: FakeElement; identifier: FakeElement } { + const container = new FakeElement('p') + appendText(container, textBefore) + const identifier = new FakeElement(tagName) + appendText(identifier, identifierText) + container.appendChild(identifier) + appendText(container, textAfter) + document.root.appendChild(container) + return { container, identifier } +} + +function createPreviewChooserConsentIdentities( + document: FakeDocument, + mode: string, +): { + sidebar: { container: FakeElement; identifier: FakeElement } + mainCard: { container: FakeElement; identifier: FakeElement } +} { + appendHandleModeMeta(document, mode) + + return { + sidebar: createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ), + mainCard: createConsentIdentity( + document, + 'wants to access your ', + 'alice.test', + ' account', + ), + } +} + +function runPreviewChooserConsentEnrichment( + document: FakeDocument, + mode: string, +): void { + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { + pathname: '/preview/chooser', + search: `?epds_handle_mode=${mode}`, + }, + ) +} + +function expectArbitraryConsentProseUntouched({ + prefix, + identifierText, + suffix, + tagName, + expectedText, +}: { + prefix: string + identifierText: string + suffix: string + tagName?: string + expectedText: string +}): void { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + prefix, + identifierText, + suffix, + tagName, + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + expect(identifier.textContent).toBe(identifierText) + expect(container.textContent).toBe(expectedText) + expect(container.textContent).not.toContain(ASSOCIATED_TOOLTIP_PREFIX) +} + +function findConsentIdentityTooltip(container: FakeElement): { + icon: FakeElement + tooltip: FakeElement +} { + const icon = findChildWithClass(container, IDENTITY_INFO_ICON_CLASS) + const tooltip = findChildWithClass(container, IDENTITY_TOOLTIP_CLASS) + + return { icon: icon as FakeElement, tooltip: tooltip as FakeElement } +} + +function selectedAliceSession(preferredUsername = 'alice.test'): unknown[] { + return [ + { + selected: true, + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + preferred_username: preferredUsername, + selected: true, + }, + }, + ] +} + +function aliceDeviceSession(): unknown[] { + return [ + { + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + preferred_username: 'alice.test', + }, + selected: true, + }, + ] +} + +function appendHandleModeMeta(document: FakeDocument, mode: string): void { + document.root.appendChild( + new FakeElement('meta', { name: 'epds-handle-mode', content: mode }), + ) +} + +describe('buildChooserEnrichmentScript account row scoping', () => { + it('does not enrich consent copy outside a chooser account row', () => { + const document = new FakeDocument() + const paragraph = new FakeElement('p') + const consentHandle = new FakeElement('span') + appendText(consentHandle, 'alice.test') + paragraph.appendChild(consentHandle) + appendText(paragraph, ' grants access to did:plc:alice') + document.root.appendChild(paragraph) + + runChooserEnrichmentScript(document) + + expect(document.root.descendants()).not.toContainEqual( + expect.objectContaining({ textContentOverride: 'alice@example.test' }), + ) + expect(consentHandle.classList.contains('epds-handle-label')).toBe(false) + }) + + it('enriches a chooser-like account row', () => { + const document = new FakeDocument() + const { + row, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript(document) + + const emailLabel = findEmailLabel(wrap) + + expect(emailLabel).toBeInstanceOf(FakeElement) + expect(emailLabel?.textContent).toBe('alice@example.test') + expect(handle.classList.contains('epds-handle-label')).toBe(true) + expect(handle.style.display).toBeUndefined() + expect(row.getAttribute('aria-label')).toBe('Sign in as alice@example.test') + }) + + it('enriches preview chooser rows in picker-with-random mode', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker-with-random') + const { + row, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/preview/chooser', search: '' }, + ) + + expect(wrap.textContent).toContain('alice@example.test') + expect(handle.classList.contains('epds-handle-label')).toBe(true) + expect(handle.style.display).toBeUndefined() + expect(row.getAttribute('aria-label')).toBe('Sign in as alice@example.test') + }) + + it('uses email as the visible random-mode identifier and describes the hidden handle', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { + row, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript(document) + + const emailLabel = findEmailLabel(wrap) + const handleDescription = findHiddenHandleDescription(row) + + expect(emailLabel?.textContent).toBe('alice@example.test') + expect(handle.style.display).toBe('none') + expect(handleDescription?.textContent).toBe('Underlying handle: alice.test') + expect(row.getAttribute('aria-describedby')).toBe(handleDescription?.id) + expect(emailLabel?.getAttribute('title')).toBeNull() + expect(row.getAttribute('aria-label')).toBe('Sign in as alice@example.test') + }) + + it('uses email as the visible random-mode identifier on preview chooser rows', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { + row, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/preview/chooser', search: '' }, + ) + + const emailLabel = findEmailLabel(wrap) + const handleDescription = findHiddenHandleDescription(row) + + expect(emailLabel?.textContent).toBe('alice@example.test') + expect(handle.style.display).toBe('none') + expect(handleDescription?.textContent).toBe('Underlying handle: alice.test') + expect(row.getAttribute('aria-describedby')).toBe(handleDescription?.id) + }) + + it('does not hide random-mode handles outside oauth authorize', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { wrap, identifier, anchor } = createAccountListRow( + document, + 'alice.test', + ) + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/account', search: '' }, + ) + + expect(wrap.textContent).toContain('alice@example.test') + expect(identifier.style.display).toBeUndefined() + expect(anchor.getAttribute('aria-label')).toBe( + 'View and manage account for alice@example.test (@alice.test)', + ) + }) + + it('uses an empty account-list title slot for the email on account pages', () => { + const document = new FakeDocument() + const { title, identifier, anchor } = createAccountListRow( + document, + 'alice.test', + { emptyTitle: true }, + ) + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/account', search: '' }, + ) + + expect(title?.textContent).toBe('alice@example.test') + expect(identifier.textContent).toBe('alice.test') + expect(identifier.style.display).toBeUndefined() + expect(anchor.getAttribute('aria-label')).toBe( + 'View and manage account for alice@example.test (@alice.test)', + ) + }) + + it('leaves non-account links on account pages untouched', () => { + const document = new FakeDocument() + const anotherAccount = new FakeElement('a', { + href: '/account/login', + 'aria-label': 'Sign in with another account', + }) + appendText(anotherAccount, 'Sign in with another account') + document.root.appendChild(anotherAccount) + const terms = new FakeElement('a', { href: '/terms' }) + appendText(terms, 'Terms') + document.root.appendChild(terms) + const prose = new FakeElement('p') + appendText(prose, 'Manage alice.test from this page.') + document.root.appendChild(prose) + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/account', search: '' }, + ) + + expect(anotherAccount.textContent).toBe('Sign in with another account') + expect(terms.textContent).toBe('Terms') + expect(prose.textContent).toBe('Manage alice.test from this page.') + expect(document.root.textContent).not.toContain('alice@example.test') + }) + + it('adds email next to the current account selector handle on account detail pages', () => { + const document = new FakeDocument() + const { button, identifiers, wrap } = createAccountSelector(document, [ + 'alice.test', + ]) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(identifiers[0].textContent).toBe('alice.test') + expect(identifiers[0].style.display).toBeUndefined() + expect(wrap.textContent).toContain('alice@example.test') + expect(button.getAttribute('aria-label')).toBe( + 'Select account alice@example.test (@alice.test)', + ) + }) + + it('collapses duplicate current account selector handle lines to email plus handle', () => { + const document = new FakeDocument() + const { button, identifiers } = createAccountSelector(document, [ + 'alice.test', + 'alice.test', + ]) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(identifiers[0].textContent).toBe('alice@example.test') + expect(identifiers[1].textContent).toBe('alice.test') + expect(button.textContent).toBe('alice@example.testalice.test') + expect(button.getAttribute('aria-label')).toBe( + 'Select account alice@example.test (@alice.test)', + ) + }) + + it('keeps the current account selector handle visible when account pages use random mode', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { identifiers, wrap } = createAccountSelector(document, [ + 'alice.test', + ]) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(identifiers[0].textContent).toBe('alice.test') + expect(identifiers[0].style.display).toBeUndefined() + expect(wrap.textContent).toContain('alice@example.test') + }) + + it('does not enrich non-selector controls on account detail pages', () => { + const document = new FakeDocument() + createAccountSelector(document, ['alice.test']) + const connectedApp = new FakeElement('button', { + 'aria-label': 'Open app settings', + }) + appendText(connectedApp, 'alice.test') + document.root.appendChild(connectedApp) + const signOut = new FakeElement('button', { 'aria-label': 'Sign out' }) + appendText(signOut, 'Sign out alice.test') + document.root.appendChild(signOut) + const breadcrumb = new FakeElement('a', { href: '/account' }) + appendText(breadcrumb, 'alice.test') + document.root.appendChild(breadcrumb) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(connectedApp.textContent).toBe('alice.test') + expect(signOut.textContent).toBe('Sign out alice.test') + expect(breadcrumb.textContent).toBe('alice.test') + }) + + it('enriches exact at-prefixed handle matches', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow(document, '@alice.test') + + runChooserEnrichmentScript(document) + + expect(wrap.textContent).toContain('alice@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(true) + }) + + it('enriches exact DID matches', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow(document, 'did:plc:alice') + + runChooserEnrichmentScript(document) + + expect(wrap.textContent).toContain('alice@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(true) + }) + + it('enriches rows from captured device sessions', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow(document, 'carol.test') + + runChooserEnrichmentScript(document, { + __sessions: [], + __deviceSessions: [ + { + account: { + sub: 'did:plc:carol', + email: 'carol@example.test', + preferred_username: 'carol.test', + }, + selected: true, + }, + ], + }) + + expect(wrap.textContent).toContain('carol@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(true) + }) + + it('does not enrich substring-only chooser row prose', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow( + document, + 'Signed in as alice.test', + ) + + runChooserEnrichmentScript(document) + + expect(wrap.textContent).not.toContain('alice@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(false) + }) + + it('enriches multiple chooser-like account rows', () => { + const document = new FakeDocument() + const rows = ['alice.test', 'bob.test'].map((handleText) => { + const row = new FakeElement('div', { role: 'button', tabindex: '0' }) + const wrap = new FakeElement('span') + const handle = new FakeElement('span') + appendText(handle, handleText) + wrap.appendChild(handle) + row.appendChild(wrap) + document.root.appendChild(row) + return { wrap, handle } + }) + + runChooserEnrichmentScript(document) + + expect(rows[0].wrap.textContent).toContain('alice@example.test') + expect(rows[1].wrap.textContent).toContain('bob@example.test') + expect(rows[0].handle.classList.contains('epds-handle-label')).toBe(true) + expect(rows[1].handle.classList.contains('epds-handle-label')).toBe(true) + }) +}) + +describe('buildChooserEnrichmentScript consent identity enrichment', () => { + it('enriches grant-access consent identity copy', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document) + + expect(identifier.textContent).toBe('alice.test') + expectConsentTooltip(container, ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches client-wants-access consent identity copy', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + 'Example App wants to access your ', + 'alice.test', + ' account', + 'strong', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession(), + }) + + expect(identifier.textContent).toBe('alice.test') + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches upstream main-card consent identity copy without same-paragraph client name', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + 'wants to access your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession(), + }) + + const iconIndex = container.childNodes.findIndex( + (child) => + child instanceof FakeElement && + child.classList.contains(IDENTITY_INFO_ICON_CLASS), + ) + const identifierIndex = container.childNodes.indexOf(identifier) + + expect(identifier.textContent).toBe('alice.test') + expect(iconIndex).toBe(identifierIndex + 1) + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches sidebar and main-card consent identities on the same page', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker-with-random') + const sidebar = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + const mainCard = createConsentIdentity( + document, + 'wants to access your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/preview/consent', search: '' }, + ) + + expect(sidebar.identifier.textContent).toBe('alice.test') + expect(mainCard.identifier.textContent).toBe('alice.test') + expectConsentTooltipTexts(document, [ + ALICE_ASSOCIATED_TOOLTIP, + ALICE_ASSOCIATED_TOOLTIP, + ]) + }) + + it('treats picker-with-random and default consent like picker consent', () => { + for (const mode of ['picker-with-random', null]) { + const document = new FakeDocument() + if (mode) appendHandleModeMeta(document, mode) + const { container, identifier } = createConsentIdentity( + document, + 'Example App wants to access your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession(), + }) + + expect(identifier.textContent).toBe('alice.test') + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + } + }) + + it('shows the email for random consent and exposes the public handle in the tooltip', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + '@alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession('@alice.test'), + }) + + expect(identifier.textContent).toBe('alice@example.test') + expect(container.textContent).toContain(ALICE_PUBLIC_HANDLE_TOOLTIP) + expect(container.textContent).not.toContain('@@alice.test') + }) + + it('enriches preview consent identity copy', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker-with-random') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/preview/consent', search: '' }, + ) + + expect(identifier.textContent).toBe('alice.test') + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches preview chooser consent state in picker-with-random mode', () => { + const document = new FakeDocument() + const { sidebar, mainCard } = createPreviewChooserConsentIdentities( + document, + 'picker-with-random', + ) + + runPreviewChooserConsentEnrichment(document, 'picker-with-random') + + expect(sidebar.identifier.textContent).toBe('alice.test') + expect(mainCard.identifier.textContent).toBe('alice.test') + expectConsentTooltipTexts(document, [ + ALICE_ASSOCIATED_TOOLTIP, + ALICE_ASSOCIATED_TOOLTIP, + ]) + }) + + it('uses email as the visible preview chooser consent identity in random mode', () => { + const document = new FakeDocument() + const { sidebar, mainCard } = createPreviewChooserConsentIdentities( + document, + 'random', + ) + + runPreviewChooserConsentEnrichment(document, 'random') + + expect(sidebar.identifier.textContent).toBe('alice@example.test') + expect(mainCard.identifier.textContent).toBe('alice@example.test') + expectConsentTooltipTexts(document, [ + ALICE_PUBLIC_HANDLE_TOOLTIP, + ALICE_PUBLIC_HANDLE_TOOLTIP, + ]) + }) + + it('uses email as the visible preview consent identity in random mode', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/preview/consent', search: '' }, + ) + + expect(identifier.textContent).toBe('alice@example.test') + expect(container.textContent).toContain(ALICE_PUBLIC_HANDLE_TOOLTIP) + }) + + it('leaves generic and legal consent paragraphs untouched', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const legal = new FakeElement('p') + appendText( + legal, + 'By clicking Authorize, you confirm that alice.test is your account.', + ) + document.root.appendChild(legal) + const unrelated = createConsentIdentity( + document, + 'Grant access to your ', + 'bob.test', + ' account', + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + expect(document.root.textContent).not.toContain(ASSOCIATED_TOOLTIP_PREFIX) + expect(unrelated.identifier.textContent).toBe('bob.test') + }) + + it('leaves arbitrary bold selected-account legal copy untouched', () => { + expectArbitraryConsentProseUntouched({ + prefix: 'By clicking Authorize, ', + identifierText: 'alice.test', + suffix: ' confirms access.', + expectedText: 'By clicking Authorize, alice.test confirms access.', + }) + }) + + it('leaves arbitrary strong selected-account technical prose untouched', () => { + expectArbitraryConsentProseUntouched({ + prefix: 'Technical details for ', + identifierText: 'alice.test', + suffix: ' may include OAuth scopes.', + tagName: 'strong', + expectedText: + 'Technical details for alice.test may include OAuth scopes.', + }) + }) + + it('opens the tooltip on hover/focus and toggles it on click/tap', () => { + const document = new FakeDocument() + const { container } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + const { icon, tooltip } = findConsentIdentityTooltip(container) + + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + icon.dispatchEvent('mouseenter') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + icon.dispatchEvent('mouseleave') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + icon.dispatchEvent('focus') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + icon.dispatchEvent('blur') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + const click = icon.dispatchEvent('click') + expect(click.defaultPrevented).toBe(true) + expect(click.propagationStopped).toBe(true) + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + icon.dispatchEvent('mouseleave') + expect(tooltip.hidden).toBe(false) + icon.dispatchEvent('blur') + expect(tooltip.hidden).toBe(false) + icon.dispatchEvent('click') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + }) + + it('keeps the tooltip pinned open when touch focus fires before click', () => { + const document = new FakeDocument() + const { container } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + const { icon, tooltip } = findConsentIdentityTooltip(container) + + icon.dispatchEvent('focus') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + + icon.dispatchEvent('click') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + + icon.dispatchEvent('blur') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + + icon.dispatchEvent('click') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + }) + + it('does not apply consent tooltip behavior on account pages', () => { + const document = new FakeDocument() + createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/account', search: '' }, + ) + + expect(document.root.textContent).not.toContain(ASSOCIATED_TOOLTIP_PREFIX) + }) +}) + describe('sha256Base64', () => { it('produces a stable SHA256 base64 hash', () => { // Known value for the empty string. @@ -388,13 +1626,15 @@ describe('buildChooserEnrichmentScript handle-mode hiding (HYPER-268 Layer 4)', expect(script).toContain('querySelector(\'meta[name="epds-handle-mode"]\')') }) - it("hides the handle span and sets a title tooltip when mode is 'random'", () => { + it("hides the handle span and adds an accessible description when mode is 'random'", () => { const script = buildChooserEnrichmentScript() - // Hiding strategy: display:none on the handle element + title - // attribute on the email label carrying the original handle text. - expect(script).toContain("hideHandle = handleMode === 'random'") + // Hiding strategy: display:none on the handle element plus an + // aria-describedby target carrying the original handle text. + expect(script).toContain( + "hideHandle = handleMode === 'random' && isChooserLikePage()", + ) expect(script).toContain("m.el.style.display = 'none'") - expect(script).toContain('label.title = ownText') + expect(script).toContain("appendAriaReference(row, 'aria-describedby'") }) it('leaves the handle visible for picker / picker-with-random', () => { @@ -456,6 +1696,92 @@ describe('createChooserEnrichmentMiddleware handle-mode meta (HYPER-268 Layer 4) expect(written).toContain('') }) + it('resolves client metadata handle mode from request_uri when client_id is absent', async () => { + const resolveClientIdFromRequestUri = vi + .fn() + .mockResolvedValue('https://demo.example/client') + const resolveClientMetadata = vi + .fn() + .mockResolvedValue({ epds_handle_mode: 'random' as const }) + + const written = await captureWrittenHtml( + { + resolveClientMetadata, + resolveClientIdFromRequestUri, + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(resolveClientIdFromRequestUri).toHaveBeenCalledWith( + 'urn:ietf:params:oauth:request_uri:req-123', + ) + expect(resolveClientMetadata).toHaveBeenCalledWith( + 'https://demo.example/client', + ) + expect(written).toContain('') + }) + + it('keeps explicit query handle mode ahead of request_uri metadata', async () => { + const resolveClientIdFromRequestUri = vi + .fn() + .mockResolvedValue('https://demo.example/client') + const resolveClientMetadata = vi + .fn() + .mockResolvedValue({ epds_handle_mode: 'random' as const }) + + const written = await captureWrittenHtml( + { + resolveClientMetadata, + resolveClientIdFromRequestUri, + }, + { + epds_handle_mode: 'picker', + request_uri: 'urn:ietf:params:oauth:request_uri:req-123', + }, + ) + + expect(resolveClientIdFromRequestUri).not.toHaveBeenCalled() + expect(resolveClientMetadata).not.toHaveBeenCalled() + expect(written).toContain('') + }) + + it('degrades silently when request_uri client-id resolution rejects', async () => { + const written = await captureWrittenHtml( + { + resolveClientMetadata: vi.fn(), + resolveClientIdFromRequestUri: () => + Promise.reject(new Error('request expired')), + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(written).toContain( + '', + ) + }) + + it('degrades silently when request_uri metadata lookup rejects', async () => { + const resolveClientMetadata = vi + .fn() + .mockRejectedValue(new Error('metadata unavailable')) + + const written = await captureWrittenHtml( + { + resolveClientMetadata, + resolveClientIdFromRequestUri: () => + Promise.resolve('https://demo.example/client'), + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(resolveClientMetadata).toHaveBeenCalledWith( + 'https://demo.example/client', + ) + expect(written).toContain( + '', + ) + }) + it('ignores invalid handle modes from metadata (fall through to fallback)', async () => { const written = await captureWrittenHtml( { @@ -472,9 +1798,29 @@ describe('createChooserEnrichmentMiddleware handle-mode meta (HYPER-268 Layer 4) ) }) - it('degrades silently when the metadata resolver rejects', async () => { + it('ignores invalid request_uri metadata handle modes through the shared resolver', async () => { const written = await captureWrittenHtml( { + resolveClientMetadata: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate bad value + Promise.resolve({ epds_handle_mode: 'garbage' as any }), + resolveClientIdFromRequestUri: () => + Promise.resolve('https://demo.example/client'), + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(written).toContain( + '', + ) + }) + + it('logs and falls back when the metadata resolver rejects', async () => { + const debug = vi.fn() + + const written = await captureWrittenHtml( + { + logger: { debug }, resolveClientMetadata: () => Promise.reject(new Error('network error')), }, { client_id: 'https://demo.example/client' }, @@ -483,6 +1829,14 @@ describe('createChooserEnrichmentMiddleware handle-mode meta (HYPER-268 Layer 4) expect(written).toContain( '', ) + expect(debug).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.any(Error), + queryMode: undefined, + requestUri: undefined, + }), + 'chooser-enrichment: failed to resolve handle mode from OAuth request context', + ) }) }) diff --git a/packages/pds-core/src/__tests__/client-css-injection.test.ts b/packages/pds-core/src/__tests__/client-css-injection.test.ts index d9148b4f..99068f85 100644 --- a/packages/pds-core/src/__tests__/client-css-injection.test.ts +++ b/packages/pds-core/src/__tests__/client-css-injection.test.ts @@ -9,7 +9,7 @@ import { } from '../lib/client-css-injection.js' function mockLogger() { - return { info: vi.fn(), warn: vi.fn(), debug: vi.fn() } + return { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } } describe('shouldInjectClientCss', () => { diff --git a/packages/pds-core/src/__tests__/epds-callback-authorize.test.ts b/packages/pds-core/src/__tests__/epds-callback-authorize.test.ts new file mode 100644 index 00000000..dad5103b --- /dev/null +++ b/packages/pds-core/src/__tests__/epds-callback-authorize.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { buildPostCallbackAuthorizeUrl } from '../lib/epds-callback-authorize.js' + +describe('buildPostCallbackAuthorizeUrl', () => { + it('preserves a valid display-only epds_handle_mode on the final authorize redirect', () => { + const url = buildPostCallbackAuthorizeUrl({ + pdsUrl: 'https://pds.example', + requestUri: 'urn:ietf:params:oauth:request_uri:req-123', + clientId: 'https://app.example/client.json', + handleMode: 'random', + }) + + expect(url.pathname).toBe('/oauth/authorize') + expect(url.searchParams.get('request_uri')).toBe( + 'urn:ietf:params:oauth:request_uri:req-123', + ) + expect(url.searchParams.get('client_id')).toBe( + 'https://app.example/client.json', + ) + expect(url.searchParams.get('epds_handle_mode')).toBe('random') + }) + + it('drops invalid epds_handle_mode values from the final authorize redirect', () => { + const url = buildPostCallbackAuthorizeUrl({ + pdsUrl: 'https://pds.example', + requestUri: 'urn:ietf:params:oauth:request_uri:req-123', + clientId: 'https://app.example/client.json', + handleMode: 'garbage', + }) + + expect(url.searchParams.has('epds_handle_mode')).toBe(false) + }) +}) diff --git a/packages/pds-core/src/__tests__/preview-chooser.test.ts b/packages/pds-core/src/__tests__/preview-chooser.test.ts index 07ad8b4f..bff33e70 100644 --- a/packages/pds-core/src/__tests__/preview-chooser.test.ts +++ b/packages/pds-core/src/__tests__/preview-chooser.test.ts @@ -122,6 +122,28 @@ describe('createPreviewChooserHandler', () => { expect(res.body).toContain(`function readHandleMode()`) }) + it('places the enrichment script before __sessions hydration and includes fixture identities', async () => { + const handler = createPreviewChooserHandler(makeDeps())! + const res = mockRes() + await handler({ query: { numAccounts: '2' } }, res) + const scriptIndex = res.body!.indexOf('function readHandleMode()') + const hydrationIndex = res.body!.indexOf('window["__sessions"]') + + expect(scriptIndex).toBeGreaterThan(-1) + expect(hydrationIndex).toBeGreaterThan(-1) + expect(scriptIndex).toBeLessThan(hydrationIndex) + expect(res.body).toContain( + String.raw`\"preferred_username\":\"alice.preview.example\"`, + ) + expect(res.body).toContain( + String.raw`\"email\":\"alice@preview.example\"`, + ) + expect(res.body).toContain( + String.raw`\"preferred_username\":\"bob.preview.example\"`, + ) + expect(res.body).toContain(String.raw`\"email\":\"bob@preview.example\"`) + }) + it('reads the override from ?epds_handle_mode (production param name)', async () => { const handler = createPreviewChooserHandler(makeDeps())! const res = mockRes() diff --git a/packages/pds-core/src/__tests__/preview-consent.test.ts b/packages/pds-core/src/__tests__/preview-consent.test.ts index 26a76c26..94b2785c 100644 --- a/packages/pds-core/src/__tests__/preview-consent.test.ts +++ b/packages/pds-core/src/__tests__/preview-consent.test.ts @@ -80,6 +80,62 @@ describe('createPreviewConsentHandler', () => { expect(res.body).toContain('/@atproto/oauth-provider/~assets/') }) + it('injects handle-mode meta and enrichment script before __sessions hydration', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: {} }, res) + const handleModeIndex = res.body!.indexOf( + '', + ) + const enrichmentIndex = res.body!.indexOf('function readHandleMode()') + const hydrationIndex = res.body!.indexOf('window["__sessions"]') + + expect(handleModeIndex).toBeGreaterThan(-1) + expect(enrichmentIndex).toBeGreaterThan(-1) + expect(hydrationIndex).toBeGreaterThan(-1) + expect(handleModeIndex).toBeLessThan(enrichmentIndex) + expect(enrichmentIndex).toBeLessThan(hydrationIndex) + }) + + it('hydrates the selected preview session with handle and email', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: {} }, res) + + expect(res.body).toContain(String.raw`\"selected\":true`) + expect(res.body).toContain( + String.raw`\"preferred_username\":\"alice.preview.example\"`, + ) + expect(res.body).toContain( + String.raw`\"email\":\"alice@preview.example\"`, + ) + }) + + it('supports ?epds_handle_mode=random for consent preview', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: { epds_handle_mode: 'random' } }, res) + + expect(res.body).toContain( + '', + ) + }) + it('resolves client metadata and injects CSS for custom client_id', async () => { const trusted = 'https://trusted.example/client-metadata.json' const resolveClientMetadata = vi.fn(() => diff --git a/packages/pds-core/src/chooser-enrichment.ts b/packages/pds-core/src/chooser-enrichment.ts index 6a38778f..8aa22fd2 100644 --- a/packages/pds-core/src/chooser-enrichment.ts +++ b/packages/pds-core/src/chooser-enrichment.ts @@ -25,6 +25,10 @@ import type { ResolveClientMetadataOptions, } from '@certified-app/shared' import { resolveHandleMode, VALID_HANDLE_MODES } from '@certified-app/shared' +import { + resolveOAuthClientIdFromQuery, + type ResolveClientIdFromRequestUri, +} from './lib/oauth-request-context.js' /** * Build the post-hydration enrichment script injected into `/account*` @@ -43,7 +47,7 @@ import { resolveHandleMode, VALID_HANDLE_MODES } from '@certified-app/shared' * this runs in a plain `` return ` @@ -109,11 +123,13 @@ async function renderConsentHtml(opts: { + ${handleModeMeta} Consent preview — ${escapeHtml(opts.fixture.clientId)} ${styleLinks} ${injectedStyle} + ${enrichmentScript}

@@ -125,6 +141,23 @@ async function renderConsentHtml(opts: { type PreviewConsentDeps = PreviewMetadataDeps +function resolveQueryHandleMode( + req: RequestLike, + metadata: ClientMetadata, +): HandleMode { + const queryMode = + typeof req.query.epds_handle_mode === 'string' + ? req.query.epds_handle_mode + : undefined + const rawMetaMode = metadata.epds_handle_mode + const metaMode = + typeof rawMetaMode === 'string' && + (VALID_HANDLE_MODES as readonly string[]).includes(rawMetaMode) + ? rawMetaMode + : undefined + return resolveHandleMode(queryMode, metaMode) +} + /** * Express handler factory: creates a GET /preview/consent handler if the * env var is on, returns null otherwise so the caller can skip wiring. @@ -142,10 +175,13 @@ export function createPreviewConsentHandler( 'Preview consent', ) - const fixture: PreviewAuthorizeFixture = { + const handleMode = resolveQueryHandleMode(req, metadata) + + const fixture: PreviewConsentFixture = { clientId, clientMetadata: metadata, isTrusted: deps.trustedClients.includes(clientId), + handleMode, } const html = await renderConsentHtml({ fixture, injectedCss }) diff --git a/packages/shared/src/__tests__/crypto.test.ts b/packages/shared/src/__tests__/crypto.test.ts index f56a8b65..cd2a57c1 100644 --- a/packages/shared/src/__tests__/crypto.test.ts +++ b/packages/shared/src/__tests__/crypto.test.ts @@ -131,6 +131,36 @@ describe('generateRandomHandle', () => { }) }) +function makeCallbackParams(overrides: Partial = {}) { + return { + request_uri: 'urn:ietf:params:oauth:request_uri:test', + email: 'alice@example.com', + approved: '1', + new_account: '1', + ...overrides, + } satisfies CallbackParams +} + +function expectSignedCallbackToVerify(callbackParams: CallbackParams) { + const secret = 'test-secret' + const { sig, ts } = signCallback(callbackParams, secret) + expect(verifyCallback(callbackParams, ts, sig, secret)).toBe(true) +} + +function expectTamperedHandleModeToFail(callbackParams: CallbackParams) { + const secret = 'test-secret' + const { sig, ts } = signCallback(callbackParams, secret) + expect(verifyCallback(callbackParams, ts, sig, secret)).toBe(true) + expect( + verifyCallback( + { ...callbackParams, epds_handle_mode: 'random' }, + ts, + sig, + secret, + ), + ).toBe(false) +} + describe('signCallback / verifyCallback', () => { const secret = 'test-secret-32bytes-padding-here' const params: CallbackParams = { @@ -182,6 +212,7 @@ describe('signCallback / verifyCallback', () => { params.new_account, '', // handle sentinel (absent) '', // client_id sentinel (absent) + '', // epds_handle_mode sentinel (absent) staleTs, ].join('\n') const { createHmac } = await import('node:crypto') @@ -189,6 +220,24 @@ describe('signCallback / verifyCallback', () => { expect(verifyCallback(params, staleTs, staleSig, secret)).toBe(false) }) + it.each([ + { name: 'with handle', handle: 'alice' }, + { name: 'without handle', handle: undefined }, + ])( + 'signs and verifies callback with epds_handle_mode $name', + ({ handle }) => { + expectSignedCallbackToVerify( + makeCallbackParams({ handle, epds_handle_mode: 'picker' }), + ) + }, + ) + + it('rejects tampered epds_handle_mode', () => { + expectTamperedHandleModeToFail( + makeCallbackParams({ epds_handle_mode: 'picker' }), + ) + }) + it('rejects future timestamp', async () => { const futureTs = (Math.floor(Date.now() / 1000) + 60).toString() const payload = [ @@ -198,6 +247,7 @@ describe('signCallback / verifyCallback', () => { params.new_account, '', // handle sentinel (absent) '', // client_id sentinel (absent) + '', // epds_handle_mode sentinel (absent) futureTs, ].join('\n') const { createHmac } = await import('node:crypto') diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index d1a40092..9cc90e13 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -74,13 +74,15 @@ export interface CallbackParams { new_account: string handle?: string // only set for new account creation with chosen handle client_id?: string // OAuth client this flow belongs to; carried only so a clean-exit redirect from the catch block on /oauth/epds-callback can recover the client's redirect_uri when the upstream PAR row is gone. Signed so an attacker cannot redirect a victim's flow at a different OAuth client. + epds_handle_mode?: string } /** * Sign the epds-callback redirect parameters with HMAC-SHA256. * Returns the hex signature and the Unix timestamp (seconds) used. * - * Payload: request_uri, email, approved, new_account, handle (empty when absent), client_id (empty when absent), and ts, joined by newlines. + * Payload: request_uri, email, approved, new_account, handle (empty when absent), client_id (empty when absent), + * epds_handle_mode (empty string when absent), and ts, joined by newlines. * A timestamp is included so signatures expire (see verifyCallback). * handle and client_id use empty string as sentinel when absent so existing flows still produce valid signatures and so the payload shape stays stable across releases. */ @@ -96,6 +98,7 @@ export function signCallback( params.new_account, params.handle ?? '', // empty string when absent params.client_id ?? '', // empty string when absent + params.epds_handle_mode ?? '', // empty string when absent ts, ].join('\n') const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex') @@ -129,6 +132,7 @@ export function verifyCallback( params.new_account, params.handle ?? '', // empty string when absent — matches signCallback sentinel params.client_id ?? '', // empty string when absent — matches signCallback sentinel + params.epds_handle_mode ?? '', // empty string when absent — matches signCallback sentinel ts, ].join('\n') const expected = crypto