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

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

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

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

View File

@@ -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<Application> {
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<Application> {
privateKey,
publicKey,
vaultClient,
idTokenService,
);
// ────────────────────────────────────────────────────────────────
@@ -156,6 +166,7 @@ export async function createApp(): Promise<Application> {
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<Application> {
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(

View File

@@ -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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@@ -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);

47
src/routes/oidc.ts Normal file
View File

@@ -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;
}

View File

@@ -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<IIDTokenClaims> {
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<string> {
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<IIDTokenClaims> {
// 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);
}
}
}

View File

@@ -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;
}
/**

View File

@@ -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<string, string>();
/**
* 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<void> {
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<IOIDCKey> {
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<OIDCKeyRow> = 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<IOIDCKey> {
const result: QueryResult<OIDCKeyRow> = 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<string> {
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<IJWKSResponse> {
const cacheKey = 'oidc:jwks';
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as IJWKSResponse;
}
const result: QueryResult<OIDCKeyRow> = 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<IOIDCKey> {
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<void> {
const result: QueryResult<OIDCKeyRow> = 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<string, string>;
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<string> {
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<void> {
await this.redis.del('oidc:jwks');
}
}

View File

@@ -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). */

152
src/types/oidc.ts Normal file
View File

@@ -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;
}