Skip to content
Merged
610 changes: 333 additions & 277 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ EPDS_INVITE_CODE=

AUTH_HOSTNAME=auth.pds.example
AUTH_PORT=3001
# Generate with: openssl rand -hex 32
AUTH_SESSION_SECRET=
AUTH_CSRF_SECRET=

Expand All @@ -116,6 +117,13 @@ SESSION_UPDATE_AGE=86400
# Default: numeric. Alphanumeric codes have higher entropy but require text input instead of numeric keyboard.
# OTP_CHARSET=numeric

# Default handle assignment mode for new user signups.
# Controls what happens when neither the OAuth request param nor the client
# metadata specifies epds_handle_mode.
# Values: random | picker | picker-with-random
# Defaults to 'picker' (users must choose a handle) if not set.
EPDS_DEFAULT_HANDLE_MODE=picker

# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
Expand Down
7 changes: 7 additions & 0 deletions packages/auth-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,10 @@ SMTP_FROM_NAME=ePDS

# Database path (separate from PDS account.sqlite)
DB_LOCATION=/data/epds.sqlite

# Default handle assignment mode for new user signups.
# Controls what happens when neither the OAuth request param nor the client
# metadata specifies epds_handle_mode.
# Values: random | picker | picker-with-random
# Defaults to 'picker' (users must choose a handle) if not set.
# EPDS_DEFAULT_HANDLE_MODE=picker
207 changes: 199 additions & 8 deletions packages/auth-service/src/__tests__/login-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,68 @@
* 2. Sets the epds_auth_flow cookie
* 3. Renders a login page with email OTP form + optional social buttons
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { randomBytes } from 'node:crypto'
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 type { HandleMode } from '@certified-app/shared'
import {
resolveHandleMode,
safeResolveClientMetadata,
} from '../routes/login-page.js'
import type { ClientMetadata } from '../lib/client-metadata.js'

// ---------------------------------------------------------------------------
// Shared DB helpers
// ---------------------------------------------------------------------------

function makeDb(prefix: string): { db: EpdsDb; dbPath: string } {
const dbPath = path.join(os.tmpdir(), `${prefix}-${Date.now()}.db`)
return { db: new EpdsDb(dbPath), dbPath }
}

function closeDb(db: EpdsDb, dbPath: string): void {
db.close()
try {
fs.unlinkSync(dbPath)
// eslint-disable-next-line no-empty
} catch {}
}

// ---------------------------------------------------------------------------
// Shared env-var helpers for resolveHandleMode tests
// ---------------------------------------------------------------------------

function withEnv(value: string | undefined, fn: () => void): void {
const orig = process.env.EPDS_DEFAULT_HANDLE_MODE
if (value === undefined) {
delete process.env.EPDS_DEFAULT_HANDLE_MODE
} else {
process.env.EPDS_DEFAULT_HANDLE_MODE = value
}
try {
fn()
} finally {
if (orig === undefined) {
delete process.env.EPDS_DEFAULT_HANDLE_MODE
} else {
process.env.EPDS_DEFAULT_HANDLE_MODE = orig
}
}
}

