feat(phase-3): workstream 4 — AGNTCY Federation

Implements cross-IdP token verification for the AGNTCY ecosystem:

- Migration 015: federation_partners table (issuer, jwks_uri,
  allowed_organizations JSONB, status, expires_at)
- FederationService: registerPartner (JWKS validation at registration),
  listPartners, getPartner, updatePartner, deletePartner,
  verifyFederatedToken (alg:none rejected, RS256/ES256 only,
  allowedOrganizations filter, expiry enforcement)
- JWKS caching in Redis (TTL: FEDERATION_JWKS_CACHE_TTL_SECONDS);
  cache invalidated on partner delete and jwks_uri change
- FederationController + routes: 5 admin:orgs endpoints +
  POST /federation/verify (agents:read)
- OPA policy: 5 federation admin endpoint → admin:orgs mappings
- 499 unit tests passing; 94.69% statement coverage on FederationService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-30 10:13:49 +00:00
parent 5e465e596a
commit 03b5de300c
12 changed files with 2092 additions and 13 deletions

View File

@@ -26,6 +26,7 @@ import { OrgService } from './services/OrgService.js';
import { DIDService } from './services/DIDService.js';
import { OIDCKeyService } from './services/OIDCKeyService.js';
import { IDTokenService } from './services/IDTokenService.js';
import { FederationService } from './services/FederationService.js';
import { AgentController } from './controllers/AgentController.js';
import { TokenController } from './controllers/TokenController.js';
@@ -34,6 +35,7 @@ import { AuditController } from './controllers/AuditController.js';
import { OrgController } from './controllers/OrgController.js';
import { DIDController } from './controllers/DIDController.js';
import { OIDCController } from './controllers/OIDCController.js';
import { FederationController } from './controllers/FederationController.js';
import { createAgentsRouter } from './routes/agents.js';
import { createTokenRouter } from './routes/token.js';
@@ -44,6 +46,7 @@ import { createMetricsRouter } from './routes/metrics.js';
import { createOrgsRouter } from './routes/organizations.js';
import { createDIDRouter } from './routes/did.js';
import { createOIDCRouter } from './routes/oidc.js';
import { createFederationRouter } from './routes/federation.js';
import { errorHandler } from './middleware/errorHandler.js';
import { createOpaMiddleware } from './middleware/opa.js';
@@ -167,6 +170,8 @@ export async function createApp(): Promise<Application> {
const orgController = new OrgController(orgService);
const didController = new DIDController(didService, agentRepo);
const oidcController = new OIDCController(oidcKeyService, agentRepo);
const federationService = new FederationService(pool, redis as RedisClientType);
const federationController = new FederationController(federationService);
// ────────────────────────────────────────────────────────────────
// Org context middleware — sets PostgreSQL session variable app.organization_id
@@ -203,6 +208,7 @@ export async function createApp(): Promise<Application> {
app.use(`${API_BASE}/token`, createTokenRouter(tokenController, opaMiddleware));
app.use(`${API_BASE}/audit`, createAuditRouter(auditController, opaMiddleware));
app.use(`${API_BASE}/organizations`, createOrgsRouter(orgController, opaMiddleware));
app.use(`${API_BASE}`, createFederationRouter(federationController, authMiddleware, opaMiddleware));
// ────────────────────────────────────────────────────────────────
// Dashboard static assets (served from dashboard/dist/)

View File

@@ -0,0 +1,157 @@
/**
* FederationController — request handlers for federation partner management and token verification.
*
* Handlers:
* POST /federation/trust → registerPartner
* GET /federation/partners → listPartners
* GET /federation/partners/:id → getPartner
* PATCH /federation/partners/:id → updatePartner
* DELETE /federation/partners/:id → deletePartner
* POST /federation/verify → verifyToken
*/
import { Request, Response, NextFunction } from 'express';
import { FederationService } from '../services/FederationService.js';
import {
ICreatePartnerRequest,
IUpdatePartnerRequest,
IFederationVerifyRequest,
} from '../types/federation.js';
/**
* Controller for all federation-related endpoints.
* Delegates all business logic to FederationService.
*/
export class FederationController {
/**
* @param federationService - Service that manages federation partners and token verification.
*/
constructor(private readonly federationService: FederationService) {}
// ─────────────────────────────────────────────────────────────────────────
// Public handlers
// ─────────────────────────────────────────────────────────────────────────
/**
* Registers a new trusted federation partner.
*
* Validates that the partner's JWKS endpoint is reachable before persisting.
* Responds 201 with the created IFederationPartner on success.
*
* @param req - Express request. Body must conform to ICreatePartnerRequest.
* @param res - Express response.
* @param next - Express next function — forwards errors to the global error handler.
*/
async registerPartner(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const body = req.body as ICreatePartnerRequest;
const partner = await this.federationService.registerPartner(body);
res.status(201).json(partner);
} catch (err) {
next(err);
}
}
/**
* Returns all registered federation partners.
*
* Partners past their expires_at are marked as 'expired' before the list is returned.
* Responds 200 with an array of IFederationPartner objects.
*
* @param _req - Express request (unused).
* @param res - Express response.
* @param next - Express next function — forwards errors to the global error handler.
*/
async listPartners(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const partners = await this.federationService.listPartners();
res.status(200).json(partners);
} catch (err) {
next(err);
}
}
/**
* Returns a single federation partner by its UUID.
*
* Responds 200 with the matching IFederationPartner.
* Responds 404 if the partner does not exist.
*
* @param req - Express request. `req.params.id` must be the partner UUID.
* @param res - Express response.
* @param next - Express next function — forwards errors to the global error handler.
*/
async getPartner(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const partner = await this.federationService.getPartner(id);
res.status(200).json(partner);
} catch (err) {
next(err);
}
}
/**
* Updates an existing federation partner.
*
* Only provided fields are updated. Responds 200 with the updated IFederationPartner.
* Responds 404 if the partner does not exist.
*
* @param req - Express request. `req.params.id` is the partner UUID; body is IUpdatePartnerRequest.
* @param res - Express response.
* @param next - Express next function — forwards errors to the global error handler.
*/
async updatePartner(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const body = req.body as IUpdatePartnerRequest;
const partner = await this.federationService.updatePartner(id, body);
res.status(200).json(partner);
} catch (err) {
next(err);
}
}
/**
* Deletes a federation partner by its UUID.
*
* Invalidates the partner's cached JWKS on success.
* Responds 204 No Content on success.
* Responds 404 if the partner does not exist.
*
* @param req - Express request. `req.params.id` must be the partner UUID.
* @param res - Express response.
* @param next - Express next function — forwards errors to the global error handler.
*/
async deletePartner(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
await this.federationService.deletePartner(id);
res.status(204).send();
} catch (err) {
next(err);
}
}
/**
* Verifies a federated JWT token from a registered partner IdP.
*
* Performs full signature verification, expiry check, and organization filtering.
* Responds 200 with an IFederationVerifyResult on success.
* Responds 401 if the token fails any verification check.
*
* @param req - Express request. Body must contain `token: string` and optional `expected_issuer`.
* @param res - Express response.
* @param next - Express next function — forwards errors to the global error handler.
*/
async verifyToken(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const body = req.body as IFederationVerifyRequest;
const result = await this.federationService.verifyFederatedToken(body);
res.status(200).json(result);
} catch (err) {
next(err);
}
}
}

