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:
Claude
2026-03-28 21:56:13 +00:00
parent 36f7276275
commit dc9b1cb3e1
2 changed files with 38 additions and 4 deletions
@@ -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);