import { Request, Response } from 'express'; import { getOAuthServer, handleTokenRequest, handleAuthenticateRequest, } from '../services/oauthServerService.js'; import { findOAuthClientById } from '../models/OAuth.js'; import { loadSettings } from '../config/index.js'; import OAuth2Server from '@node-oauth/oauth2-server'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../config/jwt.js'; const { Request: OAuth2Request, Response: OAuth2Response } = OAuth2Server; type AuthenticatedUser = { username: string; isAdmin?: boolean; }; /** * Attempt to attach a user to the request based on a JWT token present in header, query, or body. */ function resolveUserFromRequest(req: Request): AuthenticatedUser | null { const headerToken = req.header('x-auth-token'); const queryToken = typeof req.query.token === 'string' ? req.query.token : undefined; const bodyToken = req.body && typeof (req.body as Record).token === 'string' ? ((req.body as Record).token as string) : undefined; const token = headerToken || queryToken || bodyToken; if (!token) { return null; } try { const decoded = jwt.verify(token, JWT_SECRET) as { user?: AuthenticatedUser }; if (decoded?.user) { return decoded.user; } } catch (error) { console.warn('Invalid JWT supplied to OAuth authorize endpoint:', error); } return null; } /** * Helper function to escape HTML */ function escapeHtml(unsafe: string): string { return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Helper function to validate query parameters */ function validateQueryParam(value: any, name: string, pattern?: RegExp): string { if (typeof value !== 'string') { throw new Error(`${name} must be a string`); } if (pattern && !pattern.test(value)) { throw new Error(`${name} has invalid format`); } return value; } /** * Generate OAuth authorization consent HTML page with i18n support * (keeps visual style consistent with OAuth callback pages) */ const generateAuthorizeHtml = ( title: string, message: string, options: { clientName: string; scopes: { name: string; description: string }[]; approveLabel: string; denyLabel: string; approveButtonLabel: string; denyButtonLabel: string; formFields: string; }, ): string => { const backgroundColor = '#eef5ff'; const borderColor = '#c3d4ff'; const titleColor = '#23408f'; const approveColor = '#2563eb'; const denyColor = '#ef4444'; return ` ${escapeHtml(title)}

🔐${escapeHtml(title)}

${escapeHtml(message)}

