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:
435
tests/integration/oidc.test.ts
Normal file
435
tests/integration/oidc.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Integration tests for OIDC endpoints.
|
||||
* Tests discovery document, JWKS, id_token issuance, and agent-info endpoint.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { createPublicKey, JsonWebKey } from 'crypto';
|
||||
|
||||
// Set test environment variables before importing app
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
process.env['DATABASE_URL'] =
|
||||
process.env['TEST_DATABASE_URL'] ??
|
||||
'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
|
||||
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
|
||||
process.env['JWT_PRIVATE_KEY'] = privateKey;
|
||||
process.env['JWT_PUBLIC_KEY'] = publicKey;
|
||||
process.env['NODE_ENV'] = 'test';
|
||||
process.env['OIDC_ISSUER'] = 'https://idp.sentryagent.ai';
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { signToken } from '../../src/utils/jwt';
|
||||
import { closePool } from '../../src/db/pool';
|
||||
import { closeRedisClient } from '../../src/cache/redis';
|
||||
import { IJWKSKey, IJWKSResponse } from '../../src/types/oidc';
|
||||
|
||||
function makeToken(sub: string, scope = 'agents:read agents:write tokens:read'): string {
|
||||
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JWK public key to a PEM string for jwt.verify.
|
||||
*/
|
||||
function jwkToPem(jwk: IJWKSKey): string {
|
||||
const keyObj = createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' });
|
||||
return keyObj.export({ type: 'spki', format: 'pem' }) as string;
|
||||
}
|
||||
|
||||
describe('OIDC Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
// Ensure all required tables exist for this test suite
|
||||
const migrations = [
|
||||
`CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(128) NOT NULL UNIQUE,
|
||||
plan_tier VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||
max_agents INTEGER NOT NULL DEFAULT 10,
|
||||
max_tokens_per_month INTEGER NOT NULL DEFAULT 10000,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL DEFAULT 'a0000000-0000-0000-0000-000000000000',
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
agent_type VARCHAR(32) NOT NULL,
|
||||
version VARCHAR(64) NOT NULL,
|
||||
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||
owner VARCHAR(128) NOT NULL,
|
||||
deployment_env VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||
did VARCHAR(512),
|
||||
did_created_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS credentials (
|
||||
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL,
|
||||
organization_id UUID NOT NULL DEFAULT 'a0000000-0000-0000-0000-000000000000',
|
||||
secret_hash VARCHAR(255) NOT NULL,
|
||||
vault_path VARCHAR(512),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_events (
|
||||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
outcome VARCHAR(16) NOT NULL,
|
||||
ip_address VARCHAR(64) NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS token_revocations (
|
||||
jti UUID PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS token_monthly_counts (
|
||||
client_id UUID NOT NULL,
|
||||
month_key VARCHAR(7) NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (client_id, month_key)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS oidc_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kid VARCHAR(64) NOT NULL UNIQUE,
|
||||
algorithm VARCHAR(16) NOT NULL,
|
||||
public_key_jwk JSONB NOT NULL,
|
||||
vault_key_path VARCHAR(512) NOT NULL,
|
||||
is_current BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of migrations) {
|
||||
await pool.query(sql);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await pool.query('DELETE FROM audit_events');
|
||||
await pool.query('DELETE FROM token_revocations');
|
||||
await pool.query('DELETE FROM token_monthly_counts');
|
||||
await pool.query('DELETE FROM credentials');
|
||||
await pool.query('DELETE FROM agents');
|
||||
// Do NOT delete oidc_keys — ensureCurrentKey() runs once at app startup
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query('DELETE FROM oidc_keys');
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
async function createAgentWithCredentials(): Promise<{
|
||||
agentId: string;
|
||||
clientSecret: string;
|
||||
orgId: string;
|
||||
}> {
|
||||
const agentId = uuidv4();
|
||||
const orgId = 'a0000000-0000-0000-0000-000000000000';
|
||||
const token = makeToken(agentId, 'agents:read agents:write tokens:read');
|
||||
|
||||
// Insert agent directly
|
||||
await pool.query(
|
||||
`INSERT INTO agents
|
||||
(agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||
VALUES ($1, $2, $3, 'screener', '1.0.0', '{"agents:read"}', 'test-team', 'production', 'active')`,
|
||||
[agentId, orgId, `oidc-test-${agentId}@test.ai`],
|
||||
);
|
||||
|
||||
// Generate credential via API
|
||||
const credRes = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
return { agentId, clientSecret: credRes.body.clientSecret, orgId };
|
||||
}
|
||||
|
||||
// ── Discovery document ───────────────────────────────────────────────────
|
||||
|
||||
describe('GET /.well-known/openid-configuration', () => {
|
||||
it('returns a valid OIDC discovery document', async () => {
|
||||
const res = await request(app).get('/.well-known/openid-configuration');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.issuer).toBe('https://idp.sentryagent.ai');
|
||||
expect(res.body.token_endpoint).toContain('/oauth2/token');
|
||||
expect(res.body.jwks_uri).toContain('/.well-known/jwks.json');
|
||||
expect(res.body.authorization_endpoint).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes all required OIDC discovery fields', async () => {
|
||||
const res = await request(app).get('/.well-known/openid-configuration');
|
||||
|
||||
const requiredFields = [
|
||||
'issuer',
|
||||
'authorization_endpoint',
|
||||
'token_endpoint',
|
||||
'jwks_uri',
|
||||
'response_types_supported',
|
||||
'subject_types_supported',
|
||||
'id_token_signing_alg_values_supported',
|
||||
'scopes_supported',
|
||||
'claims_supported',
|
||||
'grant_types_supported',
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
expect(res.body).toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not require authentication', async () => {
|
||||
const res = await request(app).get('/.well-known/openid-configuration');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('includes openid in scopes_supported', async () => {
|
||||
const res = await request(app).get('/.well-known/openid-configuration');
|
||||
expect(res.body.scopes_supported).toContain('openid');
|
||||
});
|
||||
|
||||
it('includes RS256 in id_token_signing_alg_values_supported', async () => {
|
||||
const res = await request(app).get('/.well-known/openid-configuration');
|
||||
expect(res.body.id_token_signing_alg_values_supported).toContain('RS256');
|
||||
});
|
||||
});
|
||||
|
||||
// ── JWKS endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /.well-known/jwks.json', () => {
|
||||
it('returns JWKS with at least one key', async () => {
|
||||
const res = await request(app).get('/.well-known/jwks.json');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.keys).toBeInstanceOf(Array);
|
||||
expect(res.body.keys.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('returns keys with required JWK fields', async () => {
|
||||
const res = await request(app).get('/.well-known/jwks.json');
|
||||
const key = res.body.keys[0];
|
||||
|
||||
expect(key.kid).toBeDefined();
|
||||
expect(key.kty).toBeDefined();
|
||||
expect(key.use).toBe('sig');
|
||||
expect(key.alg).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not require authentication', async () => {
|
||||
const res = await request(app).get('/.well-known/jwks.json');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('sets Cache-Control: public, max-age=3600', async () => {
|
||||
const res = await request(app).get('/.well-known/jwks.json');
|
||||
expect(res.headers['cache-control']).toContain('public');
|
||||
expect(res.headers['cache-control']).toContain('max-age=3600');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Token endpoint with openid scope ─────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/token with openid scope', () => {
|
||||
it('returns id_token when openid scope is requested', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: agentId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'openid agents:read',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id_token).toBeDefined();
|
||||
expect(typeof res.body.id_token).toBe('string');
|
||||
expect(res.body.id_token.split('.')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('does not return id_token when openid scope is not requested', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: agentId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'agents:read',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('id_token is verifiable against JWKS from /.well-known/jwks.json', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
|
||||
// Issue token with openid scope
|
||||
const tokenRes = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: agentId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'openid agents:read',
|
||||
});
|
||||
|
||||
expect(tokenRes.status).toBe(200);
|
||||
const idToken: string = tokenRes.body.id_token;
|
||||
|
||||
// Fetch JWKS
|
||||
const jwksRes = await request(app).get('/.well-known/jwks.json');
|
||||
const jwks: IJWKSResponse = jwksRes.body;
|
||||
|
||||
// Decode header to get kid
|
||||
const decoded = jwt.decode(idToken, { complete: true });
|
||||
expect(decoded).not.toBeNull();
|
||||
const kid = decoded!.header.kid;
|
||||
|
||||
// Find matching key
|
||||
const matchingKey = jwks.keys.find((k) => k.kid === kid);
|
||||
expect(matchingKey).toBeDefined();
|
||||
|
||||
// Verify signature
|
||||
const publicKeyPem = jwkToPem(matchingKey!);
|
||||
const verified = jwt.verify(idToken, publicKeyPem, { algorithms: ['RS256', 'ES256'] });
|
||||
expect(verified).toBeDefined();
|
||||
const payload = verified as Record<string, unknown>;
|
||||
expect(payload['sub']).toBe(agentId);
|
||||
});
|
||||
|
||||
it('id_token contains correct agent claims', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: agentId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'openid agents:read',
|
||||
});
|
||||
|
||||
const idToken: string = res.body.id_token;
|
||||
const decoded = jwt.decode(idToken) as Record<string, unknown>;
|
||||
|
||||
expect(decoded['sub']).toBe(agentId);
|
||||
expect(decoded['iss']).toBe('https://idp.sentryagent.ai');
|
||||
expect(decoded['aud']).toBe(agentId);
|
||||
expect(decoded['agent_type']).toBe('screener');
|
||||
expect(decoded['deployment_env']).toBe('production');
|
||||
expect(decoded['organization_id']).toBeDefined();
|
||||
});
|
||||
|
||||
it('id_token header contains kid matching the JWKS', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
|
||||
const tokenRes = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: agentId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'openid agents:read',
|
||||
});
|
||||
|
||||
const jwksRes = await request(app).get('/.well-known/jwks.json');
|
||||
const jwks: IJWKSResponse = jwksRes.body;
|
||||
const jwksKids = jwks.keys.map((k) => k.kid);
|
||||
|
||||
const decoded = jwt.decode(tokenRes.body.id_token, { complete: true });
|
||||
expect(jwksKids).toContain(decoded!.header.kid);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent info endpoint ───────────────────────────────────────────────────
|
||||
|
||||
describe('GET /agent-info', () => {
|
||||
it('returns agent identity claims for authenticated caller', async () => {
|
||||
const { agentId } = await createAgentWithCredentials();
|
||||
const token = makeToken(agentId, 'openid agents:read');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/agent-info')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.sub).toBe(agentId);
|
||||
expect(res.body.agent_type).toBe('screener');
|
||||
expect(res.body.deployment_env).toBe('production');
|
||||
expect(res.body.organization_id).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 401 without a Bearer token', async () => {
|
||||
const res = await request(app).get('/agent-info');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 with an invalid Bearer token', async () => {
|
||||
const res = await request(app)
|
||||
.get('/agent-info')
|
||||
.set('Authorization', 'Bearer invalid.token.here');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('includes scope in the agent-info response', async () => {
|
||||
const { agentId } = await createAgentWithCredentials();
|
||||
const token = makeToken(agentId, 'openid agents:read');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/agent-info')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scope).toContain('openid');
|
||||
});
|
||||
|
||||
it('returns 404 for a token referencing a non-existent agent', async () => {
|
||||
const unknownAgentId = uuidv4();
|
||||
const token = makeToken(unknownAgentId, 'openid agents:read');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/agent-info')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
275
tests/unit/services/IDTokenService.test.ts
Normal file
275
tests/unit/services/IDTokenService.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
543
tests/unit/services/OIDCKeyService.test.ts
Normal file
543
tests/unit/services/OIDCKeyService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user