describe('Login page auth_flow creation', () => {
let db: EpdsDb
let dbPath: string

beforeEach(() => {
dbPath = path.join(os.tmpdir(), `test-login-${Date.now()}.db`)
db = new EpdsDb(dbPath)
;({ db, dbPath } = makeDb('test-login'))
})

afterEach(() => {
db.close()
try {
fs.unlinkSync(dbPath)
// eslint-disable-next-line no-empty
} catch {}
closeDb(db, dbPath)
})

it('creates an auth_flow row with correct request_uri and client_id', () => {
Expand Down Expand Up @@ -144,6 +184,51 @@ describe('Login page auth_flow creation', () => {
})
})

describe('Login page handle_mode storage', () => {
let db: EpdsDb
let dbPath: string

beforeEach(() => {
;({ db, dbPath } = makeDb('test-handle-mode'))
})

afterEach(() => {
closeDb(db, dbPath)
})

const handleModes: Array<HandleMode | null> = [
'random',
'picker',
'picker-with-random',
null,
]

it.each(handleModes)('stores handleMode=%s', (handleMode) => {
const flowId = `hm-${String(handleMode)}`
db.createAuthFlow({
flowId,
requestUri: `urn:req:${flowId}`,
clientId: null,
handleMode,
expiresAt: Date.now() + 10 * 60 * 1000,
})
expect(db.getAuthFlow(flowId)!.handleMode).toBe(handleMode)
})

it('getAuthFlowByRequestUri also returns handleMode', () => {
db.createAuthFlow({
flowId: 'hm-by-uri',
requestUri: 'urn:req:hm-by-uri',
clientId: null,
handleMode: 'picker',
expiresAt: Date.now() + 10 * 60 * 1000,
})
expect(db.getAuthFlowByRequestUri('urn:req:hm-by-uri')!.handleMode).toBe(
'picker',
)
})
})

describe('Social providers detection', () => {
it('empty socialProviders when no env vars set', () => {
// Preserve original env
Expand Down Expand Up @@ -208,3 +293,109 @@ describe('Login page redirect requirements', () => {
expect(expiresAt - nowish).toBe(600_000)
})
})

describe('resolveHandleMode', () => {
it('returns query param when it is a valid mode', () => {
const result = resolveHandleMode('random', {})
expect(result).toBe('random')
})

it('falls back to client metadata when query param is absent', () => {
const clientMeta: ClientMetadata = { epds_handle_mode: 'picker' }
const result = resolveHandleMode(undefined, clientMeta)
expect(result).toBe('picker')
})

it('falls back to env var when query param and client metadata are absent', () => {
withEnv('picker-with-random', () => {
expect(resolveHandleMode(undefined, {})).toBe('picker-with-random')
})
})

it('returns null when no valid mode is provided at any level', () => {
withEnv(undefined, () => {
expect(resolveHandleMode(undefined, {})).toBeNull()
})
})

it('ignores invalid values and falls back to next level', () => {
// Cast via unknown to simulate malformed client metadata from a real fetch
const clientMeta = {
epds_handle_mode: 'invalid-mode',
} as unknown as ClientMetadata
withEnv('random', () => {
// Query param is invalid, client metadata is invalid, env var is valid
expect(resolveHandleMode('garbage', clientMeta)).toBe('random')
})
})

it('returns null when all levels have invalid values', () => {
// Cast via unknown to simulate malformed client metadata
const clientMeta = {
epds_handle_mode: 'invalid-mode',
} as unknown as ClientMetadata
withEnv(undefined, () => {
expect(resolveHandleMode('garbage', clientMeta)).toBeNull()
})
})

it('prioritizes query param over client metadata', () => {
const clientMeta: ClientMetadata = { epds_handle_mode: 'picker' }
const result = resolveHandleMode('random', clientMeta)
expect(result).toBe('random')
})

it('prioritizes client metadata over env var', () => {
const clientMeta: ClientMetadata = { epds_handle_mode: 'picker' }
withEnv('random', () => {
expect(resolveHandleMode(undefined, clientMeta)).toBe('picker')
})
})
})

describe('safeResolveClientMetadata', () => {
const originalFetch = globalThis.fetch

afterEach(() => {
globalThis.fetch = originalFetch
})

it('returns empty object when clientId is undefined', async () => {
const result = await safeResolveClientMetadata(undefined)
expect(result).toEqual({})
})

it('returns fallback metadata when fetch fails', async () => {
// resolveClientMetadata has internal error handling, so it returns fallback
// (domain extraction) rather than throwing. safeResolveClientMetadata's
// catch is defense-in-depth but unreachable in current implementation.
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
const result = await safeResolveClientMetadata('https://app.example.com')
expect(result).toEqual({ client_name: 'app.example.com' })
})

it('returns fallback metadata when fetch returns non-OK status', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
} as Response)
const result = await safeResolveClientMetadata('https://app.example.com')
expect(result).toEqual({ client_name: 'app.example.com' })
})

it('returns metadata when fetch succeeds', async () => {
const mockMetadata: ClientMetadata = {
client_name: 'Test App',
brand_color: '#123456',
}
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMetadata),
}) as unknown as typeof fetch
// Use a unique URL to avoid hitting the cache from previous tests
const result = await safeResolveClientMetadata(
'https://unique-test-app.example.com',
)
expect(result).toEqual(mockMetadata)
})
})
125 changes: 125 additions & 0 deletions packages/auth-service/src/__tests__/ping-par-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Tests for pingParRequest().
*
* This function calls pds-core's /_internal/ping-request endpoint to
* reset the inactivity timer on a pending PAR request_uri, preventing
* "This request has expired" errors in users who take >5 min on
* intermediate pages (handle picker, OTP, etc.).
*
* The function never throws or logs — it returns a plain result object
* so each call site decides on severity and message.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { pingParRequest } from '../lib/ping-par-request.js'

