diff --git a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts index bd9f4516..3b7f4d54 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts @@ -12,6 +12,16 @@ import { extractTokenClaims, isTokenExpired } from './auth.js'; import { setCurrentToken } from './client.js'; const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net'; + +/** + * Derives the public base URL for the WWW-Authenticate header. + */ +function getPublicBaseUrl(req: Request): string { + if (process.env.MCP_PUBLIC_URL) return process.env.MCP_PUBLIC_URL; + const proto = (req.headers['x-forwarded-proto'] as string) ?? req.protocol; + const host = (req.headers['x-forwarded-host'] as string) ?? req.headers.host; + return `${proto}://${host}`; +} const RATE_LIMIT = parseInt(process.env.MCP_RATE_LIMIT_PER_MINUTE ?? '60', 10); const ACCESS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -100,8 +110,12 @@ function isRateLimited(upn: string): boolean { // --- Express middleware --- export function accessGuard(req: Request, res: Response, next: NextFunction): void { + const baseUrl = getPublicBaseUrl(req); + const resourceMetadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`; + const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { + res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}"`); res.status(401).json({ error: 'Missing or invalid Authorization header' }); return; } @@ -109,11 +123,13 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo const token = authHeader.slice(7); const claims = extractTokenClaims(token); if (!claims || !claims.upn) { + res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`); res.status(401).json({ error: 'Invalid token: missing required claims' }); return; } if (isTokenExpired(claims)) { + res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`); res.status(401).json({ error: 'Token expired' }); return; } diff --git a/src/McpServer/autopilot-monitor-mcp/src/oauth.ts b/src/McpServer/autopilot-monitor-mcp/src/oauth.ts index c74382ef..80bc56e4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/oauth.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/oauth.ts @@ -6,10 +6,15 @@ * endpoints, which redirect to/from Entra ID. * * Flow: - * 1. Claude Code → GET /oauth/authorize → 302 to Entra ID /authorize - * 2. User authenticates at Entra ID - * 3. Entra ID → GET /oauth/callback → 302 back to Claude Code with code - * 4. Claude Code → POST /oauth/token → proxied to Entra ID /token + * 1. Claude Code → POST /mcp → 401 (with WWW-Authenticate header) + * 2. Claude Code → GET /.well-known/oauth-protected-resource → discovers auth server (RFC 9728) + * 3. Claude Code → GET /.well-known/oauth-authorization-server → discovers endpoints (RFC 8414) + * 4. Claude Code → POST /oauth/register → dynamic client registration (RFC 7591) + * 5. Claude Code → GET /oauth/authorize → 302 to Entra ID /authorize + * 6. User authenticates at Entra ID + * 7. Entra ID → GET /oauth/callback → 302 back to Claude Code with code + * 8. Claude Code → POST /oauth/token → proxied to Entra ID /token + * 9. Claude Code → POST /mcp (with Bearer token) → MCP session established */ import { Router } from 'express'; import crypto from 'node:crypto'; @@ -42,6 +47,19 @@ function getPublicBaseUrl(req: import('express').Request): string { export function createOAuthRouter(): Router { const router = Router(); + // --- Protected Resource Metadata (RFC 9728) --- + // This is the FIRST endpoint Claude Code fetches after receiving a 401. + // It tells the client where the authorization server lives. + router.get('/.well-known/oauth-protected-resource', (req, res) => { + const baseUrl = getPublicBaseUrl(req); + res.json({ + resource: baseUrl, + authorization_servers: [baseUrl], + bearer_methods_supported: ['header'], + scopes_supported: ['openid', 'profile', 'offline_access', `api://${CLIENT_ID}/access_as_user`], + }); + }); + // --- OAuth Authorization Server Metadata (RFC 8414) --- router.get('/.well-known/oauth-authorization-server', (req, res) => { const baseUrl = getPublicBaseUrl(req);