/** * Integration tests for OAuth2 Token Service endpoints. */ import crypto from 'crypto'; import request from 'supertest'; import { Application } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { Pool } from 'pg'; 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'; import { createApp } from '../../src/app'; import { signToken } from '../../src/utils/jwt'; import { closePool } from '../../src/db/pool'; import { closeRedisClient } from '../../src/cache/redis'; function makeToken(sub: string, scope: string = 'agents:read tokens:read'): string { return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey); } describe('OAuth2 Token Service Integration Tests', () => { let app: Application; let pool: Pool; beforeAll(async () => { app = await createApp(); pool = new Pool({ connectionString: process.env['DATABASE_URL'] }); const migrations = [ `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', 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', 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 '{}', 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() )`, ]; 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 credentials'); await pool.query('DELETE FROM agents'); }); afterAll(async () => { await pool.end(); await closePool(); await closeRedisClient(); }); async function createAgentWithCredentials(): Promise<{ agentId: string; clientSecret: string }> { const agentId = uuidv4(); const token = makeToken(agentId, 'agents:read agents:write tokens:read'); // Create agent directly in DB await pool.query( `INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status) VALUES ($1, $2, 'screener', '1.0.0', '{"agents:read"}', 'test', 'development', 'active')`, [agentId, `agent-${agentId}@test.ai`], ); // Generate credentials via API const credRes = await request(app) .post(`/api/v1/agents/${agentId}/credentials`) .set('Authorization', `Bearer ${token}`) .send({}); return { agentId, clientSecret: credRes.body.clientSecret }; } describe('POST /api/v1/token', () => { it('should issue a token for valid credentials', 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.access_token).toBeDefined(); expect(res.body.token_type).toBe('Bearer'); expect(res.body.expires_in).toBe(3600); expect(res.headers['cache-control']).toBe('no-store'); }); it('should return 400 for missing grant_type', async () => { const res = await request(app) .post('/api/v1/token') .type('form') .send({ client_id: uuidv4(), client_secret: 'secret' }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); }); it('should return 400 for unsupported grant_type', async () => { const res = await request(app) .post('/api/v1/token') .type('form') .send({ grant_type: 'authorization_code' }); expect(res.status).toBe(400); expect(res.body.error).toBe('unsupported_grant_type'); }); it('should return 401 for invalid credentials', async () => { const res = await request(app) .post('/api/v1/token') .type('form') .send({ grant_type: 'client_credentials', client_id: uuidv4(), client_secret: 'wrong-secret', scope: 'agents:read', }); expect(res.status).toBe(401); expect(res.body.error).toBe('invalid_client'); }); it('should return 400 for invalid scope', async () => { const res = await request(app) .post('/api/v1/token') .type('form') .send({ grant_type: 'client_credentials', client_id: uuidv4(), client_secret: 'secret', scope: 'admin:all', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_scope'); }); }); describe('POST /api/v1/token/introspect', () => { it('should return active:true for a valid token', async () => { const { agentId, clientSecret } = await createAgentWithCredentials(); const scope = 'agents:read tokens:read'; const issued = await request(app) .post('/api/v1/token') .type('form') .send({ grant_type: 'client_credentials', client_id: agentId, client_secret: clientSecret, scope }); const callerToken = issued.body.access_token; const res = await request(app) .post('/api/v1/token/introspect') .set('Authorization', `Bearer ${callerToken}`) .type('form') .send({ token: callerToken }); expect(res.status).toBe(200); expect(res.body.active).toBe(true); }); it('should return active:false for an invalid token', async () => { const callerToken = makeToken(uuidv4(), 'tokens:read'); const res = await request(app) .post('/api/v1/token/introspect') .set('Authorization', `Bearer ${callerToken}`) .type('form') .send({ token: 'not.a.real.token' }); expect(res.status).toBe(200); expect(res.body.active).toBe(false); }); it('should return 401 without Bearer token', async () => { const res = await request(app) .post('/api/v1/token/introspect') .type('form') .send({ token: 'some.token' }); expect(res.status).toBe(401); }); }); describe('POST /api/v1/token/revoke', () => { it('should revoke a token and return 200', async () => { const { agentId, clientSecret } = await createAgentWithCredentials(); const issued = await request(app) .post('/api/v1/token') .type('form') .send({ grant_type: 'client_credentials', client_id: agentId, client_secret: clientSecret, scope: 'agents:read' }); const token = issued.body.access_token; const res = await request(app) .post('/api/v1/token/revoke') .set('Authorization', `Bearer ${token}`) .type('form') .send({ token }); expect(res.status).toBe(200); }); it('should return 401 without Bearer token', async () => { const res = await request(app) .post('/api/v1/token/revoke') .type('form') .send({ token: 'some.token' }); expect(res.status).toBe(401); }); }); });