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:
466
tests/integration/federation.test.ts
Normal file
466
tests/integration/federation.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user