Files
sentryagent-idp/tests/integration/federation.test.ts
SentryAgent.ai Developer 03b5de300c 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>
2026-03-30 10:13:49 +00:00

467 lines
17 KiB
TypeScript

/**
* Integration tests for Federation endpoints.
* Uses a real Postgres test DB and Redis test instance.
*
* For the verify endpoint, a real RS256 key pair is generated in test setup.
* A partner is registered with a mocked JWKS endpoint, and a valid JWT is
* signed with the private key and verified through the full stack.
*/
import crypto, { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
import jwt from 'jsonwebtoken';
// 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['DEFAULT_ORG_ID'] = 'org_system';
import { createApp } from '../../src/app';
import { signToken } from '../../src/utils/jwt';
import { closePool } from '../../src/db/pool';
import { closeRedisClient } from '../../src/cache/redis';
// ─── Token helpers ────────────────────────────────────────────────────────────
const CALLER_ID = uuidv4();
const ADMIN_SCOPE = 'admin:orgs';
const AGENT_SCOPE = 'agents:read';
function makeToken(sub: string = CALLER_ID, scope: string = ADMIN_SCOPE): string {
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
}
// ─── Partner IdP key pair (for federated token signing) ──────────────────────
/**
* A separate RS256 key pair representing an external IdP that is registered
* as a federation partner. Tokens signed with this key should be verifiable
* through POST /api/v1/federation/verify.
*/
const partnerKeys = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const partnerJwkRaw = crypto.createPublicKey(partnerKeys.publicKey).export({ format: 'jwk' }) as Record<string, string>;
const PARTNER_KID = 'partner-key-001';
const partnerJwk = {
kid: PARTNER_KID,
kty: 'RSA',
use: 'sig',
alg: 'RS256',
n: partnerJwkRaw['n'],
e: partnerJwkRaw['e'],
};
const PARTNER_ISSUER = 'https://external-idp.test.sentryagent.ai';
const PARTNER_JWKS_URI = 'https://external-idp.test.sentryagent.ai/.well-known/jwks.json';
/** Signs a JWT with the partner's private key, simulating an external IdP token. */
function makePartnerToken(claims: Record<string, unknown> = {}): string {
return jwt.sign(
{
iss: PARTNER_ISSUER,
sub: `partner-agent-${uuidv4()}`,
aud: 'sentryagent-idp',
...claims,
},
partnerKeys.privateKey,
{
algorithm: 'RS256',
header: { alg: 'RS256', kid: PARTNER_KID, typ: 'JWT' },
},
);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('Federation Endpoints Integration Tests', () => {
let app: Application;
let pool: Pool;
beforeAll(async () => {
app = await createApp();
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
const migrations: string[] = [
`CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS organizations (
organization_id VARCHAR(40) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
plan_tier VARCHAR(20) NOT NULL DEFAULT 'free',
max_agents INTEGER NOT NULL DEFAULT 100,
max_tokens_per_month INTEGER NOT NULL DEFAULT 10000,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`INSERT INTO organizations
(organization_id, name, slug, plan_tier, max_agents, max_tokens_per_month, status)
VALUES
('org_system', 'System', 'system', 'enterprise', 999999, 999999999, 'active')
ON CONFLICT (organization_id) DO NOTHING`,
`CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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',
organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system',
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,
secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system',
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(32) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system',
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 oidc_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kid VARCHAR(128) 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
)`,
`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',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ
)`,
];
for (const sql of migrations) {
await pool.query(sql);
}
});
afterEach(async () => {
await pool.query('DELETE FROM federation_partners');
});
afterAll(async () => {
await pool.end();
await closePool();
await closeRedisClient();
});
// ─── Helper: register a partner, mocking the JWKS fetch ───────────────────
async function registerPartner(overrides: Record<string, unknown> = {}): Promise<{ body: Record<string, unknown>; status: number }> {
// Mock global fetch to return the partner's JWKS when called during registration
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ keys: [partnerJwk] }),
} as unknown as Response);
const token = makeToken();
const res = await request(app)
.post('/api/v1/federation/trust')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Test External IdP',
issuer: PARTNER_ISSUER,
jwks_uri: PARTNER_JWKS_URI,
...overrides,
});
return { body: res.body as Record<string, unknown>, status: res.status };
}
// ─── POST /api/v1/federation/trust ────────────────────────────────────────
describe('POST /api/v1/federation/trust', () => {
it('registers a partner and returns 201', async () => {
const { status, body } = await registerPartner();
expect(status).toBe(201);
expect(body['id']).toBeDefined();
expect(body['issuer']).toBe(PARTNER_ISSUER);
expect(body['status']).toBe('active');
});
it('returns 401 without a token', async () => {
const res = await request(app)
.post('/api/v1/federation/trust')
.send({ name: 'x', issuer: 'https://x.example.com', jwks_uri: 'https://x.example.com/jwks' });
expect(res.status).toBe(401);
});
it('returns 403 without admin:orgs scope', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ keys: [partnerJwk] }),
} as unknown as Response);
const token = makeToken(CALLER_ID, AGENT_SCOPE);
const res = await request(app)
.post('/api/v1/federation/trust')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'x', issuer: 'https://x.example.com', jwks_uri: 'https://x.example.com/jwks' });
expect(res.status).toBe(403);
});
it('returns 400 when JWKS endpoint is unreachable', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
const token = makeToken();
const res = await request(app)
.post('/api/v1/federation/trust')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Bad Partner',
issuer: 'https://bad.example.com',
jwks_uri: 'https://bad.example.com/.well-known/jwks.json',
});
expect(res.status).toBe(400);
});
});
// ─── GET /api/v1/federation/partners ──────────────────────────────────────
describe('GET /api/v1/federation/partners', () => {
it('returns list of registered partners (admin:orgs scope)', async () => {
await registerPartner();
const token = makeToken();
const res = await request(app)
.get('/api/v1/federation/partners')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body).toHaveLength(1);
expect((res.body as Array<Record<string, unknown>>)[0]['issuer']).toBe(PARTNER_ISSUER);
});
it('returns 403 without admin:orgs scope', async () => {
const token = makeToken(CALLER_ID, AGENT_SCOPE);
const res = await request(app)
.get('/api/v1/federation/partners')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
it('returns 401 without a token', async () => {
const res = await request(app).get('/api/v1/federation/partners');
expect(res.status).toBe(401);
});
});
// ─── GET /api/v1/federation/partners/:id ──────────────────────────────────
describe('GET /api/v1/federation/partners/:id', () => {
it('returns the specific partner by ID', async () => {
const { body: created } = await registerPartner();
const partnerId = created['id'] as string;
const token = makeToken();
const res = await request(app)
.get(`/api/v1/federation/partners/${partnerId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>)['id']).toBe(partnerId);
});
it('returns 404 for unknown partner id', async () => {
const token = makeToken();
const res = await request(app)
.get(`/api/v1/federation/partners/${uuidv4()}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
});
// ─── PATCH /api/v1/federation/partners/:id ────────────────────────────────
describe('PATCH /api/v1/federation/partners/:id', () => {
it('updates the partner name and returns 200', async () => {
const { body: created } = await registerPartner();
const partnerId = created['id'] as string;
const token = makeToken();
const res = await request(app)
.patch(`/api/v1/federation/partners/${partnerId}`)
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Updated Partner Name' });
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>)['name']).toBe('Updated Partner Name');
});
it('returns 404 for unknown partner id', async () => {
const token = makeToken();
const res = await request(app)
.patch(`/api/v1/federation/partners/${uuidv4()}`)
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Ghost' });
expect(res.status).toBe(404);
});
});
// ─── DELETE /api/v1/federation/partners/:id ───────────────────────────────
describe('DELETE /api/v1/federation/partners/:id', () => {
it('deletes the partner and returns 204', async () => {
const { body: created } = await registerPartner();
const partnerId = created['id'] as string;
const token = makeToken();
const res = await request(app)
.delete(`/api/v1/federation/partners/${partnerId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(204);
});
it('returns 404 when deleting a non-existent partner', async () => {
const token = makeToken();
const res = await request(app)
.delete(`/api/v1/federation/partners/${uuidv4()}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
});
// ─── POST /api/v1/federation/verify ───────────────────────────────────────
describe('POST /api/v1/federation/verify', () => {
it('verifies a valid token from a registered partner and returns 200', async () => {
// Register the partner (JWKS cached from registration)
await registerPartner();
// After registration, the JWKS is cached in Redis from the registration fetch.
// Now mock fetch to return the JWKS again for the verify path (cache miss fallback).
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ keys: [partnerJwk] }),
} as unknown as Response);
const federatedToken = makePartnerToken();
const token = makeToken(CALLER_ID, AGENT_SCOPE);
const res = await request(app)
.post('/api/v1/federation/verify')
.set('Authorization', `Bearer ${token}`)
.send({ token: federatedToken });
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>)['valid']).toBe(true);
expect((res.body as Record<string, unknown>)['issuer']).toBe(PARTNER_ISSUER);
});
it('rejects a token from an unknown issuer with 401', async () => {
const unknownToken = jwt.sign(
{ iss: 'https://completely-unknown.example.com', sub: 'x', aud: 'a' },
partnerKeys.privateKey,
{ algorithm: 'RS256' },
);
const token = makeToken(CALLER_ID, AGENT_SCOPE);
const res = await request(app)
.post('/api/v1/federation/verify')
.set('Authorization', `Bearer ${token}`)
.send({ token: unknownToken });
expect(res.status).toBe(401);
expect((res.body as Record<string, unknown>)['code']).toBe('FEDERATION_VERIFICATION_ERROR');
});
it('rejects an alg:none token with 401', async () => {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
iss: PARTNER_ISSUER,
sub: 'x',
aud: 'a',
iat: 1,
exp: 9999999999,
})).toString('base64url');
const noneToken = `${header}.${payload}.`;
const token = makeToken(CALLER_ID, AGENT_SCOPE);
const res = await request(app)
.post('/api/v1/federation/verify')
.set('Authorization', `Bearer ${token}`)
.send({ token: noneToken });
expect(res.status).toBe(401);
});
it('returns 401 without a bearer token', async () => {
const res = await request(app)
.post('/api/v1/federation/verify')
.send({ token: 'some-token' });
expect(res.status).toBe(401);
});
});
});