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:
@@ -57,18 +57,18 @@
|
|||||||
|
|
||||||
## Workstream 3: OpenID Connect (OIDC)
|
## 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
|
- [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
|
||||||
- [ ] 3.2 Write `src/services/OIDCKeyService.ts` — generateSigningKeyPair (RSA-2048 or EC P-256), storeKeyInVault, getPublicJWKS, getCurrentKeyId, rotateKey
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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`
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [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
|
||||||
- [ ] 3.12 QA sign-off: OIDC discovery document passes conformance checks, id_token verifiable, `alg: none` rejected, zero `any`, >80% coverage
|
- [x] 3.12 QA sign-off: OIDC discovery document passes conformance checks, id_token verifiable, `alg: none` rejected, zero `any`, >80% coverage
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
14
src/app.ts
14
src/app.ts
@@ -24,6 +24,8 @@ import { CredentialService } from './services/CredentialService.js';
|
|||||||
import { OAuth2Service } from './services/OAuth2Service.js';
|
import { OAuth2Service } from './services/OAuth2Service.js';
|
||||||
import { OrgService } from './services/OrgService.js';
|
import { OrgService } from './services/OrgService.js';
|
||||||
import { DIDService } from './services/DIDService.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 { AgentController } from './controllers/AgentController.js';
|
||||||
import { TokenController } from './controllers/TokenController.js';
|
import { TokenController } from './controllers/TokenController.js';
|
||||||
@@ -31,6 +33,7 @@ import { CredentialController } from './controllers/CredentialController.js';
|
|||||||
import { AuditController } from './controllers/AuditController.js';
|
import { AuditController } from './controllers/AuditController.js';
|
||||||
import { OrgController } from './controllers/OrgController.js';
|
import { OrgController } from './controllers/OrgController.js';
|
||||||
import { DIDController } from './controllers/DIDController.js';
|
import { DIDController } from './controllers/DIDController.js';
|
||||||
|
import { OIDCController } from './controllers/OIDCController.js';
|
||||||
|
|
||||||
import { createAgentsRouter } from './routes/agents.js';
|
import { createAgentsRouter } from './routes/agents.js';
|
||||||
import { createTokenRouter } from './routes/token.js';
|
import { createTokenRouter } from './routes/token.js';
|
||||||
@@ -40,6 +43,7 @@ import { createHealthRouter } from './routes/health.js';
|
|||||||
import { createMetricsRouter } from './routes/metrics.js';
|
import { createMetricsRouter } from './routes/metrics.js';
|
||||||
import { createOrgsRouter } from './routes/organizations.js';
|
import { createOrgsRouter } from './routes/organizations.js';
|
||||||
import { createDIDRouter } from './routes/did.js';
|
import { createDIDRouter } from './routes/did.js';
|
||||||
|
import { createOIDCRouter } from './routes/oidc.js';
|
||||||
|
|
||||||
import { errorHandler } from './middleware/errorHandler.js';
|
import { errorHandler } from './middleware/errorHandler.js';
|
||||||
import { createOpaMiddleware } from './middleware/opa.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');
|
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(
|
const oauth2Service = new OAuth2Service(
|
||||||
tokenRepo,
|
tokenRepo,
|
||||||
credentialRepo,
|
credentialRepo,
|
||||||
@@ -140,6 +149,7 @@ export async function createApp(): Promise<Application> {
|
|||||||
privateKey,
|
privateKey,
|
||||||
publicKey,
|
publicKey,
|
||||||
vaultClient,
|
vaultClient,
|
||||||
|
idTokenService,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
@@ -156,6 +166,7 @@ export async function createApp(): Promise<Application> {
|
|||||||
const auditController = new AuditController(auditService);
|
const auditController = new AuditController(auditService);
|
||||||
const orgController = new OrgController(orgService);
|
const orgController = new OrgController(orgService);
|
||||||
const didController = new DIDController(didService, agentRepo);
|
const didController = new DIDController(didService, agentRepo);
|
||||||
|
const oidcController = new OIDCController(oidcKeyService, agentRepo);
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// Org context middleware — sets PostgreSQL session variable app.organization_id
|
// 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);
|
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}/agents`, createAgentsRouter(agentController, opaMiddleware));
|
||||||
app.use(`${API_BASE}`, createDIDRouter(didController, authMiddleware, opaMiddleware));
|
app.use(`${API_BASE}`, createDIDRouter(didController, authMiddleware, opaMiddleware));
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
147
src/controllers/OIDCController.ts
Normal file
147
src/controllers/OIDCController.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/db/migrations/014_create_oidc_keys_table.sql
Normal file
15
src/db/migrations/014_create_oidc_keys_table.sql
Normal 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
47
src/routes/oidc.ts
Normal 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;
|
||||||
|
}
|
||||||
190
src/services/IDTokenService.ts
Normal file
190
src/services/IDTokenService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
|||||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||||
import { AuditService } from './AuditService.js';
|
import { AuditService } from './AuditService.js';
|
||||||
import { VaultClient } from '../vault/VaultClient.js';
|
import { VaultClient } from '../vault/VaultClient.js';
|
||||||
|
import { IDTokenService } from './IDTokenService.js';
|
||||||
import {
|
import {
|
||||||
ITokenPayload,
|
ITokenPayload,
|
||||||
ITokenResponse,
|
ITokenResponse,
|
||||||
@@ -46,6 +47,8 @@ export class OAuth2Service {
|
|||||||
* @param privateKey - PEM-encoded RSA private key for signing tokens.
|
* @param privateKey - PEM-encoded RSA private key for signing tokens.
|
||||||
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
|
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
|
||||||
* @param vaultClient - Optional VaultClient for Phase 2 credential verification.
|
* @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(
|
constructor(
|
||||||
private readonly tokenRepository: TokenRepository,
|
private readonly tokenRepository: TokenRepository,
|
||||||
@@ -55,6 +58,7 @@ export class OAuth2Service {
|
|||||||
private readonly privateKey: string,
|
private readonly privateKey: string,
|
||||||
private readonly publicKey: string,
|
private readonly publicKey: string,
|
||||||
private readonly vaultClient: VaultClient | null = null,
|
private readonly vaultClient: VaultClient | null = null,
|
||||||
|
private readonly idTokenService: IDTokenService | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,12 +211,21 @@ export class OAuth2Service {
|
|||||||
// Instrument: count successful token issuances
|
// Instrument: count successful token issuances
|
||||||
tokensIssuedTotal.inc({ scope });
|
tokensIssuedTotal.inc({ scope });
|
||||||
|
|
||||||
return {
|
const tokenResponse: ITokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expires_in: expiresIn,
|
expires_in: expiresIn,
|
||||||
scope,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
372
src/services/OIDCKeyService.ts
Normal file
372
src/services/OIDCKeyService.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,6 +200,11 @@ export interface ITokenResponse {
|
|||||||
token_type: 'Bearer';
|
token_type: 'Bearer';
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
scope: string;
|
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). */
|
/** OAuth 2.0 error response (RFC 6749 §5.2). */
|
||||||
|
|||||||
152
src/types/oidc.ts
Normal file
152
src/types/oidc.ts
Normal 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;
|
||||||
|
}
|
||||||
435
tests/integration/oidc.test.ts
Normal file
435
tests/integration/oidc.test.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for OIDC endpoints.
|
||||||
|
* Tests discovery document, JWKS, id_token issuance, and agent-info endpoint.
|
||||||
|
* Uses a real Postgres test DB and Redis test instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { Application } from 'express';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { createPublicKey, JsonWebKey } from 'crypto';
|
||||||
|
|
||||||
|
// Set test environment variables before importing app
|
||||||
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['DATABASE_URL'] =
|
||||||
|
process.env['TEST_DATABASE_URL'] ??
|
||||||
|
'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
|
||||||
|
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
|
||||||
|
process.env['JWT_PRIVATE_KEY'] = privateKey;
|
||||||
|
process.env['JWT_PUBLIC_KEY'] = publicKey;
|
||||||
|
process.env['NODE_ENV'] = 'test';
|
||||||
|
process.env['OIDC_ISSUER'] = 'https://idp.sentryagent.ai';
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { signToken } from '../../src/utils/jwt';
|
||||||
|
import { closePool } from '../../src/db/pool';
|
||||||
|
import { closeRedisClient } from '../../src/cache/redis';
|
||||||
|
import { IJWKSKey, IJWKSResponse } from '../../src/types/oidc';
|
||||||
|
|
||||||
|
function makeToken(sub: string, scope = 'agents:read agents:write tokens:read'): string {
|
||||||
|
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a JWK public key to a PEM string for jwt.verify.
|
||||||
|
*/
|
||||||
|
function jwkToPem(jwk: IJWKSKey): string {
|
||||||
|
const keyObj = createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' });
|
||||||
|
return keyObj.export({ type: 'spki', format: 'pem' }) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OIDC Integration Tests', () => {
|
||||||
|
let app: Application;
|
||||||
|
let pool: Pool;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createApp();
|
||||||
|
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||||
|
|
||||||
|
// Ensure all required tables exist for this test suite
|
||||||
|
const migrations = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
organization_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
plan_tier VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||||
|
max_agents INTEGER NOT NULL DEFAULT 10,
|
||||||
|
max_tokens_per_month INTEGER NOT NULL DEFAULT 10000,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL DEFAULT 'a0000000-0000-0000-0000-000000000000',
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
agent_type VARCHAR(32) NOT NULL,
|
||||||
|
version VARCHAR(64) NOT NULL,
|
||||||
|
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
owner VARCHAR(128) NOT NULL,
|
||||||
|
deployment_env VARCHAR(16) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
did VARCHAR(512),
|
||||||
|
did_created_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS credentials (
|
||||||
|
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id UUID NOT NULL,
|
||||||
|
organization_id UUID NOT NULL DEFAULT 'a0000000-0000-0000-0000-000000000000',
|
||||||
|
secret_hash VARCHAR(255) NOT NULL,
|
||||||
|
vault_path VARCHAR(512),
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
outcome VARCHAR(16) NOT NULL,
|
||||||
|
ip_address VARCHAR(64) NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS token_revocations (
|
||||||
|
jti UUID PRIMARY KEY,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS token_monthly_counts (
|
||||||
|
client_id UUID NOT NULL,
|
||||||
|
month_key VARCHAR(7) NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (client_id, month_key)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS oidc_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
kid VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
algorithm VARCHAR(16) NOT NULL,
|
||||||
|
public_key_jwk JSONB NOT NULL,
|
||||||
|
vault_key_path VARCHAR(512) NOT NULL,
|
||||||
|
is_current BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of migrations) {
|
||||||
|
await pool.query(sql);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await pool.query('DELETE FROM audit_events');
|
||||||
|
await pool.query('DELETE FROM token_revocations');
|
||||||
|
await pool.query('DELETE FROM token_monthly_counts');
|
||||||
|
await pool.query('DELETE FROM credentials');
|
||||||
|
await pool.query('DELETE FROM agents');
|
||||||
|
// Do NOT delete oidc_keys — ensureCurrentKey() runs once at app startup
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.query('DELETE FROM oidc_keys');
|
||||||
|
await pool.end();
|
||||||
|
await closePool();
|
||||||
|
await closeRedisClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAgentWithCredentials(): Promise<{
|
||||||
|
agentId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
orgId: string;
|
||||||
|
}> {
|
||||||
|
const agentId = uuidv4();
|
||||||
|
const orgId = 'a0000000-0000-0000-0000-000000000000';
|
||||||
|
const token = makeToken(agentId, 'agents:read agents:write tokens:read');
|
||||||
|
|
||||||
|
// Insert agent directly
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO agents
|
||||||
|
(agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||||
|
VALUES ($1, $2, $3, 'screener', '1.0.0', '{"agents:read"}', 'test-team', 'production', 'active')`,
|
||||||
|
[agentId, orgId, `oidc-test-${agentId}@test.ai`],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate credential via API
|
||||||
|
const credRes = await request(app)
|
||||||
|
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
return { agentId, clientSecret: credRes.body.clientSecret, orgId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Discovery document ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GET /.well-known/openid-configuration', () => {
|
||||||
|
it('returns a valid OIDC discovery document', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.issuer).toBe('https://idp.sentryagent.ai');
|
||||||
|
expect(res.body.token_endpoint).toContain('/oauth2/token');
|
||||||
|
expect(res.body.jwks_uri).toContain('/.well-known/jwks.json');
|
||||||
|
expect(res.body.authorization_endpoint).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes all required OIDC discovery fields', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
|
||||||
|
const requiredFields = [
|
||||||
|
'issuer',
|
||||||
|
'authorization_endpoint',
|
||||||
|
'token_endpoint',
|
||||||
|
'jwks_uri',
|
||||||
|
'response_types_supported',
|
||||||
|
'subject_types_supported',
|
||||||
|
'id_token_signing_alg_values_supported',
|
||||||
|
'scopes_supported',
|
||||||
|
'claims_supported',
|
||||||
|
'grant_types_supported',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
expect(res.body).toHaveProperty(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require authentication', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes openid in scopes_supported', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
expect(res.body.scopes_supported).toContain('openid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes RS256 in id_token_signing_alg_values_supported', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
expect(res.body.id_token_signing_alg_values_supported).toContain('RS256');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── JWKS endpoint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GET /.well-known/jwks.json', () => {
|
||||||
|
it('returns JWKS with at least one key', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/jwks.json');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.keys).toBeInstanceOf(Array);
|
||||||
|
expect(res.body.keys.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns keys with required JWK fields', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/jwks.json');
|
||||||
|
const key = res.body.keys[0];
|
||||||
|
|
||||||
|
expect(key.kid).toBeDefined();
|
||||||
|
expect(key.kty).toBeDefined();
|
||||||
|
expect(key.use).toBe('sig');
|
||||||
|
expect(key.alg).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require authentication', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/jwks.json');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets Cache-Control: public, max-age=3600', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/jwks.json');
|
||||||
|
expect(res.headers['cache-control']).toContain('public');
|
||||||
|
expect(res.headers['cache-control']).toContain('max-age=3600');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Token endpoint with openid scope ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('POST /api/v1/token with openid scope', () => {
|
||||||
|
it('returns id_token when openid scope is requested', async () => {
|
||||||
|
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/token')
|
||||||
|
.type('form')
|
||||||
|
.send({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: agentId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
scope: 'openid agents:read',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.id_token).toBeDefined();
|
||||||
|
expect(typeof res.body.id_token).toBe('string');
|
||||||
|
expect(res.body.id_token.split('.')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return id_token when openid scope is not requested', async () => {
|
||||||
|
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/token')
|
||||||
|
.type('form')
|
||||||
|
.send({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: agentId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
scope: 'agents:read',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.id_token).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('id_token is verifiable against JWKS from /.well-known/jwks.json', async () => {
|
||||||
|
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||||
|
|
||||||
|
// Issue token with openid scope
|
||||||
|
const tokenRes = await request(app)
|
||||||
|
.post('/api/v1/token')
|
||||||
|
.type('form')
|
||||||
|
.send({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: agentId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
scope: 'openid agents:read',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tokenRes.status).toBe(200);
|
||||||
|
const idToken: string = tokenRes.body.id_token;
|
||||||
|
|
||||||
|
// Fetch JWKS
|
||||||
|
const jwksRes = await request(app).get('/.well-known/jwks.json');
|
||||||
|
const jwks: IJWKSResponse = jwksRes.body;
|
||||||
|
|
||||||
|
// Decode header to get kid
|
||||||
|
const decoded = jwt.decode(idToken, { complete: true });
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
const kid = decoded!.header.kid;
|
||||||
|
|
||||||
|
// Find matching key
|
||||||
|
const matchingKey = jwks.keys.find((k) => k.kid === kid);
|
||||||
|
expect(matchingKey).toBeDefined();
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
const publicKeyPem = jwkToPem(matchingKey!);
|
||||||
|
const verified = jwt.verify(idToken, publicKeyPem, { algorithms: ['RS256', 'ES256'] });
|
||||||
|
expect(verified).toBeDefined();
|
||||||
|
const payload = verified as Record<string, unknown>;
|
||||||
|
expect(payload['sub']).toBe(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('id_token contains correct agent claims', async () => {
|
||||||
|
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/token')
|
||||||
|
.type('form')
|
||||||
|
.send({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: agentId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
scope: 'openid agents:read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const idToken: string = res.body.id_token;
|
||||||
|
const decoded = jwt.decode(idToken) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(decoded['sub']).toBe(agentId);
|
||||||
|
expect(decoded['iss']).toBe('https://idp.sentryagent.ai');
|
||||||
|
expect(decoded['aud']).toBe(agentId);
|
||||||
|
expect(decoded['agent_type']).toBe('screener');
|
||||||
|
expect(decoded['deployment_env']).toBe('production');
|
||||||
|
expect(decoded['organization_id']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('id_token header contains kid matching the JWKS', async () => {
|
||||||
|
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||||
|
|
||||||
|
const tokenRes = await request(app)
|
||||||
|
.post('/api/v1/token')
|
||||||
|
.type('form')
|
||||||
|
.send({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: agentId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
scope: 'openid agents:read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwksRes = await request(app).get('/.well-known/jwks.json');
|
||||||
|
const jwks: IJWKSResponse = jwksRes.body;
|
||||||
|
const jwksKids = jwks.keys.map((k) => k.kid);
|
||||||
|
|
||||||
|
const decoded = jwt.decode(tokenRes.body.id_token, { complete: true });
|
||||||
|
expect(jwksKids).toContain(decoded!.header.kid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent info endpoint ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GET /agent-info', () => {
|
||||||
|
it('returns agent identity claims for authenticated caller', async () => {
|
||||||
|
const { agentId } = await createAgentWithCredentials();
|
||||||
|
const token = makeToken(agentId, 'openid agents:read');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/agent-info')
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.sub).toBe(agentId);
|
||||||
|
expect(res.body.agent_type).toBe('screener');
|
||||||
|
expect(res.body.deployment_env).toBe('production');
|
||||||
|
expect(res.body.organization_id).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 without a Bearer token', async () => {
|
||||||
|
const res = await request(app).get('/agent-info');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 with an invalid Bearer token', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/agent-info')
|
||||||
|
.set('Authorization', 'Bearer invalid.token.here');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes scope in the agent-info response', async () => {
|
||||||
|
const { agentId } = await createAgentWithCredentials();
|
||||||
|
const token = makeToken(agentId, 'openid agents:read');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/agent-info')
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.scope).toContain('openid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for a token referencing a non-existent agent', async () => {
|
||||||
|
const unknownAgentId = uuidv4();
|
||||||
|
const token = makeToken(unknownAgentId, 'openid agents:read');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/agent-info')
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
275
tests/unit/services/IDTokenService.test.ts
Normal file
275
tests/unit/services/IDTokenService.test.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for src/services/IDTokenService.ts
|
||||||
|
* Mocks OIDCKeyService; uses real RSA key pairs for signing/verification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { IDTokenService } from '../../../src/services/IDTokenService';
|
||||||
|
import { OIDCKeyService } from '../../../src/services/OIDCKeyService';
|
||||||
|
import { IAgent } from '../../../src/types/index';
|
||||||
|
import { IOIDCKey, IJWKSKey, IJWKSResponse } from '../../../src/types/oidc';
|
||||||
|
|
||||||
|
// ─── Real RSA key pair for signing tests ─────────────────────────────────────
|
||||||
|
|
||||||
|
const { privateKey: rsaPrivateKey, publicKey: rsaPublicKey } = crypto.generateKeyPairSync('rsa', {
|
||||||
|
modulusLength: 2048,
|
||||||
|
});
|
||||||
|
|
||||||
|
const privateKeyPem = rsaPrivateKey.export({ format: 'pem', type: 'pkcs8' }) as string;
|
||||||
|
const publicKeyJwkRaw = rsaPublicKey.export({ format: 'jwk' }) as Record<string, string>;
|
||||||
|
|
||||||
|
const TEST_KID = 'key-test-rsa-001';
|
||||||
|
|
||||||
|
const testPublicJwk: IJWKSKey = {
|
||||||
|
kid: TEST_KID,
|
||||||
|
kty: publicKeyJwkRaw['kty'] ?? 'RSA',
|
||||||
|
use: 'sig',
|
||||||
|
alg: 'RS256',
|
||||||
|
n: publicKeyJwkRaw['n'],
|
||||||
|
e: publicKeyJwkRaw['e'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCurrentKey: IOIDCKey = {
|
||||||
|
id: 'uuid-test',
|
||||||
|
kid: TEST_KID,
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: testPublicJwk,
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwks: IJWKSResponse = { keys: [testPublicJwk] };
|
||||||
|
|
||||||
|
// ─── Mock OIDCKeyService ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
jest.mock('../../../src/services/OIDCKeyService');
|
||||||
|
|
||||||
|
const MockOIDCKeyService = OIDCKeyService as jest.MockedClass<typeof OIDCKeyService>;
|
||||||
|
|
||||||
|
// ─── Mock agent ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_AGENT: IAgent = {
|
||||||
|
agentId: 'agent-uuid-001',
|
||||||
|
organizationId: 'org-uuid-001',
|
||||||
|
email: 'agent@sentryagent.ai',
|
||||||
|
agentType: 'screener',
|
||||||
|
version: '1.0.0',
|
||||||
|
capabilities: ['agents:read'],
|
||||||
|
owner: 'team-alpha',
|
||||||
|
deploymentEnv: 'production',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('IDTokenService', () => {
|
||||||
|
let mockKeyService: jest.Mocked<OIDCKeyService>;
|
||||||
|
let service: IDTokenService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delete process.env['OIDC_ISSUER'];
|
||||||
|
delete process.env['OIDC_ID_TOKEN_TTL_SECONDS'];
|
||||||
|
|
||||||
|
mockKeyService = new MockOIDCKeyService(
|
||||||
|
null as unknown as import('pg').Pool,
|
||||||
|
null as unknown as import('redis').RedisClientType,
|
||||||
|
) as jest.Mocked<OIDCKeyService>;
|
||||||
|
|
||||||
|
mockKeyService.getCurrentKey.mockResolvedValue(mockCurrentKey);
|
||||||
|
mockKeyService.getPrivateKeyPem.mockResolvedValue(privateKeyPem);
|
||||||
|
mockKeyService.getPublicJWKS.mockResolvedValue(mockJwks);
|
||||||
|
|
||||||
|
service = new IDTokenService(mockKeyService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── buildIDTokenClaims ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildIDTokenClaims()', () => {
|
||||||
|
it('includes all required OIDC claims', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid agents:read');
|
||||||
|
|
||||||
|
expect(claims.iss).toBe('https://idp.sentryagent.ai');
|
||||||
|
expect(claims.sub).toBe(MOCK_AGENT.agentId);
|
||||||
|
expect(claims.aud).toBe('client-abc');
|
||||||
|
expect(claims.iat).toBeDefined();
|
||||||
|
expect(claims.exp).toBeDefined();
|
||||||
|
expect(claims.exp).toBeGreaterThan(claims.iat);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes agent-specific claims', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
|
||||||
|
expect(claims.agent_type).toBe(MOCK_AGENT.agentType);
|
||||||
|
expect(claims.deployment_env).toBe(MOCK_AGENT.deploymentEnv);
|
||||||
|
expect(claims.organization_id).toBe(MOCK_AGENT.organizationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes nonce when provided', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(
|
||||||
|
MOCK_AGENT,
|
||||||
|
'client-abc',
|
||||||
|
'openid',
|
||||||
|
'test-nonce-xyz',
|
||||||
|
);
|
||||||
|
expect(claims.nonce).toBe('test-nonce-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits nonce when not provided', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
expect(claims.nonce).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes did when the agent has a DID', async () => {
|
||||||
|
const agentWithDID: IAgent = {
|
||||||
|
...MOCK_AGENT,
|
||||||
|
did: 'did:web:idp.sentryagent.ai:agents:agent-uuid-001',
|
||||||
|
};
|
||||||
|
const claims = await service.buildIDTokenClaims(agentWithDID, 'client-abc', 'openid');
|
||||||
|
expect(claims.did).toBe(agentWithDID.did);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits did when the agent does not have a DID', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
expect(claims.did).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses OIDC_ISSUER env var when set', async () => {
|
||||||
|
process.env['OIDC_ISSUER'] = 'https://my-custom-issuer.example.com';
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
expect(claims.iss).toBe('https://my-custom-issuer.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses OIDC_ID_TOKEN_TTL_SECONDS for expiry', async () => {
|
||||||
|
process.env['OIDC_ID_TOKEN_TTL_SECONDS'] = '7200';
|
||||||
|
const before = Math.floor(Date.now() / 1000);
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
const after = Math.floor(Date.now() / 1000);
|
||||||
|
expect(claims.exp - claims.iat).toBeGreaterThanOrEqual(7200 - 1);
|
||||||
|
expect(claims.exp).toBeGreaterThanOrEqual(before + 7200);
|
||||||
|
expect(claims.exp).toBeLessThanOrEqual(after + 7200 + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── signIDToken ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('signIDToken()', () => {
|
||||||
|
it('produces a valid JWT string', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
const token = await service.signIDToken(claims);
|
||||||
|
|
||||||
|
expect(typeof token).toBe('string');
|
||||||
|
expect(token.split('.')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the kid in the JWT header', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
const token = await service.signIDToken(claims);
|
||||||
|
|
||||||
|
const decoded = jwt.decode(token, { complete: true });
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
expect(decoded!.header.kid).toBe(TEST_KID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs with RS256 algorithm by default', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
const token = await service.signIDToken(claims);
|
||||||
|
|
||||||
|
const decoded = jwt.decode(token, { complete: true });
|
||||||
|
expect(decoded!.header.alg).toBe('RS256');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── verifyIDToken ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('verifyIDToken()', () => {
|
||||||
|
it('verifies a valid ID token and returns claims', async () => {
|
||||||
|
const claims = await service.buildIDTokenClaims(MOCK_AGENT, 'client-abc', 'openid');
|
||||||
|
const token = await service.signIDToken(claims);
|
||||||
|
|
||||||
|
const verified = await service.verifyIDToken(token);
|
||||||
|
expect(verified.sub).toBe(MOCK_AGENT.agentId);
|
||||||
|
expect(verified.iss).toBe('https://idp.sentryagent.ai');
|
||||||
|
expect(verified.aud).toBe('client-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects alg:none tokens', async () => {
|
||||||
|
// Craft a token with alg:none manually
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({ sub: 'attacker', iss: 'evil', aud: 'client', iat: 0, exp: 9999999999 })).toString('base64url');
|
||||||
|
const noneToken = `${header}.${payload}.`;
|
||||||
|
|
||||||
|
await expect(service.verifyIDToken(noneToken)).rejects.toMatchObject({
|
||||||
|
code: 'ID_TOKEN_INVALID',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expired tokens', async () => {
|
||||||
|
// Sign a token that was issued in the past and has already expired
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiredClaims = {
|
||||||
|
iss: 'https://idp.sentryagent.ai',
|
||||||
|
sub: MOCK_AGENT.agentId,
|
||||||
|
aud: 'client-abc',
|
||||||
|
iat: now - 7200,
|
||||||
|
exp: now - 3600,
|
||||||
|
agent_type: 'screener',
|
||||||
|
deployment_env: 'production',
|
||||||
|
organization_id: 'org-uuid-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign with the real private key (same as what mockKeyService returns)
|
||||||
|
const expiredToken = jwt.sign(expiredClaims, privateKeyPem, {
|
||||||
|
algorithm: 'RS256',
|
||||||
|
header: { alg: 'RS256', kid: TEST_KID, typ: 'JWT' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.verifyIDToken(expiredToken)).rejects.toMatchObject({
|
||||||
|
code: 'ID_TOKEN_INVALID',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tokens whose kid is not in the JWKS', async () => {
|
||||||
|
const unknownKidHeader = Buffer.from(JSON.stringify({ alg: 'RS256', kid: 'unknown-kid', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({ sub: 'x' })).toString('base64url');
|
||||||
|
const fakeToken = `${unknownKidHeader}.${payload}.fakesig`;
|
||||||
|
|
||||||
|
await expect(service.verifyIDToken(fakeToken)).rejects.toMatchObject({
|
||||||
|
code: 'ID_TOKEN_INVALID',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed tokens', async () => {
|
||||||
|
await expect(service.verifyIDToken('not.a.jwt')).rejects.toMatchObject({
|
||||||
|
code: 'ID_TOKEN_INVALID',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tokens with missing kid header', async () => {
|
||||||
|
// Craft a token with no kid in the header
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({ sub: 'x', exp: 9999999999 })).toString('base64url');
|
||||||
|
const noKidToken = `${header}.${payload}.fakesig`;
|
||||||
|
|
||||||
|
await expect(service.verifyIDToken(noKidToken)).rejects.toMatchObject({
|
||||||
|
code: 'ID_TOKEN_INVALID',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tokens with unsupported algorithm (HS256)', async () => {
|
||||||
|
// Craft a token header claiming HS256
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', kid: TEST_KID, typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({ sub: 'x', exp: 9999999999 })).toString('base64url');
|
||||||
|
const fakeToken = `${header}.${payload}.fakesig`;
|
||||||
|
|
||||||
|
await expect(service.verifyIDToken(fakeToken)).rejects.toMatchObject({
|
||||||
|
code: 'ID_TOKEN_INVALID',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
543
tests/unit/services/OIDCKeyService.test.ts
Normal file
543
tests/unit/services/OIDCKeyService.test.ts
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for src/services/OIDCKeyService.ts
|
||||||
|
* Mocks pg Pool and node-vault; uses a real in-memory Redis stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { RedisClientType } from 'redis';
|
||||||
|
import { OIDCKeyService } from '../../../src/services/OIDCKeyService';
|
||||||
|
import { IOIDCKey, IJWKSKey } from '../../../src/types/oidc';
|
||||||
|
|
||||||
|
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
jest.mock('pg', () => {
|
||||||
|
const mQuery = jest.fn();
|
||||||
|
const mPool = { query: mQuery };
|
||||||
|
return { Pool: jest.fn(() => mPool) };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('node-vault', () => {
|
||||||
|
return jest.fn(() => ({
|
||||||
|
write: jest.fn().mockResolvedValue({}),
|
||||||
|
read: jest.fn().mockResolvedValue({
|
||||||
|
data: { data: { privateKeyPem: 'mock-pem' } },
|
||||||
|
}),
|
||||||
|
delete: jest.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeRedis(): RedisClientType {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
get: jest.fn(async (key: string) => store.get(key) ?? null),
|
||||||
|
set: jest.fn(async (key: string, value: string, _opts?: unknown) => {
|
||||||
|
store.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
}),
|
||||||
|
del: jest.fn(async (key: string) => {
|
||||||
|
store.delete(key);
|
||||||
|
return 1;
|
||||||
|
}),
|
||||||
|
} as unknown as RedisClientType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSampleJwk(kid = 'key-test-001'): IJWKSKey {
|
||||||
|
return { kid, kty: 'RSA', use: 'sig', alg: 'RS256', n: 'abc', e: 'AQAB' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSampleRow(overrides: Partial<IOIDCKey> = {}): IOIDCKey {
|
||||||
|
return {
|
||||||
|
id: 'uuid-1',
|
||||||
|
kid: 'key-test-001',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk(),
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('OIDCKeyService', () => {
|
||||||
|
let pool: Pool;
|
||||||
|
let poolQuery: jest.Mock;
|
||||||
|
let redis: RedisClientType;
|
||||||
|
let service: OIDCKeyService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delete process.env['VAULT_ADDR'];
|
||||||
|
delete process.env['VAULT_TOKEN'];
|
||||||
|
delete process.env['VAULT_MOUNT'];
|
||||||
|
delete process.env['OIDC_KEY_ALGORITHM'];
|
||||||
|
delete process.env['OIDC_ID_TOKEN_TTL_SECONDS'];
|
||||||
|
|
||||||
|
pool = new Pool();
|
||||||
|
poolQuery = pool.query as jest.Mock;
|
||||||
|
redis = makeRedis();
|
||||||
|
service = new OIDCKeyService(pool, redis);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ensureCurrentKey ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ensureCurrentKey()', () => {
|
||||||
|
it('generates a key when no current key exists', async () => {
|
||||||
|
// COUNT returns 0 → no current key
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [{ count: '0' }] }) // ensureCurrentKey COUNT
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote old key
|
||||||
|
.mockResolvedValueOnce({ // INSERT new key
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'uuid-1',
|
||||||
|
kid: 'key-abc',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk('key-abc'),
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.ensureCurrentKey();
|
||||||
|
|
||||||
|
// Should have called INSERT (i.e. generateSigningKeyPair was invoked)
|
||||||
|
expect(poolQuery).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — does not generate when a current key already exists', async () => {
|
||||||
|
// COUNT returns 1 → key exists
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] });
|
||||||
|
|
||||||
|
await service.ensureCurrentKey();
|
||||||
|
// Only the COUNT query should have been executed
|
||||||
|
expect(poolQuery).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('called twice only generates one key', async () => {
|
||||||
|
// First call: no key → generates
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [{ count: '0' }] })
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'uuid-1',
|
||||||
|
kid: 'key-abc',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk('key-abc'),
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second call: key now exists → no generation
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] });
|
||||||
|
|
||||||
|
await service.ensureCurrentKey();
|
||||||
|
await service.ensureCurrentKey();
|
||||||
|
|
||||||
|
// First call: COUNT + UPDATE + INSERT = 3; second call: COUNT = 1 → total 4
|
||||||
|
expect(poolQuery).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── generateSigningKeyPair ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('generateSigningKeyPair()', () => {
|
||||||
|
it('generates an RSA key pair and inserts into the database', async () => {
|
||||||
|
const row = {
|
||||||
|
id: 'uuid-2',
|
||||||
|
kid: 'key-test-002',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk('key-test-002'),
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
|
||||||
|
.mockResolvedValueOnce({ rows: [row] }); // INSERT
|
||||||
|
|
||||||
|
const result = await service.generateSigningKeyPair();
|
||||||
|
|
||||||
|
expect(result.algorithm).toBe('RS256');
|
||||||
|
expect(result.is_current).toBe(true);
|
||||||
|
expect(result.vault_key_path).toBe('dev:no-vault');
|
||||||
|
expect(result.public_key_jwk.use).toBe('sig');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when OIDC_KEY_ALGORITHM is unsupported', async () => {
|
||||||
|
process.env['OIDC_KEY_ALGORITHM'] = 'RS512';
|
||||||
|
await expect(service.generateSigningKeyPair()).rejects.toThrow(
|
||||||
|
'Unsupported OIDC_KEY_ALGORITHM',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates an EC P-256 key pair when OIDC_KEY_ALGORITHM=ES256', async () => {
|
||||||
|
process.env['OIDC_KEY_ALGORITHM'] = 'ES256';
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: 'uuid-3',
|
||||||
|
kid: 'key-test-003',
|
||||||
|
algorithm: 'ES256',
|
||||||
|
public_key_jwk: { kid: 'key-test-003', kty: 'EC', use: 'sig', alg: 'ES256', crv: 'P-256', x: 'x', y: 'y' },
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rows: [row] });
|
||||||
|
|
||||||
|
const result = await service.generateSigningKeyPair();
|
||||||
|
expect(result.algorithm).toBe('ES256');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores private key as dev:no-vault in dev mode (no Vault env vars)', async () => {
|
||||||
|
const row = {
|
||||||
|
id: 'uuid-4',
|
||||||
|
kid: 'key-test-004',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk('key-test-004'),
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rows: [row] });
|
||||||
|
|
||||||
|
const result = await service.generateSigningKeyPair();
|
||||||
|
expect(result.vault_key_path).toBe('dev:no-vault');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates JWKS Redis cache after generating a new key', async () => {
|
||||||
|
const row = {
|
||||||
|
id: 'uuid-5',
|
||||||
|
kid: 'key-test-005',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk('key-test-005'),
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rows: [row] });
|
||||||
|
|
||||||
|
await service.generateSigningKeyPair();
|
||||||
|
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getCurrentKey ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getCurrentKey()', () => {
|
||||||
|
it('returns the current key from the database', async () => {
|
||||||
|
const row = makeSampleRow();
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [row] });
|
||||||
|
|
||||||
|
const key = await service.getCurrentKey();
|
||||||
|
expect(key.is_current).toBe(true);
|
||||||
|
expect(key.kid).toBe('key-test-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws OIDCKeyNotFoundError when no current key exists', async () => {
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await expect(service.getCurrentKey()).rejects.toMatchObject({
|
||||||
|
code: 'OIDC_KEY_NOT_FOUND',
|
||||||
|
httpStatus: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── rotateKey ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('rotateKey()', () => {
|
||||||
|
it('promotes a new key and the old key remains queryable', async () => {
|
||||||
|
const oldRow = makeSampleRow({ kid: 'key-old', is_current: false });
|
||||||
|
const newRow = makeSampleRow({ kid: 'key-new', is_current: true });
|
||||||
|
|
||||||
|
// generateSigningKeyPair: UPDATE + INSERT
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
|
||||||
|
.mockResolvedValueOnce({ rows: [newRow] }); // INSERT new
|
||||||
|
|
||||||
|
const result = await service.rotateKey();
|
||||||
|
expect(result.kid).toBe('key-new');
|
||||||
|
expect(result.is_current).toBe(true);
|
||||||
|
|
||||||
|
// Simulate getPublicJWKS returning both (old still in expires_at window)
|
||||||
|
poolQuery.mockResolvedValueOnce({
|
||||||
|
rows: [oldRow, newRow],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwks = await service.getPublicJWKS();
|
||||||
|
expect(jwks.keys).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates JWKS cache on rotation', async () => {
|
||||||
|
const newRow = makeSampleRow({ kid: 'key-new', is_current: true });
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rows: [newRow] });
|
||||||
|
|
||||||
|
await service.rotateKey();
|
||||||
|
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getPublicJWKS ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getPublicJWKS()', () => {
|
||||||
|
it('returns only non-expired keys', async () => {
|
||||||
|
const nonExpiredRow = makeSampleRow();
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [nonExpiredRow] });
|
||||||
|
|
||||||
|
const jwks = await service.getPublicJWKS();
|
||||||
|
expect(jwks.keys).toHaveLength(1);
|
||||||
|
expect(jwks.keys[0].kid).toBe('key-test-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached JWKS when available', async () => {
|
||||||
|
const cachedJwks = JSON.stringify({ keys: [makeSampleJwk('cached-key')] });
|
||||||
|
(redis.get as jest.Mock).mockResolvedValueOnce(cachedJwks);
|
||||||
|
|
||||||
|
const jwks = await service.getPublicJWKS();
|
||||||
|
expect(jwks.keys[0].kid).toBe('cached-key');
|
||||||
|
// Pool should NOT have been queried
|
||||||
|
expect(poolQuery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches the JWKS result in Redis', async () => {
|
||||||
|
const row = makeSampleRow();
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [row] });
|
||||||
|
|
||||||
|
await service.getPublicJWKS();
|
||||||
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
'oidc:jwks',
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({ EX: expect.any(Number) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pruneExpiredKeys ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('pruneExpiredKeys()', () => {
|
||||||
|
it('deletes past-expires_at keys from the database', async () => {
|
||||||
|
const expiredRow = makeSampleRow({
|
||||||
|
kid: 'key-expired',
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
expires_at: new Date(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
|
||||||
|
|
||||||
|
await service.pruneExpiredKeys();
|
||||||
|
// DELETE should have been called as the first (and only) DB call
|
||||||
|
expect(poolQuery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(poolQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM oidc_keys'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates JWKS cache when keys are pruned', async () => {
|
||||||
|
const expiredRow = makeSampleRow({
|
||||||
|
kid: 'key-expired',
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
expires_at: new Date(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
|
||||||
|
|
||||||
|
await service.pruneExpiredKeys();
|
||||||
|
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invalidate cache when no keys are pruned', async () => {
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [] });
|
||||||
|
await service.pruneExpiredKeys();
|
||||||
|
expect(redis.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getPrivateKeyPem ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getPrivateKeyPem()', () => {
|
||||||
|
it('returns private key from dev in-memory store for dev:no-vault path', async () => {
|
||||||
|
// generateSigningKeyPair runs real crypto; the real kid is dynamic — capture it
|
||||||
|
// from the INSERT call args by spying on poolQuery.
|
||||||
|
let capturedKid: string | undefined;
|
||||||
|
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
|
||||||
|
.mockImplementationOnce((_sql: string, params: unknown[]) => {
|
||||||
|
// INSERT — capture the kid from the query params
|
||||||
|
capturedKid = params[0] as string;
|
||||||
|
return Promise.resolve({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'uuid-dev',
|
||||||
|
kid: capturedKid,
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk(capturedKid),
|
||||||
|
vault_key_path: 'dev:no-vault',
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.generateSigningKeyPair();
|
||||||
|
|
||||||
|
expect(capturedKid).toBeDefined();
|
||||||
|
const pem = await service.getPrivateKeyPem(capturedKid!, 'dev:no-vault');
|
||||||
|
expect(pem).toBeDefined();
|
||||||
|
expect(typeof pem).toBe('string');
|
||||||
|
expect(pem.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when dev key is not found in memory', async () => {
|
||||||
|
await expect(
|
||||||
|
service.getPrivateKeyPem('nonexistent-key-xyz-999', 'dev:no-vault'),
|
||||||
|
).rejects.toThrow('not found in memory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads private key from Vault when VAULT_ADDR and VAULT_TOKEN are set', async () => {
|
||||||
|
process.env['VAULT_ADDR'] = 'http://vault:8200';
|
||||||
|
process.env['VAULT_TOKEN'] = 'test-token';
|
||||||
|
|
||||||
|
const pem = await service.getPrivateKeyPem('key-vault-001', 'secret/data/agentidp/oidc/keys/key-vault-001');
|
||||||
|
expect(pem).toBe('mock-pem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when VAULT_ADDR and VAULT_TOKEN are missing for a non-dev path', async () => {
|
||||||
|
// No VAULT_ADDR/VAULT_TOKEN set (cleared in beforeEach)
|
||||||
|
await expect(
|
||||||
|
service.getPrivateKeyPem('key-vault-001', 'secret/data/agentidp/oidc/keys/key-vault-001'),
|
||||||
|
).rejects.toThrow('VAULT_ADDR and VAULT_TOKEN are required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Vault path — storePrivateKey ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('generateSigningKeyPair() — Vault mode', () => {
|
||||||
|
it('stores private key in Vault when VAULT_ADDR and VAULT_TOKEN are set', async () => {
|
||||||
|
process.env['VAULT_ADDR'] = 'http://vault:8200';
|
||||||
|
process.env['VAULT_TOKEN'] = 'test-token';
|
||||||
|
|
||||||
|
const vaultPath = 'secret/data/agentidp/oidc/keys/key-vault-002';
|
||||||
|
const row = {
|
||||||
|
id: 'uuid-vault',
|
||||||
|
kid: 'key-vault-002',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk('key-vault-002'),
|
||||||
|
vault_key_path: vaultPath,
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE demote
|
||||||
|
.mockResolvedValueOnce({ rows: [row] }); // INSERT
|
||||||
|
|
||||||
|
const result = await service.generateSigningKeyPair();
|
||||||
|
// vault_key_path returned by DB mock confirms Vault path was used
|
||||||
|
expect(result.vault_key_path).toBe(vaultPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom VAULT_MOUNT when set', async () => {
|
||||||
|
process.env['VAULT_ADDR'] = 'http://vault:8200';
|
||||||
|
process.env['VAULT_TOKEN'] = 'test-token';
|
||||||
|
process.env['VAULT_MOUNT'] = 'kv';
|
||||||
|
|
||||||
|
const vaultPath = 'kv/data/agentidp/oidc/keys/key-vault-003';
|
||||||
|
const row = {
|
||||||
|
id: 'uuid-vault-2',
|
||||||
|
kid: 'key-vault-003',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
public_key_jwk: makeSampleJwk('key-vault-003'),
|
||||||
|
vault_key_path: vaultPath,
|
||||||
|
is_current: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
poolQuery
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rows: [row] });
|
||||||
|
|
||||||
|
const result = await service.generateSigningKeyPair();
|
||||||
|
expect(result.vault_key_path).toBe(vaultPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pruneExpiredKeys — Vault path ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('pruneExpiredKeys() — Vault mode', () => {
|
||||||
|
it('deletes Vault key when VAULT_ADDR and VAULT_TOKEN are set', async () => {
|
||||||
|
process.env['VAULT_ADDR'] = 'http://vault:8200';
|
||||||
|
process.env['VAULT_TOKEN'] = 'test-token';
|
||||||
|
|
||||||
|
const expiredRow = makeSampleRow({
|
||||||
|
kid: 'key-vault-expired',
|
||||||
|
vault_key_path: 'secret/data/agentidp/oidc/keys/key-vault-expired',
|
||||||
|
expires_at: new Date(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
|
||||||
|
|
||||||
|
await service.pruneExpiredKeys();
|
||||||
|
expect(poolQuery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Vault delete errors gracefully (best-effort)', async () => {
|
||||||
|
process.env['VAULT_ADDR'] = 'http://vault:8200';
|
||||||
|
process.env['VAULT_TOKEN'] = 'test-token';
|
||||||
|
|
||||||
|
// Make vault.delete throw
|
||||||
|
const nodeVaultMock = jest.requireMock('node-vault') as jest.Mock;
|
||||||
|
nodeVaultMock.mockReturnValueOnce({
|
||||||
|
write: jest.fn().mockResolvedValue({}),
|
||||||
|
read: jest.fn().mockResolvedValue({ data: { data: { privateKeyPem: 'mock-pem' } } }),
|
||||||
|
delete: jest.fn().mockRejectedValue(new Error('Vault unreachable')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiredRow = makeSampleRow({
|
||||||
|
kid: 'key-vault-fail',
|
||||||
|
vault_key_path: 'secret/data/agentidp/oidc/keys/key-vault-fail',
|
||||||
|
expires_at: new Date(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });
|
||||||
|
|
||||||
|
// Should not throw — vault delete failure is best-effort
|
||||||
|
await expect(service.pruneExpiredKeys()).resolves.not.toThrow();
|
||||||
|
expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user