${escapeHtml(options.clientName ? 'Application' : 'Client')} ${escapeHtml(options.clientName || '')}
${escapeHtml('This application will be able to:')}
${options.scopes .map( (s) => `
${escapeHtml(s.name)} ${escapeHtml(s.description)}
`, ) .join('')}
${options.formFields}
${options.formFields}
`; }; /** * GET /oauth/authorize * Display authorization page or handle authorization */ export const getAuthorize = async (req: Request, res: Response): Promise => { try { const oauth = getOAuthServer(); if (!oauth) { res.status(503).json({ error: 'OAuth server not available' }); return; } // Get and validate query parameters const client_id = validateQueryParam(req.query.client_id, 'client_id', /^[a-zA-Z0-9_-]+$/); const redirect_uri = validateQueryParam(req.query.redirect_uri, 'redirect_uri'); const response_type = validateQueryParam(req.query.response_type, 'response_type', /^code$/); const scope = req.query.scope ? validateQueryParam(req.query.scope, 'scope', /^[a-zA-Z0-9_ ]+$/) : undefined; const state = req.query.state ? validateQueryParam(req.query.state, 'state', /^[a-zA-Z0-9_-]+$/) : undefined; const code_challenge = req.query.code_challenge ? validateQueryParam(req.query.code_challenge, 'code_challenge', /^[a-zA-Z0-9_-]+$/) : undefined; const code_challenge_method = req.query.code_challenge_method ? validateQueryParam( req.query.code_challenge_method, 'code_challenge_method', /^(S256|plain)$/, ) : undefined; // Validate required parameters if (!client_id || !redirect_uri || !response_type) { res .status(400) .json({ error: 'invalid_request', error_description: 'Missing required parameters' }); return; } // Verify client const client = await findOAuthClientById(client_id as string); if (!client) { res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' }); return; } // Verify redirect URI if (!client.redirectUris.includes(redirect_uri as string)) { res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' }); return; } // Check if user is authenticated (including via JWT token) let user = (req as any).user; if (!user) { const tokenUser = resolveUserFromRequest(req); if (tokenUser) { (req as any).user = tokenUser; user = tokenUser; } } if (!user) { // Redirect to login page with return URL const returnUrl = encodeURIComponent(req.originalUrl); res.redirect(`/login?returnUrl=${returnUrl}`); return; } const requestToken = typeof req.query.token === 'string' ? req.query.token : ''; const tokenField = requestToken ? `` : ''; // Get translation function from request (set by i18n middleware) const t = (req as any).t || ((key: string) => key); const scopes = (scope || 'read write') .split(' ') .filter((s) => s) .map((s) => ({ name: s, description: getScopeDescription(s) })); const formFields = ` ${code_challenge ? `` : ''} ${code_challenge_method ? `` : ''} ${tokenField} `; // Render authorization consent page with consistent, localized styling res.send( generateAuthorizeHtml( t('oauthServer.authorizeTitle') || 'Authorize Application', t('oauthServer.authorizeSubtitle') || 'Allow this application to access your MCPHub account.', { clientName: client.name, scopes, approveLabel: t('oauthServer.buttons.approve') || 'Allow access', denyLabel: t('oauthServer.buttons.deny') || 'Deny', approveButtonLabel: t('oauthServer.buttons.approveSubtitle') || 'Recommended if you trust this application.', denyButtonLabel: t('oauthServer.buttons.denySubtitle') || 'You can always grant access later.', formFields, }, ), ); } catch (error) { console.error('Authorization error:', error); res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }); } }; /** * POST /oauth/authorize * Handle authorization decision */ export const postAuthorize = async (req: Request, res: Response): Promise => { try { const oauth = getOAuthServer(); if (!oauth) { res.status(503).json({ error: 'OAuth server not available' }); return; } const { allow, redirect_uri, state } = req.body; // If user denied if (allow !== 'true') { const redirectUrl = new URL(redirect_uri); redirectUrl.searchParams.set('error', 'access_denied'); if (state) { redirectUrl.searchParams.set('state', state); } res.redirect(redirectUrl.toString()); return; } // Get authenticated user (JWT support for browser form submissions) let user = (req as any).user; if (!user) { const tokenUser = resolveUserFromRequest(req); if (tokenUser) { (req as any).user = tokenUser; user = tokenUser; } } if (!user) { res.status(401).json({ error: 'unauthorized', error_description: 'User not authenticated' }); return; } // Create OAuth request/response const request = new OAuth2Request(req); const response = new OAuth2Response(res); // Authorize the request const code = await oauth.authorize(request, response, { authenticateHandler: { handle: async () => user, }, }); // Build redirect URL with authorization code const redirectUrl = new URL(redirect_uri); redirectUrl.searchParams.set('code', code.authorizationCode); if (state) { redirectUrl.searchParams.set('state', state); } res.redirect(redirectUrl.toString()); } catch (error) { console.error('Authorization error:', error); // Handle OAuth errors if (error instanceof Error && 'code' in error) { const oauthError = error as any; const redirect_uri = req.body.redirect_uri; const state = req.body.state; if (redirect_uri) { const redirectUrl = new URL(redirect_uri); redirectUrl.searchParams.set('error', oauthError.name || 'server_error'); if (oauthError.message) { redirectUrl.searchParams.set('error_description', oauthError.message); } if (state) { redirectUrl.searchParams.set('state', state); } res.redirect(redirectUrl.toString()); } else { res.status(400).json({ error: oauthError.name || 'server_error', error_description: oauthError.message || 'Internal server error', }); } } else { res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }); } } }; /** * POST /oauth/token * Exchange authorization code for access token */ export const postToken = async (req: Request, res: Response): Promise => { try { const token = await handleTokenRequest(req, res); res.json({ access_token: token.accessToken, token_type: 'Bearer', expires_in: Math.floor(((token.accessTokenExpiresAt?.getTime() || 0) - Date.now()) / 1000), refresh_token: token.refreshToken, scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope, }); } catch (error) { console.error('Token error:', error); if (error instanceof Error && 'code' in error) { const oauthError = error as any; res.status(oauthError.code || 400).json({ error: oauthError.name || 'invalid_request', error_description: oauthError.message || 'Token request failed', }); } else { res.status(400).json({ error: 'invalid_request', error_description: 'Token request failed', }); } } }; /** * GET /oauth/userinfo * Get user info from access token (OpenID Connect compatible) */ export const getUserInfo = async (req: Request, res: Response): Promise => { try { const token = await handleAuthenticateRequest(req, res); res.json({ sub: token.user.username, username: token.user.username, // Add more user info as needed }); } catch (error) { console.error('UserInfo error:', error); res.status(401).json({ error: 'invalid_token', error_description: 'Invalid or expired access token', }); } }; /** * GET /.well-known/oauth-authorization-server * OAuth 2.0 Authorization Server Metadata (RFC 8414) */ export const getMetadata = async (req: Request, res: Response): Promise => { try { const settings = loadSettings(); const oauthConfig = settings.systemConfig?.oauthServer; if (!oauthConfig || !oauthConfig.enabled) { res.status(404).json({ error: 'OAuth server not configured' }); return; } const baseUrl = settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`; const allowedScopes = oauthConfig.allowedScopes || ['read', 'write']; const metadata: any = { issuer: baseUrl, authorization_endpoint: `${baseUrl}/oauth/authorize`, token_endpoint: `${baseUrl}/oauth/token`, userinfo_endpoint: `${baseUrl}/oauth/userinfo`, scopes_supported: allowedScopes, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], token_endpoint_auth_methods_supported: oauthConfig.requireClientSecret !== false ? ['client_secret_basic', 'client_secret_post', 'none'] : ['none'], code_challenge_methods_supported: ['S256', 'plain'], }; // Add dynamic registration endpoint if enabled if (oauthConfig.dynamicRegistration?.enabled) { metadata.registration_endpoint = `${baseUrl}/oauth/register`; } res.json(metadata); } catch (error) { console.error('Metadata error:', error); res.status(500).json({ error: 'server_error' }); } }; /** * GET /.well-known/oauth-protected-resource * OAuth 2.0 Protected Resource Metadata (RFC 9728) * Provides information about authorization servers that protect this resource */ export const getProtectedResourceMetadata = async (req: Request, res: Response): Promise => { try { const settings = loadSettings(); const oauthConfig = settings.systemConfig?.oauthServer; if (!oauthConfig || !oauthConfig.enabled) { res.status(404).json({ error: 'OAuth server not configured' }); return; } const baseUrl = settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`; const allowedScopes = oauthConfig.allowedScopes || ['read', 'write']; // Return protected resource metadata according to RFC 9728 res.json({ resource: baseUrl, authorization_servers: [baseUrl], scopes_supported: allowedScopes, bearer_methods_supported: ['header'], }); } catch (error) { console.error('Protected resource metadata error:', error); res.status(500).json({ error: 'server_error' }); } }; /** * Helper function to get scope description */ function getScopeDescription(scope: string): string { const descriptions: Record = { read: 'Read access to your MCP servers and tools', write: 'Execute tools and modify MCP server configurations', admin: 'Administrative access to all resources', }; return descriptions[scope] || 'Access to MCPHub resources'; }