View File

@@ -0,0 +1,17 @@
-- federation_partners: trusted external identity providers whose tokens this IdP will accept.
-- A partner is identified by its issuer URL. Its JWKS are fetched from jwks_uri and cached.
-- allowed_organizations is an optional allowlist of organization_id values from the partner's tokens.
-- An empty array means all organizations from this partner are accepted.
CREATE TABLE IF NOT EXISTS federation_partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
issuer VARCHAR(512) NOT NULL UNIQUE,
jwks_uri VARCHAR(512) NOT NULL,
allowed_organizations JSONB NOT NULL DEFAULT '[]',
status VARCHAR(32) NOT NULL DEFAULT 'active', -- 'active' | 'suspended' | 'expired'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- NULL means never expires
);
CREATE INDEX IF NOT EXISTS idx_federation_partners_issuer ON federation_partners(issuer);
CREATE INDEX IF NOT EXISTS idx_federation_partners_status ON federation_partners(status);

View File

@@ -106,6 +106,18 @@ function normalisePath(requestPath: string): string {
if (/^\/api\/v1\/audit\/[^/]+$/.test(requestPath)) {
return '/api/v1/audit/:id';
}
// /api/v1/federation/partners/:id
if (/^\/api\/v1\/federation\/partners\/[^/]+$/.test(requestPath)) {
return '/api/v1/federation/partners/:id';
}
// /api/v1/federation/partners
if (requestPath === '/api/v1/federation/partners') {
return '/api/v1/federation/partners';
}
// /api/v1/federation/trust (static)
if (requestPath === '/api/v1/federation/trust') {
return '/api/v1/federation/trust';
}
// Unknown path — return as-is; the policy will produce no match → deny
return requestPath;
}

