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
11 changes: 11 additions & 0 deletions .changeset/oauth-otp-email-redaction.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"/)
})
})
176 changes: 176 additions & 0 deletions packages/auth-service/src/__tests__/otp-by-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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<string, unknown>; setCookie: string | null }> {
const headers: Record<string, string> = { '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<string, unknown>
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')
})
})
2 changes: 2 additions & 0 deletions packages/auth-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading