mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
feat(mcp): introduce OAuth 2.1 auth and enforce addon gating
OAuth 2.1 authentication for MCP:
- Add OAuth 2.1 authorization server with PKCE support (routes/oauth.ts)
- Add OAuth service for client CRUD, auth-code flow, and token management (services/oauthService.ts)
- Add typed scope definitions and enforcement helpers (mcp/scopes.ts)
- Add OAuth consent UI page (OAuthAuthorizePage.tsx)
- Add client-side scope labels and descriptions (api/oauthScopes.ts)
- Integrate OAuth token auth into MCP handler alongside existing static tokens
- All OAuth endpoints gated on `mcp` addon
Addon gating across MCP tools, resources, and prompts:
- Add typed ADDON_IDS constant (server/src/addons.ts) replacing all string literals
- Gate budget tools and resources (trip-budget, per-person, settlement) on `budget` addon
- Gate packing tools and resources (trip-packing, trip-packing-bags, trip-todos) on `packing` addon
- Gate todos tools on `packing` addon (mirrors web UI Lists tab behavior)
- Expand atlas gate to cover full tool body (bucket-list + country tools no longer leak)
- Expand collab gate to cover full tool body (collab notes no longer leak)
- Gate packing-list and budget-overview MCP prompts on their respective addons
- Gate get_trip_summary sections per addon; blank packing/budget/collab_notes/todos when disabled
- Remove trip-files resource and files field from get_trip_summary
- Replace all isAddonEnabled('literal') calls with ADDON_IDS constants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { isAddonEnabled } from './adminService';
|
||||
import { validateScopes } from '../mcp/scopes';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { User } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory auth code store (short-lived, no need for DB persistence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PendingCode {
|
||||
clientId: string;
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const pendingCodes = new Map<string, PendingCode>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of pendingCodes) {
|
||||
if (now > entry.expiresAt) pendingCodes.delete(key);
|
||||
}
|
||||
}, 60_000).unref();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB row types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OAuthClientRow {
|
||||
id: string;
|
||||
user_id: number;
|
||||
name: string;
|
||||
client_id: string;
|
||||
client_secret_hash: string;
|
||||
redirect_uris: string; // JSON array
|
||||
allowed_scopes: string; // JSON array
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenRow {
|
||||
id: number;
|
||||
client_id: string;
|
||||
user_id: number;
|
||||
access_token_hash: string;
|
||||
refresh_token_hash: string;
|
||||
scopes: string; // JSON array
|
||||
access_token_expires_at: string;
|
||||
refresh_token_expires_at: string;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
function generateAccessToken(): string {
|
||||
return 'trekoa_' + randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
function generateRefreshToken(): string {
|
||||
return 'trekrf_' + randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client management (self-service, gated by MCP addon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as OAuthClientRow[];
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createOAuthClient(
|
||||
userId: number,
|
||||
name: string,
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
): { 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 };
|
||||
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
|
||||
|
||||
for (const uri of redirectUris) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
|
||||
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
|
||||
}
|
||||
} catch {
|
||||
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
|
||||
const { valid, invalid } = validateScopes(allowedScopes);
|
||||
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
|
||||
|
||||
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
|
||||
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
|
||||
|
||||
const id = randomUUID();
|
||||
const clientId = randomUUID();
|
||||
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
|
||||
const secretHash = hashToken(rawSecret);
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes));
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE id = ?'
|
||||
).get(id) as OAuthClientRow;
|
||||
|
||||
return {
|
||||
client: {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
name: row.name,
|
||||
client_id: row.client_id,
|
||||
redirect_uris: JSON.parse(row.redirect_uris),
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
created_at: row.created_at,
|
||||
client_secret: rawSecret, // shown once — not stored in plain text
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function rotateOAuthClientSecret(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
): { 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;
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
|
||||
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
|
||||
const secretHash = hashToken(rawSecret);
|
||||
|
||||
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);
|
||||
|
||||
return { client_secret: rawSecret };
|
||||
}
|
||||
|
||||
export function deleteOAuthClient(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
const row = db.prepare('SELECT id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId);
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth code (in-memory, 2-minute TTL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createAuthCode(params: {
|
||||
clientId: string;
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
}): string {
|
||||
const rawCode = randomBytes(32).toString('hex');
|
||||
pendingCodes.set(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
|
||||
return rawCode;
|
||||
}
|
||||
|
||||
export function consumeAuthCode(code: string): PendingCode | null {
|
||||
const entry = pendingCodes.get(code);
|
||||
if (!entry) return null;
|
||||
pendingCodes.delete(code);
|
||||
if (Date.now() > entry.expiresAt) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consent management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getConsent(clientId: string, userId: number): string[] | null {
|
||||
const row = db.prepare(
|
||||
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?'
|
||||
).get(clientId, userId) as { scopes: string } | undefined;
|
||||
return row ? JSON.parse(row.scopes) : null;
|
||||
}
|
||||
|
||||
export function saveConsent(clientId: string, userId: number, scopes: string[]): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(clientId, userId, JSON.stringify(scopes));
|
||||
}
|
||||
|
||||
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
|
||||
return requestedScopes.every(s => existingScopes.includes(s));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token issuance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function issueTokens(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
): {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
const rawAccess = generateAccessToken();
|
||||
const rawRefresh = generateRefreshToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
const refreshHash = hashToken(rawRefresh);
|
||||
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
|
||||
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
|
||||
|
||||
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());
|
||||
|
||||
return {
|
||||
access_token: rawAccess,
|
||||
refresh_token: rawRefresh,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
scope: scopes.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token verification (used by MCP handler on every request)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OAuthTokenInfo {
|
||||
user: User;
|
||||
scopes: 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,
|
||||
u.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 = ?
|
||||
`).get(hash) as (OAuthTokenRow & { username: string; email: string; role: string }) | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
if (row.revoked_at) return null;
|
||||
if (new Date(row.access_token_expires_at) < new Date()) return null;
|
||||
|
||||
return {
|
||||
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
|
||||
scopes: JSON.parse(row.scopes),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token refresh (rotation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function refreshTokens(
|
||||
rawRefreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): { 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 };
|
||||
|
||||
const hash = hashToken(rawRefreshToken);
|
||||
const row = db.prepare(`
|
||||
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at
|
||||
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 };
|
||||
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
|
||||
|
||||
// Revoke old pair immediately (rotation)
|
||||
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)) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token revocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function revokeToken(rawToken: string, clientId: string): void {
|
||||
const hash = hashToken(rawToken);
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active session listing (for user settings page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listOAuthSessions(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
|
||||
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
||||
FROM oauth_tokens ot
|
||||
JOIN oauth_clients oc ON ot.client_id = oc.client_id
|
||||
WHERE ot.user_id = ?
|
||||
AND ot.revoked_at IS NULL
|
||||
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY ot.created_at DESC
|
||||
`).all(userId) as Record<string, unknown>[];
|
||||
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
|
||||
}
|
||||
|
||||
export function revokeSession(
|
||||
userId: number,
|
||||
sessionId: number,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
const row = db.prepare('SELECT id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId);
|
||||
if (!row) return { error: 'Session not found', status: 404 };
|
||||
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthorizeParams {
|
||||
response_type: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
}
|
||||
|
||||
export interface ValidateAuthorizeResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
client?: { name: string; allowed_scopes: string[] };
|
||||
scopes?: string[];
|
||||
/** true when user is logged in but consent UI must be shown */
|
||||
consentRequired?: boolean;
|
||||
/** true when the request is valid but user is not authenticated */
|
||||
loginRequired?: boolean;
|
||||
}
|
||||
|
||||
export function validateAuthorizeRequest(
|
||||
params: AuthorizeParams,
|
||||
userId: number | null,
|
||||
): ValidateAuthorizeResult {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
|
||||
}
|
||||
|
||||
if (params.response_type !== 'code') {
|
||||
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
|
||||
}
|
||||
|
||||
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
|
||||
}
|
||||
|
||||
if (!params.client_id) {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
|
||||
}
|
||||
|
||||
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(params.client_id) as OAuthClientRow | undefined;
|
||||
if (!client) {
|
||||
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
|
||||
}
|
||||
|
||||
const allowedUris: string[] = JSON.parse(client.redirect_uris);
|
||||
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
|
||||
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
|
||||
}
|
||||
|
||||
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
|
||||
if (requestedScopes.length === 0) {
|
||||
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
|
||||
}
|
||||
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
const disallowed = requestedScopes.filter(s => !allowedScopes.includes(s));
|
||||
if (disallowed.length > 0) {
|
||||
return { valid: false, error: 'invalid_scope', error_description: `Scopes not permitted for this client: ${disallowed.join(', ')}` };
|
||||
}
|
||||
|
||||
if (userId === null) {
|
||||
return {
|
||||
valid: true,
|
||||
client: { name: client.name, allowed_scopes: allowedScopes },
|
||||
scopes: requestedScopes,
|
||||
loginRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
const existingConsent = getConsent(params.client_id, userId);
|
||||
const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, requestedScopes);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
client: { name: client.name, allowed_scopes: allowedScopes },
|
||||
scopes: requestedScopes,
|
||||
consentRequired,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
|
||||
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
return expected === codeChallenge;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client authentication (for token endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
return client;
|
||||
}
|
||||
Reference in New Issue
Block a user