mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Add Protected Resource Metadata (RFC 9728) and WWW-Authenticate headers
The missing piece for dynamic client registration: Claude Code needs /.well-known/oauth-protected-resource to discover the authorization server after receiving a 401. Without this, the OAuth discovery chain never starts and sessions can't be established. Changes: - Add GET /.well-known/oauth-protected-resource endpoint (RFC 9728) - Add WWW-Authenticate header with resource_metadata URL to all 401s - Update flow documentation in oauth.ts header comment https://claude.ai/code/session_01S8iM5fK53kYa54tydpgsNq
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user