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
+7
View File
@@ -926,6 +926,13 @@ function runMigrations(db: Database.Database): void {
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash);
`);
},
// Migration: Refresh-token rotation chain tracking for replay detection
() => {
db.exec(`
ALTER TABLE oauth_tokens ADD COLUMN parent_token_id INTEGER REFERENCES oauth_tokens(id);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id);
`);
},
];
if (currentVersion < migrations.length) {
+17 -31
View File
@@ -9,19 +9,9 @@ import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: number;
/** null = static trek_ token or JWT (full access); string[] = OAuth 2.1 scopes */
scopes: string[] | null;
/** true when authenticated via static trek_ token — triggers deprecation prompt */
isStaticToken: boolean;
lastActivity: number;
}
const sessions = new Map<string, McpSession>();
export { revokeUserSessions, revokeUserSessionsForClient };
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
@@ -83,31 +73,38 @@ interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
scopes: string[] | null;
/** OAuth client_id when authenticated via OAuth 2.1; null otherwise */
clientId: string | null;
isStaticToken: boolean;
}
function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
const token = authHeader && authHeader.split(' ')[1];
if (!token) return null;
if (!authHeader) return null;
// M8: strictly require "Bearer" scheme (RFC 6750)
const spaceIdx = authHeader.indexOf(' ');
if (spaceIdx === -1) return null;
const scheme = authHeader.slice(0, spaceIdx);
const token = authHeader.slice(spaceIdx + 1);
if (scheme.toLowerCase() !== 'bearer' || !token) return null;
// OAuth 2.1 access token (trekoa_...)
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
return { user: result.user, scopes: result.scopes, isStaticToken: false };
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
// Long-lived static MCP token (trek_...) — full access + deprecation notice
if (token.startsWith('trek_')) {
const user = verifyMcpToken(token);
if (!user) return null;
return { user, scopes: null, isStaticToken: true };
return { user, scopes: null, clientId: null, isStaticToken: true };
}
// Short-lived JWT (TREK web session used directly) — full access, no notice
const user = verifyJwtToken(token);
if (!user) return null;
return { user, scopes: null, isStaticToken: false };
return { user, scopes: null, clientId: null, isStaticToken: false };
}
export async function mcpHandler(req: Request, res: Response): Promise<void> {
@@ -121,7 +118,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
res.status(401).json({ error: 'Access token required' });
return;
}
const { user, scopes, isStaticToken } = tokenResult;
const { user, scopes, clientId, isStaticToken } = tokenResult;
if (isRateLimited(user.id)) {
res.status(429).json({ error: 'Too many requests. Please slow down.' });
@@ -174,13 +171,13 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
prompts: { listChanged: true },
},
});
registerResources(server, user.id);
registerResources(server, user.id, scopes);
registerTools(server, user.id, scopes, isStaticToken);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, scopes, isStaticToken, lastActivity: Date.now() });
sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now() });
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
},
@@ -200,17 +197,6 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
}
}
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
}
/** Close all active MCP sessions (call during graceful shutdown). */
export function closeMcpSessions(): void {
clearInterval(sessionSweepInterval);
+34 -23
View File
@@ -15,6 +15,7 @@ import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { canRead, canReadTrips } from './scopes';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -31,6 +32,16 @@ function accessDenied(uri: string) {
};
}
function scopeDenied(uri: string) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Insufficient OAuth scope to access this resource' }),
}],
};
}
function jsonContent(uri: string, data: unknown) {
return {
contents: [{
@@ -41,9 +52,9 @@ function jsonContent(uri: string, data: unknown) {
};
}
export function registerResources(server: McpServer, userId: number): void {
export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void {
// List all accessible trips
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trips',
'trek://trips',
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
@@ -54,7 +65,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Single trip detail
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
@@ -67,7 +78,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Days with assigned places
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip-days',
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
{ description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
@@ -81,7 +92,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Places in a trip
server.registerResource(
if (canRead(scopes, 'places')) server.registerResource(
'trip-places',
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
{ description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
@@ -95,7 +106,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget items
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget',
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
@@ -108,7 +119,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Packing checklist
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
'trip-packing',
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
@@ -121,7 +132,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Reservations (flights, hotels, restaurants)
server.registerResource(
if (canRead(scopes, 'reservations')) server.registerResource(
'trip-reservations',
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
@@ -134,7 +145,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Day notes
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'day-notes',
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
{ description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
@@ -148,7 +159,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Accommodations (hotels, rentals) per trip
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip-accommodations',
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' },
@@ -161,7 +172,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Trip members (owner + collaborators)
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
@@ -176,7 +187,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Collab notes for a trip
if (isAddonEnabled(ADDON_IDS.COLLAB)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
@@ -189,7 +200,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Trip to-do list
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'collab')) server.registerResource(
'trip-todos',
new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }),
{ description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' },
@@ -201,7 +212,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
);
// All place categories (global, no trip filter)
// All place categories (global, no trip filter) — safe for any authenticated session
server.registerResource(
'categories',
'trek://categories',
@@ -213,7 +224,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// User's bucket list
if (isAddonEnabled(ADDON_IDS.ATLAS)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'places')) server.registerResource(
'bucket-list',
'trek://bucket-list',
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
@@ -224,7 +235,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// User's visited countries
if (isAddonEnabled(ADDON_IDS.ATLAS)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'places')) server.registerResource(
'visited-countries',
'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
@@ -235,7 +246,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget per-person summary
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget-per-person',
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
@@ -248,7 +259,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget settlement
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget-settlement',
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
@@ -261,7 +272,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Packing bags
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
'trip-packing-bags',
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
@@ -274,7 +285,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// In-app notifications
server.registerResource(
if (canRead(scopes, 'notifications')) server.registerResource(
'notifications-in-app',
'trek://notifications/in-app',
{ description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' },
@@ -285,7 +296,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Atlas stats and regions (addon-gated)
if (isAddonEnabled(ADDON_IDS.ATLAS)) {
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'places')) {
server.registerResource(
'atlas-stats',
'trek://atlas/stats',
@@ -308,7 +319,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
// Collab polls & messages (addon-gated)
if (isAddonEnabled(ADDON_IDS.COLLAB)) {
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-polls',
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
@@ -335,7 +346,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
// Vacay resources (addon-gated)
if (isAddonEnabled(ADDON_IDS.VACAY)) {
if (isAddonEnabled(ADDON_IDS.VACAY) && canRead(scopes, 'vacay')) {
server.registerResource(
'vacay-plan',
'trek://vacay/plan',
+41
View File
@@ -0,0 +1,41 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
export interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: number;
/** null = static trek_ token or JWT (full access); string[] = OAuth 2.1 scopes */
scopes: string[] | null;
/** OAuth 2.1 client_id that owns this session; null for static-token / JWT sessions */
clientId: string | null;
/** true when authenticated via static trek_ token — triggers deprecation prompt */
isStaticToken: boolean;
lastActivity: number;
}
export const sessions = new Map<string, McpSession>();
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
}
/** Terminate MCP sessions for a specific (user, OAuth client) pair.
* Used when an OAuth token or session is revoked so only the affected client's
* sessions are closed, not sessions from other clients for the same user. */
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
for (const [sid, session] of sessions) {
if (session.userId === userId && session.clientId === clientId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
}
+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 };
+102 -35
View File
@@ -1,6 +1,6 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService';
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
@@ -23,6 +23,41 @@ import {
AuthorizeParams,
} from '../services/oauthService';
import { getAppUrl } from '../services/oidcService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
// ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts)
// ---------------------------------------------------------------------------
interface RateEntry { count: number; first: number; }
function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) {
const store = new Map<string, RateEntry>();
setInterval(() => {
const now = Date.now();
for (const [k, r] of store) if (now - r.first >= windowMs) store.delete(k);
}, windowMs).unref();
return (req: Request, res: Response, next: () => void): void => {
const key = keyFn(req);
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
if (!record || now - record.first >= windowMs) {
store.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
// ---------------------------------------------------------------------------
// Public router: /.well-known, /oauth/token, /oauth/revoke
@@ -31,7 +66,10 @@ import { getAppUrl } from '../services/oidcService';
export const oauthPublicRouter = express.Router();
// RFC 8414 discovery document
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
// M2: return 404 (not 403) so feature presence isn't fingerprinted
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
issuer: base,
@@ -50,10 +88,15 @@ oauthPublicRouter.get('/.well-known/oauth-authorization-server', (_req: Request,
});
// Token endpoint — handles authorization_code and refresh_token grants
oauthPublicRouter.post('/oauth/token', (req: Request, res: Response) => {
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
// M1: RFC 6749 §5.1 — token responses must not be cached
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
// Accept both JSON and application/x-www-form-urlencoded
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
@@ -70,28 +113,38 @@ oauthPublicRouter.post('/oauth/token', (req: Request, res: Response) => {
}
const pending = consumeAuthCode(code);
// H5: collapse all invalid_grant cases to one message; log specifics server-side
if (!pending) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code is invalid or expired' });
writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.clientId !== client_id) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'client_id mismatch' });
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.redirectUri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// Verify client secret
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
// Verify PKCE
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip });
return res.json(tokens);
}
@@ -101,8 +154,11 @@ oauthPublicRouter.post('/oauth/token', (req: Request, res: Response) => {
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
}
const result = refreshTokens(refresh_token, client_id, client_secret);
const result = refreshTokens(refresh_token, client_id, client_secret, ip);
if (result.error) {
if (result.error === 'invalid_client') {
logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
}
return res.status(result.status || 400).json({
error: result.error,
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
@@ -116,19 +172,25 @@ oauthPublicRouter.post('/oauth/token', (req: Request, res: Response) => {
});
// Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', (req: Request, res: Response) => {
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
// M2: return 404 when MCP is disabled
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { token, client_id, client_secret } = body;
const ip = getClientIp(req);
if (!token || !client_id || !client_secret) {
return res.status(400).json({ error: 'invalid_request', error_description: 'token, client_id, and client_secret are required' });
}
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
revokeToken(token, client_id);
revokeToken(token, client_id, undefined, ip);
// RFC 7009 §2.2: always respond 200 even if token was already revoked or not found
return res.status(200).json({});
});
@@ -140,19 +202,12 @@ oauthPublicRouter.post('/oauth/revoke', (req: Request, res: Response) => {
export const oauthApiRouter = express.Router();
// SPA calls this on page load to validate OAuth params before rendering consent UI
oauthApiRouter.get('/authorize/validate', (req: Request, res: Response) => {
oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
// M2 / H3: gate by addon; 404 prevents feature fingerprinting for anonymous callers
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const params = req.query as Partial<AuthorizeParams>;
const userId = (req as any).cookies?.trek_session
? (() => {
try {
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config');
const decoded = jwt.verify((req as any).cookies.trek_session, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const userRow = require('../db/database').db.prepare('SELECT id FROM users WHERE id = ?').get(decoded.id) as { id: number } | undefined;
return userRow?.id ?? null;
} catch { return null; }
})()
: null;
const userId = (req as OptionalAuthRequest).user?.id ?? null;
const result = validateAuthorizeRequest(
{
@@ -167,11 +222,22 @@ oauthApiRouter.get('/authorize/validate', (req: Request, res: Response) => {
userId,
);
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
// (validateAuthorizeRequest already does this, but be explicit here)
if (userId === null && result.valid) {
return res.json({ valid: result.valid, loginRequired: true });
}
// For unauthenticated error cases return a generic error to prevent oracle enumeration
if (userId === null && !result.valid) {
return res.json({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
}
return res.json(result);
});
// User submits consent (approve or deny) — requires cookie auth
oauthApiRouter.post('/authorize', authenticate, (req: Request, res: Response) => {
// User submits consent (approve or deny) — requires cookie-only auth (M7)
oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => {
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
@@ -185,6 +251,7 @@ oauthApiRouter.post('/authorize', authenticate, (req: Request, res: Response) =>
code_challenge_method: string;
approved: boolean;
};
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'MCP is not enabled' });
@@ -217,8 +284,8 @@ oauthApiRouter.post('/authorize', authenticate, (req: Request, res: Response) =>
const scopes = validation.scopes!;
// Store consent so subsequent requests skip the screen
saveConsent(client_id, user.id, scopes);
// Store consent (union with any existing scopes)
saveConsent(client_id, user.id, scopes, ip);
// Issue auth code
const code = createAuthCode({
@@ -245,7 +312,7 @@ oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
return res.json({ clients: listOAuthClients(user.id) });
});
oauthApiRouter.post('/clients', authenticate, (req: Request, res: Response) => {
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const { name, redirect_uris, allowed_scopes } = req.body as {
@@ -254,23 +321,23 @@ oauthApiRouter.post('/clients', authenticate, (req: Request, res: Response) => {
allowed_scopes: string[];
};
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes);
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
oauthApiRouter.post('/clients/:id/rotate', authenticate, (req: Request, res: Response) => {
oauthApiRouter.post('/clients/:id/rotate', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = rotateOAuthClientSecret(user.id, req.params.id);
const result = rotateOAuthClientSecret(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ client_secret: result.client_secret });
});
oauthApiRouter.delete('/clients/:id', authenticate, (req: Request, res: Response) => {
oauthApiRouter.delete('/clients/:id', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = deleteOAuthClient(user.id, req.params.id);
const result = deleteOAuthClient(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
@@ -283,10 +350,10 @@ oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => {
return res.json({ sessions: listOAuthSessions(user.id) });
});
oauthApiRouter.delete('/sessions/:id', authenticate, (req: Request, res: Response) => {
oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = revokeSession(user.id, Number(req.params.id));
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
+150 -27
View File
@@ -4,15 +4,21 @@ import { isAddonEnabled } from './adminService';
import { validateScopes } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from './auditLog';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
// PKCE format (RFC 7636)
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
// ---------------------------------------------------------------------------
// In-memory auth code store (short-lived, no need for DB persistence)
// ---------------------------------------------------------------------------
@@ -61,6 +67,7 @@ interface OAuthTokenRow {
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
parent_token_id: number | null;
}
// ---------------------------------------------------------------------------
@@ -71,6 +78,14 @@ function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
/** Constant-time comparison of two hex-encoded SHA-256 hashes. */
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch { return false; }
}
function generateAccessToken(): string {
return 'trekoa_' + randomBytes(24).toString('hex');
}
@@ -99,6 +114,7 @@ export function createOAuthClient(
name: string,
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
): { error?: string; status?: number; client?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
@@ -136,6 +152,8 @@ export function createOAuthClient(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow;
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim() }, ip });
return {
client: {
id: row.id,
@@ -153,8 +171,9 @@ export function createOAuthClient(
export function rotateOAuthClientSecret(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; client_secret?: string } {
const row = db.prepare('SELECT id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
@@ -163,7 +182,13 @@ export function rotateOAuthClientSecret(
db.prepare('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?').run(secretHash, clientRowId);
// Revoke all existing tokens for this client so old sessions are invalidated
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = (SELECT client_id FROM oauth_clients WHERE id = ?) AND revoked_at IS NULL").run(clientRowId);
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL").run(row.client_id);
// Terminate active MCP sessions for this (user, client) pair
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
return { client_secret: rawSecret };
}
@@ -171,10 +196,12 @@ export function rotateOAuthClientSecret(
export function deleteOAuthClient(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId);
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
return { success: true };
}
@@ -214,10 +241,14 @@ export function getConsent(clientId: string, userId: number): string[] | null {
return row ? JSON.parse(row.scopes) : null;
}
export function saveConsent(clientId: string, userId: number, scopes: string[]): void {
export function saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
const existing = getConsent(clientId, userId) ?? [];
const merged = Array.from(new Set([...existing, ...scopes]));
db.prepare(
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
).run(clientId, userId, JSON.stringify(scopes));
).run(clientId, userId, JSON.stringify(merged));
writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
}
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
@@ -232,6 +263,7 @@ export function issueTokens(
clientId: string,
userId: number,
scopes: string[],
parentTokenId: number | null = null,
): {
access_token: string;
refresh_token: string;
@@ -250,9 +282,9 @@ export function issueTokens(
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString());
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
return {
access_token: rawAccess,
@@ -270,13 +302,14 @@ export function issueTokens(
export interface OAuthTokenInfo {
user: User;
scopes: string[];
clientId: string;
}
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = db.prepare(`
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, u.username, u.email, u.role
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
WHERE ot.access_token_hash = ?
@@ -289,50 +322,122 @@ export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
return {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
clientId: row.client_id,
};
}
// ---------------------------------------------------------------------------
// Token refresh (rotation)
// Token refresh (rotation + replay detection)
// ---------------------------------------------------------------------------
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
function findChainRoot(tokenId: number): number {
let current = tokenId;
for (let i = 0; i < 100; i++) {
const row = db.prepare('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?').get(current) as { id: number; parent_token_id: number | null } | undefined;
if (!row || row.parent_token_id === null) return current;
current = row.parent_token_id;
}
return current;
}
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
function revokeChain(rootId: number): number[] {
const rows = db.prepare(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
UNION ALL
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
)
SELECT id FROM chain
`).all(rootId) as { id: number }[];
const ids = rows.map(r => r.id);
if (ids.length > 0) {
db.prepare(
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`
).run(...ids);
}
return ids;
}
export function refreshTokens(
rawRefreshToken: string,
clientId: string,
clientSecret: string,
ip?: string | null,
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
const client = db.prepare('SELECT client_id, client_secret_hash FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return { error: 'invalid_client', status: 401 };
if (hashToken(clientSecret) !== client.client_secret_hash) return { error: 'invalid_client', status: 401 };
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return { error: 'invalid_client', status: 401 };
const hash = hashToken(rawRefreshToken);
const row = db.prepare(`
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`).get(hash) as OAuthTokenRow | undefined;
if (!row) return { error: 'invalid_grant', status: 400 };
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
if (row.revoked_at) return { error: 'invalid_grant', status: 400 };
// ---- Replay detection (C3) ----
if (row.revoked_at) {
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
const rootId = findChainRoot(row.id);
revokeChain(rootId);
revokeUserSessionsForClient(row.user_id, clientId);
writeAudit({
userId: row.user_id,
action: 'oauth.token.replay_detected',
details: { client_id: clientId },
ip,
});
logWarn(`[OAuth] Refresh token replay detected for user=${row.user_id} client=${clientId} ip=${ip ?? '-'}`);
return { error: 'invalid_grant', status: 400 };
}
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
// Revoke old pair immediately (rotation)
// Revoke old pair immediately (rotation) and issue new pair linked to old row
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
return { tokens: issueTokens(clientId, row.user_id, JSON.parse(row.scopes)) };
// Terminate active MCP sessions for the old token's client so client must re-authenticate
revokeUserSessionsForClient(row.user_id, clientId);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
}
// ---------------------------------------------------------------------------
// Token revocation
// ---------------------------------------------------------------------------
export function revokeToken(rawToken: string, clientId: string): void {
export function revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
const hash = hashToken(rawToken);
// Get the user_id for the token so we can revoke its MCP sessions
const row = db.prepare(
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?'
).get(hash, hash, clientId) as { user_id: number } | undefined;
db.prepare(`
UPDATE oauth_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
`).run(hash, hash, clientId);
const affectedUserId = row?.user_id ?? userId;
if (affectedUserId) {
revokeUserSessionsForClient(affectedUserId, clientId);
writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
}
}
// ---------------------------------------------------------------------------
@@ -356,10 +461,18 @@ export function listOAuthSessions(userId: number): Record<string, unknown>[] {
export function revokeSession(
userId: number,
sessionId: number,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId);
const row = db.prepare('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId) as { id: number; client_id: string } | undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId);
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
return { success: true };
}
@@ -405,6 +518,11 @@ export function validateAuthorizeRequest(
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
}
// H1: Enforce code_challenge format (RFC 7636 §4.2)
if (!CODE_CHALLENGE_RE.test(params.code_challenge)) {
return { valid: false, error: 'invalid_request', error_description: 'code_challenge must be 43 base64url characters (S256)' };
}
if (!params.client_id) {
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
}
@@ -433,12 +551,9 @@ export function validateAuthorizeRequest(
}
if (userId === null) {
return {
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes,
loginRequired: true,
};
// H3: return only the minimum required fields — do NOT expose scopes, client.name, or
// allowed_scopes to unauthenticated callers to prevent client enumeration.
return { valid: true, loginRequired: true };
}
const existingConsent = getConsent(params.client_id, userId);
@@ -457,8 +572,15 @@ export function validateAuthorizeRequest(
// ---------------------------------------------------------------------------
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
// H1: validate code_verifier format before hashing
if (!CODE_VERIFIER_RE.test(codeVerifier)) return false;
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
return expected === codeChallenge;
// Constant-time compare (both are base64url strings of equal length for S256)
if (expected.length !== codeChallenge.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge));
} catch { return false; }
}
// ---------------------------------------------------------------------------
@@ -468,6 +590,7 @@ export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean
export function authenticateClient(clientId: string, clientSecret: string): OAuthClientRow | null {
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return null;
if (hashToken(clientSecret) !== client.client_secret_hash) return null;
// H4: constant-time comparison to prevent timing side-channel
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
return client;
}