diff --git a/meridian-web/app/api/auth/nonce/route.ts b/meridian-web/app/api/auth/nonce/route.ts index 6945a584..ee6bcada 100644 --- a/meridian-web/app/api/auth/nonce/route.ts +++ b/meridian-web/app/api/auth/nonce/route.ts @@ -1,208 +1,158 @@ +import { randomBytes } from 'node:crypto' import { NextRequest, NextResponse } from 'next/server' -import { - handleNonceRequest, - redactedKey, - type NonceFailure, - type NonceSuccess, -} from '@/lib/auth/nonce' - /** - * POST /api/auth/nonce (+ GET with query params for simple fetch) + * GET /api/auth/nonce?address= + * + * Issues a single-use authentication nonce for a Stellar public key. * - * Issues a single-use auth nonce bound to the caller's identity. - * Identity resolution (fail-closed, never client-assertable alone): - * 1. `auth_token` cookie session principal (preferred, verified server-side) - * 2. `address` body/query field (anonymous sign-in bootstrap only) - * Neither => 401 NO_ACTIVE_SESSION. + * Invariants: + * - The address must be a well-formed Stellar public key: exactly 56 + * characters, a leading `G`, and 55 characters from the base32 alphabet + * `A-Z2-7`. Anything else is a 400. + * - The response always carries `Cache-Control: no-store, private` and + * `X-Content-Type-Options: nosniff`: a nonce must never be cached or + * reused. + * - At most one nonce is live per address. Issuing a new nonce supersedes + * the previous one, so a replayed request cannot keep an old nonce alive. + * - Concurrency: every request generates its own 32 random bytes, so N + * concurrent requests for the same address yield N distinct nonces. + * - Nothing about the request (query string, headers, secrets) is echoed + * back, and the nonce is never logged. * - * Deterministic failure boundary (issue #1712): - * 200 ok:true (+ deduped:true on idempotent retry) - * 400 INVALID_INPUT | 401 NO_ACTIVE_SESSION | 403 PRINCIPAL_NOT_AUTHORIZED - * 409 DUPLICATE_REUSED | 410 STALE_VERSION | 429 RATE_LIMITED | 503 STORE_UNAVAILABLE - * Bodies never echo nonces, tokens, or policy internals; logs carry only a - * truncated requestKey prefix. Always `Cache-Control: no-store, private`. + * NOTE: the live-nonce map is process-local. A multi-instance deployment + * needs a shared store (e.g. Redis) so that supersede/expiry hold globally; + * this route is intentionally dependency-free. */ -// Re-export so the issue's stated entry point -// `handleNonceRequest in ./app/api/auth/nonce/route.ts` holds. -export { handleNonceRequest } +export const runtime = 'nodejs' -interface PrincipalSession { - principal: string | null - isValid: boolean +export interface ValidationIssue { + field: string + code: string + message: string } -function resolvePrincipal(token: string): PrincipalSession { - if (!token) return { principal: null, isValid: false } - const segments = token.split('.') - if (segments.length === 3) { - try { - const raw = Buffer.from(segments[1], 'base64url').toString('utf8') - const payload = JSON.parse(raw) as { sub?: unknown } - if (typeof payload.sub === 'string' && payload.sub.length > 0) { - return { principal: payload.sub, isValid: true } - } - } catch { - return { principal: null, isValid: false } - } - return { principal: null, isValid: false } - } - if (token.length > 0 && token.length <= 320) { - return { principal: token, isValid: true } - } - return { principal: null, isValid: false } -} +export type AddressQueryResult = + | { ok: true; address: string } + | { ok: false; issues: ValidationIssue[] } + +const ADDRESS_LENGTH = 56 +const ADDRESS_PREFIX = 'G' +const BASE32_ALPHABET = /^[A-Z2-7]+$/ +const NONCE_BYTES = 32 +const DEFAULT_NONCE_TTL_MS = 300_000 -const NO_STORE_HEADERS = { +const NO_STORE_HEADERS: Record = { 'Cache-Control': 'no-store, private', 'X-Content-Type-Options': 'nosniff', -} as const - -function ok(result: NonceSuccess): NextResponse { - return NextResponse.json(result, { status: 200, headers: { ...NO_STORE_HEADERS } }) } -function err(result: NonceFailure, requestKey: unknown): NextResponse { - // Diagnosable without secrets: code + retryability + truncated key only. - console.error('[/api/auth/nonce]', result.code, { - key: redactedKey(typeof requestKey === 'string' ? requestKey : null), - retryable: result.retryable, - }) - const headers: Record = { ...NO_STORE_HEADERS } - if (result.retryable && (result.code === 'RATE_LIMITED' || result.code === 'STORE_UNAVAILABLE')) { - headers['Retry-After'] = '5' - } - return NextResponse.json( - { ok: false, code: result.code, message: result.message, retryable: result.retryable, uiState: result.uiState }, - { status: result.status, headers }, - ) +interface LiveNonce { + nonce: string + expiresAt: number } -function sessionFrom(req: NextRequest): string | null { - const token = req.cookies.get('auth_token')?.value ?? '' - const session = resolvePrincipal(token) - return session.isValid ? session.principal : null +/** At most one live nonce per address; a new issuance replaces the old one. */ +const nonceStore = new Map() + +/** Test-only seam: clears the process-local nonce store. */ +export function __resetNonceStore(): void { + nonceStore.clear() } -function clientVersionFrom(value: unknown): number | undefined { - if (value === undefined || value === null || value === '') return undefined - const n = typeof value === 'number' ? value : Number(value) - if (!Number.isSafeInteger(n) || n < 0) return undefined - return n +function issue(field: string, code: string, message: string): ValidationIssue { + return { field, code, message } } -export async function POST(req: NextRequest): Promise { - try { - let body: unknown = {} - try { - body = await req.json() - } catch { - body = {} +/** Pure validator for the `address` query parameter. Never throws, no I/O. */ +export function validateAddressQuery(value: string | null): AddressQueryResult { + if (value === null || value.length === 0) { + return { + ok: false, + issues: [issue('address', 'MISSING_ADDRESS', 'Query parameter "address" is required')], } - const b = (body ?? {}) as Record - const result = handleNonceRequest( - { principal: b.principal, address: b.address, requestKey: b.requestKey, clientVersion: clientVersionFrom(b.clientVersion) }, - { sessionPrincipal: sessionFrom(req) }, - ) - if (result.ok) return ok(result) - return err(result, b.requestKey) - } catch (e) { - console.error('[/api/auth/nonce]', 'UNHANDLED', e) - return NextResponse.json( - { ok: false, code: 'STORE_UNAVAILABLE', message: 'Unable to issue nonce. Retry shortly.', retryable: true, uiState: 'retry' }, - { status: 503, headers: { ...NO_STORE_HEADERS, 'Retry-After': '5' } }, - ) } -} -export async function GET(req: NextRequest): Promise { - try { - const q = req.nextUrl.searchParams - const result = handleNonceRequest( - { - principal: q.get('principal') ?? undefined, - address: q.get('address') ?? undefined, - requestKey: q.get('requestKey') ?? undefined, - clientVersion: clientVersionFrom(q.get('clientVersion')), - }, - { sessionPrincipal: sessionFrom(req) }, - ) - if (result.ok) return ok(result) - return err(result, q.get('requestKey')) - } catch (e) { - console.error('[/api/auth/nonce]', 'UNHANDLED', e) - return NextResponse.json( - { ok: false, code: 'STORE_UNAVAILABLE', message: 'Unable to issue nonce. Retry shortly.', retryable: true, uiState: 'retry' }, - { status: 503, headers: { ...NO_STORE_HEADERS, 'Retry-After': '5' } }, - ) + if (value.length !== ADDRESS_LENGTH) { + return { + ok: false, + issues: [ + issue( + 'address', + 'INVALID_ADDRESS_LENGTH', + `Address must be exactly ${ADDRESS_LENGTH} characters`, + ), + ], + } } -import { randomBytes } from 'crypto'; -import { NextRequest, NextResponse } from 'next/server'; -import { setNonce } from '@/lib/auth-cache'; -export const dynamic = 'force-dynamic'; -export const runtime = 'nodejs'; + if (!value.startsWith(ADDRESS_PREFIX)) { + return { + ok: false, + issues: [issue('address', 'INVALID_ADDRESS_PREFIX', 'Address must start with "G"')], + } + } -const STELLAR_ADDRESS_REGEX = /^G[A-Z0-9]{55}$/; + if (!BASE32_ALPHABET.test(value.slice(1))) { + return { + ok: false, + issues: [ + issue( + 'address', + 'INVALID_ADDRESS_CHARSET', + 'Address must use the Stellar base32 alphabet (A-Z, 2-7)', + ), + ], + } + } -function isValidStellarAddress(address: string): boolean { - return STELLAR_ADDRESS_REGEX.test(address); + return { ok: true, address: value } } /** - * Extracts address from query params or request body + * Test-only seam: returns the single live nonce for an address, or `null`. + * Expired entries are purged as a side effect so the store cannot grow stale. */ -export async function resolveAddressFromRequest(request: NextRequest): Promise { - const queryAddress = request.nextUrl.searchParams.get('address')?.trim(); - if (queryAddress) return queryAddress; - - if (request.method === 'POST') { - try { - const body = await request.clone().json(); - const address = (body.publicKey || body.address); - if (typeof address === 'string') return address.trim(); - } catch { - // Ignore body parsing errors - } +export function peekLiveNonce(address: string): { nonce: string; expiresAt: number } | null { + const entry = nonceStore.get(address) + if (!entry) return null + if (entry.expiresAt <= Date.now()) { + nonceStore.delete(address) + return null } - - return null; + return { nonce: entry.nonce, expiresAt: entry.expiresAt } } -async function handleNonceRequest(request: NextRequest) { - try { - const address = await resolveAddressFromRequest(request); - - if (!address || !isValidStellarAddress(address)) { - return NextResponse.json( - { error: 'Valid Stellar address is required (e.g., ?address=G...)' }, - { status: 400 } - ); - } - - const nonce = randomBytes(32).toString('hex'); +/** Reads the TTL per call so tests can shrink it; invalid values use the default. */ +function nonceTtlMs(): number { + const raw = process.env.NONCE_TTL_MS + if (raw === undefined || raw.trim() === '') return DEFAULT_NONCE_TTL_MS + const parsed = Number(raw) + if (!Number.isSafeInteger(parsed) || parsed < 0) return DEFAULT_NONCE_TTL_MS + return parsed +} - setNonce(address, nonce); +export async function GET(req: NextRequest): Promise { + const address = req.nextUrl.searchParams.get('address') + const validated = validateAddressQuery(address) - return NextResponse.json({ - nonce, - address, - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - }); - } catch (error) { - console.error('Error generating nonce:', error); + if (!validated.ok) { return NextResponse.json( - { error: 'Internal Server Error' }, - { status: 500 } - ); + { error: 'Invalid request', issues: validated.issues }, + { status: 400, headers: NO_STORE_HEADERS }, + ) } -} -export async function GET(request: NextRequest) { - return handleNonceRequest(request); -} + const issuedAt = Date.now() + const expiresAt = issuedAt + nonceTtlMs() + const nonce = randomBytes(NONCE_BYTES).toString('hex') + + // Supersede any outstanding nonce for this address. + nonceStore.set(validated.address, { nonce, expiresAt }) -export async function POST(request: NextRequest) { - return handleNonceRequest(request); + return NextResponse.json( + { address: validated.address, nonce, expiresAt: new Date(expiresAt).toISOString() }, + { status: 200, headers: NO_STORE_HEADERS }, + ) } diff --git a/meridian-web/lib/api/__tests__/auth-nonce.test.ts b/meridian-web/lib/api/__tests__/auth-nonce.test.ts new file mode 100644 index 00000000..78fd1747 --- /dev/null +++ b/meridian-web/lib/api/__tests__/auth-nonce.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { NextRequest, NextResponse } from 'next/server' + +import { + GET, + __resetNonceStore, + peekLiveNonce, + validateAddressQuery, +} from '@/app/api/auth/nonce/route' + +const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' +const VALID_ADDRESS = `G${'A'.repeat(55)}` + +/** Builds a 56-char key with the given final character, e.g. to pin the 56th char. */ +function withLastChar(char: string): string { + return `G${'A'.repeat(54)}${char}` +} + +function nonceRequest(address: string | null): NextRequest { + const url = + address === null + ? 'http://localhost/api/auth/nonce' + : `http://localhost/api/auth/nonce?address=${encodeURIComponent(address)}` + return new NextRequest(url, { method: 'GET' }) +} + +async function readJson(res: NextResponse): Promise> { + return (await res.json()) as Record +} + +function issuesOf(payload: Record): Array<{ field: string; code: string }> { + return payload.issues as Array<{ field: string; code: string }> +} + +beforeEach(() => { + delete process.env.NONCE_TTL_MS + __resetNonceStore() +}) + +describe('validateAddressQuery', () => { + it('rejects a missing address', () => { + const result = validateAddressQuery(null) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.issues[0].code).toBe('MISSING_ADDRESS') + }) + + it('rejects an empty address', () => { + expect(validateAddressQuery('').ok).toBe(false) + }) + + it('accepts a well-formed 56-character Stellar public key', () => { + expect(VALID_ADDRESS).toHaveLength(56) + const result = validateAddressQuery(VALID_ADDRESS) + expect(result.ok).toBe(true) + if (result.ok) expect(result.address).toBe(VALID_ADDRESS) + }) + + it('rejects 55- and 57-character values', () => { + expect(validateAddressQuery(`G${'A'.repeat(54)}`).ok).toBe(false) + expect(validateAddressQuery(`G${'A'.repeat(56)}`).ok).toBe(false) + }) + + it('rejects a non-G prefix', () => { + expect(validateAddressQuery(`S${'A'.repeat(55)}`).ok).toBe(false) + expect(validateAddressQuery(`T${'A'.repeat(55)}`).ok).toBe(false) + }) + + it('rejects lowercase addresses', () => { + expect(validateAddressQuery(`g${'A'.repeat(55)}`).ok).toBe(false) + expect(validateAddressQuery(`G${'a'.repeat(55)}`).ok).toBe(false) + }) + + it('rejects characters outside the base32 alphabet at the final character', () => { + // `I`, `O`, `L` and `U` are part of the RFC 4648 alphabet Stellar strkeys use; + // only the digits 0/1/8/9 and non-alphanumerics are outside it. + for (const char of ['0', '1', '8', '9', '=', '@']) { + expect(validateAddressQuery(withLastChar(char)).ok).toBe(false) + } + }) + + it('accepts every boundary character of the base32 alphabet', () => { + for (const char of [BASE32[0], BASE32[BASE32.length - 1], 'Z', '2', '7']) { + expect(validateAddressQuery(withLastChar(char)).ok).toBe(true) + } + }) +}) + +describe('GET /api/auth/nonce', () => { + it('returns 400 with no-store when the address is missing', async () => { + const res = await GET(nonceRequest(null)) + expect(res.status).toBe(400) + const body = await readJson(res) + expect(body.error).toBe('Invalid request') + expect(issuesOf(body)[0].code).toBe('MISSING_ADDRESS') + expect(res.headers.get('cache-control')).toContain('no-store') + expect(res.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('returns 400 for an invalid address', async () => { + const res = await GET(nonceRequest(`S${'A'.repeat(55)}`)) + expect(res.status).toBe(400) + const body = await readJson(res) + expect(issuesOf(body)[0].code).toBe('INVALID_ADDRESS_PREFIX') + }) + + it('issues a 64-hex nonce with an ISO expiry and private no-store', async () => { + process.env.NONCE_TTL_MS = '12345' + const before = Date.now() + const res = await GET(nonceRequest(VALID_ADDRESS)) + const after = Date.now() + + expect(res.status).toBe(200) + const body = await readJson(res) + expect(body.address).toBe(VALID_ADDRESS) + expect(typeof body.nonce).toBe('string') + expect(body.nonce).toMatch(/^[0-9a-f]{64}$/) + expect(typeof body.expiresAt).toBe('string') + + const expiresAt = new Date(body.expiresAt as string).getTime() + expect(Number.isNaN(expiresAt)).toBe(false) + expect(expiresAt).toBeGreaterThanOrEqual(before + 12_345 - 1_000) + expect(expiresAt).toBeLessThanOrEqual(after + 12_345 + 1_000) + + expect(res.headers.get('cache-control')).toBe('no-store, private') + expect(res.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('supersedes the previous live nonce for the same address', async () => { + const first = await readJson(await GET(nonceRequest(VALID_ADDRESS))) + expect(peekLiveNonce(VALID_ADDRESS)?.nonce).toBe(first.nonce) + + const second = await readJson(await GET(nonceRequest(VALID_ADDRESS))) + expect(second.nonce).not.toBe(first.nonce) + expect(peekLiveNonce(VALID_ADDRESS)?.nonce).toBe(second.nonce) + }) + + it('never hands the same nonce to concurrent requests for one address', async () => { + const responses = await Promise.all( + Array.from({ length: 8 }, () => GET(nonceRequest(VALID_ADDRESS))), + ) + const bodies = await Promise.all(responses.map((response) => readJson(response))) + const nonces = new Set(bodies.map((body) => body.nonce)) + + expect(nonces.size).toBe(8) + expect(peekLiveNonce(VALID_ADDRESS)).not.toBeNull() + }) + + it('keeps a live nonce while its TTL has not elapsed', async () => { + process.env.NONCE_TTL_MS = '60000' + const res = await GET(nonceRequest(VALID_ADDRESS)) + expect(res.status).toBe(200) + expect(peekLiveNonce(VALID_ADDRESS)).not.toBeNull() + }) + + it('purges an expired nonce from the live store', async () => { + process.env.NONCE_TTL_MS = '1' + const res = await GET(nonceRequest(VALID_ADDRESS)) + expect(res.status).toBe(200) + + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(peekLiveNonce(VALID_ADDRESS)).toBeNull() + }) +})