From be8553c14927c18210dc4b4e22e40ac6373335e0 Mon Sep 17 00:00:00 2001 From: Alfred Tarfa dev Date: Mon, 28 Sep 2026 08:38:24 +0100 Subject: [PATCH] feat(auth): add GET auth/nonce route with deterministic failure-boundary coverage for validateAddressQuery (verification fix) --- meridian-web/app/api/auth/nonce/route.ts | 158 +++++++++++++++++ .../lib/api/__tests__/auth-nonce.test.ts | 164 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 meridian-web/app/api/auth/nonce/route.ts create mode 100644 meridian-web/lib/api/__tests__/auth-nonce.test.ts diff --git a/meridian-web/app/api/auth/nonce/route.ts b/meridian-web/app/api/auth/nonce/route.ts new file mode 100644 index 00000000..ee6bcada --- /dev/null +++ b/meridian-web/app/api/auth/nonce/route.ts @@ -0,0 +1,158 @@ +import { randomBytes } from 'node:crypto' +import { NextRequest, NextResponse } from 'next/server' + +/** + * GET /api/auth/nonce?address= + * + * Issues a single-use authentication nonce for a Stellar public key. + * + * 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. + * + * 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. + */ + +export const runtime = 'nodejs' + +export interface ValidationIssue { + field: string + code: string + message: string +} + +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: Record = { + 'Cache-Control': 'no-store, private', + 'X-Content-Type-Options': 'nosniff', +} + +interface LiveNonce { + nonce: string + expiresAt: number +} + +/** 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 issue(field: string, code: string, message: string): ValidationIssue { + return { field, code, message } +} + +/** 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')], + } + } + + if (value.length !== ADDRESS_LENGTH) { + return { + ok: false, + issues: [ + issue( + 'address', + 'INVALID_ADDRESS_LENGTH', + `Address must be exactly ${ADDRESS_LENGTH} characters`, + ), + ], + } + } + + if (!value.startsWith(ADDRESS_PREFIX)) { + return { + ok: false, + issues: [issue('address', 'INVALID_ADDRESS_PREFIX', 'Address must start with "G"')], + } + } + + 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)', + ), + ], + } + } + + return { ok: true, address: value } +} + +/** + * 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 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 { nonce: entry.nonce, expiresAt: entry.expiresAt } +} + +/** 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 +} + +export async function GET(req: NextRequest): Promise { + const address = req.nextUrl.searchParams.get('address') + const validated = validateAddressQuery(address) + + if (!validated.ok) { + return NextResponse.json( + { error: 'Invalid request', issues: validated.issues }, + { status: 400, headers: NO_STORE_HEADERS }, + ) + } + + 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 }) + + 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() + }) +})