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,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);
});
});
});