Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/handle-mode-through-approval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

The approval and account-chooser screens now honour the handle mode your app asked for, instead of falling back to the server default.

**Affects:** Client app developers

**Client app developers:** no action needed — `epds_handle_mode` is resolved exactly as before (the `/oauth/authorize` query parameter, then your OAuth client metadata, then the server default). Previously the resolved mode was lost on the way to the approval step, so a flow that asked for a chosen handle could still show a generated one there. The mode is now carried through `/oauth/epds-callback` and applied on the approval and chooser screens. Values that are not one of the recognised modes are ignored rather than forwarded.
239 changes: 239 additions & 0 deletions packages/auth-service/src/__tests__/callback-handle-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
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<typeof makeAuth>,
): Promise<{ baseUrl: string; close: () => Promise<void> }> {
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<void>((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<void>((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<typeof fetch>[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') ?? '' }
: {}),
// /auth/complete signs client_id too (so a dead-PAR clean exit can still
// reach the right client); omitting it here would fail verification.
...(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<typeof fetch>[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()
}
})
})
1 change: 1 addition & 0 deletions packages/auth-service/src/routes/choose-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ export function createChooseHandleRouter(
approved: '1',
new_account: '1',
handle: normalizedLocal,
epds_handle_mode: flow.handleMode ?? '',
}
if (flow.clientId) callbackParams.client_id = flow.clientId
const { sig, ts } = signCallback(
Expand Down
74 changes: 50 additions & 24 deletions packages/auth-service/src/routes/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ const AUTH_FLOW_COOKIE = 'epds_auth_flow'
* the same `params.handle ?? ''` shape; the sentinel is pinned by
* tests in packages/shared/src/__tests__/crypto.test.ts.
*
* `epds_handle_mode` is signed too rather than appended afterwards, so
* the browser cannot flip the chooser/consent presentation mode on the
* hop to pds-core without invalidating the signature.
*
* Exported so it can be unit-tested without standing up the full
* /auth/complete route.
*/
Expand All @@ -65,6 +69,7 @@ export function buildEpdsCallbackUrl(args: {
flowClientId: string | null
email: string
isNewAccount: boolean
flowHandleMode?: string | null
pdsPublicUrl: string
epdsCallbackSecret: string
}): string {
Expand All @@ -75,11 +80,41 @@ export function buildEpdsCallbackUrl(args: {
new_account: args.isNewAccount ? '1' : '0',
}
if (args.flowClientId) callbackParams.client_id = args.flowClientId
if (args.flowHandleMode) callbackParams.epds_handle_mode = args.flowHandleMode
const { sig, ts } = signCallback(callbackParams, args.epdsCallbackSecret)
const params = new URLSearchParams({ ...callbackParams, ts, sig })
return `${args.pdsPublicUrl}/oauth/epds-callback?${params.toString()}`
}

async function resolveCompleteIdentity(
email: string,
flowId: string,
ctx: AuthServiceContext,
pdsUrl: string,
internalSecret: string,
): Promise<{ email: string; did: string | null }> {
const did = await getDidByEmail(email, pdsUrl, internalSecret)
if (did) return { email, did }

// Recovery path: session email is a backup email, not a primary. Resolve
// the backup-email -> DID mapping (auth-service-owned) and then DID ->
// primary email via pds-core's internal API, so the downstream callback
// signs the user's real account email, not the recovery address.
const recovered = await resolveRecoveryEmail(
email,
ctx,
pdsUrl,
internalSecret,
)
if (!recovered) return { email, did: null }

logger.info(
{ flowId, did: recovered.did },
'Recovery: translated backup email to primary email via DID',
)
return { email: recovered.email, did: recovered.did }
}

export function createCompleteRouter(
ctx: AuthServiceContext,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- better-auth instance has no exported type
Expand Down Expand Up @@ -127,7 +162,11 @@ export function createCompleteRouter(
*/
async function redirectNewUserRandomMode(
res: Response,
flow: { requestUri: string; clientId: string | null },
flow: {
requestUri: string
clientId: string | null
handleMode: string | null
},
email: string,
flowId: string,
): Promise<void> {
Expand All @@ -143,6 +182,7 @@ export function createCompleteRouter(
flowClientId: flow.clientId,
email,
isNewAccount: true,
flowHandleMode: flow.handleMode,
pdsPublicUrl: ctx.config.pdsPublicUrl,
epdsCallbackSecret: ctx.config.epdsCallbackSecret,
})
Expand Down Expand Up @@ -204,31 +244,16 @@ export function createCompleteRouter(
return
}

let email = session.user.email.toLowerCase()
const sessionEmail = session.user.email.toLowerCase()

// Step 4: Check whether this is a new user (no PDS account for email).
let did = await getDidByEmail(email, pdsUrl, internalSecret)

// Recovery path: session email is a backup email, not a primary. Resolve
// the backup-email → DID mapping (auth-service-owned) and then DID →
// primary email via pds-core's internal API, so the downstream callback
// signs the user's real account email, not the recovery address.
if (!did) {
const recovered = await resolveRecoveryEmail(
email,
ctx,
pdsUrl,
internalSecret,
)
if (recovered) {
logger.info(
{ flowId, did: recovered.did },
'Recovery: translated backup email to primary email via DID',
)
email = recovered.email
did = recovered.did
}
}
const { email, did } = await resolveCompleteIdentity(
sessionEmail,
flowId,
ctx,
pdsUrl,
internalSecret,
)

const isNewAccount = !did

Expand Down Expand Up @@ -264,6 +289,7 @@ export function createCompleteRouter(
flowClientId: flow.clientId,
email,
isNewAccount: false,
flowHandleMode: flow.handleMode,
pdsPublicUrl: ctx.config.pdsPublicUrl,
epdsCallbackSecret: ctx.config.epdsCallbackSecret,
})
Expand Down
Loading
Loading