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:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

77
src/middleware/auth.ts Normal file
View 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);
}
}

View 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);
}

View 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);
}
}