Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
60 lines
2.2 KiB
TypeScript
60 lines
2.2 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { writeFile, mkdtemp } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { peekPasetoClaims } from 'relay-auth/src/crypto/paseto.js'
|
|
import { createManageTokenMinter, ManageTokenError } from '../src/manage-token.js'
|
|
import { generatePkcs8Pem } from './helpers.js'
|
|
|
|
async function writeKey(): Promise<string> {
|
|
const { pem } = await generatePkcs8Pem()
|
|
const dir = await mkdtemp(join(tmpdir(), 'cp-key-'))
|
|
const path = join(dir, 'capability-sign.key.pem')
|
|
await writeFile(path, pem, 'utf8')
|
|
return path
|
|
}
|
|
|
|
describe('createManageTokenMinter', () => {
|
|
it('mints a manage token with the correct aud, sub, rights, and TTL', async () => {
|
|
const keyPath = await writeKey()
|
|
const minter = createManageTokenMinter({
|
|
capabilitySignKeyPath: keyPath,
|
|
baseDomain: 'terminal.yaojia.wang',
|
|
operatorAccountId: 'acct-op-1',
|
|
})
|
|
|
|
const token = await minter.mint()
|
|
expect(token.startsWith('v4.public.')).toBe(true)
|
|
|
|
const claims = peekPasetoClaims(token) as Record<string, unknown>
|
|
expect(claims.aud).toBe('terminal.yaojia.wang')
|
|
expect(claims.sub).toBe('acct-op-1')
|
|
expect(claims.rights).toEqual(['manage'])
|
|
expect(typeof claims.host).toBe('string')
|
|
expect((claims.exp as number) - (claims.iat as number)).toBe(60)
|
|
// The additive DPoP proof-of-possession binding must be present.
|
|
expect((claims.cnf as { jkt?: string })?.jkt).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
|
})
|
|
|
|
it('mints a fresh token each call (distinct jti)', async () => {
|
|
const keyPath = await writeKey()
|
|
const minter = createManageTokenMinter({
|
|
capabilitySignKeyPath: keyPath,
|
|
baseDomain: 'd',
|
|
operatorAccountId: 'a',
|
|
})
|
|
const a = peekPasetoClaims(await minter.mint()) as Record<string, unknown>
|
|
const b = peekPasetoClaims(await minter.mint()) as Record<string, unknown>
|
|
expect(a.jti).not.toBe(b.jti)
|
|
})
|
|
|
|
it('throws ManageTokenError when the key file is missing', async () => {
|
|
const minter = createManageTokenMinter({
|
|
capabilitySignKeyPath: '/nonexistent/does-not-exist.pem',
|
|
baseDomain: 'd',
|
|
operatorAccountId: 'a',
|
|
})
|
|
await expect(minter.mint()).rejects.toBeInstanceOf(ManageTokenError)
|
|
})
|
|
})
|