87
src/routes/federation.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* Federation routes — partner management and cross-IdP token verification.
*
* All management endpoints require the `admin:orgs` scope (enforced via opaMiddleware).
* The verify endpoint requires any authenticated agent (enforced via authMiddleware only).
*
* Endpoints:
* POST /federation/trust → registerPartner (admin:orgs)
* GET /federation/partners → listPartners (admin:orgs)
* GET /federation/partners/:id → getPartner (admin:orgs)
* PATCH /federation/partners/:id → updatePartner (admin:orgs)
* DELETE /federation/partners/:id → deletePartner (admin:orgs)
* POST /federation/verify → verifyToken (agents:read — any authenticated agent)
*
* Mount this router at `/api/v1` in app.ts so that paths become `/api/v1/federation/...`.
*/
import { Router, RequestHandler } from 'express';
import { FederationController } from '../controllers/FederationController.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for federation endpoints.
* Mount at `${API_BASE}` (i.e. `/api/v1`) in app.ts.
*
* @param controller - The federation controller instance.
* @param authMiddleware - JWT authentication middleware for the verify endpoint.
* @param opaMiddleware - OPA authorization middleware for partner management endpoints.
* @returns Configured Express router.
*/
export function createFederationRouter(
controller: FederationController,
authMiddleware: RequestHandler,
opaMiddleware: RequestHandler,
): Router {
const router = Router();
// POST /federation/trust — register a new trusted partner (admin:orgs)
router.post(
'/federation/trust',
authMiddleware,
opaMiddleware,
asyncHandler(controller.registerPartner.bind(controller)),
);
// GET /federation/partners — list all partners (admin:orgs)
router.get(
'/federation/partners',
authMiddleware,
opaMiddleware,
asyncHandler(controller.listPartners.bind(controller)),
);
// GET /federation/partners/:id — get a specific partner (admin:orgs)
router.get(
'/federation/partners/:id',
authMiddleware,
opaMiddleware,
asyncHandler(controller.getPartner.bind(controller)),
);
// PATCH /federation/partners/:id — update a partner (admin:orgs)
router.patch(
'/federation/partners/:id',
authMiddleware,
opaMiddleware,
asyncHandler(controller.updatePartner.bind(controller)),
);
// DELETE /federation/partners/:id — delete a partner (admin:orgs)
router.delete(
'/federation/partners/:id',
authMiddleware,
opaMiddleware,
asyncHandler(controller.deletePartner.bind(controller)),
);
// POST /federation/verify — verify a federated token (any authenticated agent)
// Uses authMiddleware only — not OPA — so any agent with a valid token can call this.
router.post(
'/federation/verify',
authMiddleware,
asyncHandler(controller.verifyToken.bind(controller)),
);
return router;
}

View File

