@@ -15,51 +15,97 @@ export interface AuthResult {
1515 expiresAt : number ;
1616}
1717
18- // Bearer auth supporting either introspection (opaque tokens) or JWT validation (JWKS)
19- export async function verifyAccessToken ( token : string , config : AuthConfig , expectedResource ?: string ) : Promise < AuthResult > {
20- if ( ! config . DISABLE_AUTH ) return { clientId : "dev" , scopes : [ ] , expiresAt : Math . floor ( Date . now ( ) / 1000 ) + 3600 } ;
18+ async function verifyJwtToken (
19+ token : string ,
20+ config : AuthConfig ,
21+ expectedResource ?: string
22+ ) : Promise < AuthResult > {
23+ if ( ! config . JWT_JWKS_URL ) {
24+ throw new Error ( "JWT_JWKS_URL or JWT_ISSUER required for JWT mode" ) ;
25+ }
2126
22- if ( config . AUTH_TOKEN_MODE === "jwt" ) {
23- if ( ! config . JWT_JWKS_URL ) throw new Error ( "JWT_JWKS_URL or JWT_ISSUER required for JWT mode" ) ;
24- const JWKS = createRemoteJWKSet ( new URL ( config . JWT_JWKS_URL ) ) ;
25- const { payload } = await jwtVerify ( token , JWKS , {
26- issuer : config . JWT_ISSUER ,
27- audience : config . JWT_AUDIENCE ,
28- algorithms : [ "RS256" ]
29- } ) ;
30-
31- const scopes = parseScopes ( payload ) ;
32- const exp = typeof payload . exp === "number" ? payload . exp : Math . floor ( Date . now ( ) / 1000 ) + 3600 ;
33- if ( expectedResource && payload . aud && typeof payload . aud === "string" && payload . aud !== expectedResource ) {
34- console . warn ( `[auth] audience mismatch: token.aud="${ payload . aud } " expected="${ expectedResource } "` ) ;
35- throw new Error ( "Token not intended for this resource" ) ;
36- }
37- return {
38- clientId : ( payload . client_id as string ) || ( payload . sub as string ) || "unknown" ,
39- scopes,
40- expiresAt : exp ,
41- } ;
42- } else {
43- // Use external introspection (opaque tokens)
44- const res = await fetch ( config . OAUTH_INTROSPECT_URL , {
45- method : "POST" ,
46- headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
47- body : new URLSearchParams ( { token } ) . toString ( )
48- } ) ;
49- if ( ! res . ok ) throw new Error ( `Introspection failed: ${ res . status } ` ) ;
50- const data = await res . json ( ) ;
51- if ( ! data . active ) throw new Error ( "Token inactive" ) ;
52-
53- if ( expectedResource && data . aud && data . aud !== expectedResource ) {
54- console . warn ( `[auth] audience mismatch: token.aud="${ data . aud } " expected="${ expectedResource } "` ) ;
27+ const JWKS = createRemoteJWKSet ( new URL ( config . JWT_JWKS_URL ) ) ;
28+ const { payload } = await jwtVerify ( token , JWKS , {
29+ issuer : config . JWT_ISSUER ,
30+ audience : config . JWT_AUDIENCE ,
31+ algorithms : [ "RS256" ]
32+ } ) ;
33+
34+ const scopes = parseScopes ( payload ) ;
35+
36+ if ( typeof payload . exp !== "number" ) {
37+ throw new Error ( "Token missing required exp claim" ) ;
38+ }
39+ const expiresAt = payload . exp ;
40+
41+ if ( expectedResource && payload . aud ) {
42+ const audiences = Array . isArray ( payload . aud ) ? payload . aud : [ payload . aud ] ;
43+ if ( ! audiences . includes ( expectedResource ) ) {
44+ console . warn ( `[auth] audience mismatch: token.aud="${ JSON . stringify ( payload . aud ) } " expected="${ expectedResource } "` ) ;
5545 throw new Error ( "Token not intended for this resource" ) ;
5646 }
57- return {
58- clientId : data . client_id ?? "unknown" ,
59- scopes : ( data . scope ? String ( data . scope ) . split ( " " ) : [ ] ) as string [ ] ,
60- expiresAt : typeof data . exp === "number" ? data . exp : Math . floor ( Date . now ( ) / 1000 ) + 3600
47+ }
48+
49+ return {
50+ clientId : ( payload . client_id as string ) || ( payload . sub as string ) || "unknown" ,
51+ scopes,
52+ expiresAt
53+ } ;
54+ }
55+
56+ async function verifyTokenViaIntrospection (
57+ token : string ,
58+ config : AuthConfig ,
59+ expectedResource ?: string
60+ ) : Promise < AuthResult > {
61+ const response = await fetch ( config . OAUTH_INTROSPECT_URL , {
62+ method : "POST" ,
63+ headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
64+ body : new URLSearchParams ( { token } ) . toString ( )
65+ } ) ;
66+
67+ if ( ! response . ok ) {
68+ throw new Error ( `Introspection failed: ${ response . status } ` ) ;
69+ }
70+
71+ const data = await response . json ( ) ;
72+ if ( ! data . active ) {
73+ throw new Error ( "Token inactive" ) ;
74+ }
75+
76+ if ( expectedResource && data . aud && data . aud !== expectedResource ) {
77+ console . warn ( `[auth] audience mismatch: token.aud="${ data . aud } " expected="${ expectedResource } "` ) ;
78+ throw new Error ( "Token not intended for this resource" ) ;
79+ }
80+
81+ return {
82+ clientId : data . client_id ?? "unknown" ,
83+ scopes : ( data . scope ? String ( data . scope ) . split ( " " ) : [ ] ) as string [ ] ,
84+ expiresAt : typeof data . exp === "number"
85+ ? data . exp
86+ : Math . floor ( Date . now ( ) / 1000 ) + 3600
87+ } ;
88+ }
89+
90+ // Bearer auth supporting either introspection (opaque tokens) or JWT validation (JWKS)
91+ export async function verifyAccessToken (
92+ token : string ,
93+ config : AuthConfig ,
94+ expectedResource ?: string
95+ ) : Promise < AuthResult > {
96+ if ( config . DISABLE_AUTH ) {
97+ return {
98+ clientId : "dev" ,
99+ scopes : [ ] ,
100+ expiresAt : Math . floor ( Date . now ( ) / 1000 ) + 3600
61101 } ;
62102 }
103+
104+ if ( config . AUTH_TOKEN_MODE === "jwt" ) {
105+ return await verifyJwtToken ( token , config , expectedResource ) ;
106+ }
107+
108+ return await verifyTokenViaIntrospection ( token , config , expectedResource ) ;
63109}
64110
65111export function parseScopes ( payload : JWTPayload ) : string [ ] {
@@ -68,6 +114,38 @@ export function parseScopes(payload: JWTPayload): string[] {
68114 return String ( raw ) . split ( " " ) . filter ( Boolean ) ;
69115}
70116
117+ function extractBearerToken ( authorizationHeader : string | undefined ) : string | null {
118+ if ( ! authorizationHeader ) {
119+ return null ;
120+ }
121+
122+ const [ type , token ] = authorizationHeader . split ( " " ) ;
123+ if ( ! token || type . toLowerCase ( ) !== "bearer" ) {
124+ return null ;
125+ }
126+
127+ return token ;
128+ }
129+
130+ function isTokenExpired ( authInfo : AuthResult ) : boolean {
131+ const currentTime = Math . floor ( Date . now ( ) / 1000 ) ;
132+ return authInfo . expiresAt < currentTime ;
133+ }
134+
135+ function sendUnauthorizedResponse (
136+ res : any ,
137+ resourceMetadataUrl : string ,
138+ error : string ,
139+ message ?: string
140+ ) : void {
141+ res . set ( 'WWW-Authenticate' , `Bearer resource_metadata="${ resourceMetadataUrl } ", scope="mcp:tools"` ) ;
142+ const responseBody : any = { error } ;
143+ if ( message ) {
144+ responseBody . message = message ;
145+ }
146+ res . status ( 401 ) . json ( responseBody ) ;
147+ }
148+
71149export function createAuthMiddleware ( config : AuthConfig ) {
72150 return async ( req : any , res : any , next : any ) => {
73151 if ( config . DISABLE_AUTH ) return next ( ) ;
@@ -78,30 +156,26 @@ export function createAuthMiddleware(config: AuthConfig) {
78156 const resourceMetadataUrl = `${ baseUrl } /.well-known/oauth-protected-resource` ;
79157
80158 try {
81- const auth = req . headers . authorization ;
82- if ( ! auth ) {
83- console . warn ( `[auth] missing authorization for ${ req . method } ${ req . originalUrl } ` ) ;
84- res . set ( 'WWW-Authenticate' , `Bearer resource_metadata="${ resourceMetadataUrl } ", scope="mcp:tools"` ) ;
85- return res . status ( 401 ) . json ( { error : "missing_authorization" } ) ;
86- }
87- const [ type , token ] = auth . split ( " " ) ;
88- if ( ! token || type . toLowerCase ( ) !== "bearer" ) {
89- console . warn ( `[auth] invalid authorization header for ${ req . method } ${ req . originalUrl } : ${ auth } ` ) ;
90- res . set ( 'WWW-Authenticate' , `Bearer resource_metadata="${ resourceMetadataUrl } ", scope="mcp:tools"` ) ;
91- return res . status ( 401 ) . json ( { error : "invalid_authorization" } ) ;
159+ const token = extractBearerToken ( req . headers . authorization ) ;
160+ if ( ! token ) {
161+ console . warn ( `[auth] missing or invalid authorization for ${ req . method } ${ req . originalUrl } ` ) ;
162+ sendUnauthorizedResponse ( res , resourceMetadataUrl , "missing_authorization" ) ;
163+ return ;
92164 }
93- const info = await verifyAccessToken ( token , config , expectedResource ) ;
94- if ( info . expiresAt < Math . floor ( Date . now ( ) / 1000 ) ) {
95- console . warn ( `[auth] token expired for client ${ info . clientId } ` ) ;
96- res . set ( 'WWW-Authenticate' , `Bearer resource_metadata="${ resourceMetadataUrl } ", scope="mcp:tools"` ) ;
97- return res . status ( 401 ) . json ( { error : "token_expired" } ) ;
165+
166+ const authInfo = await verifyAccessToken ( token , config , expectedResource ) ;
167+
168+ if ( isTokenExpired ( authInfo ) ) {
169+ console . warn ( `[auth] token expired for client ${ authInfo . clientId } ` ) ;
170+ sendUnauthorizedResponse ( res , resourceMetadataUrl , "token_expired" ) ;
171+ return ;
98172 }
99- req . auth = info ;
173+
174+ req . auth = authInfo ;
100175 next ( ) ;
101- } catch ( e : any ) {
102- console . warn ( `[auth] invalid_token on ${ req . method } ${ req . originalUrl } : ${ e ?. message || e } ` ) ;
103- res . set ( 'WWW-Authenticate' , `Bearer resource_metadata="${ resourceMetadataUrl } ", scope="mcp:tools"` ) ;
104- return res . status ( 401 ) . json ( { error : "invalid_token" , message : String ( e ?. message ?? e ) } ) ;
176+ } catch ( error : any ) {
177+ console . warn ( `[auth] invalid_token on ${ req . method } ${ req . originalUrl } : ${ error ?. message || error } ` ) ;
178+ sendUnauthorizedResponse ( res , resourceMetadataUrl , "invalid_token" , String ( error ?. message ?? error ) ) ;
105179 }
106180 } ;
107181}
0 commit comments