feat(phase-3): workstream 3 — OpenID Connect (OIDC) Provider

Implements full OIDC layer on top of the existing OAuth 2.0 token service:

- Migration 014: oidc_keys table (RSA/EC key pairs, is_current flag, expires_at
  for rotation grace period)
- OIDCKeyService: key generation (RS256/ES256), Vault storage, JWKS with Redis
  cache, key rotation with grace period, pruneExpiredKeys
- IDTokenService: buildIDTokenClaims (agent claims, nonce, DID), signIDToken
  (kid in JWT header), verifyIDToken (alg:none rejected, RS256/ES256 only)
- OIDCController: discovery document, JWKS (Cache-Control), /agent-info
- OIDC routes mounted at / — /.well-known/openid-configuration,
  /.well-known/jwks.json, /agent-info
- OAuth2Service: id_token appended to token response when openid scope requested
- 473 unit tests passing (100% OIDCKeyService stmts, 95.91% IDTokenService stmts)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-30 09:54:26 +00:00
parent 3d1fff15f6
commit 5e465e596a
13 changed files with 2221 additions and 13 deletions

View File

@@ -0,0 +1,275 @@
/**
* Unit tests for src/services/IDTokenService.ts
* Mocks OIDCKeyService; uses real RSA key pairs for signing/verification.
*/
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { IDTokenService } from '../../../src/services/IDTokenService';
import { OIDCKeyService } from '../../../src/services/OIDCKeyService';
import { IAgent } from '../../../src/types/index';
import { IOIDCKey, IJWKSKey, IJWKSResponse } from '../../../src/types/oidc';
// ─── Real RSA key pair for signing tests ─────────────────────────────────────
const { privateKey: rsaPrivateKey, publicKey: rsaPublicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const privateKeyPem = rsaPrivateKey.export({ format: 'pem', type: 'pkcs8' }) as string;
const publicKeyJwkRaw = rsaPublicKey.export({ format: 'jwk' }) as Record<string, string>;
const TEST_KID = 'key-test-rsa-001';
const testPublicJwk: IJWKSKey = {
kid: TEST_KID,
kty: publicKeyJwkRaw['kty'] ?? 'RSA',
use: 'sig',
alg: 'RS256',
n: publicKeyJwkRaw['n'],
e: publicKeyJwkRaw['e'],
};
const mockCurrentKey: IOIDCKey = {
id: 'uuid-test',
kid: TEST_KID,
algorithm: 'RS256',
public_key_jwk: testPublicJwk,
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
};
const mockJwks: IJWKSResponse = { keys: [testPublicJwk] };
// ─── Mock OIDCKeyService ──────────────────────────────────────────────────────
jest.mock('../../../src/services/OIDCKeyService');
const MockOIDCKeyService = OIDCKeyService as jest.MockedClass<typeof OIDCKeyService>;
// ─── Mock agent ───────────────────────────────────────────────────────────────
const MOCK_AGENT: IAgent = {
agentId: 'agent-uuid-001',
organizationId: 'org-uuid-001',
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['agents:read'],
owner: 'team-alpha',
deploymentEnv: 'production',
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
};
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('IDTokenService', () => {
let mockKeyService: jest.Mocked<OIDCKeyService>;
let service: IDTokenService;
beforeEach(() => {
jest.clearAllMocks();
delete process.env['OIDC_ISSUER'];
delete process.env['OIDC_ID_TOKEN_TTL_SECONDS'];
mockKeyService = new MockOIDCKeyService(
null as unknown as import('pg').Pool,
null as unknown as import('redis').RedisClientType,
) as jest.Mocked<OIDCKeyService>;
mockKeyService.getCurrentKey.mockResolvedValue(mockCurrentKey);
mockKeyService.getPrivateKeyPem.mockResolvedValue(privateKeyPem);
mockKeyService.getPublicJWKS.mockResolvedValue(mockJwks);
service = new IDTokenService(mockKeyService);
});
// ── buildIDTokenClaims ───────────────────────────────────────────────────
describe('buildIDTokenClaims()', () => {
it('includes all required OIDC claims', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid agents:read');
expect(claims.iss).toBe('https://idp.sentryagent.ai');
expect(claims.sub).toBe(MOCK_AGENT.agentId);
expect(claims.aud).toBe('client-abc');
expect(claims.iat).toBeDefined();
expect(claims.exp).toBeDefined();
expect(claims.exp).toBeGreaterThan(claims.iat);
});
it('includes agent-specific claims', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
expect(claims.agent_type).toBe(MOCK_AGENT.agentType);
expect(claims.deployment_env).toBe(MOCK_AGENT.deploymentEnv);
expect(claims.organization_id).toBe(MOCK_AGENT.organizationId);
});
it('includes nonce when provided', async () => {
const claims = await service.buildIDTokenClaims(
MOCK_AGENT,
'client-abc',
'openid',
'test-nonce-xyz',
);
expect(claims.nonce).toBe('test-nonce-xyz');
});
it('omits nonce when not provided', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
expect(claims.nonce).toBeUndefined();
});
it('includes did when the agent has a DID', async () => {
const agentWithDID: IAgent = {
...MOCK_AGENT,
did: 'did:web:idp.sentryagent.ai:agents:agent-uuid-001',
};
const claims = await service.buildIDTokenClaims(agentWithDID, 'client-abc', 'openid');
expect(claims.did).toBe(agentWithDID.did);
});
it('omits did when the agent does not have a DID', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
expect(claims.did).toBeUndefined();
});
it('uses OIDC_ISSUER env var when set', async () => {
process.env['OIDC_ISSUER'] = 'https://my-custom-issuer.example.com';
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
expect(claims.iss).toBe('https://my-custom-issuer.example.com');
});
it('uses OIDC_ID_TOKEN_TTL_SECONDS for expiry', async () => {
process.env['OIDC_ID_TOKEN_TTL_SECONDS'] = '7200';
const before = Math.floor(Date.now() / 1000);
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
const after = Math.floor(Date.now() / 1000);
expect(claims.exp - claims.iat).toBeGreaterThanOrEqual(7200 - 1);
expect(claims.exp).toBeGreaterThanOrEqual(before + 7200);
expect(claims.exp).toBeLessThanOrEqual(after + 7200 + 1);
});
});
// ── signIDToken ──────────────────────────────────────────────────────────
describe('signIDToken()', () => {
it('produces a valid JWT string', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
const token = await service.signIDToken(claims);
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3);
});
it('includes the kid in the JWT header', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
const token = await service.signIDToken(claims);
const decoded = jwt.decode(token, { complete: true });
expect(decoded).not.toBeNull();
expect(decoded!.header.kid).toBe(TEST_KID);
});
it('signs with RS256 algorithm by default', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
const token = await service.signIDToken(claims);
const decoded = jwt.decode(token, { complete: true });
expect(decoded!.header.alg).toBe('RS256');
});
});
// ── verifyIDToken ────────────────────────────────────────────────────────
describe('verifyIDToken()', () => {
it('verifies a valid ID token and returns claims', async () => {
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
const token = await service.signIDToken(claims);
const verified = await service.verifyIDToken(token);
expect(verified.sub).toBe(MOCK_AGENT.agentId);
expect(verified.iss).toBe('https://idp.sentryagent.ai');
expect(verified.aud).toBe('client-abc');
});
it('rejects alg:none tokens', async () => {
// Craft a token with alg:none manually
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'attacker', iss: 'evil', aud: 'client', iat: 0, exp: 9999999999 })).toString('base64url');
const noneToken = `${header}.${payload}.`;
await expect(service.verifyIDToken(noneToken)).rejects.toMatchObject({
code: 'ID_TOKEN_INVALID',
});
});
it('rejects expired tokens', async () => {
// Sign a token that was issued in the past and has already expired
const now = Math.floor(Date.now() / 1000);
const expiredClaims = {
iss: 'https://idp.sentryagent.ai',
sub: MOCK_AGENT.agentId,
aud: 'client-abc',
iat: now - 7200,
exp: now - 3600,
agent_type: 'screener',
deployment_env: 'production',
organization_id: 'org-uuid-001',
};
// Sign with the real private key (same as what mockKeyService returns)
const expiredToken = jwt.sign(expiredClaims, privateKeyPem, {
algorithm: 'RS256',
header: { alg: 'RS256', kid: TEST_KID, typ: 'JWT' },
});
await expect(service.verifyIDToken(expiredToken)).rejects.toMatchObject({
code: 'ID_TOKEN_INVALID',
});
});
it('rejects tokens whose kid is not in the JWKS', async () => {
const unknownKidHeader = Buffer.from(JSON.stringify({ alg: 'RS256', kid: 'unknown-kid', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'x' })).toString('base64url');
const fakeToken = `${unknownKidHeader}.${payload}.fakesig`;
await expect(service.verifyIDToken(fakeToken)).rejects.toMatchObject({
code: 'ID_TOKEN_INVALID',
});
});
it('rejects malformed tokens', async () => {
await expect(service.verifyIDToken('not.a.jwt')).rejects.toMatchObject({
code: 'ID_TOKEN_INVALID',
});
});
it('rejects tokens with missing kid header', async () => {
// Craft a token with no kid in the header
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'x', exp: 9999999999 })).toString('base64url');
const noKidToken = `${header}.${payload}.fakesig`;
await expect(service.verifyIDToken(noKidToken)).rejects.toMatchObject({
code: 'ID_TOKEN_INVALID',
});
});
it('rejects tokens with unsupported algorithm (HS256)', async () => {
// Craft a token header claiming HS256
const header = Buffer.from(JSON.stringify({ alg: 'HS256', kid: TEST_KID, typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'x', exp: 9999999999 })).toString('base64url');
const fakeToken = `${header}.${payload}.fakesig`;
await expect(service.verifyIDToken(fakeToken)).rejects.toMatchObject({
code: 'ID_TOKEN_INVALID',
});
});
});
});

