Skip to content

Commit c96bcdd

Browse files
unifiedjdclaude
andauthored
Add OAuth 2.1 Authorization Server endpoints for MCP clients (#2)
* Fix OAuth strategy registration and trust proxy for reverse-proxy deploys Register Passport strategies under their provider name so passport.authenticate('azure'|'google'|'okta'|'auth0'|'custom') resolves correctly; previously the OAuth2Strategy registered under its default name 'oauth2', causing "Unknown authentication strategy" 500s at /auth/login. Also honor the EXPRESS_TRUST_PROXY env var so Azure Container Apps (and other reverse proxies) don't trigger express-rate-limit X-Forwarded-For validation errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add OAuth 2.1 Authorization Server endpoints for MCP clients Implements the AS surface that OAuth 2.1 MCP clients (Claude's remote connector, etc.) need to authenticate end-users through the configured IdP (Microsoft Entra) and receive a server-minted JWT. New routes: GET /.well-known/oauth-protected-resource (RFC 9728) GET /.well-known/oauth-authorization-server (RFC 8414) POST /oauth/register (RFC 7591 DCR, public client) GET /oauth/authorize (auth code + PKCE, S256) POST /oauth/token (code -> JWT exchange) Flow: client probes /mcp -> 401 advertises /.well-known/... -> discovers AS metadata -> registers via /oauth/register -> redirects user to /oauth/authorize (we stash the pending authz in the browser session) -> federates to the IdP via the existing passport strategy -> /auth/callback detects pending authz, mints an auth code, redirects to the client's redirect_uri -> client posts code + PKCE verifier to /oauth/token -> server verifies PKCE and mints a JWT via jwtValidator.createToken(). The JWT carries the user's identity and a baseline scope set that covers all mapped tools. A TODO(option-3) marks the seam where this fixed scope will be replaced by a mapping derived from the authenticated user's Entra group memberships in a follow-up change. LM's own RBAC still applies via the shared LM_BEARER_TOKEN. Storage for registered clients, pending authorizations, and authorization codes is in-memory with TTL cleanup; a single-replica deployment is fine but multi-replica needs Redis. CSRF middleware already skipped /.well-known/*; extended to /oauth/*. Added express.urlencoded() for the /oauth/token form body per RFC 6749. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db65f1c commit c96bcdd

2 files changed

Lines changed: 542 additions & 1 deletion

File tree

src/servers/index.ts

Lines changed: 307 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ import { parseConfig, validateConfig } from '../utils/core/cli-config.js';
4949
import { configureOAuthStrategy, getRefreshTokenFunction, OAuthUser } from '../utils/core/oauth-strategy.js';
5050
import { getJWTValidator, isJWT } from '../utils/core/jwt-validator.js';
5151
import { ScopeManager } from '../utils/core/scope-manager.js';
52+
import {
53+
registerClient,
54+
getClient,
55+
issueAuthorizationCode,
56+
consumeAuthorizationCode,
57+
verifyPkceS256,
58+
buildProtectedResourceMetadata,
59+
buildAuthorizationServerMetadata,
60+
startOAuthCleanup,
61+
PENDING_AUTH_TTL_MS,
62+
PendingAuthorization,
63+
} from '../utils/core/oauth-as.js';
5264
import { isMCPError, formatErrorForUser } from '../utils/core/error-handler.js';
5365
import { createServer } from './server.js';
5466

@@ -462,6 +474,8 @@ if (TRANSPORT === 'stdio') {
462474
463475
app.use(express.json());
464476
app.use(express.text({ type: 'application/json' }));
477+
// application/x-www-form-urlencoded is required for OAuth 2.1 /oauth/token
478+
app.use(express.urlencoded({ extended: true }));
465479
466480
// Cookie parser middleware (required for CSRF protection)
467481
app.use(cookieParser());
@@ -529,8 +543,10 @@ if (TRANSPORT === 'stdio') {
529543
// Uses cookie-based CSRF tokens for better security
530544
// Exclude MCP endpoints from CSRF protection as they use Bearer token authentication
531545
app.use((req: Request, res: Response, next: NextFunction) => {
532-
// Skip CSRF for MCP endpoints and API routes that use Bearer tokens
546+
// Skip CSRF for MCP endpoints and API routes that use Bearer tokens,
547+
// OAuth AS endpoints (Claude and other OAuth 2.1 clients), and discovery
533548
if (req.path.startsWith('/mcp') || req.path.startsWith('/.well-known/') ||
549+
req.path.startsWith('/oauth/') ||
534550
req.path === '/healthz' || req.path === '/health' || req.path === '/') {
535551
return next();
536552
}
@@ -997,6 +1013,52 @@ if (TRANSPORT === 'stdio') {
9971013
}
9981014
}
9991015

1016+
// If this callback is part of an OAuth 2.1 authorization code flow
1017+
// initiated by an MCP client (e.g. Claude's remote connector) via
1018+
// /oauth/authorize, mint an authorization code and redirect back to
1019+
// the client's registered redirect_uri. Otherwise, fall back to the
1020+
// legacy browser flow that lands the user on '/'.
1021+
const pending = (req.session as any)?.pendingAuth as PendingAuthorization | undefined;
1022+
if (pending && req.user) {
1023+
// Enforce TTL
1024+
if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) {
1025+
delete (req.session as any).pendingAuth;
1026+
log('warn', 'Pending OAuth authorization expired', { clientId: pending.clientId });
1027+
const expiredUrl = new URL(pending.redirectUri);
1028+
expiredUrl.searchParams.set('error', 'access_denied');
1029+
expiredUrl.searchParams.set('error_description', 'authorization request expired');
1030+
if (pending.state) expiredUrl.searchParams.set('state', pending.state);
1031+
return res.redirect(expiredUrl.toString());
1032+
}
1033+
1034+
const oauthUser = req.user as OAuthUser;
1035+
const code = issueAuthorizationCode({
1036+
clientId: pending.clientId,
1037+
redirectUri: pending.redirectUri,
1038+
codeChallenge: pending.codeChallenge,
1039+
codeChallengeMethod: pending.codeChallengeMethod,
1040+
scope: DEFAULT_USER_SCOPE,
1041+
user: {
1042+
id: oauthUser.id,
1043+
username: oauthUser.username,
1044+
displayName: oauthUser.displayName,
1045+
email: oauthUser.email,
1046+
},
1047+
});
1048+
1049+
delete (req.session as any).pendingAuth;
1050+
1051+
const redirectUrl = new URL(pending.redirectUri);
1052+
redirectUrl.searchParams.set('code', code);
1053+
if (pending.state) redirectUrl.searchParams.set('state', pending.state);
1054+
1055+
log('info', 'Issued authorization code to MCP client', {
1056+
clientId: pending.clientId,
1057+
user: oauthUser.username || oauthUser.id,
1058+
});
1059+
return res.redirect(redirectUrl.toString());
1060+
}
1061+
10001062
res.redirect('/');
10011063
},
10021064
);
@@ -1014,6 +1076,244 @@ if (TRANSPORT === 'stdio') {
10141076
csrfToken: res.locals._csrf || (req as any).csrfToken?.() || null,
10151077
});
10161078
});
1079+
1080+
// ================================================================
1081+
// OAuth 2.1 Authorization Server endpoints (for MCP clients)
1082+
// ================================================================
1083+
// These let OAuth 2.1 clients like Claude's remote MCP connector
1084+
// authenticate end-users through the server's configured IdP
1085+
// (Microsoft Entra, etc.) and receive a server-minted JWT they
1086+
// can present as a Bearer token on /mcp requests.
1087+
//
1088+
// Flow:
1089+
// 1. Client hits /mcp → 401 with WWW-Authenticate advertising
1090+
// /.well-known/oauth-protected-resource
1091+
// 2. Client discovers AS via /.well-known/oauth-authorization-server
1092+
// 3. Client registers via POST /oauth/register (RFC 7591 DCR)
1093+
// 4. Client redirects user to /oauth/authorize with PKCE challenge
1094+
// 5. Server federates to Microsoft; on return, /auth/callback
1095+
// mints an authorization code and redirects to the client
1096+
// 6. Client exchanges code at POST /oauth/token → JWT access token
1097+
1098+
const SCOPES_SUPPORTED = [
1099+
'mcp:tools',
1100+
'lm:read', 'lm:write', 'lm:admin',
1101+
'lm:alerts:read', 'lm:alerts:write',
1102+
'lm:devices:read', 'lm:devices:write',
1103+
'lm:dashboards:read', 'lm:dashboards:write',
1104+
'lm:reports:read',
1105+
'lm:users:manage',
1106+
];
1107+
1108+
// TODO(option-3): replace this fixed scope with a mapping derived
1109+
// from the authenticated user's Entra group memberships. For now
1110+
// every Entra-authenticated user gets the full scope set so that
1111+
// every LM tool is reachable; LM's own RBAC still applies via the
1112+
// shared LM_BEARER_TOKEN.
1113+
const DEFAULT_USER_SCOPE = [
1114+
'mcp:tools',
1115+
'lm:admin',
1116+
'lm:alerts:write',
1117+
'lm:devices:write',
1118+
'lm:dashboards:write',
1119+
'lm:users:manage',
1120+
].join(' ');
1121+
1122+
// --- Discovery: RFC 9728 Protected Resource Metadata ---
1123+
app.get('/.well-known/oauth-protected-resource', (req: Request, res: Response) => {
1124+
res.json(buildProtectedResourceMetadata(BASE_URL, SCOPES_SUPPORTED));
1125+
});
1126+
1127+
// --- Discovery: RFC 8414 Authorization Server Metadata ---
1128+
app.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
1129+
res.json(buildAuthorizationServerMetadata(BASE_URL, SCOPES_SUPPORTED));
1130+
});
1131+
1132+
// --- RFC 7591 Dynamic Client Registration ---
1133+
app.post('/oauth/register', authLimiter, (req: Request, res: Response) => {
1134+
const body = req.body || {};
1135+
const redirectUris: unknown = body.redirect_uris;
1136+
const clientName: unknown = body.client_name;
1137+
const scope: unknown = body.scope;
1138+
1139+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
1140+
return res.status(400).json({
1141+
error: 'invalid_redirect_uri',
1142+
error_description: 'redirect_uris (non-empty array) is required',
1143+
});
1144+
}
1145+
1146+
for (const uri of redirectUris) {
1147+
if (typeof uri !== 'string') {
1148+
return res.status(400).json({
1149+
error: 'invalid_redirect_uri',
1150+
error_description: 'redirect_uris must be strings',
1151+
});
1152+
}
1153+
try {
1154+
new URL(uri);
1155+
} catch {
1156+
return res.status(400).json({
1157+
error: 'invalid_redirect_uri',
1158+
error_description: `malformed redirect_uri: ${uri}`,
1159+
});
1160+
}
1161+
}
1162+
1163+
const reg = registerClient({
1164+
redirectUris: redirectUris as string[],
1165+
clientName: typeof clientName === 'string' ? clientName : undefined,
1166+
scope: typeof scope === 'string' ? scope : undefined,
1167+
});
1168+
1169+
log('info', 'OAuth client registered', {
1170+
clientId: reg.clientId,
1171+
clientName: reg.clientName,
1172+
redirectUris: reg.redirectUris,
1173+
});
1174+
1175+
res.status(201).json({
1176+
client_id: reg.clientId,
1177+
client_id_issued_at: reg.clientIdIssuedAt,
1178+
redirect_uris: reg.redirectUris,
1179+
client_name: reg.clientName,
1180+
token_endpoint_auth_method: reg.tokenEndpointAuthMethod,
1181+
grant_types: reg.grantTypes,
1182+
response_types: reg.responseTypes,
1183+
scope: reg.scope,
1184+
});
1185+
});
1186+
1187+
// --- Authorization endpoint (OAuth 2.1 + PKCE) ---
1188+
app.get('/oauth/authorize', loginLimiter, (req: Request, res: Response, next: NextFunction) => {
1189+
const {
1190+
response_type,
1191+
client_id,
1192+
redirect_uri,
1193+
code_challenge,
1194+
code_challenge_method,
1195+
scope,
1196+
state,
1197+
} = req.query as Record<string, string | undefined>;
1198+
1199+
// Pre-client validation → direct 400 (no redirect)
1200+
if (!client_id) {
1201+
return res.status(400).json({ error: 'invalid_request', error_description: 'client_id required' });
1202+
}
1203+
const client = getClient(client_id);
1204+
if (!client) {
1205+
return res.status(400).json({ error: 'invalid_client', error_description: 'unknown client_id' });
1206+
}
1207+
if (!redirect_uri || !client.redirectUris.includes(redirect_uri)) {
1208+
return res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri not registered' });
1209+
}
1210+
1211+
// Post-client validation → redirect to client with error
1212+
const redirectWithError = (err: string, description: string) => {
1213+
const url = new URL(redirect_uri);
1214+
url.searchParams.set('error', err);
1215+
url.searchParams.set('error_description', description);
1216+
if (state) url.searchParams.set('state', state);
1217+
return res.redirect(url.toString());
1218+
};
1219+
1220+
if (response_type !== 'code') {
1221+
return redirectWithError('unsupported_response_type', 'only response_type=code is supported');
1222+
}
1223+
if (!code_challenge) {
1224+
return redirectWithError('invalid_request', 'code_challenge is required (PKCE)');
1225+
}
1226+
if (code_challenge_method !== 'S256') {
1227+
return redirectWithError('invalid_request', 'code_challenge_method must be S256');
1228+
}
1229+
1230+
// Stash the pending authorization in the user's browser session so we
1231+
// can pick it back up after Microsoft redirects to /auth/callback.
1232+
(req.session as any).pendingAuth = {
1233+
clientId: client_id,
1234+
redirectUri: redirect_uri,
1235+
codeChallenge: code_challenge,
1236+
codeChallengeMethod: 'S256',
1237+
scope: scope || 'mcp:tools',
1238+
state,
1239+
createdAt: Date.now(),
1240+
} satisfies PendingAuthorization;
1241+
1242+
log('info', 'Starting MCP-client OAuth authorization', {
1243+
clientId: client_id,
1244+
redirectUri: redirect_uri,
1245+
scope,
1246+
});
1247+
1248+
// Kick off Microsoft (or configured IdP) auth — same as /auth/login.
1249+
const idpScope = oauthConfig!.scope ? oauthConfig!.scope.split(',') : undefined;
1250+
return passport.authenticate(oauthConfig!.provider, { scope: idpScope })(req, res, next);
1251+
});
1252+
1253+
// --- Token endpoint (authorization_code grant + PKCE) ---
1254+
app.post('/oauth/token', authLimiter, (req: Request, res: Response) => {
1255+
// express-urlencoded and express.json both populate req.body, so both
1256+
// application/x-www-form-urlencoded (per RFC 6749) and application/json
1257+
// callers are accepted.
1258+
const body = req.body || {};
1259+
const grant_type = body.grant_type;
1260+
const code = body.code;
1261+
const redirect_uri = body.redirect_uri;
1262+
const client_id = body.client_id;
1263+
const code_verifier = body.code_verifier;
1264+
1265+
res.setHeader('Cache-Control', 'no-store');
1266+
res.setHeader('Pragma', 'no-cache');
1267+
1268+
if (grant_type !== 'authorization_code') {
1269+
return res.status(400).json({ error: 'unsupported_grant_type' });
1270+
}
1271+
if (!code || !redirect_uri || !client_id || !code_verifier) {
1272+
return res.status(400).json({
1273+
error: 'invalid_request',
1274+
error_description: 'code, redirect_uri, client_id and code_verifier are required',
1275+
});
1276+
}
1277+
1278+
const record = consumeAuthorizationCode(code);
1279+
if (!record) {
1280+
return res.status(400).json({ error: 'invalid_grant', error_description: 'code invalid or expired' });
1281+
}
1282+
if (record.clientId !== client_id) {
1283+
return res.status(400).json({ error: 'invalid_grant', error_description: 'client_id mismatch' });
1284+
}
1285+
if (record.redirectUri !== redirect_uri) {
1286+
return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
1287+
}
1288+
if (!verifyPkceS256(code_verifier, record.codeChallenge)) {
1289+
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
1290+
}
1291+
1292+
const accessToken = jwtValidator.createToken({
1293+
sub: record.user.id,
1294+
scope: record.scope,
1295+
user: {
1296+
id: record.user.id,
1297+
username: record.user.username || record.user.email || record.user.id,
1298+
displayName: record.user.displayName,
1299+
email: record.user.email,
1300+
},
1301+
client_id: record.clientId,
1302+
});
1303+
1304+
log('info', 'Issued access token to MCP client', {
1305+
clientId: record.clientId,
1306+
user: record.user.username || record.user.id,
1307+
scope: record.scope,
1308+
});
1309+
1310+
res.json({
1311+
access_token: accessToken,
1312+
token_type: 'Bearer',
1313+
expires_in: 3600,
1314+
scope: record.scope,
1315+
});
1316+
});
10171317
}
10181318

10191319
// Health check endpoints (with rate limiting)
@@ -1547,6 +1847,12 @@ if (TRANSPORT === 'stdio') {
15471847
log('info', 'Token refresh system initialized');
15481848
}
15491849

1850+
// Start OAuth AS cleanup (expired auth codes / stale client registrations)
1851+
if (oauthConfig) {
1852+
startOAuthCleanup();
1853+
log('info', 'OAuth authorization server cleanup started');
1854+
}
1855+
15501856
// Start server
15511857
const useTLS = !!(appConfig.tlsCertFile && appConfig.tlsKeyFile);
15521858

0 commit comments

Comments
 (0)