Files
web-terminal/relay-run/tests/auth-mint.test.ts
Yaojia Wang bfe1be1dfe feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint
- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers);
  header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect.
- F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst,
  before password compare).
- F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0).
- F5: scrub error logs to e.message/.code (no DSN leak, INV9).
relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
2026-07-06 16:26:05 +02:00

355 lines
13 KiB
TypeScript

/**
* B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability
* signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding),
* the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong
* method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { Readable } from 'node:stream'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { HostRecord } from 'control-plane/src/model/records.js'
import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js'
import type { TokenBucketStore } from 'relay-auth'
import {
createAuthMintRoute,
loadSigningKeyFromEnv,
MINT_RATE_BURST,
type SubdomainHostLookup,
type MintRateLimit,
} from '../src/servers/auth-mint.js'
const subtle = globalThis.crypto.subtle
const NOW = 1_800_000_000
const PASSWORD = 'staging-operator-secret'
// A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars).
const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43)
interface Loaded {
readonly signingKey: CryptoKey
readonly publicKey: CryptoKey
}
let keys: Loaded
beforeAll(async () => {
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
publicKey: CryptoKey
privateKey: CryptoKey
}
keys = { signingKey: kp.privateKey, publicKey: kp.publicKey }
})
function mkHost(overrides: Partial<HostRecord> = {}): HostRecord {
return {
hostId: 'host-1',
accountId: 'acct-1',
subdomain: 'alice',
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr-host-1',
status: 'online',
lastSeen: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
...overrides,
}
}
function fakeHosts(rec: HostRecord | null): SubdomainHostLookup {
return { getBySubdomain: async () => rec }
}
interface CapturedRes {
statusCode: number
headers: Record<string, string>
body: string
headersSent: boolean
writeHead(status: number, headers: Record<string, string>): CapturedRes
end(chunk?: string): void
}
function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
let resolveDone!: () => void
const done = new Promise<void>((r) => (resolveDone = r))
const captured: CapturedRes = {
statusCode: 0,
headers: {},
body: '',
headersSent: false,
writeHead(status, headers) {
this.statusCode = status
this.headers = headers
this.headersSent = true
return this
},
end(chunk?: string) {
if (chunk !== undefined) this.body += chunk
resolveDone()
},
}
return { res: captured as unknown as ServerResponse, captured, done }
}
function fakeReq(method: string, url: string, body?: string, remoteAddr = '203.0.113.7'): IncomingMessage {
const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')]
const req = Readable.from(chunks) as unknown as IncomingMessage
;(req as { method?: string }).method = method
;(req as { url?: string }).url = url
;(req as { socket?: { remoteAddress: string } }).socket = { remoteAddress: remoteAddr }
return req
}
function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean {
return createAuthMintRoute({
signingKey: keys.signingKey,
hosts,
operatorPassword: PASSWORD,
now: () => NOW,
onError: () => {},
})
}
async function post(
hosts: SubdomainHostLookup,
bodyObj: unknown,
): Promise<CapturedRes> {
const route = mkRoute(hosts)
const { res, captured, done } = fakeRes()
const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res)
expect(claimed).toBe(true)
await done
return captured
}
describe('createAuthMintRoute — happy path', () => {
it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' })
expect(captured.statusCode).toBe(200)
expect(captured.headers['cache-control']).toBe('no-store')
const parsedBody = JSON.parse(captured.body) as { token: string }
expect(typeof parsedBody.token).toBe('string')
const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as {
sub: string
aud: string
host: string
rights: string[]
iat: number
exp: number
cnf: { jkt: string }
}
// Identity is the STORE row's, never the request body's (INV3).
expect(claims.sub).toBe('acct-1')
expect(claims.host).toBe('host-1')
expect(claims.aud).toBe('alice')
expect(claims.rights).toContain('attach')
// DPoP proof-of-possession binding to the client-provided thumbprint.
expect(claims.cnf.jkt).toBe(JKT)
// Short-lived (<= 60 s).
expect(claims.iat).toBe(NOW)
expect(claims.exp - claims.iat).toBeLessThanOrEqual(60)
expect(claims.exp - claims.iat).toBeGreaterThan(0)
// The token itself must never leak into logs — asserted by construction (no console here).
})
})
describe('createAuthMintRoute — deny by default', () => {
it('rejects a wrong password with 401 (no token)', async () => {
const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' })
expect(captured.statusCode).toBe(401)
expect(captured.body).not.toContain('token')
})
it('rejects an unknown subdomain with 404', async () => {
const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' })
expect(captured.statusCode).toBe(404)
})
it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => {
const captured = await post(
fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })),
{ password: PASSWORD, jkt: JKT, subdomain: 'alice' },
)
expect(captured.statusCode).toBe(403)
})
it('rejects a malformed jkt with 400', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' })
expect(captured.statusCode).toBe(400)
})
it('rejects a malformed subdomain with 400', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' })
expect(captured.statusCode).toBe(400)
})
it('rejects invalid JSON with 400', async () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured, done } = fakeRes()
const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res)
expect(claimed).toBe(true)
await done
expect(captured.statusCode).toBe(400)
})
it('rejects an oversized body with 413', async () => {
const big = 'x'.repeat(5000)
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big })
expect(captured.statusCode).toBe(413)
})
})
describe('createAuthMintRoute — routing semantics', () => {
it('claims /auth/mint but 405s a non-POST method', () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured } = fakeRes()
const claimed = route(fakeReq('GET', '/auth/mint', undefined), res)
expect(claimed).toBe(true)
expect(captured.statusCode).toBe(405)
})
it('does NOT claim a non-matching path (returns false, response untouched)', () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured } = fakeRes()
const claimed = route(fakeReq('POST', '/index.html', undefined), res)
expect(claimed).toBe(false)
expect(captured.statusCode).toBe(0)
})
it('claims /auth/mint even with a query string', () => {
const route = mkRoute(fakeHosts(null))
const { res } = fakeRes()
const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res)
expect(claimed).toBe(true)
})
})
// ── F1 · per-IP mint throttle ─────────────────────────────────────────────────────────────────────
/** Lazy-refill token bucket mirroring the production Redis Lua — deterministic at a fixed `now`. */
function inMemoryBucket(): TokenBucketStore {
const state = new Map<string, { tokens: number; ts: number }>()
return {
async take(key, refillPerSec, burst, now) {
const s = state.get(key) ?? { tokens: burst, ts: now }
const elapsed = now - s.ts
let tokens = elapsed > 0 ? Math.min(burst, s.tokens + elapsed * refillPerSec) : s.tokens
const allowed = tokens >= 1
if (allowed) tokens -= 1
state.set(key, { tokens, ts: now })
return allowed
},
}
}
function mkRouteRl(
hosts: SubdomainHostLookup,
rateLimit: MintRateLimit,
): (req: IncomingMessage, res: ServerResponse) => boolean {
return createAuthMintRoute({
signingKey: keys.signingKey,
hosts,
operatorPassword: PASSWORD,
now: () => NOW,
rateLimit,
onError: () => {},
})
}
async function postRl(
route: (req: IncomingMessage, res: ServerResponse) => boolean,
bodyObj: unknown,
remoteAddr: string,
): Promise<CapturedRes> {
const { res, captured, done } = fakeRes()
route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj), remoteAddr), res)
await done
return captured
}
describe('createAuthMintRoute — F1 per-IP throttle', () => {
const GOOD = { password: PASSWORD, jkt: JKT, subdomain: 'alice' }
it('admits the burst then returns 429 once the bucket is exhausted (same IP)', async () => {
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's' })
const ip = '198.51.100.4'
for (let i = 0; i < MINT_RATE_BURST; i++) {
const ok = await postRl(route, GOOD, ip)
expect(ok.statusCode).toBe(200)
}
const throttled = await postRl(route, GOOD, ip)
expect(throttled.statusCode).toBe(429)
expect(throttled.body).not.toContain('token')
})
it('throttles BEFORE the password compare (exhausted IP + WRONG password → 429, not 401)', async () => {
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
const ip = '198.51.100.9'
expect((await postRl(route, GOOD, ip)).statusCode).toBe(200) // drains the single token
const wrong = await postRl(route, { ...GOOD, password: 'nope' }, ip)
expect(wrong.statusCode).toBe(429) // throttle wins over the 401 password check
})
it('keeps per-IP buckets independent (a throttled IP does not affect another)', async () => {
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(200)
expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(429) // IP-A exhausted
expect((await postRl(route, GOOD, '10.0.0.2')).statusCode).toBe(200) // IP-B unaffected
})
it('keys the bucket on a SALTED HASH, never the raw client IP', async () => {
const seenKeys: string[] = []
const spyBucket: TokenBucketStore = {
async take(key) {
seenKeys.push(key)
return true
},
}
const rawIp = '203.0.113.55'
const route = mkRouteRl(fakeHosts(mkHost()), { buckets: spyBucket, salt: 'pepper' })
await postRl(route, GOOD, rawIp)
expect(seenKeys).toHaveLength(1)
expect(seenKeys[0]).toMatch(/^mint:[0-9a-f]{32}$/)
expect(seenKeys[0]).not.toContain(rawIp)
})
})
describe('loadSigningKeyFromEnv', () => {
async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> {
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
publicKey: CryptoKey
privateKey: CryptoKey
}
const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey))
const b64 = Buffer.from(der).toString('base64')
const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n`
return { pem, b64, publicKey: kp.publicKey }
}
async function canSign(priv: CryptoKey, pub: CryptoKey): Promise<boolean> {
const data = new Uint8Array([1, 2, 3, 4])
const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data))
return subtle.verify({ name: 'Ed25519' }, pub, sig, data)
}
it('loads a PKCS#8 PEM into a usable signing key', async () => {
const { pem, publicKey } = await exportPkcs8Pem()
const key = await loadSigningKeyFromEnv(pem)
expect(await canSign(key, publicKey)).toBe(true)
})
it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => {
const { b64, publicKey } = await exportPkcs8Pem()
const key = await loadSigningKeyFromEnv(b64)
expect(await canSign(key, publicKey)).toBe(true)
})
it('throws on an empty value', async () => {
await expect(loadSigningKeyFromEnv(' ')).rejects.toThrow()
})
it('throws on a non-key value (never leaking material)', async () => {
await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow()
})
})