diff --git a/.changeset/oauth-otp-email-redaction.md b/.changeset/oauth-otp-email-redaction.md new file mode 100644 index 00000000..876d6056 --- /dev/null +++ b/.changeset/oauth-otp-email-redaction.md @@ -0,0 +1,11 @@ +--- +'ePDS': patch +--- + +Signing in with a handle no longer reveals the account's email anywhere on the sign-in screen. + +**Affects:** End users, Client app developers + +**End users:** when you start a sign-in by entering your handle (for example via an app's "Sign in with Bluesky/ATProto" button), the verification-code screen no longer shows the email address tied to that handle — not in the visible text and not hidden in the page. Previously it displayed a partially-masked email (e.g. `da***@attpslabs.com`), and the full address was also present in the page's underlying HTML. The screen now reads "We've sent a 6-digit code to your account email." and the code is still delivered to your account's email as before. Typing your own email to sign in is unchanged. + +**Client app developers:** no change to the OAuth flow or to anything your app receives — your app never received the email (the authorization-code redirect and token response carry no email). This only changes what the ePDS-hosted sign-in page does when the `login_hint` is a handle or DID: the resolved email is never sent to the browser. The verification code is sent and verified server-side, keyed to the in-progress sign-in (the `epds_auth_flow` cookie) rather than to an email field in the page. diff --git a/packages/auth-service/src/__tests__/login-page-email-redaction.test.ts b/packages/auth-service/src/__tests__/login-page-email-redaction.test.ts new file mode 100644 index 00000000..8661fcff --- /dev/null +++ b/packages/auth-service/src/__tests__/login-page-email-redaction.test.ts @@ -0,0 +1,78 @@ +/** + * Privacy tests: the OAuth login page must not expose the account email on + * the HANDLE path — neither displayed nor anywhere in the page source. + * + * When a public handle/DID is resolved to an email, the page is rendered + * with emailFromHandle=true and an empty loginHint; the email lives only on + * the auth_flow row server-side, and the browser drives OTP via the + * flow-keyed endpoints. So the resolved email must appear nowhere in the + * HTML (no visible subtitle, no hidden input value, no JS var). + */ +import { describe, it, expect } from 'vitest' +import { renderLoginPage } from '../routes/login-page.js' +import type { ClientMetadata } from '../lib/client-metadata.js' + +const baseOpts = { + flowId: 'flow123', + clientId: 'https://leaflet.pub/client-metadata.json', + clientName: 'Leaflet', + branding: {} as ClientMetadata, + customCss: null, + csrfToken: 'csrf-token-abc', + authBasePath: '/api/auth', + pdsPublicUrl: 'https://self.surf', + otpLength: 6, + otpCharset: 'numeric' as const, + initialStep: 'otp' as const, + otpAlreadySent: false, +} + +const RESOLVED_EMAIL = 'dave@attpslabs.com' + +describe('renderLoginPage — handle path (emailFromHandle=true)', () => { + // On the handle path the route passes an empty loginHint (the email is + // never sent to the browser) and emailFromHandle=true. + const html = renderLoginPage({ + ...baseOpts, + loginHint: '', + emailFromHandle: true, + }) + + it('does not contain the resolved email anywhere in the page', () => { + expect(html).not.toContain('dave@attpslabs.com') + }) + + it('does not contain the email domain anywhere in the page', () => { + expect(html).not.toContain('attpslabs.com') + }) + + it('does not leak a weak-regex masked form', () => { + expect(html).not.toContain('da***@') + }) + + it('the hidden otp-email input is empty', () => { + expect(html).toMatch(/id="otp-email"[^>]*value=""/) + }) + + it('uses the flow-keyed endpoints and constant anti-enumeration copy', () => { + expect(html).toContain('/auth/otp/send-by-flow') + expect(html).toContain('/auth/otp/verify-by-flow') + expect(html).toContain('to your account email') + // Carries the CSRF token for the same-origin POSTs. + expect(html).toContain('csrf-token-abc') + }) +}) + +describe('renderLoginPage — email-typed path (emailFromHandle=false)', () => { + it('still pre-fills the email the user supplied (unchanged behavior)', () => { + const html = renderLoginPage({ + ...baseOpts, + loginHint: RESOLVED_EMAIL, + emailFromHandle: false, + otpAlreadySent: true, + }) + // On the email-typed path the email is the user's own — pre-filling the + // hidden input is expected. + expect(html).toMatch(/id="otp-email"[^>]*value="dave@attpslabs.com"/) + }) +}) diff --git a/packages/auth-service/src/__tests__/otp-by-flow.test.ts b/packages/auth-service/src/__tests__/otp-by-flow.test.ts new file mode 100644 index 00000000..3a6a21e8 --- /dev/null +++ b/packages/auth-service/src/__tests__/otp-by-flow.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for the flow-keyed OTP endpoints used by the OAuth login page's + * handle path (/auth/otp/send-by-flow, /auth/otp/verify-by-flow). The + * browser sends no email — the server resolves it from the auth_flow row + * keyed by the epds_auth_flow cookie. + */ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest' +import express, { type Express } from 'express' +import cookieParser from 'cookie-parser' +import type { AddressInfo } from 'node:net' +import type { Server } from 'node:http' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { EpdsDb } from '@certified-app/shared' +import { createOtpByFlowRouter } from '../routes/otp-by-flow.js' +import type { AuthServiceContext } from '../context.js' + +let db: EpdsDb +let dbPath: string +let server: Server +let baseUrl: string +let app: Express +let sentOtps: Array<{ email: string }> + +beforeAll(async () => { + dbPath = path.join( + os.tmpdir(), + `epds-otpflow-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`, + ) + db = new EpdsDb(dbPath) + sentOtps = [] + + const auth = { + api: { + sendVerificationOTP({ body }: { body: { email: string } }) { + sentOtps.push({ email: body.email }) + return Promise.resolve() + }, + signInEmailOTP({ body }: { body: { email: string; otp: string } }) { + if (body.otp !== 'GOOD-OTP') { + return Promise.reject(new Error('invalid otp')) + } + // Mimic better-auth asResponse:true returning a Response with a cookie. + return Promise.resolve( + new Response(null, { + headers: { 'set-cookie': 'better-auth.session=abc; Path=/' }, + }), + ) + }, + }, + } + + const ctx = { db } as unknown as AuthServiceContext + + app = express() + app.use(express.json()) + app.use(cookieParser()) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app.use(createOtpByFlowRouter(ctx, auth as any)) + await new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { + resolve() + }) + }) + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err) + else resolve() + }) + }) + db.close() + for (const suffix of ['', '-wal', '-shm']) { + try { + fs.unlinkSync(dbPath + suffix) + // eslint-disable-next-line no-empty + } catch {} + } +}) + +beforeEach(() => { + sentOtps = [] +}) + +function makeFlow(email: string | null): string { + const flowId = `flow-${Math.random().toString(36).slice(2)}` + db.createAuthFlow({ + flowId, + requestUri: `urn:req:${flowId}`, + clientId: null, + email, + expiresAt: Date.now() + 600_000, + }) + return flowId +} + +async function post( + routePath: string, + body: unknown, + flowId?: string, +): Promise<{ status: number; json: Record; setCookie: string | null }> { + const headers: Record = { 'Content-Type': 'application/json' } + if (flowId) headers['Cookie'] = `epds_auth_flow=${flowId}` + const res = await fetch(`${baseUrl}${routePath}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + const json = (await res.json().catch(() => ({}))) as Record + return { status: res.status, json, setCookie: res.headers.get('set-cookie') } +} + +describe('POST /auth/otp/send-by-flow', () => { + it('sends the OTP to the flow-stored email', async () => { + const flowId = makeFlow('dave@attpslabs.com') + const res = await post('/auth/otp/send-by-flow', {}, flowId) + expect(res.status).toBe(200) + expect(res.json.ok).toBe(true) + expect(sentOtps).toEqual([{ email: 'dave@attpslabs.com' }]) + }) + + it('returns ok without sending when there is no flow cookie (anti-enumeration)', async () => { + const res = await post('/auth/otp/send-by-flow', {}) + expect(res.status).toBe(200) + expect(res.json.ok).toBe(true) + expect(sentOtps).toHaveLength(0) + }) + + it('returns ok without sending when the flow has no email', async () => { + const flowId = makeFlow(null) + const res = await post('/auth/otp/send-by-flow', {}, flowId) + expect(res.status).toBe(200) + expect(res.json.ok).toBe(true) + expect(sentOtps).toHaveLength(0) + }) +}) + +describe('POST /auth/otp/verify-by-flow', () => { + it('rejects a missing otp with 400', async () => { + const flowId = makeFlow('dave@attpslabs.com') + const res = await post('/auth/otp/verify-by-flow', {}, flowId) + expect(res.status).toBe(400) + }) + + it('returns SessionExpired (400) when there is no flow/email', async () => { + const res = await post('/auth/otp/verify-by-flow', { otp: 'GOOD-OTP' }) + expect(res.status).toBe(400) + expect(res.json.error).toBe('SessionExpired') + }) + + it('returns InvalidCode (400) on a wrong code', async () => { + const flowId = makeFlow('dave@attpslabs.com') + const res = await post('/auth/otp/verify-by-flow', { otp: 'WRONG' }, flowId) + expect(res.status).toBe(400) + expect(res.json.error).toBe('InvalidCode') + }) + + it('verifies and forwards the session Set-Cookie on success', async () => { + const flowId = makeFlow('dave@attpslabs.com') + const res = await post('/auth/otp/verify-by-flow', { otp: 'GOOD-OTP' }, flowId) + expect(res.status).toBe(200) + expect(res.json.ok).toBe(true) + expect(res.setCookie).toContain('better-auth.session=abc') + }) +}) diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 4952a6d2..76423445 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -15,6 +15,7 @@ import { createAccountLoginRouter } from './routes/account-login.js' import { createAccountSettingsRouter } from './routes/account-settings.js' import { createCompleteRouter } from './routes/complete.js' import { createChooseHandleRouter } from './routes/choose-handle.js' +import { createOtpByFlowRouter } from './routes/otp-by-flow.js' import { createHeadlessOtpRouter } from './routes/headless-otp.js' import { createHeartbeatRouter } from './routes/heartbeat.js' import { createPreviewEmailsRouter } from './routes/preview-emails.js' @@ -92,6 +93,7 @@ export function createAuthService(config: AuthServiceConfig): { // Routes app.use(createRootRouter()) app.use(createLoginPageRouter(ctx)) + app.use(createOtpByFlowRouter(ctx, betterAuthInstance)) app.use(createRecoveryRouter(ctx, betterAuthInstance)) app.use(createAccountLoginRouter(betterAuthInstance, ctx)) app.use(createAccountSettingsRouter(ctx, betterAuthInstance)) diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 73ec031f..b37537b6 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -213,6 +213,25 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { const hasLoginHint = !!resolvedEmail const initialStep = hasLoginHint ? 'otp' : 'email' + // Privacy: when the OTP step was reached by resolving a public handle/DID + // (an email hint contains '@'; a handle/DID does not), the user never + // disclosed their own email. We must not put the email anywhere the + // browser can see it — not displayed, not in the page source. Instead we + // persist the resolved email on the auth_flow row and drive OTP + // send/verify server-side via the flow-keyed endpoints + // (/auth/otp/{send,verify}-by-flow), keyed off the epds_auth_flow cookie. + const emailFromHandle = + hasLoginHint && + !!effectiveLoginHint && + !effectiveLoginHint.includes('@') + if (emailFromHandle && resolvedEmail) { + try { + ctx.db.updateAuthFlowEmail(flowId, resolvedEmail) + } catch (err) { + logger.error({ err, flowId }, 'Failed to persist resolved email on flow') + } + } + // Pillar 3 — Idempotency (Option A): when this is a duplicate GET for an // existing flow (e.g. browser extension, StayFocusd), tell the client-side // script that OTP was already sent so it skips the auto-send. @@ -230,9 +249,10 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { 'Serving login page for auth_flow', ) - // Use the resolved email (not the raw loginHint) for pre-filling forms. - // This ensures handle-based hints get resolved to the correct email. - const emailHint = resolvedEmail ?? '' + // On the handle path the email must never reach the browser, so we pass + // an empty loginHint and let the page drive OTP by flowId. On the + // email-typed path the resolved email pre-fills the form as before. + const emailHint = emailFromHandle ? '' : (resolvedEmail ?? '') res.type('html').send( renderLoginPage({ @@ -242,6 +262,7 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { branding: clientMeta, customCss, loginHint: emailHint, + emailFromHandle, initialStep, otpAlreadySent, csrfToken: res.locals.csrfToken, @@ -256,13 +277,14 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { return router } -function renderLoginPage(opts: { +export function renderLoginPage(opts: { flowId: string clientId: string clientName: string branding: ClientMetadata customCss: string | null loginHint: string + emailFromHandle: boolean initialStep: 'email' | 'otp' otpAlreadySent: boolean csrfToken: string @@ -382,7 +404,9 @@ function renderLoginPage(opts: {