@@ -0,0 +1,550 @@
/**
* FederationService — manages trusted federation partners and cross-IdP token verification.
*
* Trust registration: partner's JWKS are fetched and validated at registration time.
* Token verification: fetches (or uses cached) partner JWKS, verifies signature and claims.
* Organization filtering: if allowed_organizations is non-empty, rejects tokens whose
* organization_id claim is not in the allow list.
* Expiry: partners past expires_at are treated as status 'expired' and their tokens rejected.
*/
import { Pool, QueryResult } from 'pg';
import jwt from 'jsonwebtoken';
import { createPublicKey, JsonWebKey } from 'crypto';
import { RedisClientType } from 'redis';
import {
IFederationPartner,
FederationPartnerStatus,
ICreatePartnerRequest,
IUpdatePartnerRequest,
IFederationVerifyRequest,
IFederationVerifyResult,
IFederatedTokenClaims,
} from '../types/federation.js';
import { IJWKSKey } from '../types/oidc.js';
import { SentryAgentError } from '../utils/errors.js';
// ─────────────────────────────────────────────────────────────────────────────
// Error classes
// ─────────────────────────────────────────────────────────────────────────────
/**
* 400 — Federation partner operation failed (e.g. unreachable JWKS endpoint, invalid JWKS).
*/
export class FederationPartnerError extends SentryAgentError {
constructor(message: string) {
super(message, 'FEDERATION_PARTNER_ERROR', 400);
}
}
/**
* 404 — The requested federation partner was not found.
*/
export class FederationPartnerNotFoundError extends SentryAgentError {
constructor(id?: string) {
super(
'Federation partner not found.',
'FEDERATION_PARTNER_NOT_FOUND',
404,
id ? { id } : undefined,
);
}
}
/**
* 401 — Federated token verification failed.
*/
export class FederationVerificationError extends SentryAgentError {
constructor(reason: string) {
super(reason, 'FEDERATION_VERIFICATION_ERROR', 401);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Internal DB row shape
// ─────────────────────────────────────────────────────────────────────────────
/** Raw database row for federation_partners. */
interface FederationPartnerRow {
id: string;
name: string;
issuer: string;
jwks_uri: string;
allowed_organizations: string[];
status: string;
created_at: Date;
updated_at: Date;
expires_at: Date | null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Maps a raw database row to the IFederationPartner domain model.
*
* @param row - Raw row from the federation_partners table.
* @returns Typed IFederationPartner object.
*/
function mapRowToPartner(row: FederationPartnerRow): IFederationPartner {
return {
id: row.id,
name: row.name,
issuer: row.issuer,
jwks_uri: row.jwks_uri,
allowed_organizations: row.allowed_organizations ?? [],
status: row.status as FederationPartnerStatus,
created_at: row.created_at,
updated_at: row.updated_at,
expires_at: row.expires_at ?? null,
};
}
/**
* 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 {
const keyObj = createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' });
return keyObj.export({ type: 'spki', format: 'pem' }) as string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Service
// ─────────────────────────────────────────────────────────────────────────────
/**
* Service that manages federation partners and verifies cross-IdP tokens.
* Integrates with PostgreSQL for partner persistence and Redis for JWKS caching.
*/
export class FederationService {
/**
* @param pool - PostgreSQL connection pool.
* @param redis - Redis client for JWKS caching.
*/
constructor(
private readonly pool: Pool,
private readonly redis: RedisClientType,
) {}
// ───────────────────────────────────────────────────────────────────────────
// Public API — Partner Management
// ───────────────────────────────────────────────────────────────────────────
/**
* Registers a new federation partner.
*
* Validates the JWKS endpoint is reachable and returns a valid JWKS before persisting.
* The fetched JWKS is cached in Redis on successful registration.
*
* @param req - The partner registration request.
* @returns The newly created IFederationPartner.
* @throws FederationPartnerError (400) if the JWKS endpoint is unreachable or returns invalid JWKS.
*/
async registerPartner(req: ICreatePartnerRequest): Promise<IFederationPartner> {
// Validate the JWKS endpoint is reachable and returns valid JWKS
const jwks = await this.fetchJWKS(req.jwks_uri);
const allowedOrgs = req.allowed_organizations ?? [];
const expiresAt = req.expires_at ? new Date(req.expires_at) : null;
const result: QueryResult<FederationPartnerRow> = await this.pool.query(
`INSERT INTO federation_partners
(name, issuer, jwks_uri, allowed_organizations, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[req.name, req.issuer, req.jwks_uri, JSON.stringify(allowedOrgs), expiresAt],
);
const partner = mapRowToPartner(result.rows[0]);
// Cache the fetched JWKS for future token verifications
await this.cacheJWKS(partner.issuer, jwks);
return partner;
}
/**
* Returns all registered federation partners.
*
* Partners past their expires_at are automatically updated to status 'expired' in the DB
* before the list is returned.
*
* @returns Array of IFederationPartner objects.
*/
async listPartners(): Promise<IFederationPartner[]> {
// Update any partners that have passed their expiry date
await this.pool.query(
`UPDATE federation_partners
SET status = 'expired', updated_at = NOW()
WHERE expires_at IS NOT NULL
AND expires_at <= NOW()
AND status != 'expired'`,
);
const result: QueryResult<FederationPartnerRow> = await this.pool.query(
`SELECT * FROM federation_partners ORDER BY created_at DESC`,
);
return result.rows.map(mapRowToPartner);
}
/**
* Returns a single federation partner by its UUID.
*
* Applies the same expiry update logic as listPartners.
*
* @param id - The UUID of the federation partner.
* @returns The matching IFederationPartner.
* @throws FederationPartnerNotFoundError (404) if no partner with that id exists.
*/
async getPartner(id: string): Promise<IFederationPartner> {
// Update expiry status if needed
await this.pool.query(
`UPDATE federation_partners
SET status = 'expired', updated_at = NOW()
WHERE id = $1
AND expires_at IS NOT NULL
AND expires_at <= NOW()
AND status != 'expired'`,
[id],
);
const result: QueryResult<FederationPartnerRow> = await this.pool.query(
`SELECT * FROM federation_partners WHERE id = $1`,
[id],
);
if (result.rows.length === 0) {
throw new FederationPartnerNotFoundError(id);
}
return mapRowToPartner(result.rows[0]);
}
/**
* Updates an existing federation partner.
*
* Only the provided fields are updated. The updated_at column is always refreshed.
* If jwks_uri changes, the cached JWKS for the old issuer is invalidated.
*
* @param id - The UUID of the federation partner to update.
* @param req - Fields to update.
* @returns The updated IFederationPartner.
* @throws FederationPartnerNotFoundError (404) if the partner does not exist.
*/
async updatePartner(id: string, req: IUpdatePartnerRequest): Promise<IFederationPartner> {
// Fetch current partner to get old issuer for cache invalidation
const current = await this.getPartner(id);
const setClauses: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (req.name !== undefined) {
setClauses.push(`name = $${idx++}`);
values.push(req.name);
}
if (req.jwks_uri !== undefined) {
setClauses.push(`jwks_uri = $${idx++}`);
values.push(req.jwks_uri);
}
if (req.allowed_organizations !== undefined) {
setClauses.push(`allowed_organizations = $${idx++}`);
values.push(JSON.stringify(req.allowed_organizations));
}
if (req.status !== undefined) {
setClauses.push(`status = $${idx++}`);
values.push(req.status);
}
if (req.expires_at !== undefined) {
setClauses.push(`expires_at = $${idx++}`);
values.push(req.expires_at === null ? null : new Date(req.expires_at));
}
// Always update updated_at
setClauses.push(`updated_at = NOW()`);
values.push(id);
const result: QueryResult<FederationPartnerRow> = await this.pool.query(
`UPDATE federation_partners SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
values,
);
if (result.rows.length === 0) {
throw new FederationPartnerNotFoundError(id);
}
// Invalidate JWKS cache if jwks_uri changed
if (req.jwks_uri !== undefined && req.jwks_uri !== current.jwks_uri) {
await this.invalidateJWKSCache(current.issuer);
}
return mapRowToPartner(result.rows[0]);
}
/**
* Deletes a federation partner by its UUID.
*
* Invalidates the partner's JWKS cache entry on successful deletion.
*
* @param id - The UUID of the federation partner to delete.
* @throws FederationPartnerNotFoundError (404) if the partner does not exist.
*/
async deletePartner(id: string): Promise<void> {
// Fetch to get issuer before deletion (for cache invalidation)
const partner = await this.getPartner(id);
const result: QueryResult<FederationPartnerRow> = await this.pool.query(
`DELETE FROM federation_partners WHERE id = $1 RETURNING id`,
[id],
);
if (result.rows.length === 0) {
throw new FederationPartnerNotFoundError(id);
}
await this.invalidateJWKSCache(partner.issuer);
}
// ───────────────────────────────────────────────────────────────────────────
// Public API — Token Verification
// ───────────────────────────────────────────────────────────────────────────
/**
* Verifies a federated JWT token from a registered partner IdP.
*
* Performs the following checks in order:
* 1. Token is structurally valid (decodable header + payload).
* 2. `alg: none` is explicitly rejected.
* 3. `iss` matches a known, active, non-expired federation partner.
* 4. If expected_issuer is provided, `iss` must match exactly.
* 5. Partner JWKS is fetched (cache-first) and the matching key used for signature verification.
* 6. JWT signature and `exp` claim are verified by jsonwebtoken.
* 7. If the partner has a non-empty allowed_organizations list, the token's
* `organization_id` claim must be in that list.
*
* @param req - The verification request containing the token and optional issuer hint.
* @returns IFederationVerifyResult with verified claims.
* @throws FederationVerificationError (401) if any verification step fails.
*/
async verifyFederatedToken(req: IFederationVerifyRequest): Promise<IFederationVerifyResult> {
// Decode the token header without verification to extract kid and alg
const decoded = jwt.decode(req.token, { complete: true });
if (!decoded || typeof decoded === 'string') {
throw new FederationVerificationError('Token is malformed or not a valid JWT');
}
const header = decoded.header;
// Explicitly reject alg:none
if (!header.alg || header.alg.toLowerCase() === 'none') {
throw new FederationVerificationError('alg:none tokens are not accepted');
}
// Extract issuer from the payload
const payload = decoded.payload as IFederatedTokenClaims;
const iss = payload.iss;
if (!iss) {
throw new FederationVerificationError('Token is missing the iss claim');
}
// Check expected_issuer hint
if (req.expected_issuer !== undefined && iss !== req.expected_issuer) {
throw new FederationVerificationError(
'Token issuer does not match expected issuer',
);
}
// Look up partner by issuer
const partnerResult: QueryResult<FederationPartnerRow> = await this.pool.query(
`SELECT * FROM federation_partners WHERE issuer = $1`,
[iss],
);
if (partnerResult.rows.length === 0) {
throw new FederationVerificationError(`Unknown federation partner: ${iss}`);
}
const partner = mapRowToPartner(partnerResult.rows[0]);
// Check partner status
if (partner.status === 'suspended') {
throw new FederationVerificationError('Federation partner is suspended');
}
// Check partner expiry
if (partner.expires_at !== null && partner.expires_at <= new Date()) {
throw new FederationVerificationError('Federation partner has expired');
}
// Fetch JWKS (cache-first)
const jwksKeys = await this.fetchAndCacheJWKS(partner);
// Find the JWK matching the token's kid
const kid = header.kid;
const matchingKey = kid
? jwksKeys.find((k) => k.kid === kid)
: jwksKeys[0]; // fall back to first key if no kid in header
if (!matchingKey) {
const kidLabel = kid ?? '(none)';
throw new FederationVerificationError(
`No matching key in partner JWKS for kid: ${kidLabel}`,
);
}
// Convert JWK to PEM
const publicKeyPem = jwkToPem(matchingKey);
// Verify signature and exp claim
let verifiedPayload: IFederatedTokenClaims;
try {
const result = jwt.verify(req.token, publicKeyPem, {
algorithms: ['RS256', 'ES256'],
});
if (typeof result === 'string') {
throw new FederationVerificationError('Unexpected string payload after verification');
}
verifiedPayload = result as IFederatedTokenClaims;
} catch (err) {
if (err instanceof FederationVerificationError) {
throw err;
}
const message = err instanceof Error ? err.message : 'Unknown verification error';
throw new FederationVerificationError(message);
}
// Enforce allowed_organizations filter
if (partner.allowed_organizations.length > 0) {
const tokenOrgId = verifiedPayload['organization_id'];
if (
typeof tokenOrgId !== 'string' ||
!partner.allowed_organizations.includes(tokenOrgId)
) {
throw new FederationVerificationError(
"Token organization_id is not in the partner's allowed list",
);
}
}
// Build and return the verification result
const { iss: verifiedIss, sub, organization_id, ...restClaims } = verifiedPayload;
return {
valid: true,
issuer: verifiedIss,
subject: sub,
organization_id,
claims: {
iss: verifiedIss,
sub,
...(organization_id !== undefined ? { organization_id } : {}),
...restClaims,
} as Record<string, unknown>,
};
}
// ───────────────────────────────────────────────────────────────────────────
// Private helpers
// ───────────────────────────────────────────────────────────────────────────
/**
* Fetches JWKS from the given URL with a 5-second timeout.
* Validates that the response contains a `keys` array.
*
* @param jwksUri - The JWKS endpoint URL.
* @returns Array of IJWKSKey objects from the partner.
* @throws FederationPartnerError (400) if the endpoint is unreachable or returns invalid JWKS.
*/
private async fetchJWKS(jwksUri: string): Promise<IJWKSKey[]> {
let response: Response;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
response = await fetch(jwksUri, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
} catch {
throw new FederationPartnerError(`Failed to reach JWKS endpoint: ${jwksUri}`);
}
if (!response.ok) {
throw new FederationPartnerError(`Failed to reach JWKS endpoint: ${jwksUri}`);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new FederationPartnerError(
`JWKS endpoint returned non-JSON response: ${jwksUri}`,
);
}
if (
typeof body !== 'object' ||
body === null ||
!Array.isArray((body as Record<string, unknown>)['keys'])
) {
throw new FederationPartnerError(
`JWKS endpoint returned invalid JWKS (missing keys array): ${jwksUri}`,
);
}
return (body as { keys: IJWKSKey[] }).keys;
}
/**
* Fetches JWKS for a partner — tries Redis cache first, falls back to HTTP.
* Caches the result in Redis on a cache miss.
*
* @param partner - The federation partner whose JWKS to fetch.
* @returns Array of IJWKSKey objects.
*/
private async fetchAndCacheJWKS(partner: IFederationPartner): Promise<IJWKSKey[]> {
const cacheKey = `federation:jwks:${partner.issuer}`;
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as IJWKSKey[];
}
const keys = await this.fetchJWKS(partner.jwks_uri);
await this.cacheJWKS(partner.issuer, keys);
return keys;
}
/**
* Stores JWKS keys in Redis with the configured TTL.
*
* @param issuer - The partner issuer URL (used as cache key suffix).
* @param keys - The JWKS keys to cache.
*/
private async cacheJWKS(issuer: string, keys: IJWKSKey[]): Promise<void> {
const cacheKey = `federation:jwks:${issuer}`;
const ttl = parseInt(
process.env['FEDERATION_JWKS_CACHE_TTL_SECONDS'] ?? '3600',
10,
);
await this.redis.set(cacheKey, JSON.stringify(keys), { EX: ttl });
}
/**
* Invalidates the Redis JWKS cache entry for a given issuer.
*
* @param issuer - The partner issuer URL whose cache entry to remove.
*/
private async invalidateJWKSCache(issuer: string): Promise<void> {
await this.redis.del(`federation:jwks:${issuer}`);
}
}

151
src/types/federation.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* Federation type definitions for SentryAgent.ai AgentIdP.
* Covers federation partner management and cross-IdP token verification.
*/
// ============================================================================
// Federation Partner Status
// ============================================================================
/**
* Lifecycle status of a federation partner.
* - 'active' — partner is trusted; tokens accepted.
* - 'suspended' — partner is temporarily disabled; tokens rejected.
* - 'expired' — partner's expires_at has passed; tokens rejected.
*/
export type FederationPartnerStatus = 'active' | 'suspended' | 'expired';
// ============================================================================
// Federation Partner (database row)
// ============================================================================
/**
* Represents a row in the `federation_partners` table.
* A federation partner is an external IdP whose tokens this IdP trusts.
*/
export interface IFederationPartner {
/** UUID primary key. */
id: string;
/** Human-readable partner name. */
name: string;
/** Issuer URL — must match the `iss` claim in federated tokens. */
issuer: string;
/** URL of the partner's JWKS endpoint. */
jwks_uri: string;
/**
* Allowlist of organization_id values accepted from this partner.
* An empty array means all organizations are accepted.
*/
allowed_organizations: string[];
/** Current lifecycle status. */
status: FederationPartnerStatus;
/** Timestamp when this partner was registered. */
created_at: Date;
/** Timestamp of the last update. */
updated_at: Date;
/** Optional expiry timestamp. NULL means the partner never expires. */
expires_at: Date | null;
}
// ============================================================================
// Request / Response shapes
// ============================================================================
/**
* Request body for registering a new federation partner.
* POST /api/v1/federation/trust
*/
export interface ICreatePartnerRequest {
/** Human-readable partner name. */
name: string;
/** Issuer URL of the external IdP. */
issuer: string;
/** URL of the partner's JWKS endpoint (must be reachable at registration time). */
jwks_uri: string;
/**
* Optional allowlist of organization_id values to accept from this partner.
* Defaults to empty array (all organizations accepted).
*/
allowed_organizations?: string[];
/** Optional ISO 8601 date-time string after which the partner will be expired. */
expires_at?: string;
}
/**
* Request body for updating an existing federation partner.
* PATCH /api/v1/federation/partners/:id
* All fields are optional; only provided fields are updated.
*/
export interface IUpdatePartnerRequest {
/** Updated human-readable partner name. */
name?: string;
/** Updated JWKS endpoint URL. Cache will be invalidated if this changes. */
jwks_uri?: string;
/** Updated organization allowlist. */
allowed_organizations?: string[];
/** Updated lifecycle status. */
status?: FederationPartnerStatus;
/**
* Updated expiry timestamp.
* Pass an ISO 8601 string to set a new expiry, or null to clear it.
*/
expires_at?: string | null;
}
/**
* Request body for verifying a federated token.
* POST /api/v1/federation/verify
*/
export interface IFederationVerifyRequest {
/** The JWT token string to verify. */
token: string;
/**
* Optional issuer hint. If provided, the token's `iss` claim must match this value exactly.
* Useful to prevent issuer confusion when multiple partners share JWKS.
*/
expected_issuer?: string;
}
/**
* Result returned by a successful federated token verification.
*/
export interface IFederationVerifyResult {
/** Whether the token is valid (true when verification passed). */
valid: boolean;
/** The issuer (`iss` claim) of the verified token. */
issuer: string;
/** The subject (`sub` claim) of the verified token. */
subject: string;
/**
* The `organization_id` claim from the token, if present.
* Populated when the external IdP includes this standard SentryAgent claim.
*/
organization_id?: string;
/** All claims from the verified token payload. */
claims: Record<string, unknown>;
}
/**
* Decoded claims payload of a federated JWT token.
* Matches the standard JWT claims plus optional SentryAgent extensions.
* Used internally by FederationService during token verification.
*/
export interface IFederatedTokenClaims {
/** Issuer — identifies the external IdP. */
iss: string;
/** Subject — identifies the agent in the external IdP. */
sub: string;
/** Audience — the intended recipient(s) of the token. */
aud: string | string[];
/** Issued-at time (Unix seconds). */
iat: number;
/** Expiry time (Unix seconds). */
exp: number;
/**
* Optional organization_id claim.
* When present, used to enforce the partner's allowed_organizations filter.
*/
organization_id?: string;
/** Additional claims from the token payload. */
[key: string]: unknown;
}