View File

@@ -0,0 +1,543 @@
/**
* Unit tests for src/services/OIDCKeyService.ts
* Mocks pg Pool and node-vault; uses a real in-memory Redis stub.
*/
import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import { OIDCKeyService } from '../../../src/services/OIDCKeyService';
import { IOIDCKey, IJWKSKey } from '../../../src/types/oidc';
// ─── Mocks ────────────────────────────────────────────────────────────────────
jest.mock('pg', () => {
const mQuery = jest.fn();
const mPool = { query: mQuery };
return { Pool: jest.fn(() => mPool) };
});
jest.mock('node-vault', () => {
return jest.fn(() => ({
write: jest.fn().mockResolvedValue({}),
read: jest.fn().mockResolvedValue({
data: { data: { privateKeyPem: 'mock-pem' } },
}),
delete: jest.fn().mockResolvedValue({}),
}));
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeRedis(): RedisClientType {
const store = new Map<string, string>();
return {
get: jest.fn(async (key: string) => store.get(key) ?? null),
set: jest.fn(async (key: string, value: string, _opts?: unknown) => {
store.set(key, value);
return 'OK';
}),
del: jest.fn(async (key: string) => {
store.delete(key);
return 1;
}),
} as unknown as RedisClientType;
}
function makeSampleJwk(kid = 'key-test-001'): IJWKSKey {
return { kid, kty: 'RSA', use: 'sig', alg: 'RS256', n: 'abc', e: 'AQAB' };
}
function makeSampleRow(overrides: Partial<IOIDCKey> = {}): IOIDCKey {
return {
id: 'uuid-1',
kid: 'key-test-001',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk(),
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date('2026-01-01T00:00:00Z'),
expires_at: new Date(Date.now() + 3600 * 1000),
...overrides,
};
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('OIDCKeyService', () => {
let pool: Pool;
let poolQuery: jest.Mock;
let redis: RedisClientType;
let service: OIDCKeyService;
beforeEach(() => {
jest.clearAllMocks();
delete process.env['VAULT_ADDR'];
delete process.env['VAULT_TOKEN'];
delete process.env['VAULT_MOUNT'];
delete process.env['OIDC_KEY_ALGORITHM'];
delete process.env['OIDC_ID_TOKEN_TTL_SECONDS'];
pool = new Pool();
poolQuery = pool.query as jest.Mock;
redis = makeRedis();
service = new OIDCKeyService(pool, redis);
});
// ── ensureCurrentKey ──────────────────────────────────────────────────────
describe('ensureCurrentKey()', () => {
it('generates a key when no current key exists', async () => {
// COUNT returns 0 → no current key
poolQuery
.mockResolvedValueOnce({ rows: [{ count: '0' }] }) // ensureCurrentKey COUNT
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote old key
.mockResolvedValueOnce({ // INSERT new key
rows: [
{
id: 'uuid-1',
kid: 'key-abc',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk('key-abc'),
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
},
],
});
await service.ensureCurrentKey();
// Should have called INSERT (i.e. generateSigningKeyPair was invoked)
expect(poolQuery).toHaveBeenCalledTimes(3);
});
it('is idempotent — does not generate when a current key already exists', async () => {
// COUNT returns 1 → key exists
poolQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] });
await service.ensureCurrentKey();
// Only the COUNT query should have been executed
expect(poolQuery).toHaveBeenCalledTimes(1);
});
it('called twice only generates one key', async () => {
// First call: no key → generates
poolQuery
.mockResolvedValueOnce({ rows: [{ count: '0' }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({
rows: [
{
id: 'uuid-1',
kid: 'key-abc',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk('key-abc'),
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
},
],
});
// Second call: key now exists → no generation
poolQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] });
await service.ensureCurrentKey();
await service.ensureCurrentKey();
// First call: COUNT + UPDATE + INSERT = 3; second call: COUNT = 1 → total 4
expect(poolQuery).toHaveBeenCalledTimes(4);
});
});
// ── generateSigningKeyPair ────────────────────────────────────────────────
describe('generateSigningKeyPair()', () => {
it('generates an RSA key pair and inserts into the database', async () => {
const row = {
id: 'uuid-2',
kid: 'key-test-002',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk('key-test-002'),
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
};
poolQuery
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
.mockResolvedValueOnce({ rows: [row] }); // INSERT
const result = await service.generateSigningKeyPair();
expect(result.algorithm).toBe('RS256');
expect(result.is_current).toBe(true);
expect(result.vault_key_path).toBe('dev:no-vault');
expect(result.public_key_jwk.use).toBe('sig');
});
it('throws when OIDC_KEY_ALGORITHM is unsupported', async () => {
process.env['OIDC_KEY_ALGORITHM'] = 'RS512';
await expect(service.generateSigningKeyPair()).rejects.toThrow(
'Unsupported OIDC_KEY_ALGORITHM',
);
});
it('generates an EC P-256 key pair when OIDC_KEY_ALGORITHM=ES256', async () => {
process.env['OIDC_KEY_ALGORITHM'] = 'ES256';
const row = {
id: 'uuid-3',
kid: 'key-test-003',
algorithm: 'ES256',
public_key_jwk: { kid: 'key-test-003', kty: 'EC', use: 'sig', alg: 'ES256', crv: 'P-256', x: 'x', y: 'y' },
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
};
poolQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [row] });
const result = await service.generateSigningKeyPair();
expect(result.algorithm).toBe('ES256');
});
it('stores private key as dev:no-vault in dev mode (no Vault env vars)', async () => {
const row = {
id: 'uuid-4',
kid: 'key-test-004',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk('key-test-004'),
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
};
poolQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [row] });
const result = await service.generateSigningKeyPair();
expect(result.vault_key_path).toBe('dev:no-vault');
});
it('invalidates JWKS Redis cache after generating a new key', async () => {
const row = {
id: 'uuid-5',
kid: 'key-test-005',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk('key-test-005'),
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
};
poolQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [row] });
await service.generateSigningKeyPair();
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
});
});
// ── getCurrentKey ────────────────────────────────────────────────────────
describe('getCurrentKey()', () => {
it('returns the current key from the database', async () => {
const row = makeSampleRow();
poolQuery.mockResolvedValueOnce({ rows: [row] });
const key = await service.getCurrentKey();
expect(key.is_current).toBe(true);
expect(key.kid).toBe('key-test-001');
});
it('throws OIDCKeyNotFoundError when no current key exists', async () => {
poolQuery.mockResolvedValueOnce({ rows: [] });
await expect(service.getCurrentKey()).rejects.toMatchObject({
code: 'OIDC_KEY_NOT_FOUND',
httpStatus: 500,
});
});
});
// ── rotateKey ────────────────────────────────────────────────────────────
describe('rotateKey()', () => {
it('promotes a new key and the old key remains queryable', async () => {
const oldRow = makeSampleRow({ kid: 'key-old', is_current: false });
const newRow = makeSampleRow({ kid: 'key-new', is_current: true });
// generateSigningKeyPair: UPDATE + INSERT
poolQuery
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
.mockResolvedValueOnce({ rows: [newRow] }); // INSERT new
const result = await service.rotateKey();
expect(result.kid).toBe('key-new');
expect(result.is_current).toBe(true);
// Simulate getPublicJWKS returning both (old still in expires_at window)
poolQuery.mockResolvedValueOnce({
rows: [oldRow, newRow],
});
const jwks = await service.getPublicJWKS();
expect(jwks.keys).toHaveLength(2);
});
it('invalidates JWKS cache on rotation', async () => {
const newRow = makeSampleRow({ kid: 'key-new', is_current: true });
poolQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [newRow] });
await service.rotateKey();
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
});
});
// ── getPublicJWKS ────────────────────────────────────────────────────────
describe('getPublicJWKS()', () => {
it('returns only non-expired keys', async () => {
const nonExpiredRow = makeSampleRow();
poolQuery.mockResolvedValueOnce({ rows: [nonExpiredRow] });
const jwks = await service.getPublicJWKS();
expect(jwks.keys).toHaveLength(1);
expect(jwks.keys[0].kid).toBe('key-test-001');
});
it('returns cached JWKS when available', async () => {
const cachedJwks = JSON.stringify({ keys: [makeSampleJwk('cached-key')] });
(redis.get as jest.Mock).mockResolvedValueOnce(cachedJwks);
const jwks = await service.getPublicJWKS();
expect(jwks.keys[0].kid).toBe('cached-key');
// Pool should NOT have been queried
expect(poolQuery).not.toHaveBeenCalled();
});
it('caches the JWKS result in Redis', async () => {
const row = makeSampleRow();
poolQuery.mockResolvedValueOnce({ rows: [row] });
await service.getPublicJWKS();
expect(redis.set).toHaveBeenCalledWith(
'oidc:jwks',
expect.any(String),
expect.objectContaining({ EX: expect.any(Number) }),
);
});
});
// ── pruneExpiredKeys ─────────────────────────────────────────────────────
describe('pruneExpiredKeys()', () => {
it('deletes past-expires_at keys from the database', async () => {
const expiredRow = makeSampleRow({
kid: 'key-expired',
vault_key_path: 'dev:no-vault',
expires_at: new Date(Date.now() - 1000),
});
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
await service.pruneExpiredKeys();
// DELETE should have been called as the first (and only) DB call
expect(poolQuery).toHaveBeenCalledTimes(1);
expect(poolQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM oidc_keys'),
);
});
it('invalidates JWKS cache when keys are pruned', async () => {
const expiredRow = makeSampleRow({
kid: 'key-expired',
vault_key_path: 'dev:no-vault',
expires_at: new Date(Date.now() - 1000),
});
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
await service.pruneExpiredKeys();
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
});
it('does not invalidate cache when no keys are pruned', async () => {
poolQuery.mockResolvedValueOnce({ rows: [] });
await service.pruneExpiredKeys();
expect(redis.del).not.toHaveBeenCalled();
});
});
// ── getPrivateKeyPem ─────────────────────────────────────────────────────
describe('getPrivateKeyPem()', () => {
it('returns private key from dev in-memory store for dev:no-vault path', async () => {
// generateSigningKeyPair runs real crypto; the real kid is dynamic — capture it
// from the INSERT call args by spying on poolQuery.
let capturedKid: string | undefined;
poolQuery
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
.mockImplementationOnce((_sql: string, params: unknown[]) => {
// INSERT — capture the kid from the query params
capturedKid = params[0] as string;
return Promise.resolve({
rows: [
{
id: 'uuid-dev',
kid: capturedKid,
algorithm: 'RS256',
public_key_jwk: makeSampleJwk(capturedKid),
vault_key_path: 'dev:no-vault',
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
},
],
});
});
await service.generateSigningKeyPair();
expect(capturedKid).toBeDefined();
const pem = await service.getPrivateKeyPem(capturedKid!, 'dev:no-vault');
expect(pem).toBeDefined();
expect(typeof pem).toBe('string');
expect(pem.length).toBeGreaterThan(0);
});
it('throws when dev key is not found in memory', async () => {
await expect(
service.getPrivateKeyPem('nonexistent-key-xyz-999', 'dev:no-vault'),
).rejects.toThrow('not found in memory');
});
it('reads private key from Vault when VAULT_ADDR and VAULT_TOKEN are set', async () => {
process.env['VAULT_ADDR'] = 'http://vault:8200';
process.env['VAULT_TOKEN'] = 'test-token';
const pem = await service.getPrivateKeyPem('key-vault-001', 'secret/data/agentidp/oidc/keys/key-vault-001');
expect(pem).toBe('mock-pem');
});
it('throws when VAULT_ADDR and VAULT_TOKEN are missing for a non-dev path', async () => {
// No VAULT_ADDR/VAULT_TOKEN set (cleared in beforeEach)
await expect(
service.getPrivateKeyPem('key-vault-001', 'secret/data/agentidp/oidc/keys/key-vault-001'),
).rejects.toThrow('VAULT_ADDR and VAULT_TOKEN are required');
});
});
// ── Vault path — storePrivateKey ─────────────────────────────────────────
describe('generateSigningKeyPair() — Vault mode', () => {
it('stores private key in Vault when VAULT_ADDR and VAULT_TOKEN are set', async () => {
process.env['VAULT_ADDR'] = 'http://vault:8200';
process.env['VAULT_TOKEN'] = 'test-token';
const vaultPath = 'secret/data/agentidp/oidc/keys/key-vault-002';
const row = {
id: 'uuid-vault',
kid: 'key-vault-002',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk('key-vault-002'),
vault_key_path: vaultPath,
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
};
poolQuery
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
.mockResolvedValueOnce({ rows: [row] }); // INSERT
const result = await service.generateSigningKeyPair();
// vault_key_path returned by DB mock confirms Vault path was used
expect(result.vault_key_path).toBe(vaultPath);
});
it('uses custom VAULT_MOUNT when set', async () => {
process.env['VAULT_ADDR'] = 'http://vault:8200';
process.env['VAULT_TOKEN'] = 'test-token';
process.env['VAULT_MOUNT'] = 'kv';
const vaultPath = 'kv/data/agentidp/oidc/keys/key-vault-003';
const row = {
id: 'uuid-vault-2',
kid: 'key-vault-003',
algorithm: 'RS256',
public_key_jwk: makeSampleJwk('key-vault-003'),
vault_key_path: vaultPath,
is_current: true,
created_at: new Date(),
expires_at: new Date(Date.now() + 3600 * 1000),
};
poolQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [row] });
const result = await service.generateSigningKeyPair();
expect(result.vault_key_path).toBe(vaultPath);
});
});
// ── pruneExpiredKeys — Vault path ────────────────────────────────────────
describe('pruneExpiredKeys() — Vault mode', () => {
it('deletes Vault key when VAULT_ADDR and VAULT_TOKEN are set', async () => {
process.env['VAULT_ADDR'] = 'http://vault:8200';
process.env['VAULT_TOKEN'] = 'test-token';
const expiredRow = makeSampleRow({
kid: 'key-vault-expired',
vault_key_path: 'secret/data/agentidp/oidc/keys/key-vault-expired',
expires_at: new Date(Date.now() - 1000),
});
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
await service.pruneExpiredKeys();
expect(poolQuery).toHaveBeenCalledTimes(1);
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
});
it('handles Vault delete errors gracefully (best-effort)', async () => {
process.env['VAULT_ADDR'] = 'http://vault:8200';
process.env['VAULT_TOKEN'] = 'test-token';
// Make vault.delete throw
const nodeVaultMock = jest.requireMock('node-vault') as jest.Mock;
nodeVaultMock.mockReturnValueOnce({
write: jest.fn().mockResolvedValue({}),
read: jest.fn().mockResolvedValue({ data: { data: { privateKeyPem: 'mock-pem' } } }),
delete: jest.fn().mockRejectedValue(new Error('Vault unreachable')),
});
const expiredRow = makeSampleRow({
kid: 'key-vault-fail',
vault_key_path: 'secret/data/agentidp/oidc/keys/key-vault-fail',
expires_at: new Date(Date.now() - 1000),
});
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
// Should not throw — vault delete failure is best-effort
await expect(service.pruneExpiredKeys()).resolves.not.toThrow();
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
});
});
});