${ opts.initialStep === 'otp' && opts.otpAlreadySent - ? `Code already sent to ${escapeHtml(opts.loginHint.replace(/(.{2})[^@]*(@.*)/, '$1***$2'))}` + ? opts.emailFromHandle + ? `We've sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to your account email.` + : `Code already sent to ${escapeHtml(opts.loginHint.replace(/(.{2})[^@]*(@.*)/, '$1***$2'))}` : '' }

@@ -432,6 +456,51 @@ function renderLoginPage(opts: { var otpLength = ${opts.otpLength}; var otpCharset = ${JSON.stringify(opts.otpCharset)}; + // emailFromHandle: the OTP step was reached by resolving a public + // handle/DID, so the email is NOT in this page — OTP send/verify are + // driven server-side by the flow cookie via /auth/otp/*-by-flow. + var emailFromHandle = ${JSON.stringify(opts.emailFromHandle)}; + var csrfToken = ${JSON.stringify(opts.csrfToken)}; + var handleCodeSentText = "We've sent a " + otpLength + (otpCharset === 'alphanumeric' ? '-character' : '-digit') + ' code to your account email.'; + + // Flow-keyed OTP send (handle path): no email in the request — the + // server resolves it from the epds_auth_flow cookie. CSRF token is + // required because these are same-origin routes behind CSRF. + async function sendOtpByFlow() { + try { + var res = await fetch('/auth/otp/send-by-flow', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken }, + body: JSON.stringify({}), + }); + if (!res.ok) { + var data = await res.json().catch(function() { return {}; }); + return { error: data.error || 'Failed to send code' }; + } + return { ok: true }; + } catch (err) { + return { error: 'Network error. Please try again.' }; + } + } + + async function verifyOtpByFlow(otp) { + try { + var res = await fetch('/auth/otp/verify-by-flow', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken }, + body: JSON.stringify({ otp: otp }), + }); + if (!res.ok) { + var data = await res.json().catch(function() { return {}; }); + return { error: data.error === 'InvalidCode' ? 'Invalid code' : (data.error || 'Invalid code') }; + } + window.location.href = '/auth/complete'; + return { ok: true }; + } catch (err) { + return { error: 'Network error. Please try again.' }; + } + } + function showOtpStep(email) { currentEmail = email; otpEmailInput.value = email; @@ -445,6 +514,10 @@ function renderLoginPage(opts: { } function showEmailStep() { + // Leaving the handle-resolved OTP step: from here the user types + // their own email, so switch off the flow-keyed (handle) path — + // subsequent send/verify use the email the user enters. + emailFromHandle = false; stepOtp.classList.remove('active'); stepEmail.classList.remove('hidden'); recoveryLink.style.display = 'none'; @@ -519,7 +592,9 @@ function renderLoginPage(opts: { btn.disabled = true; btn.textContent = 'Verifying...'; - var result = await verifyOtp(currentEmail, otp); + var result = emailFromHandle + ? await verifyOtpByFlow(otp) + : await verifyOtp(currentEmail, otp); btn.disabled = false; btn.textContent = 'Verify'; @@ -533,7 +608,9 @@ function renderLoginPage(opts: { clearError(); this.disabled = true; this.textContent = 'Sending...'; - var result = await sendOtp(currentEmail); + var result = emailFromHandle + ? await sendOtpByFlow() + : await sendOtp(currentEmail); this.disabled = false; this.textContent = 'Resend code'; if (result.error) { @@ -559,7 +636,20 @@ function renderLoginPage(opts: { var initialStep = ${JSON.stringify(opts.initialStep)}; var otpAlreadySent = ${JSON.stringify(opts.otpAlreadySent)}; - if (initialStep === 'otp' && loginHint) { + if (initialStep === 'otp' && emailFromHandle) { + // Handle path: the email is not in this page. Auto-send via the + // flow-keyed endpoint and show the constant, anti-enumeration text. + otpSubtitle.textContent = handleCodeSentText; + if (!otpAlreadySent) { + sendOtpByFlow().then(function(result) { + if (result.error) { + showError(result.error); + } else { + otpSubtitle.textContent = handleCodeSentText; + } + }); + } + } else if (initialStep === 'otp' && loginHint) { currentEmail = loginHint; var masked = loginHint.replace(/(.{2})[^@]*(@.*)/, '$1***$2'); if (!otpAlreadySent) { diff --git a/packages/auth-service/src/routes/otp-by-flow.ts b/packages/auth-service/src/routes/otp-by-flow.ts new file mode 100644 index 00000000..3b4555d5 --- /dev/null +++ b/packages/auth-service/src/routes/otp-by-flow.ts @@ -0,0 +1,114 @@ +/** + * Flow-keyed OTP endpoints for the OAuth login page's HANDLE path. + * + * When a user starts an OAuth sign-in with a public handle/DID (rather than + * typing their email), the resolved account email must never reach the + * browser — otherwise anyone who knows the public handle could read it from + * the page source. These endpoints let the browser drive OTP send/verify + * using only the `epds_auth_flow` cookie (which carries the flowId); the + * server looks the email up from the auth_flow row and calls better-auth. + * + * POST /auth/otp/send-by-flow — send the OTP to the flow's stored email + * POST /auth/otp/verify-by-flow — verify the code, set the session cookie + * + * These are browser (same-origin) routes, mounted AFTER CSRF middleware. + */ +import { Router, type Request, type Response } from 'express' +import { createLogger } from '@certified-app/shared' +import type { AuthServiceContext } from '../context.js' + +const logger = createLogger('auth:otp-by-flow') + +const AUTH_FLOW_COOKIE = 'epds_auth_flow' + +export function createOtpByFlowRouter( + ctx: AuthServiceContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- better-auth instance has no exported type; asResponse:true needs the loose type (see recovery.ts) + auth: any, +): Router { + const router = Router() + + // Resolve the flow's stored email from the epds_auth_flow cookie. Returns + // null when the cookie is missing, the flow is expired/unknown, or no email + // was stored on it. + function emailForRequest(req: Request): string | null { + const flowId = req.cookies[AUTH_FLOW_COOKIE] as string | undefined + if (!flowId) return null + const flow = ctx.db.getAuthFlow(flowId) + return flow?.email ?? null + } + + // ─── POST /auth/otp/send-by-flow ──────────────────────────────────── + router.post( + '/auth/otp/send-by-flow', + async (_req: Request, res: Response) => { + const email = emailForRequest(_req) + + // Anti-enumeration: always report success. If the flow has no email + // (expired cookie, or somehow reached here without a resolved handle), + // there is simply nothing to send. + if (!email) { + logger.info('send-by-flow: no flow email; returning ok (no send)') + res.json({ ok: true }) + return + } + + try { + await auth.api.sendVerificationOTP({ + body: { email, type: 'sign-in' }, + }) + } catch (err) { + logger.error({ err }, 'send-by-flow: failed to send OTP') + // Anti-enumeration: do not surface whether the address exists. + } + res.json({ ok: true }) + }, + ) + + // ─── POST /auth/otp/verify-by-flow ────────────────────────────────── + router.post( + '/auth/otp/verify-by-flow', + async (req: Request, res: Response) => { + const otp = ((req.body?.otp as string) || '').trim() + if (!otp) { + res.status(400).json({ error: 'otp is required' }) + return + } + + const email = emailForRequest(req) + if (!email) { + // Flow/cookie expired or no email — fail gracefully, no 500. + res.status(400).json({ error: 'SessionExpired' }) + return + } + + try { + const response = await auth.api.signInEmailOTP({ + body: { email, otp: otp.toUpperCase() }, + asResponse: true, + }) + + // Forward better-auth's session Set-Cookie to the browser so the + // subsequent /auth/complete navigation is authenticated. (Mirrors + // the pattern in recovery.ts verify.) + if ( + response instanceof Response || + (response && typeof response.headers?.get === 'function') + ) { + const setCookie = response.headers.get('set-cookie') + if (setCookie) { + res.setHeader('Set-Cookie', setCookie) + } + } + } catch (err) { + logger.warn({ err }, 'verify-by-flow: OTP verification failed') + res.status(400).json({ error: 'InvalidCode' }) + return + } + + res.json({ ok: true }) + }, + ) + + return router +} diff --git a/packages/shared/src/__tests__/db-extended.test.ts b/packages/shared/src/__tests__/db-extended.test.ts index 7de43e27..f6ec550b 100644 --- a/packages/shared/src/__tests__/db-extended.test.ts +++ b/packages/shared/src/__tests__/db-extended.test.ts @@ -169,6 +169,53 @@ describe('getAuthFlowByRequestUri', () => { }) }) +describe('auth_flow email (handle-path server-side email storage)', () => { + it('createAuthFlow persists an email when provided', () => { + db.createAuthFlow({ + flowId: 'flow-with-email', + requestUri: 'urn:req:with-email', + clientId: null, + email: 'dave@attpslabs.com', + expiresAt: Date.now() + 600_000, + }) + expect(db.getAuthFlow('flow-with-email')!.email).toBe('dave@attpslabs.com') + }) + + it('email defaults to null when not provided', () => { + db.createAuthFlow({ + flowId: 'flow-no-email', + requestUri: 'urn:req:no-email', + clientId: null, + expiresAt: Date.now() + 600_000, + }) + expect(db.getAuthFlow('flow-no-email')!.email).toBeNull() + }) + + it('updateAuthFlowEmail sets the email on an existing non-expired flow', () => { + db.createAuthFlow({ + flowId: 'flow-update-email', + requestUri: 'urn:req:update-email', + clientId: null, + expiresAt: Date.now() + 600_000, + }) + db.updateAuthFlowEmail('flow-update-email', 'resolved@attpslabs.com') + expect(db.getAuthFlow('flow-update-email')!.email).toBe( + 'resolved@attpslabs.com', + ) + }) + + it('updateAuthFlowEmail does not resurrect an expired flow', () => { + db.createAuthFlow({ + flowId: 'flow-expired-email', + requestUri: 'urn:req:expired-email', + clientId: null, + expiresAt: Date.now() - 1000, + }) + db.updateAuthFlowEmail('flow-expired-email', 'x@y.com') + expect(db.getAuthFlow('flow-expired-email')).toBeUndefined() + }) +}) + describe('deleteAccountData', () => { it('deletes backup emails for a DID', () => { db.addBackupEmail('did:plc:delete-me', 'a@test.com', 'h1') diff --git a/packages/shared/src/db.ts b/packages/shared/src/db.ts index 05d9fec7..2cce81af 100644 --- a/packages/shared/src/db.ts +++ b/packages/shared/src/db.ts @@ -454,24 +454,40 @@ export class EpdsDb { flowId: string requestUri: string clientId: string | null + email?: string | null handleMode?: HandleMode | null expiresAt: number }): void { this.db .prepare( - `INSERT INTO auth_flow (flow_id, request_uri, client_id, handle_mode, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO auth_flow (flow_id, request_uri, client_id, email, handle_mode, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run( data.flowId, data.requestUri, data.clientId, + data.email ?? null, data.handleMode ?? null, Date.now(), data.expiresAt, ) } + /** + * Set the resolved email on an existing (non-expired) auth_flow. Used when + * a handle/DID login_hint is resolved server-side so the email can be kept + * off the browser — the client drives OTP send/verify by flowId, and the + * server looks the email up here. + */ + updateAuthFlowEmail(flowId: string, email: string): void { + this.db + .prepare( + `UPDATE auth_flow SET email = ? WHERE flow_id = ? AND expires_at > ?`, + ) + .run(email, flowId, Date.now()) + } + getAuthFlow(flowId: string): AuthFlowRow | undefined { return this.db .prepare(