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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user