UNPKG

@vfarcic/dot-ai

Version:

AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance

415 lines (414 loc) 17.7 kB
"use strict"; /** * MCP SDK OAuth Server Provider for PRD #380. * * Implements the SDK's OAuthServerProvider interface with: * - In-memory client store (clients re-register on restart per MCP spec) * - Dual-mode token verification (JWT + legacy DOT_AI_AUTH_TOKEN) * - Dex OIDC integration for authorize/callback/token flow (Task 2.3) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.DotAIOAuthProvider = exports.DotAIClientsStore = void 0; const node_crypto_1 = require("node:crypto"); const errors_js_1 = require("@modelcontextprotocol/sdk/server/auth/errors.js"); const jwt_1 = require("./jwt"); const dex_client_1 = require("./dex-client"); /** * Safely extract a single string from an Express query parameter. * Query params can be string, string[], or undefined when tampered. */ function extractQueryParam(value) { if (typeof value === 'string') return value; if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; return undefined; } /** * In-memory client store for OAuth registered clients. * Clients re-register on server restart per the MCP Authorization spec. */ class DotAIClientsStore { clients = new Map(); getClient(clientId) { return this.clients.get(clientId); } registerClient(client) { // SDK pre-populates client_id, client_secret, timestamps before calling this. // The Omit type is the interface contract, but the actual object has all fields. const fullClient = client; this.clients.set(fullClient.client_id, fullClient); return fullClient; } /** Clear all registered clients. For testing only. @internal */ _clearClients() { this.clients.clear(); } } exports.DotAIClientsStore = DotAIClientsStore; /** Max age for pending auth requests (10 minutes). */ const PENDING_REQUEST_TTL_MS = 10 * 60 * 1000; /** Max age for authorization codes (5 minutes). */ const AUTH_CODE_TTL_MS = 5 * 60 * 1000; /** Separator between session ID and original state in the Dex state param. */ const STATE_SEPARATOR = '|'; /** Default token expiry: 1 day (86400 seconds). */ const DEFAULT_TOKEN_EXPIRY_SECONDS = 86400; /** Maximum allowed token expiry: 90 days (7776000 seconds). */ const MAX_TOKEN_EXPIRY_SECONDS = 7776000; /** * OAuth Server Provider for dot-ai. * * Acts as the OAuth Authorization Server for MCP clients. On authorize, * redirects the browser to Dex for authentication, then exchanges the * Dex code for an ID token and issues a dot-ai JWT. * * Token verification supports dual-mode: JWT first, legacy token fallback. */ class DotAIOAuthProvider { clientsStore; pendingRequests = new Map(); authCodes = new Map(); requestedExpiries = new Map(); // Maps auth code -> requested expiry dexConfig; dotAiExternalUrl; pruneTimer = null; constructor() { this.clientsStore = new DotAIClientsStore(); this.dexConfig = this.loadDexConfig(); this.dotAiExternalUrl = (process.env.DOT_AI_EXTERNAL_URL || '').replace(/\/$/, ''); this.startPruning(); } /** * Periodically remove expired entries from pendingRequests and authCodes * to prevent unbounded memory growth from abandoned OAuth flows. */ startPruning() { // Run every 60 seconds — lightweight scan of small maps this.pruneTimer = setInterval(() => this.pruneExpired(), 60_000); this.pruneTimer.unref(); // Don't prevent Node.js from exiting } /** Remove expired pending requests and authorization codes. */ pruneExpired() { const now = Date.now(); for (const [key, req] of this.pendingRequests) { if (now - req.createdAt > PENDING_REQUEST_TTL_MS) { this.pendingRequests.delete(key); } } for (const [key, code] of this.authCodes) { if (now > code.expiresAt) { this.authCodes.delete(key); } } } /** Stop the pruning timer. For testing only. @internal */ _stopPruning() { if (this.pruneTimer) { clearInterval(this.pruneTimer); this.pruneTimer = null; } } /** * Store a client-requested token expiry for an upcoming token exchange. * Called by middleware that intercepts POST /token before the SDK handler. * * @param authorizationCode - The authorization code from the token request * @param requestedExpiry - Requested expiry in seconds */ setRequestedExpiry(authorizationCode, requestedExpiry) { this.requestedExpiries.set(authorizationCode, requestedExpiry); } /** * Calculate token expiry based on client request, defaults, and limits. * * Priority: * 1. Client-requested expiry (if valid and within max limit) * 2. OAUTH_DEFAULT_TOKEN_TTL_SECONDS env var * 3. Built-in default (1 day) * * @param authorizationCode - The authorization code being exchanged * @returns Expiry time in seconds */ getTokenExpiry(authorizationCode) { const defaultExpiry = parseInt(process.env.OAUTH_DEFAULT_TOKEN_TTL_SECONDS || String(DEFAULT_TOKEN_EXPIRY_SECONDS)); const maxExpiry = parseInt(process.env.OAUTH_MAX_TOKEN_TTL_SECONDS || String(MAX_TOKEN_EXPIRY_SECONDS)); // Check if client requested a specific expiry const requestedExpiry = this.requestedExpiries.get(authorizationCode); if (requestedExpiry !== undefined) { // Clean up the stored value (one-time use) this.requestedExpiries.delete(authorizationCode); // Validate: must be positive and not exceed max if (requestedExpiry > 0 && requestedExpiry <= maxExpiry) { return requestedExpiry; } } return defaultExpiry; } loadDexConfig() { const issuerUrl = process.env.DEX_ISSUER_URL; const clientId = process.env.DEX_CLIENT_ID; const clientSecret = process.env.DEX_CLIENT_SECRET; if (!issuerUrl || !clientId || !clientSecret) { return null; } const tokenEndpoint = process.env.DEX_TOKEN_ENDPOINT || `${issuerUrl.replace(/\/$/, '')}/token`; return { issuerUrl, tokenEndpoint, clientId, clientSecret }; } /** * Start the authorization flow by redirecting the browser to Dex. * * Stores the pending auth request (PKCE challenge, redirect URI, state) * keyed by a random session ID, then encodes sessionId|originalState * in the Dex state param so the callback can recover the pending request. */ async authorize(client, params, res) { if (!this.dexConfig) { throw new errors_js_1.ServerError('Dex not configured (set DEX_ISSUER_URL, DEX_CLIENT_ID, DEX_CLIENT_SECRET)'); } if (!this.dotAiExternalUrl) { throw new errors_js_1.ServerError('DOT_AI_EXTERNAL_URL is required for OAuth. Set it to the external URL of the dot-ai server.'); } const sessionId = (0, node_crypto_1.randomBytes)(16).toString('hex'); const pending = { clientId: client.client_id, redirectUri: params.redirectUri, codeChallenge: params.codeChallenge, codeChallengeMethod: 'S256', state: params.state ?? '', createdAt: Date.now(), }; this.pendingRequests.set(sessionId, pending); const dexState = `${sessionId}${STATE_SEPARATOR}${params.state ?? ''}`; const callbackUrl = `${this.dotAiExternalUrl}/callback`; const dexAuthUrl = (0, dex_client_1.buildAuthorizeUrl)(this.dexConfig, { redirectUri: callbackUrl, state: dexState, }); res.redirect(302, dexAuthUrl); } /** * Return the PKCE code challenge for a given authorization code. * * Called by the SDK's tokenHandler BEFORE exchangeAuthorizationCode. * Do NOT delete the code here — it is consumed in exchangeAuthorizationCode. */ async challengeForAuthorizationCode(client, authorizationCode) { const record = this.authCodes.get(authorizationCode); if (!record) { throw new errors_js_1.InvalidGrantError('Authorization code not found or expired'); } if (Date.now() > record.expiresAt) { this.authCodes.delete(authorizationCode); throw new errors_js_1.InvalidGrantError('Authorization code expired'); } // Verify the code was issued to the requesting client (RFC 6749 §4.1.3) if (record.clientId !== client.client_id) { this.authCodes.delete(authorizationCode); throw new errors_js_1.InvalidGrantError('Authorization code was not issued to this client'); } return record.codeChallenge; } /** * Exchange a dot-ai authorization code for a JWT access token. * * Called by the SDK's tokenHandler AFTER PKCE verification passes. * Consumes the authorization code (one-time use) and signs a JWT * containing the user's identity from the Dex ID token. */ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, _resource) { const record = this.authCodes.get(authorizationCode); if (!record) { throw new errors_js_1.InvalidGrantError('Authorization code not found or expired'); } if (Date.now() > record.expiresAt) { this.authCodes.delete(authorizationCode); throw new errors_js_1.InvalidGrantError('Authorization code expired'); } // Verify client_id matches (RFC 6749 §4.1.3) if (record.clientId !== client.client_id) { this.authCodes.delete(authorizationCode); throw new errors_js_1.InvalidGrantError('Authorization code was not issued to this client'); } // Verify redirect_uri matches the original request (RFC 6749 §4.1.3) if (redirectUri && redirectUri !== record.redirectUri) { this.authCodes.delete(authorizationCode); throw new errors_js_1.InvalidGrantError('redirect_uri does not match the original authorization request'); } // Consume the authorization code (one-time use) this.authCodes.delete(authorizationCode); const now = Math.floor(Date.now() / 1000); const expiresIn = this.getTokenExpiry(authorizationCode); const secret = (0, jwt_1.getJwtSecret)(); const accessToken = (0, jwt_1.signJwt)({ sub: record.userIdentity.userId, email: record.userIdentity.email, groups: record.userIdentity.groups, iat: now, exp: now + expiresIn, }, secret); return { access_token: accessToken, token_type: 'bearer', expires_in: expiresIn, }; } async exchangeRefreshToken(_client, _refreshToken, _scopes, _resource) { throw new errors_js_1.ServerError('Refresh tokens not supported'); } /** * Handle the Dex OIDC callback after user authenticates. * * Receives the redirect from Dex with ?code=DEX_CODE&state=sessionId|originalState. * Exchanges the Dex code for an ID token, extracts user identity, * creates a dot-ai authorization code, and redirects to the MCP client. */ async handleCallback(req, res) { const dexCode = extractQueryParam(req.query.code); const encodedState = extractQueryParam(req.query.state); const error = extractQueryParam(req.query.error); if (error) { const sessionId = encodedState?.split(STATE_SEPARATOR)[0]; const pending = sessionId ? this.pendingRequests.get(sessionId) : undefined; if (pending) { this.pendingRequests.delete(sessionId); const errUrl = new URL(pending.redirectUri); errUrl.searchParams.set('error', 'access_denied'); errUrl.searchParams.set('error_description', extractQueryParam(req.query.error_description) ?? 'Authentication failed'); if (pending.state) errUrl.searchParams.set('state', pending.state); res.redirect(302, errUrl.toString()); } else { res.status(400).send('Authentication failed and no pending session found'); } return; } if (!dexCode || !encodedState) { res.status(400).send('Missing code or state parameter'); return; } const separatorIndex = encodedState.indexOf(STATE_SEPARATOR); if (separatorIndex === -1) { res.status(400).send('Invalid state parameter'); return; } const sessionId = encodedState.slice(0, separatorIndex); const originalState = encodedState.slice(separatorIndex + 1); const pending = this.pendingRequests.get(sessionId); if (!pending) { res.status(400).send('No pending auth request for this session (expired or invalid)'); return; } if (Date.now() - pending.createdAt > PENDING_REQUEST_TTL_MS) { this.pendingRequests.delete(sessionId); res.status(400).send('Auth request expired'); return; } this.pendingRequests.delete(sessionId); if (!this.dexConfig) { res.status(500).send('Dex not configured'); return; } try { const callbackUrl = `${this.dotAiExternalUrl}/callback`; const { idToken } = await (0, dex_client_1.exchangeDexCode)(this.dexConfig, dexCode, callbackUrl); const claims = (0, dex_client_1.parseIdToken)(idToken); const userIdentity = { userId: claims.sub, email: claims.email, groups: claims.groups ?? [], source: 'oauth', }; const dotAiCode = (0, node_crypto_1.randomBytes)(32).toString('hex'); const authCode = { code: dotAiCode, clientId: pending.clientId, redirectUri: pending.redirectUri, codeChallenge: pending.codeChallenge, codeChallengeMethod: 'S256', userIdentity, createdAt: Date.now(), expiresAt: Date.now() + AUTH_CODE_TTL_MS, }; this.authCodes.set(dotAiCode, authCode); const redirectUrl = new URL(pending.redirectUri); redirectUrl.searchParams.set('code', dotAiCode); if (originalState) redirectUrl.searchParams.set('state', originalState); res.redirect(302, redirectUrl.toString()); } catch { const errUrl = new URL(pending.redirectUri); errUrl.searchParams.set('error', 'server_error'); errUrl.searchParams.set('error_description', 'Failed to exchange code with identity provider'); if (originalState) errUrl.searchParams.set('state', originalState); res.redirect(302, errUrl.toString()); } } /** * Verify an access token (dual-mode: JWT + legacy token). * * 1. If no auth configured → anonymous access (backward compatible) * 2. Try JWT verification → returns AuthInfo with identity in `extra` * 3. Fall back to legacy DOT_AI_AUTH_TOKEN → returns AuthInfo without identity * 4. Throw InvalidTokenError on failure */ async verifyAccessToken(token) { const legacyToken = process.env.DOT_AI_AUTH_TOKEN; const jwtSecretEnv = process.env.DOT_AI_JWT_SECRET; // No auth configured → allow all (backward compatible) if (!legacyToken && !jwtSecretEnv) { return { token, clientId: 'anonymous', scopes: [], expiresAt: Math.floor(Date.now() / 1000) + 3600, }; } // Mode 1: JWT verification const secret = jwtSecretEnv || (0, jwt_1.getJwtSecret)(); const claims = (0, jwt_1.verifyJwt)(token, secret); if (claims) { return { token, clientId: claims.sub, scopes: [], expiresAt: claims.exp, extra: { identity: { userId: claims.sub, email: claims.email, groups: claims.groups ?? [], source: 'oauth', }, }, }; } // Mode 2: Legacy DOT_AI_AUTH_TOKEN comparison if (legacyToken) { const configuredBuffer = Buffer.from(legacyToken, 'utf8'); const providedBuffer = Buffer.from(token, 'utf8'); let isMatch = false; if (configuredBuffer.length === providedBuffer.length) { isMatch = (0, node_crypto_1.timingSafeEqual)(configuredBuffer, providedBuffer); } else { // Dummy comparison to maintain constant time (0, node_crypto_1.timingSafeEqual)(configuredBuffer, configuredBuffer); } if (isMatch) { return { token, clientId: 'legacy', scopes: [], expiresAt: Math.floor(Date.now() / 1000) + 3600, }; } } throw new errors_js_1.InvalidTokenError('Invalid authentication token.'); } } exports.DotAIOAuthProvider = DotAIOAuthProvider;