@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
JavaScript
"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;