Files
web-terminal/control-plane/test/auth-login.test.ts
Yaojia Wang fff011bb7f feat(control-plane): POST /auth/login mints device:enroll bearer (B1)
Unblocks the phone-enrollment track: an operator-password login mints a
short-lived device:enroll capability token that POST /device/enroll requires.
Constant-time (SHA-256 fixed-length) compare, per-client rate-limit, fail-closed
when unset. 260 tests pass.
2026-07-18 13:32:05 +02:00

185 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* B1 — operator login → device:enroll bearer mint. Proves the PINNED contract:
* POST /auth/login { password } -> 201 { enrollToken, accountId, expiresIn }
* then POST /device/enroll Authorization: Bearer <enrollToken> is reachable (201) and rejected
* WITHOUT the bearer (401).
*
* Security surface under test: constant-time credential compare (via loginToAccountId), rate-limited
* login attempts, deny-by-default / fail-closed when the operator credential is unset, and that the
* minted bearer carries the correct right ('enroll') + audience ('device-enroll') + TTL — verified
* through relay-auth's REAL §4.3 verify path (verifyDeviceEnrollToken), the same one production uses.
*/
import 'reflect-metadata'
import { describe, test, expect, beforeEach } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import Fastify, { type FastifyInstance } from 'fastify'
import { configureVerifyKey } from 'relay-auth'
import { resetVerifyKeyForTest } from 'relay-auth/src/config/keys.js'
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
import { buildAuthLoginRouter, type AuthLoginDeps } from '../src/api/auth-login.js'
import {
verifyDeviceEnrollToken,
DEVICE_ENROLL_AUD,
DEFAULT_DEVICE_ENROLL_TTL_SEC,
} from '../src/auth/session.js'
import { buildControlPlane } from '../src/main.js'
import { createCapabilityVerifier } from '../src/boot/verifier.js'
import { loadEnv } from '../src/env.js'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { bytesToBase64 } from '../src/util/bytes.js'
x509.cryptoProvider.set(webcrypto)
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const OPERATOR_PASSWORD = 'operator-secret-abcdef123456'
// ───────────────────────────────────────────────────────────────────────────────────────────────
// Block A — the login route in isolation (fast; verify-key configured so the mint round-trips)
// ───────────────────────────────────────────────────────────────────────────────────────────────
describe('B1 POST /auth/login (route in isolation)', () => {
let signingKey: CryptoKey
beforeEach(async () => {
resetVerifyKeyForTest()
const pair = await generateEd25519KeyPair()
await configureVerifyKey(pair.publicKey)
signingKey = pair.privateKey
})
function buildApp(overrides: Partial<AuthLoginDeps> = {}): FastifyInstance {
const deps: AuthLoginDeps = {
signingKey,
loginConfig: { operatorCredential: OPERATOR_PASSWORD, accountId: ACCOUNT_A },
clientKey: () => 'test-client', // deterministic rate-limit bucket
...overrides,
}
const app = Fastify({ logger: false })
void app.register(buildAuthLoginRouter(deps))
return app
}
test('valid password → 201 { enrollToken, accountId, expiresIn }', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(body.accountId).toBe(ACCOUNT_A)
expect(body.expiresIn).toBe(DEFAULT_DEVICE_ENROLL_TTL_SEC)
expect(typeof body.enrollToken).toBe('string')
expect(body.enrollToken.startsWith('v4.public.')).toBe(true)
})
test('the minted bearer carries the enroll right + device-enroll aud + minutes TTL', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
const { enrollToken, expiresIn } = JSON.parse(res.body)
// A successful verify through the REAL §4.3 path asserts aud === device-enroll AND the enroll right.
const { accountId } = await verifyDeviceEnrollToken(enrollToken, { aud: DEVICE_ENROLL_AUD })
expect(accountId).toBe(ACCOUNT_A)
expect(expiresIn).toBeGreaterThanOrEqual(60) // minutes-scale, separate from the 3060s connect clamp
})
test('wrong password → 401, no token minted', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
expect(res.statusCode).toBe(401)
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
})
test('missing password field → 400 (Zod at the boundary)', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: {} })
expect(res.statusCode).toBe(400)
})
test('fail-closed when the operator credential is unset → 503, never mints', async () => {
const app = buildApp({ signingKey: null, loginConfig: {} })
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
expect(res.statusCode).toBe(503)
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
})
test('login attempts are rate-limited → 429 after the threshold', async () => {
const app = buildApp({ rateMax: 2 })
await app.ready()
const attempt = () =>
app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
expect((await attempt()).statusCode).toBe(401)
expect((await attempt()).statusCode).toBe(401)
expect((await attempt()).statusCode).toBe(429) // over the per-client window
})
})
// ───────────────────────────────────────────────────────────────────────────────────────────────
// Block B — end-to-end through the WIRED control-plane (login → bearer → /device/enroll)
// ───────────────────────────────────────────────────────────────────────────────────────────────
describe('B1 login → device:enroll e2e (wired app)', () => {
let app: FastifyInstance
beforeEach(async () => {
resetVerifyKeyForTest()
// Capability keypair: raw public → boot verify key; PKCS#8 private → the enroll signing key env.
const pair = await generateEd25519KeyPair()
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
const pkcs8 = new Uint8Array(await webcrypto.subtle.exportKey('pkcs8', pair.privateKey))
const env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
CAPABILITY_SIGN_PRIVKEY_B64: bytesToBase64(pkcs8),
OPERATOR_PASSWORD,
OPERATOR_ACCOUNT_ID: ACCOUNT_A,
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
RELAY_TRUST_DOMAIN: 'terminal.yaojia.wang',
BASE_DOMAIN: 'term.example.com',
})
const stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
app = built.app
await app.ready()
// Onboard a host so ACCOUNT_A OWNS 'alice' (device-enroll ownership gate reads this registry).
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw } = generateEd25519()
await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
})
test('login mints a bearer that /device/enroll accepts (201); without it → 401', async () => {
const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
expect(login.statusCode).toBe(201)
const { enrollToken, accountId } = JSON.parse(login.body)
expect(accountId).toBe(ACCOUNT_A)
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const payload = { csr: Buffer.from(csr).toString('base64'), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' }
const enrolled = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${enrollToken}` },
payload,
})
expect(enrolled.statusCode).toBe(201)
expect(JSON.parse(enrolled.body).deviceId.length).toBeGreaterThan(0)
const noBearer = await app.inject({ method: 'POST', url: '/device/enroll', payload })
expect(noBearer.statusCode).toBe(401)
})
test('wrong operator password on the wired app → 401', async () => {
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'definitely-the-wrong-secret' } })
expect(res.statusCode).toBe(401)
})
})