Spaces:
Sleeping
Sleeping
| 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'; | |
| import { writeAudit, logWarn } from './auditLog'; | |
| import { revokeUserSessionsForClient } from '../mcp/sessionManager'; | |
| import { getMcpSafeUrl } from './notifications'; | |
| // --------------------------------------------------------------------------- | |
| // 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 | |
| // 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) | |
| // --------------------------------------------------------------------------- | |
| interface PendingCode { | |
| clientId: string; | |
| userId: number; | |
| redirectUri: string; | |
| scopes: string[]; | |
| resource: string | null; | |
| codeChallenge: string; | |
| codeChallengeMethod: 'S256'; | |
| expiresAt: number; | |
| } | |
| const MAX_PENDING_CODES = 500; | |
| 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; | |
| is_public: number; // 0 | 1 (SQLite boolean) | |
| created_via: string; // 'settings_ui' | 'browser-registration' | |
| allows_client_credentials: number; // 0 | 1 | |
| } | |
| interface OAuthTokenRow { | |
| id: number; | |
| client_id: string; | |
| user_id: number; | |
| access_token_hash: string; | |
| refresh_token_hash: string; | |
| scopes: string; // JSON array | |
| audience: string | null; | |
| access_token_expires_at: string; | |
| refresh_token_expires_at: string; | |
| revoked_at: string | null; | |
| parent_token_id: number | null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Token helpers | |
| // --------------------------------------------------------------------------- | |
| 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(32).toString('hex'); | |
| } | |
| function generateRefreshToken(): string { | |
| return 'trekrf_' + randomBytes(32).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, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC' | |
| ).all(userId) as OAuthClientRow[]; | |
| return rows.map(r => ({ | |
| ...r, | |
| is_public: Boolean(r.is_public), | |
| allows_client_credentials: Boolean(r.allows_client_credentials), | |
| redirect_uris: JSON.parse(r.redirect_uris), | |
| allowed_scopes: JSON.parse(r.allowed_scopes), | |
| })); | |
| } | |
| export function createOAuthClient( | |
| userId: number | null, | |
| name: string, | |
| redirectUris: string[], | |
| allowedScopes: string[], | |
| ip?: string | null, | |
| options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean }, | |
| ): { 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 }; | |
| const isMachineClient = Boolean(options?.allowsClientCredentials); | |
| if (!isMachineClient && (!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) { | |
| let parsed: URL; | |
| try { | |
| parsed = new URL(uri); | |
| } catch { | |
| return { error: `Invalid redirect URI: ${uri}`, status: 400 }; | |
| } | |
| if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') { | |
| return { error: `Redirect URI must use HTTPS (localhost exempt): ${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 }; | |
| if (userId !== null) { | |
| 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 }; | |
| } else { | |
| // Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse | |
| const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL').get() as { count: number }).count; | |
| if (count >= 500) return { error: 'server_error', status: 503 }; | |
| } | |
| // Machine clients (client_credentials) must always be confidential — ignore isPublic for them. | |
| const isPublic = isMachineClient ? false : (options?.isPublic ?? false); | |
| const createdVia = options?.createdVia ?? 'settings_ui'; | |
| const id = randomUUID(); | |
| const clientId = randomUUID(); | |
| // Public clients have no usable secret; store an opaque random value to satisfy NOT NULL. | |
| const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex'); | |
| const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex'); | |
| db.prepare( | |
| 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' | |
| ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0); | |
| const row = db.prepare( | |
| 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?' | |
| ).get(id) as OAuthClientRow; | |
| writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip }); | |
| 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, | |
| is_public: Boolean(row.is_public), | |
| allows_client_credentials: Boolean(row.allows_client_credentials), | |
| created_via: row.created_via, | |
| // client_secret only present for confidential clients — shown once, not stored in plain text | |
| ...(rawSecret ? { client_secret: rawSecret } : {}), | |
| }, | |
| }; | |
| } | |
| export function rotateOAuthClientSecret( | |
| userId: number, | |
| clientRowId: string, | |
| ip?: string | null, | |
| ): { error?: string; status?: number; client_secret?: string } { | |
| const row = db.prepare('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined; | |
| if (!row) return { error: 'Client not found', status: 404 }; | |
| if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 }; | |
| 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 = ? 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 }; | |
| } | |
| export function deleteOAuthClient( | |
| userId: number, | |
| clientRowId: string, | |
| ip?: string | null, | |
| ): { error?: string; status?: number; success?: boolean } { | |
| 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 }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Auth code (in-memory, 2-minute TTL) | |
| // --------------------------------------------------------------------------- | |
| export function createAuthCode(params: { | |
| clientId: string; | |
| userId: number; | |
| redirectUri: string; | |
| scopes: string[]; | |
| resource: string | null; | |
| codeChallenge: string; | |
| codeChallengeMethod: 'S256'; | |
| }): string | null { | |
| if (pendingCodes.size >= MAX_PENDING_CODES) return null; | |
| 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[], 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(merged)); | |
| writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip }); | |
| } | |
| 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[], | |
| parentTokenId: number | null = null, | |
| audience: string | null = null, | |
| ): { | |
| 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, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| `).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId); | |
| return { | |
| access_token: rawAccess, | |
| refresh_token: rawRefresh, | |
| token_type: 'Bearer', | |
| expires_in: ACCESS_TOKEN_TTL_S, | |
| scope: scopes.join(' '), | |
| }; | |
| } | |
| // Issues an access token only — no refresh token (RFC 6749 §4.4.3). | |
| // Used exclusively for the client_credentials grant. A random opaque hash is | |
| // stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it | |
| // can never be presented as a valid refresh token (same precedent as public | |
| // client secret hashes stored in client_secret_hash). | |
| export function issueClientCredentialsToken( | |
| clientId: string, | |
| userId: number, | |
| scopes: string[], | |
| audience: string, | |
| ): { | |
| access_token: string; | |
| token_type: 'Bearer'; | |
| expires_in: number; | |
| scope: string; | |
| } { | |
| const rawAccess = generateAccessToken(); | |
| const accessHash = hashToken(rawAccess); | |
| const placeholderHash = randomBytes(32).toString('hex'); | |
| const now = new Date(); | |
| const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000); | |
| db.prepare(` | |
| INSERT INTO oauth_tokens | |
| (client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| `).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null); | |
| return { | |
| access_token: rawAccess, | |
| 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[]; | |
| clientId: string; | |
| audience: string | null; | |
| } | |
| export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null { | |
| const hash = hashToken(rawToken); | |
| const row = db.prepare(` | |
| SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at, | |
| 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 = ? | |
| `).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), | |
| clientId: row.client_id, | |
| audience: row.audience ?? null, | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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 | undefined, | |
| ip?: string | null, | |
| ): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } { | |
| const client = db.prepare('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined; | |
| if (!client) return { error: 'invalid_client', status: 401 }; | |
| if (!client.is_public) { | |
| if (!clientSecret || !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, audience, 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 }; | |
| // ---- 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) and issue new pair linked to old row | |
| db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id); | |
| // 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, row.audience ?? null); | |
| 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, 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 }); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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, | |
| ip?: string | null, | |
| ): { error?: string; status?: number; success?: boolean } { | |
| 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 }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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; | |
| resource?: string; | |
| } | |
| export interface ValidateAuthorizeResult { | |
| valid: boolean; | |
| error?: string; | |
| error_description?: string; | |
| client?: { name: string; allowed_scopes: string[] }; | |
| scopes?: string[]; | |
| resource?: string | null; | |
| /** 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; | |
| /** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */ | |
| scopeSelectable?: 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)' }; | |
| } | |
| // 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' }; | |
| } | |
| 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' }; | |
| } | |
| // RFC 8707 resource indicator: if provided, must identify the TREK | |
| // MCP endpoint exactly. If the client didn't supply `resource`, we | |
| // bind the token to the MCP endpoint by default — previously this | |
| // left `audience = null`, and the audience-bind check on MCP requests | |
| // then treated a null audience as "valid for any resource". | |
| const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`; | |
| const resource = params.resource | |
| ? params.resource.replace(/\/+$/, '') | |
| : mcpResource; | |
| if (resource !== mcpResource) { | |
| return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' }; | |
| } | |
| 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); | |
| // Narrow to the intersection: drop scopes the client isn't permitted for rather | |
| // than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing). | |
| const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s)); | |
| if (grantedScopes.length === 0) { | |
| return { valid: false, error: 'invalid_scope', error_description: 'None of the requested scopes are permitted for this client' }; | |
| } | |
| if (userId === null) { | |
| // 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); | |
| const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, grantedScopes); | |
| return { | |
| valid: true, | |
| client: { name: client.name, allowed_scopes: allowedScopes }, | |
| scopes: grantedScopes, | |
| resource: resource ?? mcpResource, | |
| consentRequired, | |
| scopeSelectable: client.created_via === 'dcr', | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // PKCE verification | |
| // --------------------------------------------------------------------------- | |
| 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'); | |
| // 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; } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Client authentication (for token endpoint) | |
| // --------------------------------------------------------------------------- | |
| export function authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null { | |
| const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined; | |
| if (!client) return null; | |
| if (client.is_public) { | |
| // Public clients are identified by client_id alone — PKCE provides the security guarantee. | |
| return client; | |
| } | |
| // H4: constant-time comparison to prevent timing side-channel | |
| if (!clientSecret) return null; | |
| if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null; | |
| return client; | |
| } | |