security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)

Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:

Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay

High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side

Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification

Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
  into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
  requireCookieAuth middleware

Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
This commit is contained in:
jubnl
2026-04-10 02:03:12 +02:00
parent e91ee04d93
commit 7c0a0d5f39
9 changed files with 1024 additions and 155 deletions
+36 -13
View File
@@ -12,6 +12,18 @@ export function extractToken(req: Request): string | null {
return (authHeader && authHeader.split(' ')[1]) || null;
}
function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
return user ?? null;
} catch {
return null;
}
}
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
const token = extractToken(req);
@@ -20,20 +32,31 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
return;
}
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
res.status(401).json({ error: 'User not found', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
} catch (err: unknown) {
const user = verifyJwtAndLoadUser(token);
if (!user) {
res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
};
/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
* Used on state-mutating OAuth endpoints (consent POST, client CRUD, session revoke)
* to prevent Bearer JWT tokens obtained by other means from managing OAuth clients. */
const requireCookieAuth = (req: Request, res: Response, next: NextFunction): void => {
const cookieToken = (req as any).cookies?.trek_session;
if (!cookieToken) {
res.status(401).json({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
return;
}
const user = verifyJwtAndLoadUser(cookieToken);
if (!user) {
res.status(401).json({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
};
const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
@@ -74,4 +97,4 @@ const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void
next();
};
export { authenticate, optionalAuth, adminOnly, demoUploadBlock };
export { authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock };