@@ -49,6 +49,18 @@ import { parseConfig, validateConfig } from '../utils/core/cli-config.js';
4949import { configureOAuthStrategy , getRefreshTokenFunction , OAuthUser } from '../utils/core/oauth-strategy.js' ;
5050import { getJWTValidator , isJWT } from '../utils/core/jwt-validator.js' ;
5151import { 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' ;
5264import { isMCPError , formatErrorForUser } from '../utils/core/error-handler.js' ;
5365import { 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