Files
web-terminal/relay-run/tests/auth-mint.test.ts
Yaojia Wang aa1912b962 feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
2026-07-06 16:13:34 +02:00

261 lines
9.2 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 {
createAuthMintRoute,
loadSigningKeyFromEnv,
type SubdomainHostLookup,
} 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): 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
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)
})
})
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()
})
})