diff --git a/openspec/changes/phase-3-enterprise/tasks.md b/openspec/changes/phase-3-enterprise/tasks.md index 378994d..e18d384 100644 --- a/openspec/changes/phase-3-enterprise/tasks.md +++ b/openspec/changes/phase-3-enterprise/tasks.md @@ -57,18 +57,18 @@ ## Workstream 3: OpenID Connect (OIDC) -- [ ] 3.1 Write `src/db/migrations/014_create_oidc_keys_table.sql` — oidc_keys table with kid, public_key_jwk, vault_key_path, is_current -- [ ] 3.2 Write `src/services/OIDCKeyService.ts` — generateSigningKeyPair (RSA-2048 or EC P-256), storeKeyInVault, getPublicJWKS, getCurrentKeyId, rotateKey -- [ ] 3.3 Write `src/services/IDTokenService.ts` — buildIDTokenClaims (agent claims), signIDToken using current Vault-stored key, verifyIDToken -- [ ] 3.4 Write `src/types/oidc.ts` — IIDTokenClaims, IJWKSResponse, IOIDCDiscoveryDocument, IAgentInfoResponse interfaces -- [ ] 3.5 Write `src/controllers/OIDCController.ts` — handlers for discovery, JWKS, agent-info -- [ ] 3.6 Write `src/routes/oidc.ts` — mount `/.well-known/openid-configuration`, `/.well-known/jwks.json`, `/agent-info` -- [ ] 3.7 Update `src/services/OAuth2Service.ts` — when `openid` scope is present in request, generate and append `id_token` to token response -- [ ] 3.8 Implement JWKS caching — cache JWKS in Redis with TTL; invalidate on key rotation -- [ ] 3.9 Implement key rotation logic — on rotation, old key remains in JWKS until all tokens signed with it have expired -- [ ] 3.10 Write unit tests for OIDCKeyService and IDTokenService — key generation, token signing, JWKS format -- [ ] 3.11 Write integration tests — POST /oauth2/token with `openid` scope returns id_token; validate id_token against JWKS; GET /agent-info returns correct claims -- [ ] 3.12 QA sign-off: OIDC discovery document passes conformance checks, id_token verifiable, `alg: none` rejected, zero `any`, >80% coverage +- [x] 3.1 Write `src/db/migrations/014_create_oidc_keys_table.sql` — oidc_keys table with kid, public_key_jwk, vault_key_path, is_current +- [x] 3.2 Write `src/services/OIDCKeyService.ts` — generateSigningKeyPair (RSA-2048 or EC P-256), storeKeyInVault, getPublicJWKS, getCurrentKeyId, rotateKey +- [x] 3.3 Write `src/services/IDTokenService.ts` — buildIDTokenClaims (agent claims), signIDToken using current Vault-stored key, verifyIDToken +- [x] 3.4 Write `src/types/oidc.ts` — IIDTokenClaims, IJWKSResponse, IOIDCDiscoveryDocument, IAgentInfoResponse interfaces +- [x] 3.5 Write `src/controllers/OIDCController.ts` — handlers for discovery, JWKS, agent-info +- [x] 3.6 Write `src/routes/oidc.ts` — mount `/.well-known/openid-configuration`, `/.well-known/jwks.json`, `/agent-info` +- [x] 3.7 Update `src/services/OAuth2Service.ts` — when `openid` scope is present in request, generate and append `id_token` to token response +- [x] 3.8 Implement JWKS caching — cache JWKS in Redis with TTL; invalidate on key rotation +- [x] 3.9 Implement key rotation logic — on rotation, old key remains in JWKS until all tokens signed with it have expired +- [x] 3.10 Write unit tests for OIDCKeyService and IDTokenService — key generation, token signing, JWKS format +- [x] 3.11 Write integration tests — POST /oauth2/token with `openid` scope returns id_token; validate id_token against JWKS; GET /agent-info returns correct claims +- [x] 3.12 QA sign-off: OIDC discovery document passes conformance checks, id_token verifiable, `alg: none` rejected, zero `any`, >80% coverage --- diff --git a/src/app.ts b/src/app.ts index 6c32e7b..623c2dc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,8 @@ import { CredentialService } from './services/CredentialService.js'; import { OAuth2Service } from './services/OAuth2Service.js'; import { OrgService } from './services/OrgService.js'; import { DIDService } from './services/DIDService.js'; +import { OIDCKeyService } from './services/OIDCKeyService.js'; +import { IDTokenService } from './services/IDTokenService.js'; import { AgentController } from './controllers/AgentController.js'; import { TokenController } from './controllers/TokenController.js'; @@ -31,6 +33,7 @@ import { CredentialController } from './controllers/CredentialController.js'; import { AuditController } from './controllers/AuditController.js'; import { OrgController } from './controllers/OrgController.js'; import { DIDController } from './controllers/DIDController.js'; +import { OIDCController } from './controllers/OIDCController.js'; import { createAgentsRouter } from './routes/agents.js'; import { createTokenRouter } from './routes/token.js'; @@ -40,6 +43,7 @@ import { createHealthRouter } from './routes/health.js'; import { createMetricsRouter } from './routes/metrics.js'; import { createOrgsRouter } from './routes/organizations.js'; import { createDIDRouter } from './routes/did.js'; +import { createOIDCRouter } from './routes/oidc.js'; import { errorHandler } from './middleware/errorHandler.js'; import { createOpaMiddleware } from './middleware/opa.js'; @@ -132,6 +136,11 @@ export async function createApp(): Promise { throw new Error('JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required'); } + // OIDC services — initialised after DB pool is ready + const oidcKeyService = new OIDCKeyService(pool, redis as RedisClientType); + await oidcKeyService.ensureCurrentKey(); + const idTokenService = new IDTokenService(oidcKeyService); + const oauth2Service = new OAuth2Service( tokenRepo, credentialRepo, @@ -140,6 +149,7 @@ export async function createApp(): Promise { privateKey, publicKey, vaultClient, + idTokenService, ); // ──────────────────────────────────────────────────────────────── @@ -156,6 +166,7 @@ export async function createApp(): Promise { const auditController = new AuditController(auditService); const orgController = new OrgController(orgService); const didController = new DIDController(didService, agentRepo); + const oidcController = new OIDCController(oidcKeyService, agentRepo); // ──────────────────────────────────────────────────────────────── // Org context middleware — sets PostgreSQL session variable app.organization_id @@ -180,6 +191,9 @@ export async function createApp(): Promise { void didController.getInstanceDIDDocument(req, res, next); }); + // OIDC well-known endpoints and agent-info — mounted at root so /.well-known/* paths resolve + app.use('/', createOIDCRouter(oidcController, authMiddleware)); + app.use(`${API_BASE}/agents`, createAgentsRouter(agentController, opaMiddleware)); app.use(`${API_BASE}`, createDIDRouter(didController, authMiddleware, opaMiddleware)); app.use( diff --git a/src/controllers/OIDCController.ts b/src/controllers/OIDCController.ts new file mode 100644 index 0000000..08062fc --- /dev/null +++ b/src/controllers/OIDCController.ts @@ -0,0 +1,147 @@ +/** + * OIDCController — request handlers for OIDC discovery, JWKS, and agent-info endpoints. + * + * Handlers: + * GET /.well-known/openid-configuration → getDiscoveryDocument + * GET /.well-known/jwks.json → getJWKS + * GET /agent-info → getAgentInfo (protected) + */ + +import { Request, Response, NextFunction } from 'express'; + +import { OIDCKeyService } from '../services/OIDCKeyService.js'; +import { AgentRepository } from '../repositories/AgentRepository.js'; +import { AgentNotFoundError } from '../utils/errors.js'; +import { + IOIDCDiscoveryDocument, + IJWKSResponse, + IAgentInfoResponse, +} from '../types/oidc.js'; + +/** + * Controller for all OIDC-related endpoints. + * Delegates key management to OIDCKeyService and agent lookups to AgentRepository. + */ +export class OIDCController { + /** + * @param oidcKeyService - Service managing OIDC signing key pairs and JWKS. + * @param agentRepository - Repository for agent identity lookups. + */ + constructor( + private readonly oidcKeyService: OIDCKeyService, + private readonly agentRepository: AgentRepository, + ) {} + + // ───────────────────────────────────────────────────────────────────────────── + // Public handlers + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Returns the OpenID Connect Discovery 1.0 document. + * No authentication required — this is a public metadata endpoint. + * + * @param _req - Express request (unused). + * @param res - Express response. + * @param _next - Express next function (unused — no async errors possible). + */ + async getDiscoveryDocument( + _req: Request, + res: Response, + _next: NextFunction, + ): Promise { + const issuer = process.env['OIDC_ISSUER'] ?? 'https://idp.sentryagent.ai'; + + const document: IOIDCDiscoveryDocument = { + issuer, + authorization_endpoint: `${issuer}/oauth2/authorize`, + token_endpoint: `${issuer}/oauth2/token`, + jwks_uri: `${issuer}/.well-known/jwks.json`, + response_types_supported: ['token', 'id_token'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256', 'ES256'], + scopes_supported: [ + 'openid', + 'agents:read', + 'agents:write', + 'tokens:read', + 'audit:read', + 'admin:orgs', + ], + claims_supported: [ + 'sub', + 'iss', + 'aud', + 'iat', + 'exp', + 'agent_type', + 'deployment_env', + 'organization_id', + 'did', + ], + grant_types_supported: ['client_credentials'], + }; + + res.status(200).json(document); + } + + /** + * Returns the JWKS (JSON Web Key Set) for ID token verification. + * Includes all non-expired keys to support rotation grace periods. + * Sets a Cache-Control header to allow public caching for 1 hour. + * No authentication required — this is a public key material endpoint. + * + * @param _req - Express request (unused). + * @param res - Express response. + * @param next - Express next function — forwards errors to global error handler. + */ + async getJWKS(_req: Request, res: Response, next: NextFunction): Promise { + try { + const jwks: IJWKSResponse = await this.oidcKeyService.getPublicJWKS(); + res.set('Cache-Control', 'public, max-age=3600'); + res.status(200).json(jwks); + } catch (err) { + next(err); + } + } + + /** + * Returns agent identity claims for the authenticated caller. + * Equivalent to OIDC UserInfo endpoint — protected by authMiddleware. + * The authenticated agent's UUID is taken from `req.user.sub`. + * + * @param req - Express request with `req.user` populated by authMiddleware. + * @param res - Express response. + * @param next - Express next function — forwards errors to global error handler. + * @throws AgentNotFoundError if the agent referenced in the token no longer exists. + */ + async getAgentInfo(req: Request, res: Response, next: NextFunction): Promise { + try { + if (!req.user) { + // authMiddleware guarantees this; guard for type safety + throw new Error('req.user is not populated. Ensure authMiddleware runs first.'); + } + + const agentId = req.user.sub; + const agent = await this.agentRepository.findById(agentId); + if (!agent) { + throw new AgentNotFoundError(agentId); + } + + const response: IAgentInfoResponse = { + sub: agent.agentId, + agent_type: agent.agentType, + deployment_env: agent.deploymentEnv, + organization_id: agent.organizationId, + scope: req.user.scope, + }; + + if (agent.did !== undefined) { + response.did = agent.did; + } + + res.status(200).json(response); + } catch (err) { + next(err); + } + } +} diff --git a/src/db/migrations/014_create_oidc_keys_table.sql b/src/db/migrations/014_create_oidc_keys_table.sql new file mode 100644 index 0000000..c8df352 --- /dev/null +++ b/src/db/migrations/014_create_oidc_keys_table.sql @@ -0,0 +1,15 @@ +-- oidc_keys: stores RSA-2048 or EC P-256 signing key pairs for OIDC ID token issuance. +-- Only one key is current at a time. Old keys remain in the table until all tokens +-- signed with them have expired (key rotation grace period). +CREATE TABLE IF NOT EXISTS oidc_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kid VARCHAR(64) NOT NULL UNIQUE, -- Key ID, e.g. "key-20260330-001" + algorithm VARCHAR(16) NOT NULL, -- "RS256" or "ES256" + public_key_jwk JSONB NOT NULL, -- Public key in JWK format + vault_key_path VARCHAR(512) NOT NULL, -- Vault KV2 path for private key + is_current BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL -- when tokens signed with this key all expire +); +CREATE INDEX IF NOT EXISTS idx_oidc_keys_is_current ON oidc_keys(is_current) WHERE is_current = TRUE; +CREATE INDEX IF NOT EXISTS idx_oidc_keys_expires_at ON oidc_keys(expires_at); diff --git a/src/routes/oidc.ts b/src/routes/oidc.ts new file mode 100644 index 0000000..c7819b0 --- /dev/null +++ b/src/routes/oidc.ts @@ -0,0 +1,47 @@ +/** + * OIDC routes — discovery document, JWKS endpoint, and agent-info. + * /.well-known/openid-configuration and /.well-known/jwks.json are registered at app root. + * /agent-info is protected by authMiddleware. + * + * Mount this router at `/` in app.ts so that `/.well-known/*` paths work at the root level. + */ + +import { Router, RequestHandler } from 'express'; +import { OIDCController } from '../controllers/OIDCController.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; + +/** + * Creates and returns the Express router for OIDC endpoints. + * Mount at `/` (root) so that well-known paths resolve correctly. + * + * @param controller - The OIDC controller instance. + * @param authMiddleware - The JWT authentication middleware for protected endpoints. + * @returns Configured Express router. + */ +export function createOIDCRouter( + controller: OIDCController, + authMiddleware: RequestHandler, +): Router { + const router = Router(); + + // GET /.well-known/openid-configuration — unauthenticated OIDC discovery document + router.get( + '/.well-known/openid-configuration', + asyncHandler(controller.getDiscoveryDocument.bind(controller)), + ); + + // GET /.well-known/jwks.json — unauthenticated JWKS (public keys for ID token verification) + router.get( + '/.well-known/jwks.json', + asyncHandler(controller.getJWKS.bind(controller)), + ); + + // GET /agent-info — protected; returns agent identity claims for the authenticated caller + router.get( + '/agent-info', + authMiddleware, + asyncHandler(controller.getAgentInfo.bind(controller)), + ); + + return router; +} diff --git a/src/services/IDTokenService.ts b/src/services/IDTokenService.ts new file mode 100644 index 0000000..8e7f068 --- /dev/null +++ b/src/services/IDTokenService.ts @@ -0,0 +1,190 @@ +/** + * IDTokenService — builds and signs OIDC ID tokens for agent identities. + * ID tokens are signed RS256 or ES256 JWTs using the current OIDC signing key. + * They conform to OpenID Connect Core 1.0 §2 and contain agent-specific claims. + * + * Security constraints: + * - `alg: none` is explicitly rejected in verifyIDToken(). + * - Only RS256 and ES256 algorithms are accepted for verification. + * - Signature is verified against the JWKS from OIDCKeyService. + */ + +import jwt from 'jsonwebtoken'; +import { createPublicKey, JsonWebKey } from 'crypto'; + +import { OIDCKeyService } from './OIDCKeyService.js'; +import { IIDTokenClaims, IJWKSKey } from '../types/oidc.js'; +import { IAgent } from '../types/index.js'; +import { SentryAgentError } from '../utils/errors.js'; + +/** 401 — ID token failed verification. */ +class IDTokenVerificationError extends SentryAgentError { + constructor(reason: string) { + super(`ID token verification failed: ${reason}`, 'ID_TOKEN_INVALID', 401); + } +} + +/** + * Converts a JWK object to a PEM public key string using Node.js crypto. + * + * @param jwk - The IJWKSKey to convert. + * @returns A PEM-encoded public key string. + */ +function jwkToPem(jwk: IJWKSKey): string { + // createPublicKey accepts JsonWebKeyInput format + const keyObj = createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' }); + return keyObj.export({ type: 'spki', format: 'pem' }) as string; +} + +/** + * Service that builds, signs, and verifies OIDC ID tokens. + * Delegates key management to OIDCKeyService. + */ +export class IDTokenService { + /** + * @param oidcKeyService - Service that manages OIDC signing key pairs. + */ + constructor(private readonly oidcKeyService: OIDCKeyService) {} + + // ───────────────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Builds the full ID token claims payload for an agent. + * Follows OpenID Connect Core 1.0 §2 claim requirements. + * + * @param agent - The agent identity record. + * @param clientId - The OAuth 2.0 client_id that requested the token (becomes `aud`). + * @param scope - The granted OAuth 2.0 scope string. + * @param nonce - Optional nonce for replay protection (echoed from the token request). + * @returns The fully populated IIDTokenClaims object. + */ + async buildIDTokenClaims( + agent: IAgent, + clientId: string, + scope: string, + nonce?: string, + ): Promise { + const issuer = process.env['OIDC_ISSUER'] ?? 'https://idp.sentryagent.ai'; + const ttlSeconds = parseInt(process.env['OIDC_ID_TOKEN_TTL_SECONDS'] ?? '3600', 10); + const now = Math.floor(Date.now() / 1000); + + const claims: IIDTokenClaims = { + iss: issuer, + sub: agent.agentId, + aud: clientId, + iat: now, + exp: now + ttlSeconds, + agent_type: agent.agentType, + deployment_env: agent.deploymentEnv, + organization_id: agent.organizationId, + }; + + if (nonce !== undefined) { + claims.nonce = nonce; + } + + if (agent.did !== undefined) { + claims.did = agent.did; + } + + // scope is captured in the access token; included here for downstream consumers + void scope; + + return claims; + } + + /** + * Signs an ID token claims payload using the current OIDC signing key. + * The `kid` of the signing key is included in the JWT header for JWKS lookup. + * + * @param claims - The IIDTokenClaims payload to sign. + * @returns The signed JWT ID token string. + * @throws OIDCKeyNotFoundError if no current signing key is configured. + */ + async signIDToken(claims: IIDTokenClaims): Promise { + const currentKey = await this.oidcKeyService.getCurrentKey(); + const privateKeyPem = await this.oidcKeyService.getPrivateKeyPem( + currentKey.kid, + currentKey.vault_key_path, + ); + + const algorithm = currentKey.algorithm as 'RS256' | 'ES256'; + + return jwt.sign(claims, privateKeyPem, { + algorithm, + header: { + alg: algorithm, + kid: currentKey.kid, + typ: 'JWT', + }, + }); + } + + /** + * Verifies an ID token JWT. + * Fetches the JWKS from OIDCKeyService and selects the key matching the `kid` header. + * Explicitly rejects tokens with `alg: none`. + * + * @param token - The JWT ID token string to verify. + * @returns The verified IIDTokenClaims payload. + * @throws IDTokenVerificationError if verification fails for any reason. + */ + async verifyIDToken(token: string): Promise { + // Decode header without verification to extract kid and alg + const decoded = jwt.decode(token, { complete: true }); + if (!decoded || typeof decoded === 'string') { + throw new IDTokenVerificationError('token is malformed'); + } + + const header = decoded.header; + + // Explicitly reject alg: none + if (!header.alg || header.alg.toLowerCase() === 'none') { + throw new IDTokenVerificationError('alg:none tokens are not accepted'); + } + + const alg = header.alg; + if (alg !== 'RS256' && alg !== 'ES256') { + throw new IDTokenVerificationError( + `unsupported algorithm "${alg}"; only RS256 and ES256 are accepted`, + ); + } + + const kid = header.kid; + if (!kid) { + throw new IDTokenVerificationError('missing kid header'); + } + + // Fetch JWKS and find the matching key + const jwks = await this.oidcKeyService.getPublicJWKS(); + const jwkKey = jwks.keys.find((k) => k.kid === kid); + if (!jwkKey) { + throw new IDTokenVerificationError( + `no matching key found in JWKS for kid "${kid}"`, + ); + } + + // Convert JWK to PEM for jsonwebtoken verification + const publicKeyPem = jwkToPem(jwkKey); + + try { + const verifiedPayload = jwt.verify(token, publicKeyPem, { + algorithms: ['RS256', 'ES256'], + }); + + if (typeof verifiedPayload === 'string') { + throw new IDTokenVerificationError('unexpected string payload'); + } + + return verifiedPayload as IIDTokenClaims; + } catch (err) { + if (err instanceof IDTokenVerificationError) { + throw err; + } + const message = err instanceof Error ? err.message : 'unknown error'; + throw new IDTokenVerificationError(message); + } + } +} diff --git a/src/services/OAuth2Service.ts b/src/services/OAuth2Service.ts index c085cad..ad37e7b 100644 --- a/src/services/OAuth2Service.ts +++ b/src/services/OAuth2Service.ts @@ -8,6 +8,7 @@ import { CredentialRepository } from '../repositories/CredentialRepository.js'; import { AgentRepository } from '../repositories/AgentRepository.js'; import { AuditService } from './AuditService.js'; import { VaultClient } from '../vault/VaultClient.js'; +import { IDTokenService } from './IDTokenService.js'; import { ITokenPayload, ITokenResponse, @@ -46,6 +47,8 @@ export class OAuth2Service { * @param privateKey - PEM-encoded RSA private key for signing tokens. * @param publicKey - PEM-encoded RSA public key for verifying tokens. * @param vaultClient - Optional VaultClient for Phase 2 credential verification. + * @param idTokenService - Optional IDTokenService; when provided and `openid` scope + * is requested, an OIDC ID token is appended to the token response. */ constructor( private readonly tokenRepository: TokenRepository, @@ -55,6 +58,7 @@ export class OAuth2Service { private readonly privateKey: string, private readonly publicKey: string, private readonly vaultClient: VaultClient | null = null, + private readonly idTokenService: IDTokenService | null = null, ) {} /** @@ -207,12 +211,21 @@ export class OAuth2Service { // Instrument: count successful token issuances tokensIssuedTotal.inc({ scope }); - return { + const tokenResponse: ITokenResponse = { access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, scope, }; + + // OIDC: append id_token when the `openid` scope was requested and IDTokenService is wired + const scopeList = scope.split(' '); + if (scopeList.includes('openid') && this.idTokenService !== null) { + const claims = await this.idTokenService.buildIDTokenClaims(agent, clientId, scope); + tokenResponse.id_token = await this.idTokenService.signIDToken(claims); + } + + return tokenResponse; } /** diff --git a/src/services/OIDCKeyService.ts b/src/services/OIDCKeyService.ts new file mode 100644 index 0000000..d72bfd1 --- /dev/null +++ b/src/services/OIDCKeyService.ts @@ -0,0 +1,372 @@ +/** + * OIDCKeyService — manages OIDC signing key pairs for ID token issuance. + * Keys are RSA-2048 (RS256) or EC P-256 (ES256) depending on OIDC_KEY_ALGORITHM env var. + * Private keys are stored in Vault KV v2; public keys in the oidc_keys table. + * Only one key is `is_current = true` at a time. + * + * Key storage strategy (mirrors DIDService pattern): + * - When VAULT_ADDR + VAULT_TOKEN are set: private key is stored in Vault KV v2 at + * `{mount}/data/agentidp/oidc/keys/{kid}`. + * - When Vault is not configured (dev mode): `vault_key_path` column stores the marker + * `dev:no-vault` and the private key is held in-memory only (ephemeral). + */ + +import { Pool, QueryResult } from 'pg'; +import { generateKeyPairSync, KeyObject } from 'crypto'; +import nodeVault from 'node-vault'; +import { RedisClientType } from 'redis'; + +import { IOIDCKey, IJWKSKey, IJWKSResponse } from '../types/oidc.js'; +import { SentryAgentError } from '../utils/errors.js'; + +/** Raw database row for oidc_keys. */ +interface OIDCKeyRow { + id: string; + kid: string; + algorithm: string; + public_key_jwk: IJWKSKey; + vault_key_path: string; + is_current: boolean; + created_at: Date; + expires_at: Date; +} + +/** Dev-mode in-memory store for private keys (not persisted). */ +const devPrivateKeys = new Map(); + +/** + * 500 — No current OIDC signing key is available. + */ +class OIDCKeyNotFoundError extends SentryAgentError { + constructor() { + super( + 'No current OIDC signing key is configured. Call ensureCurrentKey() first.', + 'OIDC_KEY_NOT_FOUND', + 500, + ); + } +} + +/** + * Maps a raw database row to the IOIDCKey domain model. + * + * @param row - Raw row from the oidc_keys table. + * @returns Typed IOIDCKey object. + */ +function mapRowToOIDCKey(row: OIDCKeyRow): IOIDCKey { + return { + id: row.id, + kid: row.kid, + algorithm: row.algorithm, + public_key_jwk: row.public_key_jwk, + vault_key_path: row.vault_key_path, + is_current: row.is_current, + created_at: row.created_at, + expires_at: row.expires_at, + }; +} + +/** + * Service that manages RSA-2048 or EC P-256 signing key pairs for OIDC ID token issuance. + * Integrates with Vault for private key storage and Redis for JWKS caching. + */ +export class OIDCKeyService { + /** + * @param pool - PostgreSQL connection pool. + * @param redis - Redis client for JWKS caching. + */ + constructor( + private readonly pool: Pool, + private readonly redis: RedisClientType, + ) {} + + // ───────────────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Ensures a current signing key exists. If none exists, generates one. + * Idempotent — safe to call on every application startup. + * + * @returns Promise that resolves when a current key is guaranteed to exist. + */ + async ensureCurrentKey(): Promise { + const result: QueryResult<{ count: string }> = await this.pool.query( + `SELECT COUNT(*) AS count FROM oidc_keys WHERE is_current = TRUE`, + ); + const count = parseInt(result.rows[0].count, 10); + if (count === 0) { + await this.generateSigningKeyPair(); + } + } + + /** + * Generates a new RSA-2048 (RS256) or EC P-256 (ES256) signing key pair. + * Stores the private key in Vault (or in-memory for dev mode). + * Stores the public key in the oidc_keys table and sets it as the current key. + * The previously current key is demoted to is_current = false. + * + * Algorithm is controlled by the OIDC_KEY_ALGORITHM environment variable (default: RS256). + * + * @returns The newly created IOIDCKey record. + */ + async generateSigningKeyPair(): Promise { + const algorithm = this.getAlgorithm(); + const kid = this.buildKid(); + const ttlSeconds = this.getTokenTTLSeconds(); + + const { publicKey, privateKey } = this.generateKeyPair(algorithm); + + const publicKeyJwk = this.exportPublicJWK(publicKey, algorithm, kid); + const privateKeyPem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string; + + const vaultKeyPath = await this.storePrivateKey(kid, privateKeyPem); + + // Demote the previous current key + await this.pool.query( + `UPDATE oidc_keys SET is_current = FALSE WHERE is_current = TRUE`, + ); + + // Insert the new key + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + const insertResult: QueryResult = await this.pool.query( + `INSERT INTO oidc_keys (kid, algorithm, public_key_jwk, vault_key_path, is_current, expires_at) + VALUES ($1, $2, $3, $4, TRUE, $5) + RETURNING *`, + [kid, algorithm, JSON.stringify(publicKeyJwk), vaultKeyPath, expiresAt], + ); + + // Invalidate JWKS cache + await this.invalidateJWKSCache(); + + return mapRowToOIDCKey(insertResult.rows[0]); + } + + /** + * Returns the current signing key from the database. + * + * @returns The current IOIDCKey. + * @throws OIDCKeyNotFoundError if no current key is configured. + */ + async getCurrentKey(): Promise { + const result: QueryResult = await this.pool.query( + `SELECT * FROM oidc_keys WHERE is_current = TRUE LIMIT 1`, + ); + if (result.rows.length === 0) { + throw new OIDCKeyNotFoundError(); + } + return mapRowToOIDCKey(result.rows[0]); + } + + /** + * Returns the private key PEM for a given key ID. + * Fetches from Vault if configured; falls back to dev in-memory store. + * + * @param kid - The key ID. + * @param vaultKeyPath - The Vault path recorded in the database. + * @returns The PEM-encoded private key string. + * @throws Error if the private key cannot be retrieved. + */ + async getPrivateKeyPem(kid: string, vaultKeyPath: string): Promise { + if (vaultKeyPath === 'dev:no-vault') { + const pem = devPrivateKeys.get(kid); + if (!pem) { + throw new Error( + `Dev mode: private key for kid "${kid}" not found in memory. Was the process restarted?`, + ); + } + return pem; + } + + const vaultAddr = process.env['VAULT_ADDR']; + const vaultToken = process.env['VAULT_TOKEN']; + if (!vaultAddr || !vaultToken) { + throw new Error('VAULT_ADDR and VAULT_TOKEN are required to read private keys from Vault.'); + } + + const vault = nodeVault({ endpoint: vaultAddr, token: vaultToken }); + const response = await vault.read(vaultKeyPath) as { data: { data: { privateKeyPem: string } } }; + return response.data.data.privateKeyPem; + } + + /** + * Returns the JWKS (JSON Web Key Set) for all non-expired keys. + * Includes older keys that are past is_current=false but within their expires_at window, + * so consumers can still verify tokens issued before the last rotation. + * Result is cached in Redis with TTL from OIDC_JWKS_CACHE_TTL_SECONDS (default: 3600). + * + * @returns IJWKSResponse containing all valid public keys. + */ + async getPublicJWKS(): Promise { + const cacheKey = 'oidc:jwks'; + const cached = await this.redis.get(cacheKey); + if (cached !== null) { + return JSON.parse(cached) as IJWKSResponse; + } + + const result: QueryResult = await this.pool.query( + `SELECT * FROM oidc_keys WHERE expires_at > NOW() ORDER BY created_at DESC`, + ); + + const jwks: IJWKSResponse = { + keys: result.rows.map((row) => row.public_key_jwk), + }; + + const ttl = parseInt(process.env['OIDC_JWKS_CACHE_TTL_SECONDS'] ?? '3600', 10); + await this.redis.set(cacheKey, JSON.stringify(jwks), { EX: ttl }); + + return jwks; + } + + /** + * Generates a new key pair and promotes it to current. + * The old current key is demoted (is_current = false) but remains in the oidc_keys table + * until its expires_at passes — consumers can still verify tokens signed with it. + * + * @returns The new current IOIDCKey. + */ + async rotateKey(): Promise { + return this.generateSigningKeyPair(); + } + + /** + * Deletes all keys whose expires_at is in the past from both the database and Vault. + * Should be called periodically (e.g. via a cron job) to prevent table bloat. + * + * @returns Promise that resolves when all expired keys have been pruned. + */ + async pruneExpiredKeys(): Promise { + const result: QueryResult = await this.pool.query( + `DELETE FROM oidc_keys WHERE expires_at <= NOW() RETURNING *`, + ); + + // Remove private keys from Vault for each pruned key + const vaultAddr = process.env['VAULT_ADDR']; + const vaultToken = process.env['VAULT_TOKEN']; + + for (const row of result.rows) { + if (row.vault_key_path === 'dev:no-vault') { + devPrivateKeys.delete(row.kid); + continue; + } + if (vaultAddr && vaultToken) { + try { + const vault = nodeVault({ endpoint: vaultAddr, token: vaultToken }); + await vault.delete(row.vault_key_path); + } catch { + // Best-effort cleanup — log but do not throw + console.warn(`[OIDCKeyService] Failed to delete Vault key at ${row.vault_key_path}`); + } + } + } + + // Invalidate cache if any keys were pruned + if (result.rows.length > 0) { + await this.invalidateJWKSCache(); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Reads the OIDC key algorithm from environment. + * + * @returns "RS256" or "ES256". + */ + private getAlgorithm(): string { + const alg = process.env['OIDC_KEY_ALGORITHM'] ?? 'RS256'; + if (alg !== 'RS256' && alg !== 'ES256') { + throw new Error(`Unsupported OIDC_KEY_ALGORITHM: "${alg}". Must be RS256 or ES256.`); + } + return alg; + } + + /** + * Reads the ID token TTL from environment. + * + * @returns Token TTL in seconds (default: 3600). + */ + private getTokenTTLSeconds(): number { + return parseInt(process.env['OIDC_ID_TOKEN_TTL_SECONDS'] ?? '3600', 10); + } + + /** + * Builds a deterministic key ID based on the current timestamp. + * + * @returns A unique kid string. + */ + private buildKid(): string { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ''); + const ms = now.getTime().toString(36).toUpperCase(); + return `key-${date}-${ms}`; + } + + /** + * Generates an RSA-2048 or EC P-256 key pair depending on the algorithm. + * + * @param algorithm - "RS256" for RSA-2048, "ES256" for EC P-256. + * @returns The generated public and private key objects. + */ + private generateKeyPair(algorithm: string): { publicKey: KeyObject; privateKey: KeyObject } { + if (algorithm === 'ES256') { + return generateKeyPairSync('ec', { namedCurve: 'P-256' }); + } + // For RSA, use the overload that returns KeyObject (no encoding options). + return generateKeyPairSync('rsa', { modulusLength: 2048 }); + } + + /** + * Exports a public key as a JWK with OIDC-specific metadata fields. + * + * @param publicKey - The public KeyObject to export. + * @param algorithm - "RS256" or "ES256". + * @param kid - The key ID to embed in the JWK. + * @returns The JWK representation of the public key. + */ + private exportPublicJWK(publicKey: KeyObject, algorithm: string, kid: string): IJWKSKey { + const jwk = publicKey.export({ format: 'jwk' }) as Record; + return { + kid, + kty: jwk['kty'] ?? (algorithm === 'ES256' ? 'EC' : 'RSA'), + use: 'sig', + alg: algorithm, + ...(algorithm === 'RS256' + ? { n: jwk['n'], e: jwk['e'] } + : { crv: jwk['crv'], x: jwk['x'], y: jwk['y'] }), + }; + } + + /** + * Stores the private key PEM in Vault (if configured) or the dev in-memory map. + * + * @param kid - The key ID (used to build the Vault path and dev store key). + * @param privateKeyPem - The PKCS#8 PEM-encoded private key. + * @returns The Vault path where the key was stored, or "dev:no-vault". + */ + private async storePrivateKey(kid: string, privateKeyPem: string): Promise { + const vaultAddr = process.env['VAULT_ADDR']; + const vaultToken = process.env['VAULT_TOKEN']; + + if (vaultAddr && vaultToken) { + const mount = process.env['VAULT_MOUNT'] ?? 'secret'; + const vaultPath = `${mount}/data/agentidp/oidc/keys/${kid}`; + const vault = nodeVault({ endpoint: vaultAddr, token: vaultToken }); + await vault.write(vaultPath, { data: { privateKeyPem } }); + return vaultPath; + } + + // Dev mode: private key is held in-memory only — not persisted across restarts + devPrivateKeys.set(kid, privateKeyPem); + return 'dev:no-vault'; + } + + /** + * Invalidates the JWKS Redis cache entry. + */ + private async invalidateJWKSCache(): Promise { + await this.redis.del('oidc:jwks'); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 1ce182d..57a3539 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -200,6 +200,11 @@ export interface ITokenResponse { token_type: 'Bearer'; expires_in: number; scope: string; + /** + * OIDC ID token — included when the `openid` scope is requested. + * Signed RS256 or ES256 JWT containing agent identity claims. + */ + id_token?: string; } /** OAuth 2.0 error response (RFC 6749 §5.2). */ diff --git a/src/types/oidc.ts b/src/types/oidc.ts new file mode 100644 index 0000000..67720c0 --- /dev/null +++ b/src/types/oidc.ts @@ -0,0 +1,152 @@ +/** + * OIDC type definitions for SentryAgent.ai AgentIdP. + * Covers ID token claims, JWKS response, discovery document, and agent-info response. + */ + +// ============================================================================ +// JWKS Key and Response +// ============================================================================ + +/** + * A single JSON Web Key as returned in the JWKS endpoint. + * Supports both RSA (RS256) and EC P-256 (ES256) keys. + */ +export interface IJWKSKey { + /** Key ID — matches the `kid` header in signed JWTs. */ + kid: string; + /** Key type: "RSA" or "EC". */ + kty: string; + /** Intended use: always "sig" for signing keys. */ + use: string; + /** Algorithm: "RS256" or "ES256". */ + alg: string; + /** RSA: Base64url-encoded modulus. */ + n?: string; + /** RSA: Base64url-encoded public exponent. */ + e?: string; + /** EC: Curve name, e.g. "P-256". */ + crv?: string; + /** EC: Base64url-encoded x coordinate. */ + x?: string; + /** EC: Base64url-encoded y coordinate. */ + y?: string; +} + +/** + * JWKS (JSON Web Key Set) response returned by the `/.well-known/jwks.json` endpoint. + */ +export interface IJWKSResponse { + /** Array of JSON Web Keys. Includes all non-expired keys (for rotation grace period). */ + keys: IJWKSKey[]; +} + +// ============================================================================ +// OIDC Key record (database row) +// ============================================================================ + +/** + * Represents a row in the `oidc_keys` table. + */ +export interface IOIDCKey { + /** UUID primary key. */ + id: string; + /** Key identifier (e.g. "key-20260330-001"). Used as JWT `kid` header. */ + kid: string; + /** Signing algorithm: "RS256" or "ES256". */ + algorithm: string; + /** Public key in JWK format as stored in DB. */ + public_key_jwk: IJWKSKey; + /** Vault KV2 path where the private key is stored, or "dev:no-vault" in dev mode. */ + vault_key_path: string; + /** True if this is the active signing key. Only one key is current at a time. */ + is_current: boolean; + /** Timestamp when this key was generated. */ + created_at: Date; + /** Timestamp when all tokens signed with this key will have expired. */ + expires_at: Date; +} + +// ============================================================================ +// ID Token Claims +// ============================================================================ + +/** + * Claims payload of an OIDC ID token. + * Conforms to OpenID Connect Core 1.0 §2 with additional agent-specific claims. + */ +export interface IIDTokenClaims { + /** Issuer — the OIDC provider URL. */ + iss: string; + /** Subject — the agent UUID. */ + sub: string; + /** Audience — the client_id that requested the token. */ + aud: string; + /** Issued-at time (Unix seconds). */ + iat: number; + /** Expiry time (Unix seconds). */ + exp: number; + /** Nonce — if provided in the original request, echoed here for replay protection. */ + nonce?: string; + /** Functional classification of the agent. */ + agent_type: string; + /** Target deployment environment of the agent. */ + deployment_env: string; + /** Organization UUID the agent belongs to. */ + organization_id: string; + /** W3C DID identifier for the agent, if one has been generated. */ + did?: string; +} + +// ============================================================================ +// OIDC Discovery Document +// ============================================================================ + +/** + * OpenID Connect Discovery 1.0 document returned by `/.well-known/openid-configuration`. + * All standard fields are included; `authorization_endpoint` is a stub (not implemented in Phase 3). + */ +export interface IOIDCDiscoveryDocument { + /** OIDC Issuer URL. Must match the `iss` claim in ID tokens. */ + issuer: string; + /** Authorization endpoint (stub — not implemented in Phase 3). */ + authorization_endpoint: string; + /** Token endpoint for the client_credentials grant. */ + token_endpoint: string; + /** JWKS endpoint for ID token verification public keys. */ + jwks_uri: string; + /** Supported response types. */ + response_types_supported: string[]; + /** Supported subject types. */ + subject_types_supported: string[]; + /** Supported ID token signing algorithms. */ + id_token_signing_alg_values_supported: string[]; + /** Supported OAuth 2.0 scopes. */ + scopes_supported: string[]; + /** Claims that may appear in ID tokens or the agent-info response. */ + claims_supported: string[]; + /** Supported grant types. */ + grant_types_supported: string[]; +} + +// ============================================================================ +// Agent Info Response +// ============================================================================ + +/** + * Response body for the `GET /agent-info` endpoint. + * Returns agent identity claims for the authenticated agent, similar to OIDC UserInfo. + */ +export interface IAgentInfoResponse { + /** Agent UUID (subject). */ + sub: string; + /** Functional classification of the agent. */ + agent_type: string; + /** Target deployment environment of the agent. */ + deployment_env: string; + /** Organization UUID the agent belongs to. */ + organization_id: string; + /** W3C DID identifier, if one has been generated. */ + did?: string; + /** The OAuth 2.0 scope associated with the Bearer token used to call this endpoint. */ + scope: string; +} diff --git a/tests/integration/oidc.test.ts b/tests/integration/oidc.test.ts new file mode 100644 index 0000000..cb67144 --- /dev/null +++ b/tests/integration/oidc.test.ts @@ -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; + 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; + + 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); + }); + }); +}); diff --git a/tests/unit/services/IDTokenService.test.ts b/tests/unit/services/IDTokenService.test.ts new file mode 100644 index 0000000..9feef92 --- /dev/null +++ b/tests/unit/services/IDTokenService.test.ts @@ -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; + +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; + +// ─── 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; + 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; + + 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', + }); + }); + }); +}); diff --git a/tests/unit/services/OIDCKeyService.test.ts b/tests/unit/services/OIDCKeyService.test.ts new file mode 100644 index 0000000..8cc2886 --- /dev/null +++ b/tests/unit/services/OIDCKeyService.test.ts @@ -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(); + 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 { + 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'); + }); + }); +});