const PDS_URL = 'http://core:3000'
const SECRET = 'test-internal-secret'
const REQUEST_URI = 'urn:ietf:params:oauth:request_uri:req-abc123'

let fetchSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch')
})

afterEach(() => {
fetchSpy.mockRestore()
})

describe('pingParRequest', () => {
it('returns { ok: true } when endpoint responds with 200', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
)

const result = await pingParRequest(REQUEST_URI, PDS_URL, SECRET)

expect(result).toEqual({ ok: true })
})

it('returns { ok: false, status: 404 } when request_uri is expired', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'request_expired' }), {
status: 404,
}),
)

const result = await pingParRequest(REQUEST_URI, PDS_URL, SECRET)

expect(result).toEqual({ ok: false, status: 404 })
})

it('returns { ok: false, status } for other non-OK responses', async () => {
fetchSpy.mockResolvedValueOnce(
new Response('Internal Server Error', { status: 503 }),
)

const result = await pingParRequest(REQUEST_URI, PDS_URL, SECRET)

expect(result).toEqual({ ok: false, status: 503 })
})

it('returns { ok: false, err } on network error', async () => {
const networkError = new Error('ECONNREFUSED')
fetchSpy.mockRejectedValueOnce(networkError)

const result = await pingParRequest(REQUEST_URI, PDS_URL, SECRET)

expect(result.ok).toBe(false)
expect((result as { ok: false; err?: unknown }).err).toBe(networkError)
})

it('returns { ok: false, err } on timeout (AbortError)', async () => {
const abortError = new DOMException('Aborted', 'AbortError')
fetchSpy.mockRejectedValueOnce(abortError)

const result = await pingParRequest(REQUEST_URI, PDS_URL, SECRET)

expect(result.ok).toBe(false)
expect((result as { ok: false; err?: unknown }).err).toBe(abortError)
})

it('URL-encodes request_uri in the query string', async () => {
fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 200 }))

await pingParRequest(REQUEST_URI, PDS_URL, SECRET)

expect(fetchSpy).toHaveBeenCalledWith(
`${PDS_URL}/_internal/ping-request?request_uri=${encodeURIComponent(REQUEST_URI)}`,
expect.anything(),
)
})

it('passes the x-internal-secret header', async () => {
fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 200 }))

await pingParRequest(REQUEST_URI, PDS_URL, 'my-secret-token')

expect(fetchSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { 'x-internal-secret': 'my-secret-token' },
}),
)
})

it('includes an AbortSignal on the request', async () => {
fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 200 }))

await pingParRequest(REQUEST_URI, PDS_URL, SECRET)

const options = fetchSpy.mock.calls[0][1] as RequestInit
expect(options.signal).toBeInstanceOf(AbortSignal)
})

it('works with different pdsUrl values', async () => {
fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 200 }))

await pingParRequest(REQUEST_URI, 'https://pds.example.com', SECRET)

expect(fetchSpy).toHaveBeenCalledWith(
`https://pds.example.com/_internal/ping-request?request_uri=${encodeURIComponent(REQUEST_URI)}`,
expect.anything(),
)
})
})
Loading
Loading