feat: Phase 1 MVP — complete AgentIdP implementation
Implements all P0 features per OpenSpec change phase-1-mvp-implementation: - Agent Registry Service (CRUD) — full lifecycle management - OAuth 2.0 Token Service (Client Credentials flow) - Credential Management (generate, rotate, revoke) - Immutable Audit Log Service Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+ Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types Quality: 18 unit test suites, 244 tests passing, 97%+ coverage OpenAPI: 4 complete specs (14 endpoints total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
138
src/app.ts
Normal file
138
src/app.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Express application factory for SentryAgent.ai AgentIdP.
|
||||
* Creates and configures the Express app with all middleware and routes.
|
||||
* Exported as a factory function — does NOT call listen (testable).
|
||||
*/
|
||||
|
||||
import express, { Application } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
|
||||
import { getPool } from './db/pool.js';
|
||||
import { getRedisClient } from './cache/redis.js';
|
||||
|
||||
import { AgentRepository } from './repositories/AgentRepository.js';
|
||||
import { CredentialRepository } from './repositories/CredentialRepository.js';
|
||||
import { TokenRepository } from './repositories/TokenRepository.js';
|
||||
import { AuditRepository } from './repositories/AuditRepository.js';
|
||||
|
||||
import { AuditService } from './services/AuditService.js';
|
||||
import { AgentService } from './services/AgentService.js';
|
||||
import { CredentialService } from './services/CredentialService.js';
|
||||
import { OAuth2Service } from './services/OAuth2Service.js';
|
||||
|
||||
import { AgentController } from './controllers/AgentController.js';
|
||||
import { TokenController } from './controllers/TokenController.js';
|
||||
import { CredentialController } from './controllers/CredentialController.js';
|
||||
import { AuditController } from './controllers/AuditController.js';
|
||||
|
||||
import { createAgentsRouter } from './routes/agents.js';
|
||||
import { createTokenRouter } from './routes/token.js';
|
||||
import { createCredentialsRouter } from './routes/credentials.js';
|
||||
import { createAuditRouter } from './routes/audit.js';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { RedisClientType } from 'redis';
|
||||
|
||||
/**
|
||||
* Creates and returns a configured Express application.
|
||||
* All infrastructure dependencies (DB pool, Redis) are initialised here.
|
||||
*
|
||||
* @returns Promise resolving to the configured Express Application.
|
||||
* @throws Error if required environment variables are missing.
|
||||
*/
|
||||
export async function createApp(): Promise<Application> {
|
||||
const app = express();
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Security headers
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(helmet());
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// CORS
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const corsOrigin = process.env['CORS_ORIGIN'] ?? '*';
|
||||
app.use(cors({ origin: corsOrigin }));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Request logging
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
if (process.env['NODE_ENV'] !== 'test') {
|
||||
app.use(morgan('combined'));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Body parsers
|
||||
// JSON body parser for most routes
|
||||
// urlencoded parser for token endpoint (application/x-www-form-urlencoded)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Infrastructure singletons
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const pool = getPool();
|
||||
const redis = await getRedisClient();
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Repository layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const agentRepo = new AgentRepository(pool);
|
||||
const credentialRepo = new CredentialRepository(pool);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const tokenRepo = new TokenRepository(pool, redis as RedisClientType);
|
||||
const auditRepo = new AuditRepository(pool);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Service layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const auditService = new AuditService(auditRepo);
|
||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService);
|
||||
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService);
|
||||
|
||||
const privateKey = process.env['JWT_PRIVATE_KEY'];
|
||||
const publicKey = process.env['JWT_PUBLIC_KEY'];
|
||||
if (!privateKey || !publicKey) {
|
||||
throw new Error('JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required');
|
||||
}
|
||||
|
||||
const oauth2Service = new OAuth2Service(
|
||||
tokenRepo,
|
||||
credentialRepo,
|
||||
agentRepo,
|
||||
auditService,
|
||||
privateKey,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Controller layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const agentController = new AgentController(agentService);
|
||||
const tokenController = new TokenController(oauth2Service);
|
||||
const credentialController = new CredentialController(credentialService);
|
||||
const auditController = new AuditController(auditService);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Routes
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController));
|
||||
app.use(
|
||||
`${API_BASE}/agents/:agentId/credentials`,
|
||||
createCredentialsRouter(credentialController),
|
||||
);
|
||||
app.use(`${API_BASE}/token`, createTokenRouter(tokenController));
|
||||
app.use(`${API_BASE}/audit`, createAuditRouter(auditController));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Global error handler (must be last)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
47
src/cache/redis.ts
vendored
Normal file
47
src/cache/redis.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Redis client singleton for SentryAgent.ai AgentIdP.
|
||||
* Used for token revocation tracking, rate limiting, and monthly token counts.
|
||||
*/
|
||||
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
let redisClient: RedisClientType | null = null;
|
||||
|
||||
/**
|
||||
* Returns the singleton Redis client instance.
|
||||
* Initialises and connects the client on first call using REDIS_URL from env.
|
||||
*
|
||||
* @returns Promise resolving to the connected Redis client.
|
||||
* @throws Error if REDIS_URL is not set or connection fails.
|
||||
*/
|
||||
export async function getRedisClient(): Promise<RedisClientType> {
|
||||
if (!redisClient) {
|
||||
const url = process.env['REDIS_URL'];
|
||||
if (!url) {
|
||||
throw new Error('REDIS_URL environment variable is required');
|
||||
}
|
||||
|
||||
redisClient = createClient({ url }) as RedisClientType;
|
||||
|
||||
redisClient.on('error', (err: Error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Redis client error', err);
|
||||
});
|
||||
|
||||
await redisClient.connect();
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the Redis client and resets the singleton.
|
||||
* Used for graceful shutdown and tests.
|
||||
*
|
||||
* @returns Promise that resolves when the client is disconnected.
|
||||
*/
|
||||
export async function closeRedisClient(): Promise<void> {
|
||||
if (redisClient) {
|
||||
await redisClient.quit();
|
||||
redisClient = null;
|
||||
}
|
||||
}
|
||||
186
src/controllers/AgentController.ts
Normal file
186
src/controllers/AgentController.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Agent Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for all 5 agent endpoints. No business logic — delegates to AgentService.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AgentService } from '../services/AgentService.js';
|
||||
import {
|
||||
createAgentSchema,
|
||||
updateAgentSchema,
|
||||
listAgentsQuerySchema,
|
||||
} from '../utils/validators.js';
|
||||
import { ValidationError, AuthorizationError } from '../utils/errors.js';
|
||||
import {
|
||||
ICreateAgentRequest,
|
||||
IUpdateAgentRequest,
|
||||
IAgentListFilters,
|
||||
} from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Controller for the Agent Registry endpoints.
|
||||
* Receives AgentService via constructor injection.
|
||||
*/
|
||||
export class AgentController {
|
||||
/**
|
||||
* @param agentService - The agent registry service.
|
||||
*/
|
||||
constructor(private readonly agentService: AgentService) {}
|
||||
|
||||
/**
|
||||
* Handles POST /agents — registers a new agent.
|
||||
*
|
||||
* @param req - Express request with CreateAgentRequest body.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
registerAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = createAgentSchema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
throw new ValidationError('Request validation failed.', {
|
||||
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
|
||||
});
|
||||
}
|
||||
|
||||
const data = value as ICreateAgentRequest;
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
const agent = await this.agentService.registerAgent(data, ipAddress, userAgent);
|
||||
res.status(201).json(agent);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /agents — returns a paginated list of agents.
|
||||
*
|
||||
* @param req - Express request with optional query filters.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
listAgents = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = listAgentsQuerySchema.validate(req.query, { abortEarly: false });
|
||||
if (error) {
|
||||
throw new ValidationError('Invalid query parameter value.', {
|
||||
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
|
||||
});
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
const filters: IAgentListFilters = {
|
||||
page: value.page as number,
|
||||
limit: value.limit as number,
|
||||
owner: value.owner as string | undefined,
|
||||
agentType: value.agentType as IAgentListFilters['agentType'],
|
||||
status: value.status as IAgentListFilters['status'],
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
const result = await this.agentService.listAgents(filters);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /agents/:agentId — retrieves a single agent.
|
||||
*
|
||||
* @param req - Express request with agentId path param.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAgentById = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
const { agentId } = req.params;
|
||||
const agent = await this.agentService.getAgentById(agentId);
|
||||
res.status(200).json(agent);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles PATCH /agents/:agentId — partially updates an agent.
|
||||
*
|
||||
* @param req - Express request with agentId path param and UpdateAgentRequest body.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
updateAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = updateAgentSchema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
const immutableFields = ['agentId', 'email', 'createdAt'];
|
||||
const firstImmutable = error.details.find((d) =>
|
||||
immutableFields.includes(d.path[0] as string),
|
||||
);
|
||||
if (firstImmutable) {
|
||||
throw new ValidationError(`The field '${String(firstImmutable.path[0])}' cannot be modified after registration.`, {
|
||||
field: firstImmutable.path[0],
|
||||
});
|
||||
}
|
||||
throw new ValidationError('Request validation failed.', {
|
||||
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
|
||||
});
|
||||
}
|
||||
|
||||
const { agentId } = req.params;
|
||||
const data = value as IUpdateAgentRequest;
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
const updated = await this.agentService.updateAgent(agentId, data, ipAddress, userAgent);
|
||||
res.status(200).json(updated);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles DELETE /agents/:agentId — decommissions an agent.
|
||||
*
|
||||
* @param req - Express request with agentId path param.
|
||||
* @param res - Express response (204 No Content).
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
decommissionAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
const { agentId } = req.params;
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
await this.agentService.decommissionAgent(agentId, ipAddress, userAgent);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
100
src/controllers/AuditController.ts
Normal file
100
src/controllers/AuditController.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Audit Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for GET /audit and GET /audit/:eventId.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AuditService } from '../services/AuditService.js';
|
||||
import { auditQuerySchema } from '../utils/validators.js';
|
||||
import {
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
InsufficientScopeError,
|
||||
} from '../utils/errors.js';
|
||||
import { IAuditListFilters } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Controller for the Audit Log endpoints.
|
||||
* Enforces `audit:read` scope on all handlers.
|
||||
*/
|
||||
export class AuditController {
|
||||
/**
|
||||
* @param auditService - The audit log service.
|
||||
*/
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
/**
|
||||
* Handles GET /audit — queries the audit log with optional filters.
|
||||
* Requires Bearer token with `audit:read` scope.
|
||||
*
|
||||
* @param req - Express request with optional query filters.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
queryAuditLog = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// Enforce audit:read scope
|
||||
const scopes = req.user.scope.split(' ');
|
||||
if (!scopes.includes('audit:read')) {
|
||||
throw new InsufficientScopeError('audit:read');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = auditQuerySchema.validate(req.query, { abortEarly: false });
|
||||
if (error) {
|
||||
throw new ValidationError('Invalid query parameter value.', {
|
||||
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
|
||||
});
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
const filters: IAuditListFilters = {
|
||||
page: value.page as number,
|
||||
limit: value.limit as number,
|
||||
agentId: value.agentId as string | undefined,
|
||||
action: value.action as IAuditListFilters['action'],
|
||||
outcome: value.outcome as IAuditListFilters['outcome'],
|
||||
fromDate: value.fromDate as string | undefined,
|
||||
toDate: value.toDate as string | undefined,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
const result = await this.auditService.queryEvents(filters);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /audit/:eventId — retrieves a single audit event.
|
||||
* Requires Bearer token with `audit:read` scope.
|
||||
*
|
||||
* @param req - Express request with eventId path param.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAuditEventById = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// Enforce audit:read scope
|
||||
const scopes = req.user.scope.split(' ');
|
||||
if (!scopes.includes('audit:read')) {
|
||||
throw new InsufficientScopeError('audit:read');
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
const event = await this.auditService.getEventById(eventId);
|
||||
res.status(200).json(event);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
196
src/controllers/CredentialController.ts
Normal file
196
src/controllers/CredentialController.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Credential Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for all 4 credential management endpoints.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { CredentialService } from '../services/CredentialService.js';
|
||||
import {
|
||||
generateCredentialSchema,
|
||||
listCredentialsQuerySchema,
|
||||
} from '../utils/validators.js';
|
||||
import { ValidationError, AuthorizationError, AuthenticationError } from '../utils/errors.js';
|
||||
import {
|
||||
IGenerateCredentialRequest,
|
||||
ICredentialListFilters,
|
||||
} from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Controller for the Credential Management endpoints.
|
||||
*/
|
||||
export class CredentialController {
|
||||
/**
|
||||
* @param credentialService - The credential management service.
|
||||
*/
|
||||
constructor(private readonly credentialService: CredentialService) {}
|
||||
|
||||
/**
|
||||
* Handles POST /agents/:agentId/credentials — generates new credentials.
|
||||
* Returns 201 with CredentialWithSecret (secret shown once only).
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
generateCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { agentId } = req.params;
|
||||
|
||||
// An agent may only manage its own credentials (Phase 1 — no admin scope)
|
||||
if (req.user.sub !== agentId) {
|
||||
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = generateCredentialSchema.validate(req.body ?? {}, {
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw new ValidationError('Request validation failed.', {
|
||||
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
|
||||
});
|
||||
}
|
||||
|
||||
const data = value as IGenerateCredentialRequest;
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
const result = await this.credentialService.generateCredential(
|
||||
agentId,
|
||||
data,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /agents/:agentId/credentials — lists credentials for an agent.
|
||||
* clientSecret is never returned in list responses.
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
listCredentials = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { agentId } = req.params;
|
||||
|
||||
if (req.user.sub !== agentId) {
|
||||
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = listCredentialsQuerySchema.validate(req.query, {
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw new ValidationError('Invalid query parameter value.', {
|
||||
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
|
||||
});
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
const filters: ICredentialListFilters = {
|
||||
page: value.page as number,
|
||||
limit: value.limit as number,
|
||||
status: value.status as ICredentialListFilters['status'],
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
const result = await this.credentialService.listCredentials(agentId, filters);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles POST /agents/:agentId/credentials/:credentialId/rotate — rotates a credential.
|
||||
* Returns 200 with CredentialWithSecret (new secret shown once only).
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
rotateCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { agentId, credentialId } = req.params;
|
||||
|
||||
if (req.user.sub !== agentId) {
|
||||
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = generateCredentialSchema.validate(req.body ?? {}, {
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw new ValidationError('Request validation failed.', {
|
||||
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
|
||||
});
|
||||
}
|
||||
|
||||
const data = value as IGenerateCredentialRequest;
|
||||
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
const result = await this.credentialService.rotateCredential(
|
||||
agentId,
|
||||
credentialId,
|
||||
data,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles DELETE /agents/:agentId/credentials/:credentialId — revokes a credential.
|
||||
* Returns 204 No Content.
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
revokeCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { agentId, credentialId } = req.params;
|
||||
|
||||
if (req.user.sub !== agentId) {
|
||||
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
|
||||
}
|
||||
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
await this.credentialService.revokeCredential(agentId, credentialId, ipAddress, userAgent);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
243
src/controllers/TokenController.ts
Normal file
243
src/controllers/TokenController.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Token Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for POST /token, POST /token/introspect, POST /token/revoke.
|
||||
* Parses application/x-www-form-urlencoded bodies.
|
||||
* Returns OAuth2ErrorResponse for /token errors, ErrorResponse for introspect/revoke.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { OAuth2Service } from '../services/OAuth2Service.js';
|
||||
import { tokenRequestSchema, introspectRequestSchema, revokeRequestSchema } from '../utils/validators.js';
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
FreeTierLimitError,
|
||||
} from '../utils/errors.js';
|
||||
import { ITokenRequest, IIntrospectRequest, IRevokeRequest, IOAuth2ErrorResponse } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Maps an error from the token issuance flow to an OAuth2ErrorResponse.
|
||||
*
|
||||
* @param err - The error to map.
|
||||
* @returns Object with error, error_description, and httpStatus.
|
||||
*/
|
||||
function mapToOAuth2Error(err: unknown): {
|
||||
body: IOAuth2ErrorResponse;
|
||||
httpStatus: number;
|
||||
} {
|
||||
if (err instanceof FreeTierLimitError) {
|
||||
return {
|
||||
body: {
|
||||
error: 'unauthorized_client',
|
||||
error_description: err.message,
|
||||
},
|
||||
httpStatus: 403,
|
||||
};
|
||||
}
|
||||
if (err instanceof AuthorizationError) {
|
||||
return {
|
||||
body: {
|
||||
error: 'unauthorized_client',
|
||||
error_description: err.message,
|
||||
},
|
||||
httpStatus: 403,
|
||||
};
|
||||
}
|
||||
if (err instanceof AuthenticationError) {
|
||||
return {
|
||||
body: {
|
||||
error: 'invalid_client',
|
||||
error_description: 'Client authentication failed. Invalid client_id or client_secret.',
|
||||
},
|
||||
httpStatus: 401,
|
||||
};
|
||||
}
|
||||
// Default: internal server error
|
||||
return {
|
||||
body: {
|
||||
error: 'invalid_request',
|
||||
error_description: 'An unexpected error occurred.',
|
||||
},
|
||||
httpStatus: 500,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller for the OAuth 2.0 Token endpoints.
|
||||
*/
|
||||
export class TokenController {
|
||||
/**
|
||||
* @param oauth2Service - The OAuth2 token service.
|
||||
*/
|
||||
constructor(private readonly oauth2Service: OAuth2Service) {}
|
||||
|
||||
/**
|
||||
* Handles POST /token — issues an access token via Client Credentials grant.
|
||||
* Accepts application/x-www-form-urlencoded body.
|
||||
* Returns OAuth2ErrorResponse on failure.
|
||||
*
|
||||
* @param req - Express request with form-encoded body.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
issueToken = async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const body = req.body as ITokenRequest;
|
||||
|
||||
// Validate grant_type first
|
||||
if (!body.grant_type) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: "The 'grant_type' parameter is required.",
|
||||
} as IOAuth2ErrorResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.grant_type !== 'client_credentials') {
|
||||
res.status(400).json({
|
||||
error: 'unsupported_grant_type',
|
||||
error_description: "Only 'client_credentials' grant type is supported.",
|
||||
} as IOAuth2ErrorResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = tokenRequestSchema.validate(body, { abortEarly: false });
|
||||
if (error) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: error.details.map((d) => d.message).join('; '),
|
||||
} as IOAuth2ErrorResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenBody = value as ITokenRequest;
|
||||
|
||||
// Support HTTP Basic auth fallback
|
||||
let clientId = tokenBody.client_id;
|
||||
let clientSecret = tokenBody.client_secret;
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (authHeader?.startsWith('Basic ')) {
|
||||
const base64 = authHeader.slice(6);
|
||||
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
const colonIndex = decoded.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
clientId = decoded.slice(0, colonIndex);
|
||||
clientSecret = decoded.slice(colonIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: "The 'client_id' and 'client_secret' parameters are required.",
|
||||
} as IOAuth2ErrorResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate requested scope
|
||||
const requestedScope = tokenBody.scope ?? 'agents:read';
|
||||
const validScopes = ['agents:read', 'agents:write', 'tokens:read', 'audit:read'];
|
||||
const scopeList = requestedScope.split(' ');
|
||||
const invalidScope = scopeList.find((s) => !validScopes.includes(s));
|
||||
if (invalidScope) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_scope',
|
||||
error_description: `Requested scope '${invalidScope}' is not available.`,
|
||||
} as IOAuth2ErrorResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
const tokenResponse = await this.oauth2Service.issueToken(
|
||||
clientId,
|
||||
clientSecret,
|
||||
requestedScope,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.status(200).json(tokenResponse);
|
||||
} catch (err) {
|
||||
// Token endpoint uses OAuth2ErrorResponse format
|
||||
const mapped = mapToOAuth2Error(err);
|
||||
res.status(mapped.httpStatus).json(mapped.body);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles POST /token/introspect — introspects a token per RFC 7662.
|
||||
* Requires Bearer auth with tokens:read scope.
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
introspectToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = introspectRequestSchema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
const messages = error.details.map((d) => d.message).join('; ');
|
||||
throw new Error(messages);
|
||||
}
|
||||
|
||||
const body = value as IIntrospectRequest;
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
const result = await this.oauth2Service.introspectToken(
|
||||
body.token,
|
||||
req.user,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles POST /token/revoke — revokes a token per RFC 7009.
|
||||
* Requires Bearer auth.
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
revokeToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = revokeRequestSchema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
const messages = error.details.map((d) => d.message).join('; ');
|
||||
throw new Error(messages);
|
||||
}
|
||||
|
||||
const body = value as IRevokeRequest;
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
await this.oauth2Service.revokeToken(body.token, req.user, ipAddress, userAgent);
|
||||
res.status(200).json({});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
28
src/db/migrations/001_create_agents.sql
Normal file
28
src/db/migrations/001_create_agents.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Migration: 001_create_agents
|
||||
-- Creates the agents table for the Agent Registry service.
|
||||
|
||||
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(),
|
||||
|
||||
CONSTRAINT agents_agent_type_check
|
||||
CHECK (agent_type IN ('screener','classifier','orchestrator','extractor','summarizer','router','monitor','custom')),
|
||||
CONSTRAINT agents_deployment_env_check
|
||||
CHECK (deployment_env IN ('development','staging','production')),
|
||||
CONSTRAINT agents_status_check
|
||||
CHECK (status IN ('active','suspended','decommissioned'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_email ON agents (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_owner ON agents (owner);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_agent_type ON agents (agent_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_created_at ON agents (created_at DESC);
|
||||
19
src/db/migrations/002_create_credentials.sql
Normal file
19
src/db/migrations/002_create_credentials.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration: 002_create_credentials
|
||||
-- Creates the credentials table for the Credential Management service.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE,
|
||||
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,
|
||||
|
||||
CONSTRAINT credentials_status_check
|
||||
CHECK (status IN ('active','revoked'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_client_id ON credentials (client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_created_at ON credentials (created_at DESC);
|
||||
28
src/db/migrations/003_create_audit_events.sql
Normal file
28
src/db/migrations/003_create_audit_events.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Migration: 003_create_audit_events
|
||||
-- Creates the audit_events table for the Audit Log service.
|
||||
-- Append-only by design — no UPDATE or DELETE operations are permitted.
|
||||
|
||||
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(),
|
||||
|
||||
CONSTRAINT audit_events_action_check
|
||||
CHECK (action IN (
|
||||
'agent.created','agent.updated','agent.decommissioned','agent.suspended',
|
||||
'agent.reactivated','token.issued','token.revoked','token.introspected',
|
||||
'credential.generated','credential.rotated','credential.revoked','auth.failed'
|
||||
)),
|
||||
CONSTRAINT audit_events_outcome_check
|
||||
CHECK (outcome IN ('success','failure'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_agent_id ON audit_events (agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events (action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_outcome ON audit_events (outcome);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events (timestamp DESC);
|
||||
11
src/db/migrations/004_create_tokens.sql
Normal file
11
src/db/migrations/004_create_tokens.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Migration: 004_create_tokens
|
||||
-- Creates the token_revocations table for soft revocation tracking.
|
||||
-- Supplementary to Redis — provides durability across Redis restarts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_revocations (
|
||||
jti UUID PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_token_revocations_expires_at ON token_revocations (expires_at);
|
||||
44
src/db/pool.ts
Normal file
44
src/db/pool.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* PostgreSQL connection pool singleton.
|
||||
* All database access flows through this pool.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
let pool: Pool | null = null;
|
||||
|
||||
/**
|
||||
* Returns the singleton pg Pool instance.
|
||||
* Initialises the pool on first call using DATABASE_URL from the environment.
|
||||
*
|
||||
* @returns The PostgreSQL connection pool.
|
||||
* @throws Error if DATABASE_URL is not set.
|
||||
*/
|
||||
export function getPool(): Pool {
|
||||
if (!pool) {
|
||||
const connectionString = process.env['DATABASE_URL'];
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL environment variable is required');
|
||||
}
|
||||
pool = new Pool({ connectionString });
|
||||
|
||||
pool.on('error', (err: Error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Unexpected pg pool error', err);
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the pool and resets the singleton.
|
||||
* Used for graceful shutdown and tests.
|
||||
*
|
||||
* @returns Promise that resolves when the pool is closed.
|
||||
*/
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
77
src/middleware/auth.ts
Normal file
77
src/middleware/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Authentication middleware for SentryAgent.ai AgentIdP.
|
||||
* Extracts and verifies Bearer tokens from the Authorization header.
|
||||
* Checks Redis for token revocation before attaching the payload to req.user.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
import { verifyToken } from '../utils/jwt.js';
|
||||
import { getRedisClient } from '../cache/redis.js';
|
||||
import { AuthenticationError } from '../utils/errors.js';
|
||||
import { ITokenPayload } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Express middleware that validates a Bearer JWT token on every protected request.
|
||||
*
|
||||
* Behaviour:
|
||||
* 1. Extracts the Bearer token from the Authorization header.
|
||||
* 2. Verifies the RS256 signature and expiry using the public key.
|
||||
* 3. Checks Redis whether the JTI has been explicitly revoked.
|
||||
* 4. Attaches the decoded payload to `req.user`.
|
||||
* 5. Throws `AuthenticationError` on any failure.
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param _res - Express response (unused).
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
export async function authMiddleware(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AuthenticationError('A valid Bearer token is required to access this resource.');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7).trim();
|
||||
if (!token) {
|
||||
throw new AuthenticationError('A valid Bearer token is required to access this resource.');
|
||||
}
|
||||
|
||||
const publicKey = process.env['JWT_PUBLIC_KEY'];
|
||||
if (!publicKey) {
|
||||
throw new Error('JWT_PUBLIC_KEY environment variable is required');
|
||||
}
|
||||
|
||||
let payload: ITokenPayload;
|
||||
try {
|
||||
payload = verifyToken(token, publicKey);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
throw new AuthenticationError('Token has expired.');
|
||||
}
|
||||
if (err instanceof JsonWebTokenError) {
|
||||
throw new AuthenticationError('Token signature is invalid.');
|
||||
}
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// Check Redis revocation list
|
||||
const redis = await getRedisClient();
|
||||
const revocationKey = `revoked:${payload.jti}`;
|
||||
const isRevoked = await redis.get(revocationKey);
|
||||
|
||||
if (isRevoked !== null) {
|
||||
throw new AuthenticationError('Token has been revoked.');
|
||||
}
|
||||
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
48
src/middleware/errorHandler.ts
Normal file
48
src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Global Express error-handling middleware for SentryAgent.ai AgentIdP.
|
||||
* Maps SentryAgentError subclasses to their HTTP status codes and error shapes.
|
||||
* Unknown errors are mapped to 500 Internal Server Error.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { SentryAgentError } from '../utils/errors.js';
|
||||
import { IErrorResponse } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Express error-handling middleware.
|
||||
* Must have exactly 4 parameters to be recognised as an error handler.
|
||||
*
|
||||
* @param err - The error thrown by a route handler or upstream middleware.
|
||||
* @param _req - Express request (unused).
|
||||
* @param res - Express response.
|
||||
* @param _next - Express next function (unused but required by Express signature).
|
||||
*/
|
||||
export function errorHandler(
|
||||
err: unknown,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
if (err instanceof SentryAgentError) {
|
||||
const body: IErrorResponse = {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
};
|
||||
if (err.details !== undefined) {
|
||||
body.details = err.details;
|
||||
}
|
||||
res.status(err.httpStatus).json(body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unexpected error — log and return generic 500
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Unhandled error:', err);
|
||||
|
||||
const body: IErrorResponse = {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'An unexpected error occurred. Please try again later.',
|
||||
};
|
||||
res.status(500).json(body);
|
||||
}
|
||||
69
src/middleware/rateLimit.ts
Normal file
69
src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Redis-backed rate limiting middleware for SentryAgent.ai AgentIdP.
|
||||
* Enforces 100 requests per minute per client_id using a sliding window counter.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { getRedisClient } from '../cache/redis.js';
|
||||
import { RateLimitError } from '../utils/errors.js';
|
||||
|
||||
const RATE_LIMIT_MAX = 100;
|
||||
const WINDOW_MS = 60000; // 60 seconds
|
||||
|
||||
/**
|
||||
* Computes the current rate-limit window key and next reset timestamp.
|
||||
*
|
||||
* @returns Object with `windowKey` (minute index) and `resetAt` (Unix seconds).
|
||||
*/
|
||||
function getWindowInfo(): { windowKey: number; resetAt: number } {
|
||||
const windowKey = Math.floor(Date.now() / WINDOW_MS);
|
||||
const resetAt = (windowKey + 1) * (WINDOW_MS / 1000);
|
||||
return { windowKey, resetAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware that applies Redis-based rate limiting per client_id.
|
||||
*
|
||||
* The client_id is sourced from `req.user.client_id` (set by authMiddleware).
|
||||
* For unauthenticated requests (token endpoint), the client IP is used instead.
|
||||
*
|
||||
* Sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
|
||||
* headers on every response. Throws `RateLimitError` when the limit is exceeded.
|
||||
*
|
||||
* @param req - Express request.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
export async function rateLimitMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const clientId = req.user?.client_id ?? req.ip ?? 'unknown';
|
||||
const { windowKey, resetAt } = getWindowInfo();
|
||||
const redisKey = `rate:${clientId}:${windowKey}`;
|
||||
|
||||
const redis = await getRedisClient();
|
||||
|
||||
// Atomically increment and set TTL
|
||||
const count = await redis.incr(redisKey);
|
||||
if (count === 1) {
|
||||
await redis.expire(redisKey, 60);
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, RATE_LIMIT_MAX - count);
|
||||
|
||||
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
|
||||
res.setHeader('X-RateLimit-Remaining', remaining);
|
||||
res.setHeader('X-RateLimit-Reset', resetAt);
|
||||
|
||||
if (count > RATE_LIMIT_MAX) {
|
||||
throw new RateLimitError();
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
247
src/repositories/AgentRepository.ts
Normal file
247
src/repositories/AgentRepository.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Agent Repository for SentryAgent.ai AgentIdP.
|
||||
* All SQL queries for the agents table live exclusively here.
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
IAgent,
|
||||
ICreateAgentRequest,
|
||||
IUpdateAgentRequest,
|
||||
IAgentListFilters,
|
||||
AgentStatus,
|
||||
} from '../types/index.js';
|
||||
|
||||
/** Raw database row for an agent. */
|
||||
interface AgentRow {
|
||||
agent_id: string;
|
||||
email: string;
|
||||
agent_type: string;
|
||||
version: string;
|
||||
capabilities: string[];
|
||||
owner: string;
|
||||
deployment_env: string;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw database row to the IAgent domain model.
|
||||
*
|
||||
* @param row - Raw row from the agents table.
|
||||
* @returns Typed IAgent object.
|
||||
*/
|
||||
function mapRowToAgent(row: AgentRow): IAgent {
|
||||
return {
|
||||
agentId: row.agent_id,
|
||||
email: row.email,
|
||||
agentType: row.agent_type as IAgent['agentType'],
|
||||
version: row.version,
|
||||
capabilities: row.capabilities,
|
||||
owner: row.owner,
|
||||
deploymentEnv: row.deployment_env as IAgent['deploymentEnv'],
|
||||
status: row.status as AgentStatus,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for all agent database operations.
|
||||
* Receives a pg.Pool via constructor injection.
|
||||
*/
|
||||
export class AgentRepository {
|
||||
/**
|
||||
* @param pool - The PostgreSQL connection pool.
|
||||
*/
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Creates a new agent record in the database.
|
||||
*
|
||||
* @param data - The fields for the new agent.
|
||||
* @returns The created agent record.
|
||||
*/
|
||||
async create(data: ICreateAgentRequest): Promise<IAgent> {
|
||||
const agentId = uuidv4();
|
||||
const result: QueryResult<AgentRow> = await this.pool.query(
|
||||
`INSERT INTO agents
|
||||
(agent_id, email, agent_type, version, capabilities, owner, deployment_env, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
agentId,
|
||||
data.email,
|
||||
data.agentType,
|
||||
data.version,
|
||||
data.capabilities,
|
||||
data.owner,
|
||||
data.deploymentEnv,
|
||||
],
|
||||
);
|
||||
return mapRowToAgent(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an agent by its UUID.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns The agent record, or null if not found.
|
||||
*/
|
||||
async findById(agentId: string): Promise<IAgent | null> {
|
||||
const result: QueryResult<AgentRow> = await this.pool.query(
|
||||
'SELECT * FROM agents WHERE agent_id = $1',
|
||||
[agentId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToAgent(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an agent by its email address.
|
||||
*
|
||||
* @param email - The agent email.
|
||||
* @returns The agent record, or null if not found.
|
||||
*/
|
||||
async findByEmail(email: string): Promise<IAgent | null> {
|
||||
const result: QueryResult<AgentRow> = await this.pool.query(
|
||||
'SELECT * FROM agents WHERE email = $1',
|
||||
[email],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToAgent(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated list of agents with optional filters.
|
||||
*
|
||||
* @param filters - Pagination and filter criteria.
|
||||
* @returns Object containing the agent list and total count.
|
||||
*/
|
||||
async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.owner !== undefined) {
|
||||
conditions.push(`owner = $${paramIndex++}`);
|
||||
params.push(filters.owner);
|
||||
}
|
||||
if (filters.agentType !== undefined) {
|
||||
conditions.push(`agent_type = $${paramIndex++}`);
|
||||
params.push(filters.agentType);
|
||||
}
|
||||
if (filters.status !== undefined) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const countResult: QueryResult<{ count: string }> = await this.pool.query(
|
||||
`SELECT COUNT(*) as count FROM agents ${whereClause}`,
|
||||
params,
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
const offset = (filters.page - 1) * filters.limit;
|
||||
const dataParams = [...params, filters.limit, offset];
|
||||
|
||||
const dataResult: QueryResult<AgentRow> = await this.pool.query(
|
||||
`SELECT * FROM agents ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||
dataParams,
|
||||
);
|
||||
|
||||
return {
|
||||
agents: dataResult.rows.map(mapRowToAgent),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially updates an agent record.
|
||||
*
|
||||
* @param agentId - The agent UUID to update.
|
||||
* @param data - The fields to update (only provided fields are changed).
|
||||
* @returns The updated agent record, or null if not found.
|
||||
*/
|
||||
async update(agentId: string, data: IUpdateAgentRequest): Promise<IAgent | null> {
|
||||
const setClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.agentType !== undefined) {
|
||||
setClauses.push(`agent_type = $${paramIndex++}`);
|
||||
params.push(data.agentType);
|
||||
}
|
||||
if (data.version !== undefined) {
|
||||
setClauses.push(`version = $${paramIndex++}`);
|
||||
params.push(data.version);
|
||||
}
|
||||
if (data.capabilities !== undefined) {
|
||||
setClauses.push(`capabilities = $${paramIndex++}`);
|
||||
params.push(data.capabilities);
|
||||
}
|
||||
if (data.owner !== undefined) {
|
||||
setClauses.push(`owner = $${paramIndex++}`);
|
||||
params.push(data.owner);
|
||||
}
|
||||
if (data.deploymentEnv !== undefined) {
|
||||
setClauses.push(`deployment_env = $${paramIndex++}`);
|
||||
params.push(data.deploymentEnv);
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
setClauses.push(`status = $${paramIndex++}`);
|
||||
params.push(data.status);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) return null;
|
||||
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
params.push(agentId);
|
||||
|
||||
const result: QueryResult<AgentRow> = await this.pool.query(
|
||||
`UPDATE agents SET ${setClauses.join(', ')}
|
||||
WHERE agent_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
params,
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToAgent(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an agent's status to 'decommissioned'.
|
||||
*
|
||||
* @param agentId - The agent UUID to decommission.
|
||||
* @returns The updated agent record, or null if not found.
|
||||
*/
|
||||
async decommission(agentId: string): Promise<IAgent | null> {
|
||||
const result: QueryResult<AgentRow> = await this.pool.query(
|
||||
`UPDATE agents
|
||||
SET status = 'decommissioned', updated_at = NOW()
|
||||
WHERE agent_id = $1
|
||||
RETURNING *`,
|
||||
[agentId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToAgent(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts all agents excluding decommissioned ones (for free-tier limit checks).
|
||||
*
|
||||
* @returns Total count of active and suspended agents.
|
||||
*/
|
||||
async countActive(): Promise<number> {
|
||||
const result: QueryResult<{ count: string }> = await this.pool.query(
|
||||
`SELECT COUNT(*) as count FROM agents WHERE status != 'decommissioned'`,
|
||||
);
|
||||
return parseInt(result.rows[0].count, 10);
|
||||
}
|
||||
}
|
||||
152
src/repositories/AuditRepository.ts
Normal file
152
src/repositories/AuditRepository.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Audit Repository for SentryAgent.ai AgentIdP.
|
||||
* All SQL queries for the audit_events table live exclusively here.
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IAuditEvent, ICreateAuditEventInput, IAuditListFilters } from '../types/index.js';
|
||||
|
||||
/** Raw database row for an audit event. */
|
||||
interface AuditEventRow {
|
||||
event_id: string;
|
||||
agent_id: string;
|
||||
action: string;
|
||||
outcome: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
metadata: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw database row to the IAuditEvent domain model.
|
||||
*
|
||||
* @param row - Raw row from the audit_events table.
|
||||
* @returns Typed IAuditEvent object.
|
||||
*/
|
||||
function mapRowToAuditEvent(row: AuditEventRow): IAuditEvent {
|
||||
return {
|
||||
eventId: row.event_id,
|
||||
agentId: row.agent_id,
|
||||
action: row.action as IAuditEvent['action'],
|
||||
outcome: row.outcome as IAuditEvent['outcome'],
|
||||
ipAddress: row.ip_address,
|
||||
userAgent: row.user_agent,
|
||||
metadata: row.metadata,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for all audit event database operations.
|
||||
* Receives a pg.Pool via constructor injection.
|
||||
*/
|
||||
export class AuditRepository {
|
||||
/**
|
||||
* @param pool - The PostgreSQL connection pool.
|
||||
*/
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Creates a new audit event record.
|
||||
*
|
||||
* @param event - The audit event input data.
|
||||
* @returns The created audit event.
|
||||
*/
|
||||
async create(event: ICreateAuditEventInput): Promise<IAuditEvent> {
|
||||
const eventId = uuidv4();
|
||||
const result: QueryResult<AuditEventRow> = await this.pool.query(
|
||||
`INSERT INTO audit_events
|
||||
(event_id, agent_id, action, outcome, ip_address, user_agent, metadata, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
eventId,
|
||||
event.agentId,
|
||||
event.action,
|
||||
event.outcome,
|
||||
event.ipAddress,
|
||||
event.userAgent,
|
||||
JSON.stringify(event.metadata),
|
||||
],
|
||||
);
|
||||
return mapRowToAuditEvent(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single audit event by its UUID.
|
||||
*
|
||||
* @param eventId - The audit event UUID.
|
||||
* @returns The audit event, or null if not found.
|
||||
*/
|
||||
async findById(eventId: string): Promise<IAuditEvent | null> {
|
||||
const result: QueryResult<AuditEventRow> = await this.pool.query(
|
||||
'SELECT * FROM audit_events WHERE event_id = $1',
|
||||
[eventId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToAuditEvent(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated, filtered list of audit events.
|
||||
* Automatically enforces the 90-day retention window on query results.
|
||||
*
|
||||
* @param filters - Query filters and pagination parameters.
|
||||
* @param retentionCutoff - The earliest date to include (retention window).
|
||||
* @returns Object containing the audit events list and total count.
|
||||
*/
|
||||
async findAll(
|
||||
filters: IAuditListFilters,
|
||||
retentionCutoff: Date,
|
||||
): Promise<{ events: IAuditEvent[]; total: number }> {
|
||||
const conditions: string[] = ['timestamp >= $1'];
|
||||
const params: unknown[] = [retentionCutoff];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.agentId !== undefined) {
|
||||
conditions.push(`agent_id = $${paramIndex++}`);
|
||||
params.push(filters.agentId);
|
||||
}
|
||||
if (filters.action !== undefined) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
if (filters.outcome !== undefined) {
|
||||
conditions.push(`outcome = $${paramIndex++}`);
|
||||
params.push(filters.outcome);
|
||||
}
|
||||
if (filters.fromDate !== undefined) {
|
||||
conditions.push(`timestamp >= $${paramIndex++}`);
|
||||
params.push(new Date(filters.fromDate));
|
||||
}
|
||||
if (filters.toDate !== undefined) {
|
||||
conditions.push(`timestamp <= $${paramIndex++}`);
|
||||
params.push(new Date(filters.toDate));
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
const countResult: QueryResult<{ count: string }> = await this.pool.query(
|
||||
`SELECT COUNT(*) as count FROM audit_events ${whereClause}`,
|
||||
params,
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
const offset = (filters.page - 1) * filters.limit;
|
||||
const dataParams = [...params, filters.limit, offset];
|
||||
|
||||
const dataResult: QueryResult<AuditEventRow> = await this.pool.query(
|
||||
`SELECT * FROM audit_events ${whereClause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||
dataParams,
|
||||
);
|
||||
|
||||
return {
|
||||
events: dataResult.rows.map(mapRowToAuditEvent),
|
||||
total,
|
||||
};
|
||||
}
|
||||
}
|
||||
201
src/repositories/CredentialRepository.ts
Normal file
201
src/repositories/CredentialRepository.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Credential Repository for SentryAgent.ai AgentIdP.
|
||||
* All SQL queries for the credentials table live exclusively here.
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ICredential, ICredentialRow, ICredentialListFilters } from '../types/index.js';
|
||||
|
||||
/** Raw database row for a credential. */
|
||||
interface CredentialDbRow {
|
||||
credential_id: string;
|
||||
client_id: string;
|
||||
secret_hash: string;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
expires_at: Date | null;
|
||||
revoked_at: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw database row to the ICredentialRow domain model.
|
||||
*
|
||||
* @param row - Raw row from the credentials table.
|
||||
* @returns Typed ICredentialRow including the secret hash.
|
||||
*/
|
||||
function mapRowToCredentialRow(row: CredentialDbRow): ICredentialRow {
|
||||
return {
|
||||
credentialId: row.credential_id,
|
||||
clientId: row.client_id,
|
||||
secretHash: row.secret_hash,
|
||||
status: row.status as ICredential['status'],
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw database row to the ICredential domain model (no secret hash).
|
||||
*
|
||||
* @param row - Raw row from the credentials table.
|
||||
* @returns Typed ICredential without the secret hash.
|
||||
*/
|
||||
function mapRowToCredential(row: CredentialDbRow): ICredential {
|
||||
const { secretHash: _secretHash, ...credential } = mapRowToCredentialRow(row);
|
||||
void _secretHash;
|
||||
return credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for all credential database operations.
|
||||
* Receives a pg.Pool via constructor injection.
|
||||
*/
|
||||
export class CredentialRepository {
|
||||
/**
|
||||
* @param pool - The PostgreSQL connection pool.
|
||||
*/
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Creates a new credential record.
|
||||
*
|
||||
* @param clientId - The agent ID this credential belongs to.
|
||||
* @param secretHash - The bcrypt hash of the plain-text secret.
|
||||
* @param expiresAt - Optional expiry date.
|
||||
* @returns The created credential record (without secret hash).
|
||||
*/
|
||||
async create(
|
||||
clientId: string,
|
||||
secretHash: string,
|
||||
expiresAt: Date | null,
|
||||
): Promise<ICredential> {
|
||||
const credentialId = uuidv4();
|
||||
const result: QueryResult<CredentialDbRow> = await this.pool.query(
|
||||
`INSERT INTO credentials
|
||||
(credential_id, client_id, secret_hash, status, created_at, expires_at)
|
||||
VALUES ($1, $2, $3, 'active', NOW(), $4)
|
||||
RETURNING *`,
|
||||
[credentialId, clientId, secretHash, expiresAt],
|
||||
);
|
||||
return mapRowToCredential(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a credential by its UUID, including the secret hash.
|
||||
*
|
||||
* @param credentialId - The credential UUID.
|
||||
* @returns The credential row including secret hash, or null if not found.
|
||||
*/
|
||||
async findById(credentialId: string): Promise<ICredentialRow | null> {
|
||||
const result: QueryResult<CredentialDbRow> = await this.pool.query(
|
||||
'SELECT * FROM credentials WHERE credential_id = $1',
|
||||
[credentialId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToCredentialRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated list of credentials for an agent.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param filters - Pagination and optional status filter.
|
||||
* @returns Object with credential list and total count.
|
||||
*/
|
||||
async findByAgentId(
|
||||
agentId: string,
|
||||
filters: ICredentialListFilters,
|
||||
): Promise<{ credentials: ICredential[]; total: number }> {
|
||||
const conditions: string[] = ['client_id = $1'];
|
||||
const params: unknown[] = [agentId];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.status !== undefined) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
const countResult: QueryResult<{ count: string }> = await this.pool.query(
|
||||
`SELECT COUNT(*) as count FROM credentials ${whereClause}`,
|
||||
params,
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
const offset = (filters.page - 1) * filters.limit;
|
||||
const dataParams = [...params, filters.limit, offset];
|
||||
|
||||
const dataResult: QueryResult<CredentialDbRow> = await this.pool.query(
|
||||
`SELECT * FROM credentials ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||
dataParams,
|
||||
);
|
||||
|
||||
return {
|
||||
credentials: dataResult.rows.map(mapRowToCredential),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the bcrypt hash for an existing credential (rotation).
|
||||
*
|
||||
* @param credentialId - The credential UUID.
|
||||
* @param newSecretHash - The new bcrypt hash.
|
||||
* @param newExpiresAt - Optional new expiry date.
|
||||
* @returns The updated credential record, or null if not found.
|
||||
*/
|
||||
async updateHash(
|
||||
credentialId: string,
|
||||
newSecretHash: string,
|
||||
newExpiresAt: Date | null,
|
||||
): Promise<ICredential | null> {
|
||||
const result: QueryResult<CredentialDbRow> = await this.pool.query(
|
||||
`UPDATE credentials
|
||||
SET secret_hash = $1, expires_at = $2, status = 'active', revoked_at = NULL
|
||||
WHERE credential_id = $3
|
||||
RETURNING *`,
|
||||
[newSecretHash, newExpiresAt, credentialId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToCredential(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a credential's status to 'revoked'.
|
||||
*
|
||||
* @param credentialId - The credential UUID.
|
||||
* @returns The updated credential record, or null if not found.
|
||||
*/
|
||||
async revoke(credentialId: string): Promise<ICredential | null> {
|
||||
const result: QueryResult<CredentialDbRow> = await this.pool.query(
|
||||
`UPDATE credentials
|
||||
SET status = 'revoked', revoked_at = NOW()
|
||||
WHERE credential_id = $1
|
||||
RETURNING *`,
|
||||
[credentialId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return mapRowToCredential(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all active credentials for an agent (used on decommission).
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns The number of credentials revoked.
|
||||
*/
|
||||
async revokeAllForAgent(agentId: string): Promise<number> {
|
||||
const result = await this.pool.query(
|
||||
`UPDATE credentials
|
||||
SET status = 'revoked', revoked_at = NOW()
|
||||
WHERE client_id = $1 AND status = 'active'`,
|
||||
[agentId],
|
||||
);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
113
src/repositories/TokenRepository.ts
Normal file
113
src/repositories/TokenRepository.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Token Repository for SentryAgent.ai AgentIdP.
|
||||
* Manages token revocation tracking (Redis primary, PostgreSQL fallback)
|
||||
* and monthly token count tracking (Redis with monthly TTL).
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { RedisClientType } from 'redis';
|
||||
|
||||
/** Raw database row for a token revocation record. */
|
||||
interface TokenRevocationRow {
|
||||
jti: string;
|
||||
expires_at: Date;
|
||||
revoked_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for token revocation and monthly usage tracking.
|
||||
* Receives a pg.Pool and RedisClientType via constructor injection.
|
||||
*/
|
||||
export class TokenRepository {
|
||||
/**
|
||||
* @param pool - The PostgreSQL connection pool.
|
||||
* @param redis - The Redis client.
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly redis: RedisClientType,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Adds a token JTI to both the Redis revocation list and the PostgreSQL
|
||||
* token_revocations table. Redis TTL is set to the token's remaining lifetime.
|
||||
*
|
||||
* @param jti - The JWT ID to revoke.
|
||||
* @param expiresAt - The token expiry date (used to calculate Redis TTL).
|
||||
*/
|
||||
async addToRevocationList(jti: string, expiresAt: Date): Promise<void> {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const expirySeconds = Math.floor(expiresAt.getTime() / 1000);
|
||||
const ttl = Math.max(1, expirySeconds - nowSeconds);
|
||||
|
||||
const redisKey = `revoked:${jti}`;
|
||||
await this.redis.set(redisKey, '1', { EX: ttl });
|
||||
|
||||
await this.pool.query(
|
||||
`INSERT INTO token_revocations (jti, expires_at)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (jti) DO NOTHING`,
|
||||
[jti, expiresAt],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a JTI has been revoked.
|
||||
* Checks Redis first for performance; falls back to PostgreSQL.
|
||||
*
|
||||
* @param jti - The JWT ID to check.
|
||||
* @returns True if the token has been revoked, false otherwise.
|
||||
*/
|
||||
async isRevoked(jti: string): Promise<boolean> {
|
||||
const redisKey = `revoked:${jti}`;
|
||||
const cached = await this.redis.get(redisKey);
|
||||
if (cached !== null) return true;
|
||||
|
||||
const result: QueryResult<TokenRevocationRow> = await this.pool.query(
|
||||
'SELECT jti FROM token_revocations WHERE jti = $1',
|
||||
[jti],
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the monthly token count for a client in Redis.
|
||||
* Sets a TTL to the end of the current month if the key is new.
|
||||
*
|
||||
* @param clientId - The agent/client ID.
|
||||
* @returns The new count after incrementing.
|
||||
*/
|
||||
async incrementMonthlyCount(clientId: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const key = `monthly:tokens:${clientId}:${year}-${month}`;
|
||||
|
||||
const count = await this.redis.incr(key);
|
||||
|
||||
if (count === 1) {
|
||||
// Set TTL to end of month
|
||||
const endOfMonth = new Date(Date.UTC(year, now.getUTCMonth() + 1, 1, 0, 0, 0));
|
||||
const ttlSeconds = Math.floor((endOfMonth.getTime() - Date.now()) / 1000);
|
||||
await this.redis.expire(key, Math.max(1, ttlSeconds));
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current monthly token count for a client.
|
||||
*
|
||||
* @param clientId - The agent/client ID.
|
||||
* @returns The current count, or 0 if no tokens have been issued this month.
|
||||
*/
|
||||
async getMonthlyCount(clientId: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const key = `monthly:tokens:${clientId}:${year}-${month}`;
|
||||
|
||||
const value = await this.redis.get(key);
|
||||
return value !== null ? parseInt(value, 10) : 0;
|
||||
}
|
||||
}
|
||||
40
src/routes/agents.ts
Normal file
40
src/routes/agents.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Agent Registry routes for SentryAgent.ai AgentIdP.
|
||||
* Wires AgentController handlers to Express paths with auth and rateLimit middleware.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { AgentController } from '../controllers/AgentController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for agent registry endpoints.
|
||||
*
|
||||
* @param agentController - The agent controller instance.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createAgentsRouter(agentController: AgentController): Router {
|
||||
const router = Router();
|
||||
|
||||
router.use(asyncHandler(authMiddleware));
|
||||
router.use(asyncHandler(rateLimitMiddleware));
|
||||
|
||||
// POST /agents — Register a new agent
|
||||
router.post('/', asyncHandler(agentController.registerAgent.bind(agentController)));
|
||||
|
||||
// GET /agents — List agents with optional filters
|
||||
router.get('/', asyncHandler(agentController.listAgents.bind(agentController)));
|
||||
|
||||
// GET /agents/:agentId — Get a single agent
|
||||
router.get('/:agentId', asyncHandler(agentController.getAgentById.bind(agentController)));
|
||||
|
||||
// PATCH /agents/:agentId — Update agent metadata
|
||||
router.patch('/:agentId', asyncHandler(agentController.updateAgent.bind(agentController)));
|
||||
|
||||
// DELETE /agents/:agentId — Decommission an agent
|
||||
router.delete('/:agentId', asyncHandler(agentController.decommissionAgent.bind(agentController)));
|
||||
|
||||
return router;
|
||||
}
|
||||
31
src/routes/audit.ts
Normal file
31
src/routes/audit.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Audit Log routes for SentryAgent.ai AgentIdP.
|
||||
* All routes require Bearer auth and are rate-limited.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { AuditController } from '../controllers/AuditController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for audit log endpoints.
|
||||
*
|
||||
* @param auditController - The audit controller instance.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createAuditRouter(auditController: AuditController): Router {
|
||||
const router = Router();
|
||||
|
||||
router.use(asyncHandler(authMiddleware));
|
||||
router.use(asyncHandler(rateLimitMiddleware));
|
||||
|
||||
// GET /audit — Query audit log
|
||||
router.get('/', asyncHandler(auditController.queryAuditLog.bind(auditController)));
|
||||
|
||||
// GET /audit/:eventId — Get a single audit event
|
||||
router.get('/:eventId', asyncHandler(auditController.getAuditEventById.bind(auditController)));
|
||||
|
||||
return router;
|
||||
}
|
||||
38
src/routes/credentials.ts
Normal file
38
src/routes/credentials.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Credential Management routes for SentryAgent.ai AgentIdP.
|
||||
* All routes are under /agents/:agentId/credentials with auth and rateLimit middleware.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { CredentialController } from '../controllers/CredentialController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for credential management endpoints.
|
||||
* This router is mounted at /agents — the :agentId param is part of the path.
|
||||
*
|
||||
* @param credentialController - The credential controller instance.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createCredentialsRouter(credentialController: CredentialController): Router {
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
router.use(asyncHandler(authMiddleware));
|
||||
router.use(asyncHandler(rateLimitMiddleware));
|
||||
|
||||
// POST /agents/:agentId/credentials — Generate new credentials
|
||||
router.post('/', asyncHandler(credentialController.generateCredential.bind(credentialController)));
|
||||
|
||||
// GET /agents/:agentId/credentials — List credentials
|
||||
router.get('/', asyncHandler(credentialController.listCredentials.bind(credentialController)));
|
||||
|
||||
// POST /agents/:agentId/credentials/:credentialId/rotate — Rotate a credential
|
||||
router.post('/:credentialId/rotate', asyncHandler(credentialController.rotateCredential.bind(credentialController)));
|
||||
|
||||
// DELETE /agents/:agentId/credentials/:credentialId — Revoke a credential
|
||||
router.delete('/:credentialId', asyncHandler(credentialController.revokeCredential.bind(credentialController)));
|
||||
|
||||
return router;
|
||||
}
|
||||
42
src/routes/token.ts
Normal file
42
src/routes/token.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* OAuth 2.0 Token routes for SentryAgent.ai AgentIdP.
|
||||
* POST /token uses no Bearer auth (credentials are in the body).
|
||||
* POST /token/introspect and POST /token/revoke require Bearer auth.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { TokenController } from '../controllers/TokenController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for token endpoints.
|
||||
*
|
||||
* @param tokenController - The token controller instance.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createTokenRouter(tokenController: TokenController): Router {
|
||||
const router = Router();
|
||||
|
||||
// POST /token — Issue token (no auth — credentials in body or Basic header)
|
||||
router.post('/', asyncHandler(rateLimitMiddleware), asyncHandler(tokenController.issueToken.bind(tokenController)));
|
||||
|
||||
// POST /token/introspect — Introspect token (requires Bearer auth)
|
||||
router.post(
|
||||
'/introspect',
|
||||
asyncHandler(authMiddleware),
|
||||
asyncHandler(rateLimitMiddleware),
|
||||
asyncHandler(tokenController.introspectToken.bind(tokenController)),
|
||||
);
|
||||
|
||||
// POST /token/revoke — Revoke token (requires Bearer auth)
|
||||
router.post(
|
||||
'/revoke',
|
||||
asyncHandler(authMiddleware),
|
||||
asyncHandler(rateLimitMiddleware),
|
||||
asyncHandler(tokenController.revokeToken.bind(tokenController)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
47
src/server.ts
Normal file
47
src/server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Server entry point for SentryAgent.ai AgentIdP.
|
||||
* Loads environment variables, creates the app, and starts listening.
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
|
||||
|
||||
/**
|
||||
* Bootstraps the application and starts the HTTP server.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const app = await createApp();
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`SentryAgent.ai AgentIdP listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = (): void => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Shutting down gracefully...');
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown();
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
shutdown();
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
213
src/services/AgentService.ts
Normal file
213
src/services/AgentService.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Agent Registry Service for SentryAgent.ai AgentIdP.
|
||||
* Business logic for agent lifecycle management.
|
||||
*/
|
||||
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import {
|
||||
IAgent,
|
||||
ICreateAgentRequest,
|
||||
IUpdateAgentRequest,
|
||||
IAgentListFilters,
|
||||
IPaginatedAgentsResponse,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AgentNotFoundError,
|
||||
AgentAlreadyExistsError,
|
||||
AgentAlreadyDecommissionedError,
|
||||
FreeTierLimitError,
|
||||
} from '../utils/errors.js';
|
||||
|
||||
const FREE_TIER_MAX_AGENTS = 100;
|
||||
|
||||
/**
|
||||
* Service for agent registration and lifecycle management.
|
||||
* Enforces free-tier limits and coordinates with AuditService.
|
||||
*/
|
||||
export class AgentService {
|
||||
/**
|
||||
* @param agentRepository - The agent data repository.
|
||||
* @param credentialRepository - The credential repository (for decommission cleanup).
|
||||
* @param auditService - The audit log service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers a new AI agent identity.
|
||||
* Enforces the free-tier 100-agent limit and unique email constraint.
|
||||
*
|
||||
* @param data - The agent registration data.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The newly created agent record.
|
||||
* @throws FreeTierLimitError if the 100-agent limit is reached.
|
||||
* @throws AgentAlreadyExistsError if the email is already registered.
|
||||
*/
|
||||
async registerAgent(
|
||||
data: ICreateAgentRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IAgent> {
|
||||
// Enforce free-tier agent count limit
|
||||
const currentCount = await this.agentRepository.countActive();
|
||||
if (currentCount >= FREE_TIER_MAX_AGENTS) {
|
||||
throw new FreeTierLimitError(
|
||||
'Free tier limit of 100 registered agents has been reached.',
|
||||
{ limit: FREE_TIER_MAX_AGENTS, current: currentCount },
|
||||
);
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
const existing = await this.agentRepository.findByEmail(data.email);
|
||||
if (existing !== null) {
|
||||
throw new AgentAlreadyExistsError(data.email);
|
||||
}
|
||||
|
||||
const agent = await this.agentRepository.create(data);
|
||||
|
||||
// Synchronous audit insert
|
||||
await this.auditService.logEvent(
|
||||
agent.agentId,
|
||||
'agent.created',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ agentType: agent.agentType, owner: agent.owner },
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single agent by its UUID.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns The agent record.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
*/
|
||||
async getAgentById(agentId: string): Promise<IAgent> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated, optionally filtered list of agents.
|
||||
*
|
||||
* @param filters - Pagination and filter criteria.
|
||||
* @returns Paginated agents response.
|
||||
*/
|
||||
async listAgents(filters: IAgentListFilters): Promise<IPaginatedAgentsResponse> {
|
||||
const { agents, total } = await this.agentRepository.findAll(filters);
|
||||
return {
|
||||
data: agents,
|
||||
total,
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially updates an agent's metadata.
|
||||
* Immutable fields (agentId, email, createdAt) cannot be changed.
|
||||
* Decommissioned agents cannot be updated.
|
||||
*
|
||||
* @param agentId - The agent UUID to update.
|
||||
* @param data - The fields to update.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The updated agent record.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws AgentAlreadyDecommissionedError if the agent is decommissioned.
|
||||
* @throws ValidationError if immutable fields are included.
|
||||
*/
|
||||
async updateAgent(
|
||||
agentId: string,
|
||||
data: IUpdateAgentRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IAgent> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
if (agent.status === 'decommissioned') {
|
||||
throw new AgentAlreadyDecommissionedError(agentId);
|
||||
}
|
||||
|
||||
// Detect if status changes
|
||||
const oldStatus = agent.status;
|
||||
const updated = await this.agentRepository.update(agentId, data);
|
||||
if (!updated) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
// Determine which audit action to log
|
||||
let auditAction: 'agent.updated' | 'agent.suspended' | 'agent.reactivated' | 'agent.decommissioned' =
|
||||
'agent.updated';
|
||||
if (data.status !== undefined && data.status !== oldStatus) {
|
||||
if (data.status === 'suspended') auditAction = 'agent.suspended';
|
||||
else if (data.status === 'active') auditAction = 'agent.reactivated';
|
||||
else if (data.status === 'decommissioned') auditAction = 'agent.decommissioned';
|
||||
}
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
auditAction,
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ updatedFields: Object.keys(data) },
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently decommissions an agent (soft delete).
|
||||
* Revokes all active credentials for the agent.
|
||||
*
|
||||
* @param agentId - The agent UUID to decommission.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws AgentAlreadyDecommissionedError if already decommissioned.
|
||||
*/
|
||||
async decommissionAgent(
|
||||
agentId: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<void> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
if (agent.status === 'decommissioned') {
|
||||
throw new AgentAlreadyDecommissionedError(agentId);
|
||||
}
|
||||
|
||||
// Revoke all active credentials
|
||||
await this.credentialRepository.revokeAllForAgent(agentId);
|
||||
|
||||
await this.agentRepository.decommission(agentId);
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'agent.decommissioned',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/services/AuditService.ts
Normal file
136
src/services/AuditService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Audit Log Service for SentryAgent.ai AgentIdP.
|
||||
* Provides methods for logging and querying immutable audit events.
|
||||
*/
|
||||
|
||||
import { AuditRepository } from '../repositories/AuditRepository.js';
|
||||
import {
|
||||
IAuditEvent,
|
||||
IAuditListFilters,
|
||||
IPaginatedAuditEventsResponse,
|
||||
AuditAction,
|
||||
AuditOutcome,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AuditEventNotFoundError,
|
||||
RetentionWindowError,
|
||||
ValidationError,
|
||||
} from '../utils/errors.js';
|
||||
|
||||
const FREE_TIER_RETENTION_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Service for creating and querying audit log events.
|
||||
* Enforces 90-day retention window on all queries.
|
||||
*/
|
||||
export class AuditService {
|
||||
/**
|
||||
* @param auditRepository - The audit event repository.
|
||||
*/
|
||||
constructor(private readonly auditRepository: AuditRepository) {}
|
||||
|
||||
/**
|
||||
* Computes the earliest allowed timestamp for audit queries (90-day retention).
|
||||
*
|
||||
* @returns The retention cutoff Date.
|
||||
*/
|
||||
private getRetentionCutoff(): Date {
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - FREE_TIER_RETENTION_DAYS);
|
||||
cutoff.setUTCHours(0, 0, 0, 0);
|
||||
return cutoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an audit event. This is a fire-and-forget async insert for token
|
||||
* endpoints (do not await). For DB-backed operations, await this method.
|
||||
*
|
||||
* @param agentId - The agent that triggered the event.
|
||||
* @param action - The action that occurred.
|
||||
* @param outcome - Whether the action succeeded or failed.
|
||||
* @param ipAddress - The client IP address.
|
||||
* @param userAgent - The client User-Agent header.
|
||||
* @param metadata - Action-specific structured context data.
|
||||
* @returns Promise resolving to the created audit event.
|
||||
*/
|
||||
async logEvent(
|
||||
agentId: string,
|
||||
action: AuditAction,
|
||||
outcome: AuditOutcome,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): Promise<IAuditEvent> {
|
||||
return this.auditRepository.create({
|
||||
agentId,
|
||||
action,
|
||||
outcome,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the audit log with optional filters, pagination, and retention enforcement.
|
||||
*
|
||||
* @param filters - Query filters and pagination parameters.
|
||||
* @returns Paginated audit events response.
|
||||
* @throws RetentionWindowError if fromDate is before the 90-day retention cutoff.
|
||||
* @throws ValidationError if fromDate is after toDate.
|
||||
*/
|
||||
async queryEvents(filters: IAuditListFilters): Promise<IPaginatedAuditEventsResponse> {
|
||||
const retentionCutoff = this.getRetentionCutoff();
|
||||
|
||||
if (filters.fromDate !== undefined) {
|
||||
const fromDate = new Date(filters.fromDate);
|
||||
if (fromDate < retentionCutoff) {
|
||||
throw new RetentionWindowError(
|
||||
FREE_TIER_RETENTION_DAYS,
|
||||
retentionCutoff.toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.fromDate !== undefined && filters.toDate !== undefined) {
|
||||
const fromDate = new Date(filters.fromDate);
|
||||
const toDate = new Date(filters.toDate);
|
||||
if (fromDate > toDate) {
|
||||
throw new ValidationError('Invalid date range.', {
|
||||
reason: 'fromDate must be before or equal to toDate.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { events, total } = await this.auditRepository.findAll(filters, retentionCutoff);
|
||||
|
||||
return {
|
||||
data: events,
|
||||
total,
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single audit event by its UUID.
|
||||
*
|
||||
* @param eventId - The audit event UUID.
|
||||
* @returns The audit event record.
|
||||
* @throws AuditEventNotFoundError if the event does not exist.
|
||||
*/
|
||||
async getEventById(eventId: string): Promise<IAuditEvent> {
|
||||
const event = await this.auditRepository.findById(eventId);
|
||||
if (!event) {
|
||||
throw new AuditEventNotFoundError(eventId);
|
||||
}
|
||||
|
||||
// Check retention window — events older than 90 days are not accessible
|
||||
const retentionCutoff = this.getRetentionCutoff();
|
||||
if (event.timestamp < retentionCutoff) {
|
||||
throw new AuditEventNotFoundError(eventId);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
}
|
||||
226
src/services/CredentialService.ts
Normal file
226
src/services/CredentialService.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Credential Management Service for SentryAgent.ai AgentIdP.
|
||||
* Business logic for generating, listing, rotating, and revoking credentials.
|
||||
*/
|
||||
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import {
|
||||
ICredentialWithSecret,
|
||||
ICredentialListFilters,
|
||||
IPaginatedCredentialsResponse,
|
||||
IGenerateCredentialRequest,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AgentNotFoundError,
|
||||
CredentialNotFoundError,
|
||||
CredentialAlreadyRevokedError,
|
||||
CredentialError,
|
||||
} from '../utils/errors.js';
|
||||
import { generateClientSecret, hashSecret } from '../utils/crypto.js';
|
||||
|
||||
/**
|
||||
* Service for credential lifecycle management.
|
||||
* The plain-text clientSecret is only returned on generation and rotation.
|
||||
*/
|
||||
export class CredentialService {
|
||||
/**
|
||||
* @param credentialRepository - The credential data repository.
|
||||
* @param agentRepository - The agent repository (for status checks).
|
||||
* @param auditService - The audit log service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generates a new client credential for an agent.
|
||||
* The agent must be in 'active' status.
|
||||
* Returns the plain-text clientSecret once — it is never retrievable again.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param data - Optional expiry date for the credential.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The credential with the one-time plain-text clientSecret.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws CredentialError if the agent is not in 'active' status.
|
||||
*/
|
||||
async generateCredential(
|
||||
agentId: string,
|
||||
data: IGenerateCredentialRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<ICredentialWithSecret> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
if (agent.status !== 'active') {
|
||||
throw new CredentialError(
|
||||
'Credentials can only be generated for active agents.',
|
||||
'AGENT_NOT_ACTIVE',
|
||||
{ agentId, status: agent.status },
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
|
||||
const plainSecret = generateClientSecret();
|
||||
const secretHash = await hashSecret(plainSecret);
|
||||
|
||||
const credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'credential.generated',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ credentialId: credential.credentialId },
|
||||
);
|
||||
|
||||
return { ...credential, clientSecret: plainSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated list of credentials for an agent.
|
||||
* The clientSecret is never included in list responses.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param filters - Pagination and optional status filter.
|
||||
* @returns Paginated credentials response.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
*/
|
||||
async listCredentials(
|
||||
agentId: string,
|
||||
filters: ICredentialListFilters,
|
||||
): Promise<IPaginatedCredentialsResponse> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const { credentials, total } = await this.credentialRepository.findByAgentId(
|
||||
agentId,
|
||||
filters,
|
||||
);
|
||||
|
||||
return {
|
||||
data: credentials,
|
||||
total,
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a credential by generating a new secret for the same credentialId.
|
||||
* Only 'active' credentials can be rotated.
|
||||
* Returns the new plain-text clientSecret once.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param credentialId - The credential UUID to rotate.
|
||||
* @param data - Optional new expiry date.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The updated credential with the new one-time clientSecret.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws CredentialNotFoundError if the credential does not exist.
|
||||
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
|
||||
*/
|
||||
async rotateCredential(
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
data: IGenerateCredentialRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<ICredentialWithSecret> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const existing = await this.credentialRepository.findById(credentialId);
|
||||
if (!existing || existing.clientId !== agentId) {
|
||||
throw new CredentialNotFoundError(credentialId);
|
||||
}
|
||||
|
||||
if (existing.status === 'revoked') {
|
||||
throw new CredentialAlreadyRevokedError(
|
||||
credentialId,
|
||||
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
|
||||
const plainSecret = generateClientSecret();
|
||||
const newHash = await hashSecret(plainSecret);
|
||||
|
||||
const updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
|
||||
if (!updated) {
|
||||
throw new CredentialNotFoundError(credentialId);
|
||||
}
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'credential.rotated',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ credentialId },
|
||||
);
|
||||
|
||||
return { ...updated, clientSecret: plainSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently revokes a credential.
|
||||
* Revoking an already-revoked credential returns 409 Conflict.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param credentialId - The credential UUID to revoke.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws CredentialNotFoundError if the credential does not exist or belongs to another agent.
|
||||
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
|
||||
*/
|
||||
async revokeCredential(
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<void> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const existing = await this.credentialRepository.findById(credentialId);
|
||||
if (!existing || existing.clientId !== agentId) {
|
||||
throw new CredentialNotFoundError(credentialId);
|
||||
}
|
||||
|
||||
if (existing.status === 'revoked') {
|
||||
throw new CredentialAlreadyRevokedError(
|
||||
credentialId,
|
||||
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
await this.credentialRepository.revoke(credentialId);
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'credential.revoked',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ credentialId },
|
||||
);
|
||||
}
|
||||
}
|
||||
303
src/services/OAuth2Service.ts
Normal file
303
src/services/OAuth2Service.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* OAuth 2.0 Token Service for SentryAgent.ai AgentIdP.
|
||||
* Issues, introspects, and revokes RS256 JWT access tokens.
|
||||
*/
|
||||
|
||||
import { TokenRepository } from '../repositories/TokenRepository.js';
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import {
|
||||
ITokenPayload,
|
||||
ITokenResponse,
|
||||
IIntrospectResponse,
|
||||
IOAuth2ErrorResponse,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
FreeTierLimitError,
|
||||
InsufficientScopeError,
|
||||
} from '../utils/errors.js';
|
||||
import { signToken, verifyToken, decodeToken, getTokenExpiresIn } from '../utils/jwt.js';
|
||||
import { verifySecret } from '../utils/crypto.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const FREE_TIER_MAX_MONTHLY_TOKENS = 10000;
|
||||
|
||||
/** Result of a token issuance, including either a success response or OAuth2 error. */
|
||||
export interface IssueTokenResult {
|
||||
success: boolean;
|
||||
response?: ITokenResponse;
|
||||
error?: IOAuth2ErrorResponse;
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for OAuth 2.0 Client Credentials token issuance, introspection, and revocation.
|
||||
*/
|
||||
export class OAuth2Service {
|
||||
/**
|
||||
* @param tokenRepository - Repository for token revocation and monthly counts.
|
||||
* @param credentialRepository - Repository for credential lookup and verification.
|
||||
* @param agentRepository - Repository for agent status lookup.
|
||||
* @param auditService - The audit log service.
|
||||
* @param privateKey - PEM-encoded RSA private key for signing tokens.
|
||||
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
|
||||
*/
|
||||
constructor(
|
||||
private readonly tokenRepository: TokenRepository,
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly privateKey: string,
|
||||
private readonly publicKey: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Issues a signed RS256 JWT access token via the OAuth 2.0 Client Credentials grant.
|
||||
* Validates client credentials, checks agent status, enforces 10k monthly limit,
|
||||
* and writes an async fire-and-forget audit event.
|
||||
*
|
||||
* @param clientId - The agent UUID acting as client_id.
|
||||
* @param clientSecret - The plain-text client secret.
|
||||
* @param scope - Space-separated OAuth 2.0 scopes requested.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The token response with access_token, token_type, expires_in, scope.
|
||||
* @throws AuthenticationError if the client credentials are invalid.
|
||||
* @throws AuthorizationError if the agent is suspended or decommissioned.
|
||||
* @throws FreeTierLimitError if the monthly token limit is reached.
|
||||
*/
|
||||
async issueToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scope: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<ITokenResponse> {
|
||||
// Look up the agent
|
||||
const agent = await this.agentRepository.findById(clientId);
|
||||
if (!agent) {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'agent_not_found', clientId },
|
||||
);
|
||||
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
|
||||
}
|
||||
|
||||
// Find active credentials for the agent and verify secret
|
||||
const { credentials } = await this.credentialRepository.findByAgentId(clientId, {
|
||||
status: 'active',
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
let credentialVerified = false;
|
||||
for (const cred of credentials) {
|
||||
const credRow = await this.credentialRepository.findById(cred.credentialId);
|
||||
if (credRow) {
|
||||
const matches = await verifySecret(clientSecret, credRow.secretHash);
|
||||
if (matches) {
|
||||
// Check if credential is expired
|
||||
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
|
||||
continue;
|
||||
}
|
||||
credentialVerified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!credentialVerified) {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'invalid_client_secret', clientId },
|
||||
);
|
||||
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
|
||||
}
|
||||
|
||||
// Check agent status
|
||||
if (agent.status === 'suspended') {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'agent_suspended', clientId },
|
||||
);
|
||||
throw new AuthorizationError('Agent is currently suspended and cannot obtain tokens.');
|
||||
}
|
||||
|
||||
if (agent.status === 'decommissioned') {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'agent_decommissioned', clientId },
|
||||
);
|
||||
throw new AuthorizationError('Agent is decommissioned and cannot obtain tokens.');
|
||||
}
|
||||
|
||||
// Check monthly token limit
|
||||
const monthlyCount = await this.tokenRepository.getMonthlyCount(clientId);
|
||||
if (monthlyCount >= FREE_TIER_MAX_MONTHLY_TOKENS) {
|
||||
throw new FreeTierLimitError(
|
||||
'Free tier monthly token limit of 10,000 requests has been reached.',
|
||||
{ limit: FREE_TIER_MAX_MONTHLY_TOKENS, current: monthlyCount },
|
||||
);
|
||||
}
|
||||
|
||||
// Issue the token
|
||||
const jti = uuidv4();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = getTokenExpiresIn();
|
||||
|
||||
const payload: Omit<ITokenPayload, 'iat' | 'exp'> = {
|
||||
sub: clientId,
|
||||
client_id: clientId,
|
||||
scope,
|
||||
jti,
|
||||
};
|
||||
|
||||
const accessToken = signToken(payload, this.privateKey);
|
||||
|
||||
// Increment monthly count (fire-and-forget)
|
||||
void this.tokenRepository.incrementMonthlyCount(clientId);
|
||||
|
||||
// Audit event (fire-and-forget — do not await for latency)
|
||||
const expiresAtDate = new Date((now + expiresIn) * 1000);
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'token.issued',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ scope, expiresAt: expiresAtDate.toISOString() },
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: expiresIn,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Introspects a token per RFC 7662.
|
||||
* Always returns 200; check the `active` field for validity.
|
||||
* Requires the caller to hold a token with `tokens:read` scope.
|
||||
*
|
||||
* @param token - The JWT string to introspect.
|
||||
* @param callerPayload - The decoded payload of the calling agent's token (for scope check).
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The introspection response.
|
||||
* @throws InsufficientScopeError if the caller lacks `tokens:read` scope.
|
||||
*/
|
||||
async introspectToken(
|
||||
token: string,
|
||||
callerPayload: ITokenPayload,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IIntrospectResponse> {
|
||||
// Check caller has tokens:read scope
|
||||
const callerScopes = callerPayload.scope.split(' ');
|
||||
if (!callerScopes.includes('tokens:read')) {
|
||||
throw new InsufficientScopeError('tokens:read');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = verifyToken(token, this.publicKey);
|
||||
const revoked = await this.tokenRepository.isRevoked(payload.jti);
|
||||
|
||||
if (revoked) {
|
||||
void this.auditService.logEvent(
|
||||
callerPayload.sub,
|
||||
'token.introspected',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ targetJti: payload.jti, active: false },
|
||||
);
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
void this.auditService.logEvent(
|
||||
callerPayload.sub,
|
||||
'token.introspected',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ targetJti: payload.jti, active: true },
|
||||
);
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sub: payload.sub,
|
||||
client_id: payload.client_id,
|
||||
scope: payload.scope,
|
||||
token_type: 'Bearer',
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
};
|
||||
} catch {
|
||||
// Token is invalid or expired — return inactive per RFC 7662
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a token per RFC 7009.
|
||||
* Idempotent — revoking an already-revoked or expired token returns success.
|
||||
* An agent may only revoke its own tokens.
|
||||
*
|
||||
* @param token - The JWT string to revoke.
|
||||
* @param callerPayload - The decoded payload of the calling agent's token.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @throws AuthorizationError if the caller tries to revoke another agent's token.
|
||||
*/
|
||||
async revokeToken(
|
||||
token: string,
|
||||
callerPayload: ITokenPayload,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<void> {
|
||||
// Decode the token without verification to extract claims
|
||||
const decoded = decodeToken(token);
|
||||
|
||||
if (decoded !== null) {
|
||||
// Only the token owner can revoke their own token
|
||||
if (decoded.sub !== callerPayload.sub) {
|
||||
throw new AuthorizationError('You do not have permission to revoke this token.');
|
||||
}
|
||||
|
||||
// Add to revocation list
|
||||
const expiresAt = new Date(decoded.exp * 1000);
|
||||
await this.tokenRepository.addToRevocationList(decoded.jti, expiresAt);
|
||||
|
||||
void this.auditService.logEvent(
|
||||
callerPayload.sub,
|
||||
'token.revoked',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ jti: decoded.jti },
|
||||
);
|
||||
}
|
||||
// If token is malformed/undecoded, per RFC 7009 we still return success
|
||||
}
|
||||
}
|
||||
283
src/types/index.ts
Normal file
283
src/types/index.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Shared TypeScript interfaces and types for SentryAgent.ai AgentIdP.
|
||||
* All interfaces and types live here — no inline type definitions in service/controller files.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Enumerations / Union Types
|
||||
// ============================================================================
|
||||
|
||||
/** Functional classification of an AI agent. */
|
||||
export type AgentType =
|
||||
| 'screener'
|
||||
| 'classifier'
|
||||
| 'orchestrator'
|
||||
| 'extractor'
|
||||
| 'summarizer'
|
||||
| 'router'
|
||||
| 'monitor'
|
||||
| 'custom';
|
||||
|
||||
/** Lifecycle status of an AI agent. */
|
||||
export type AgentStatus = 'active' | 'suspended' | 'decommissioned';
|
||||
|
||||
/** Target deployment environment for an agent. */
|
||||
export type DeploymentEnv = 'development' | 'staging' | 'production';
|
||||
|
||||
/** Lifecycle status of an agent credential. */
|
||||
export type CredentialStatus = 'active' | 'revoked';
|
||||
|
||||
/** OAuth 2.0 scope values supported by this IdP. */
|
||||
export type OAuthScope = 'agents:read' | 'agents:write' | 'tokens:read' | 'audit:read';
|
||||
|
||||
/** Audit action identifiers for all significant platform events. */
|
||||
export type AuditAction =
|
||||
| 'agent.created'
|
||||
| 'agent.updated'
|
||||
| 'agent.decommissioned'
|
||||
| 'agent.suspended'
|
||||
| 'agent.reactivated'
|
||||
| 'token.issued'
|
||||
| 'token.revoked'
|
||||
| 'token.introspected'
|
||||
| 'credential.generated'
|
||||
| 'credential.rotated'
|
||||
| 'credential.revoked'
|
||||
| 'auth.failed';
|
||||
|
||||
/** Outcome of an audited action. */
|
||||
export type AuditOutcome = 'success' | 'failure';
|
||||
|
||||
// ============================================================================
|
||||
// Agent Registry
|
||||
// ============================================================================
|
||||
|
||||
/** Full representation of a registered AI agent identity. */
|
||||
export interface IAgent {
|
||||
agentId: string;
|
||||
email: string;
|
||||
agentType: AgentType;
|
||||
version: string;
|
||||
capabilities: string[];
|
||||
owner: string;
|
||||
deploymentEnv: DeploymentEnv;
|
||||
status: AgentStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Request body for registering a new AI agent. */
|
||||
export interface ICreateAgentRequest {
|
||||
email: string;
|
||||
agentType: AgentType;
|
||||
version: string;
|
||||
capabilities: string[];
|
||||
owner: string;
|
||||
deploymentEnv: DeploymentEnv;
|
||||
}
|
||||
|
||||
/** Request body for partially updating an agent. */
|
||||
export interface IUpdateAgentRequest {
|
||||
agentType?: AgentType;
|
||||
version?: string;
|
||||
capabilities?: string[];
|
||||
owner?: string;
|
||||
deploymentEnv?: DeploymentEnv;
|
||||
status?: AgentStatus;
|
||||
}
|
||||
|
||||
/** Paginated list of agents. */
|
||||
export interface IPaginatedAgentsResponse {
|
||||
data: IAgent[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Query filters for listing agents. */
|
||||
export interface IAgentListFilters {
|
||||
owner?: string;
|
||||
agentType?: AgentType;
|
||||
status?: AgentStatus;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Credentials
|
||||
// ============================================================================
|
||||
|
||||
/** A credential record for an AI agent (clientSecret never included). */
|
||||
export interface ICredential {
|
||||
credentialId: string;
|
||||
clientId: string;
|
||||
status: CredentialStatus;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Credential with the plain-text secret — returned once only on create/rotate. */
|
||||
export interface ICredentialWithSecret extends ICredential {
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
/** Database row for a credential, including the bcrypt hash. */
|
||||
export interface ICredentialRow extends ICredential {
|
||||
secretHash: string;
|
||||
}
|
||||
|
||||
/** Request body for generating or rotating a credential. */
|
||||
export interface IGenerateCredentialRequest {
|
||||
expiresAt?: string | Date;
|
||||
}
|
||||
|
||||
/** Paginated list of credentials. */
|
||||
export interface IPaginatedCredentialsResponse {
|
||||
data: ICredential[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Query filters for listing credentials. */
|
||||
export interface ICredentialListFilters {
|
||||
status?: CredentialStatus;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth2 Token
|
||||
// ============================================================================
|
||||
|
||||
/** JWT access token payload (claims). */
|
||||
export interface ITokenPayload {
|
||||
/** Subject — agentId. */
|
||||
sub: string;
|
||||
/** client_id — agentId. */
|
||||
client_id: string;
|
||||
/** Space-separated OAuth 2.0 scopes. */
|
||||
scope: string;
|
||||
/** JWT ID — UUID v4. */
|
||||
jti: string;
|
||||
/** Issued at (Unix seconds). */
|
||||
iat: number;
|
||||
/** Expiry (Unix seconds). */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** OAuth 2.0 token request (form-encoded). */
|
||||
export interface ITokenRequest {
|
||||
grant_type: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** Successful OAuth 2.0 token response. */
|
||||
export interface ITokenResponse {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/** OAuth 2.0 error response (RFC 6749 §5.2). */
|
||||
export interface IOAuth2ErrorResponse {
|
||||
error: string;
|
||||
error_description: string;
|
||||
}
|
||||
|
||||
/** Token introspection request (RFC 7662). */
|
||||
export interface IIntrospectRequest {
|
||||
token: string;
|
||||
token_type_hint?: string;
|
||||
}
|
||||
|
||||
/** Token introspection response (RFC 7662). */
|
||||
export interface IIntrospectResponse {
|
||||
active: boolean;
|
||||
sub?: string;
|
||||
client_id?: string;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/** Token revocation request (RFC 7009). */
|
||||
export interface IRevokeRequest {
|
||||
token: string;
|
||||
token_type_hint?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Log
|
||||
// ============================================================================
|
||||
|
||||
/** An immutable audit event record. */
|
||||
export interface IAuditEvent {
|
||||
eventId: string;
|
||||
agentId: string;
|
||||
action: AuditAction;
|
||||
outcome: AuditOutcome;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
metadata: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/** Input for creating a new audit event. */
|
||||
export interface ICreateAuditEventInput {
|
||||
agentId: string;
|
||||
action: AuditAction;
|
||||
outcome: AuditOutcome;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Paginated list of audit events. */
|
||||
export interface IPaginatedAuditEventsResponse {
|
||||
data: IAuditEvent[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Query filters for the audit log. */
|
||||
export interface IAuditListFilters {
|
||||
agentId?: string;
|
||||
action?: AuditAction;
|
||||
outcome?: AuditOutcome;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Error Response
|
||||
// ============================================================================
|
||||
|
||||
/** Standard error response envelope used across all SentryAgent.ai APIs. */
|
||||
export interface IErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Express type augmentation
|
||||
// ============================================================================
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
/** Decoded JWT payload attached by the auth middleware. */
|
||||
user?: ITokenPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/utils/asyncHandler.ts
Normal file
16
src/utils/asyncHandler.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
* Wraps an async Express handler to forward rejected promises to next().
|
||||
* Required because Express 4.x does not natively handle async route errors.
|
||||
*
|
||||
* @param fn - Async Express handler function.
|
||||
* @returns Synchronous Express RequestHandler.
|
||||
*/
|
||||
export function asyncHandler(
|
||||
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>,
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
fn(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
43
src/utils/crypto.ts
Normal file
43
src/utils/crypto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Cryptographic utilities for SentryAgent.ai AgentIdP.
|
||||
* Handles client secret generation and bcrypt hashing.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const SECRET_PREFIX = 'sk_live_';
|
||||
const SECRET_RANDOM_BYTES = 32;
|
||||
|
||||
/**
|
||||
* Generates a new client secret with the `sk_live_` prefix followed by 64 hex chars
|
||||
* (32 random bytes = 256 bits of entropy).
|
||||
*
|
||||
* @returns Plain-text client secret in the format `sk_live_<64 hex chars>`.
|
||||
*/
|
||||
export function generateClientSecret(): string {
|
||||
const randomBytes = crypto.randomBytes(SECRET_RANDOM_BYTES);
|
||||
return `${SECRET_PREFIX}${randomBytes.toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a plain-text secret using bcrypt with 10 rounds.
|
||||
*
|
||||
* @param plain - The plain-text secret to hash.
|
||||
* @returns Promise resolving to the bcrypt hash string.
|
||||
*/
|
||||
export async function hashSecret(plain: string): Promise<string> {
|
||||
return bcrypt.hash(plain, BCRYPT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a plain-text secret against a stored bcrypt hash.
|
||||
*
|
||||
* @param plain - The plain-text secret provided by the client.
|
||||
* @param hash - The bcrypt hash stored in the database.
|
||||
* @returns Promise resolving to `true` if the secret matches, `false` otherwise.
|
||||
*/
|
||||
export async function verifySecret(plain: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
170
src/utils/errors.ts
Normal file
170
src/utils/errors.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* SentryAgentError hierarchy.
|
||||
* All custom errors extend SentryAgentError.
|
||||
* Error-to-HTTP-status mapping is handled exclusively in errorHandler.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class for all SentryAgent.ai custom errors.
|
||||
* Carry a machine-readable `code`, HTTP status, and optional structured details.
|
||||
*/
|
||||
export class SentryAgentError extends Error {
|
||||
/**
|
||||
* @param message - Human-readable error description.
|
||||
* @param code - Machine-readable error code.
|
||||
* @param httpStatus - HTTP status code to return.
|
||||
* @param details - Optional structured detail map.
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly httpStatus: number,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
// Restore prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/** 400 — Request failed validation. */
|
||||
export class ValidationError extends SentryAgentError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(message, 'VALIDATION_ERROR', 400, details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 404 — Referenced agent was not found. */
|
||||
export class AgentNotFoundError extends SentryAgentError {
|
||||
constructor(agentId?: string) {
|
||||
super(
|
||||
'Agent with the specified ID was not found.',
|
||||
'AGENT_NOT_FOUND',
|
||||
404,
|
||||
agentId ? { agentId } : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 409 — Agent with this email already exists. */
|
||||
export class AgentAlreadyExistsError extends SentryAgentError {
|
||||
constructor(email: string) {
|
||||
super(
|
||||
'An agent with this email address is already registered.',
|
||||
'AGENT_ALREADY_EXISTS',
|
||||
409,
|
||||
{ email },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 404 — Referenced credential was not found. */
|
||||
export class CredentialNotFoundError extends SentryAgentError {
|
||||
constructor(credentialId?: string) {
|
||||
super(
|
||||
'Credential with the specified ID was not found.',
|
||||
'CREDENTIAL_NOT_FOUND',
|
||||
404,
|
||||
credentialId ? { credentialId } : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 409 — Credential is already revoked. */
|
||||
export class CredentialAlreadyRevokedError extends SentryAgentError {
|
||||
constructor(credentialId: string, revokedAt: string) {
|
||||
super(
|
||||
'This credential has already been revoked.',
|
||||
'CREDENTIAL_ALREADY_REVOKED',
|
||||
409,
|
||||
{ credentialId, revokedAt },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 409 — Agent is already decommissioned. */
|
||||
export class AgentAlreadyDecommissionedError extends SentryAgentError {
|
||||
constructor(agentId: string) {
|
||||
super(
|
||||
'This agent has already been decommissioned.',
|
||||
'AGENT_ALREADY_DECOMMISSIONED',
|
||||
409,
|
||||
{ agentId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 400 — Credential operation error (e.g. agent not active). */
|
||||
export class CredentialError extends SentryAgentError {
|
||||
constructor(message: string, code: string, details?: Record<string, unknown>) {
|
||||
super(message, code, 400, details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 401 — Authentication failed (missing or invalid token). */
|
||||
export class AuthenticationError extends SentryAgentError {
|
||||
constructor(message = 'A valid Bearer token is required to access this resource.') {
|
||||
super(message, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
}
|
||||
|
||||
/** 403 — Authorisation failed (insufficient permissions). */
|
||||
export class AuthorizationError extends SentryAgentError {
|
||||
constructor(message = 'You do not have permission to perform this action.') {
|
||||
super(message, 'FORBIDDEN', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** 429 — Rate limit exceeded. */
|
||||
export class RateLimitError extends SentryAgentError {
|
||||
constructor() {
|
||||
super(
|
||||
'Too many requests. Please retry after the rate limit window resets.',
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 403 — Free tier resource limit reached. */
|
||||
export class FreeTierLimitError extends SentryAgentError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(message, 'FREE_TIER_LIMIT_EXCEEDED', 403, details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 403 — Token does not have the required scope. */
|
||||
export class InsufficientScopeError extends SentryAgentError {
|
||||
constructor(requiredScope: string) {
|
||||
super(
|
||||
`The '${requiredScope}' scope is required to access this resource.`,
|
||||
'INSUFFICIENT_SCOPE',
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 404 — Audit event not found. */
|
||||
export class AuditEventNotFoundError extends SentryAgentError {
|
||||
constructor(eventId?: string) {
|
||||
super(
|
||||
'Audit event with the specified ID was not found.',
|
||||
'AUDIT_EVENT_NOT_FOUND',
|
||||
404,
|
||||
eventId ? { eventId } : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 400 — Requested date range exceeds audit log retention window. */
|
||||
export class RetentionWindowError extends SentryAgentError {
|
||||
constructor(retentionDays: number, earliestAvailable: string) {
|
||||
super(
|
||||
`Free tier audit log retention is ${retentionDays} days. Requested date is outside the retention window.`,
|
||||
'RETENTION_WINDOW_EXCEEDED',
|
||||
400,
|
||||
{ retentionDays, earliestAvailable },
|
||||
);
|
||||
}
|
||||
}
|
||||
69
src/utils/jwt.ts
Normal file
69
src/utils/jwt.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* JWT utilities for SentryAgent.ai AgentIdP.
|
||||
* Signs and verifies RS256 JWTs for agent access tokens.
|
||||
*/
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { ITokenPayload } from '../types/index.js';
|
||||
|
||||
const TOKEN_EXPIRES_IN = 3600; // 1 hour in seconds
|
||||
|
||||
/**
|
||||
* Signs a JWT access token using RS256 (RSA private key).
|
||||
*
|
||||
* @param payload - The token payload containing sub, client_id, scope, jti.
|
||||
* @param privateKey - PEM-encoded RSA private key.
|
||||
* @returns The signed JWT string.
|
||||
* @throws Error if signing fails.
|
||||
*/
|
||||
export function signToken(
|
||||
payload: Omit<ITokenPayload, 'iat' | 'exp'>,
|
||||
privateKey: string,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const fullPayload: ITokenPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + TOKEN_EXPIRES_IN,
|
||||
};
|
||||
|
||||
return jwt.sign(fullPayload, privateKey, { algorithm: 'RS256' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a JWT access token using RS256 (RSA public key).
|
||||
* Throws if the token is expired, has an invalid signature, or is malformed.
|
||||
*
|
||||
* @param token - The JWT string to verify.
|
||||
* @param publicKey - PEM-encoded RSA public key.
|
||||
* @returns The decoded, verified token payload.
|
||||
* @throws JsonWebTokenError | TokenExpiredError if verification fails.
|
||||
*/
|
||||
export function verifyToken(token: string, publicKey: string): ITokenPayload {
|
||||
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
|
||||
return decoded as ITokenPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a JWT without verifying the signature.
|
||||
* Used for extracting claims (e.g. jti, exp) from tokens that may be expired.
|
||||
*
|
||||
* @param token - The JWT string to decode.
|
||||
* @returns The decoded payload or null if the token is malformed.
|
||||
*/
|
||||
export function decodeToken(token: string): ITokenPayload | null {
|
||||
const decoded = jwt.decode(token);
|
||||
if (!decoded || typeof decoded === 'string') {
|
||||
return null;
|
||||
}
|
||||
return decoded as ITokenPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the token lifetime in seconds.
|
||||
*
|
||||
* @returns Token lifetime (3600 seconds = 1 hour).
|
||||
*/
|
||||
export function getTokenExpiresIn(): number {
|
||||
return TOKEN_EXPIRES_IN;
|
||||
}
|
||||
137
src/utils/validators.ts
Normal file
137
src/utils/validators.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Joi validation schemas for all request bodies and query parameters.
|
||||
* All validation logic lives here — controllers invoke these schemas.
|
||||
*/
|
||||
|
||||
import Joi from 'joi';
|
||||
|
||||
const SEMVER_PATTERN =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||
|
||||
const CAPABILITY_PATTERN = /^[a-z0-9_-]+:[a-z0-9_*-]+$/;
|
||||
|
||||
const AGENT_TYPES = [
|
||||
'screener',
|
||||
'classifier',
|
||||
'orchestrator',
|
||||
'extractor',
|
||||
'summarizer',
|
||||
'router',
|
||||
'monitor',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
const DEPLOYMENT_ENVS = ['development', 'staging', 'production'] as const;
|
||||
|
||||
const AGENT_STATUSES = ['active', 'suspended', 'decommissioned'] as const;
|
||||
|
||||
const CREDENTIAL_STATUSES = ['active', 'revoked'] as const;
|
||||
|
||||
const AUDIT_ACTIONS = [
|
||||
'agent.created',
|
||||
'agent.updated',
|
||||
'agent.decommissioned',
|
||||
'agent.suspended',
|
||||
'agent.reactivated',
|
||||
'token.issued',
|
||||
'token.revoked',
|
||||
'token.introspected',
|
||||
'credential.generated',
|
||||
'credential.rotated',
|
||||
'credential.revoked',
|
||||
'auth.failed',
|
||||
] as const;
|
||||
|
||||
const AUDIT_OUTCOMES = ['success', 'failure'] as const;
|
||||
|
||||
const OAUTH_SCOPES = ['agents:read', 'agents:write', 'tokens:read', 'audit:read'] as const;
|
||||
|
||||
/** Schema for POST /agents request body. */
|
||||
export const createAgentSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
agentType: Joi.string()
|
||||
.valid(...AGENT_TYPES)
|
||||
.required(),
|
||||
version: Joi.string().pattern(SEMVER_PATTERN).required(),
|
||||
capabilities: Joi.array()
|
||||
.items(Joi.string().pattern(CAPABILITY_PATTERN))
|
||||
.min(1)
|
||||
.required(),
|
||||
owner: Joi.string().min(1).max(128).required(),
|
||||
deploymentEnv: Joi.string()
|
||||
.valid(...DEPLOYMENT_ENVS)
|
||||
.required(),
|
||||
});
|
||||
|
||||
/** Schema for PATCH /agents/:agentId request body. */
|
||||
export const updateAgentSchema = Joi.object({
|
||||
agentType: Joi.string().valid(...AGENT_TYPES),
|
||||
version: Joi.string().pattern(SEMVER_PATTERN),
|
||||
capabilities: Joi.array().items(Joi.string().pattern(CAPABILITY_PATTERN)).min(1),
|
||||
owner: Joi.string().min(1).max(128),
|
||||
deploymentEnv: Joi.string().valid(...DEPLOYMENT_ENVS),
|
||||
status: Joi.string().valid(...AGENT_STATUSES),
|
||||
})
|
||||
.min(1)
|
||||
.options({ allowUnknown: false });
|
||||
|
||||
/** Schema for GET /agents query params. */
|
||||
export const listAgentsQuerySchema = Joi.object({
|
||||
page: Joi.number().integer().min(1).default(1),
|
||||
limit: Joi.number().integer().min(1).max(100).default(20),
|
||||
owner: Joi.string(),
|
||||
agentType: Joi.string().valid(...AGENT_TYPES),
|
||||
status: Joi.string().valid(...AGENT_STATUSES),
|
||||
});
|
||||
|
||||
/** Schema for POST /token request body (form-encoded). */
|
||||
export const tokenRequestSchema = Joi.object({
|
||||
grant_type: Joi.string().required(),
|
||||
client_id: Joi.string().uuid(),
|
||||
client_secret: Joi.string(),
|
||||
scope: Joi.string().pattern(
|
||||
new RegExp(
|
||||
`^(${OAUTH_SCOPES.join('|')})(\\s(${OAUTH_SCOPES.join('|')}))*$`,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
/** Schema for POST /token/introspect request body. */
|
||||
export const introspectRequestSchema = Joi.object({
|
||||
token: Joi.string().required(),
|
||||
token_type_hint: Joi.string().valid('access_token'),
|
||||
});
|
||||
|
||||
/** Schema for POST /token/revoke request body. */
|
||||
export const revokeRequestSchema = Joi.object({
|
||||
token: Joi.string().required(),
|
||||
token_type_hint: Joi.string().valid('access_token'),
|
||||
});
|
||||
|
||||
/** Schema for POST /agents/:agentId/credentials request body. */
|
||||
export const generateCredentialSchema = Joi.object({
|
||||
expiresAt: Joi.date().iso().min('now').optional(),
|
||||
});
|
||||
|
||||
/** Schema for GET /agents/:agentId/credentials query params. */
|
||||
export const listCredentialsQuerySchema = Joi.object({
|
||||
page: Joi.number().integer().min(1).default(1),
|
||||
limit: Joi.number().integer().min(1).max(100).default(20),
|
||||
status: Joi.string().valid(...CREDENTIAL_STATUSES),
|
||||
});
|
||||
|
||||
/** Schema for GET /audit query params. */
|
||||
export const auditQuerySchema = Joi.object({
|
||||
page: Joi.number().integer().min(1).default(1),
|
||||
limit: Joi.number().integer().min(1).max(200).default(50),
|
||||
agentId: Joi.string().uuid(),
|
||||
action: Joi.string().valid(...AUDIT_ACTIONS),
|
||||
outcome: Joi.string().valid(...AUDIT_OUTCOMES),
|
||||
fromDate: Joi.string().isoDate(),
|
||||
toDate: Joi.string().isoDate(),
|
||||
});
|
||||
|
||||
/** Schema for UUID path parameters. */
|
||||
export const uuidParamSchema = Joi.object({
|
||||
id: Joi.string().uuid().required(),
|
||||
});
|
||||
Reference in New Issue
Block a user