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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user