@@ -113,6 +113,10 @@ type ExtractAPIKeyConfig struct {
113
113
// a user is authenticated to prevent additional CLI invocations.
114
114
PostAuthAdditionalHeadersFunc func (a rbac.Subject , header http.Header )
115
115
116
+ // AccessURL is the configured access URL for this Coder deployment.
117
+ // Used for generating OAuth2 resource metadata URLs in WWW-Authenticate headers.
118
+ AccessURL * url.URL
119
+
116
120
// Logger is used for logging middleware operations.
117
121
Logger slog.Logger
118
122
}
@@ -214,29 +218,9 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
214
218
return nil , nil , false
215
219
}
216
220
217
- // Add WWW-Authenticate header for 401/403 responses (RFC 6750)
221
+ // Add WWW-Authenticate header for 401/403 responses (RFC 6750 + RFC 9728 )
218
222
if code == http .StatusUnauthorized || code == http .StatusForbidden {
219
- var wwwAuth string
220
-
221
- switch code {
222
- case http .StatusUnauthorized :
223
- // Map 401 to invalid_token with specific error descriptions
224
- switch {
225
- case strings .Contains (response .Message , "expired" ) || strings .Contains (response .Detail , "expired" ):
226
- wwwAuth = `Bearer realm="coder", error="invalid_token", error_description="The access token has expired"`
227
- case strings .Contains (response .Message , "audience" ) || strings .Contains (response .Message , "mismatch" ):
228
- wwwAuth = `Bearer realm="coder", error="invalid_token", error_description="The access token audience does not match this resource"`
229
- default :
230
- wwwAuth = `Bearer realm="coder", error="invalid_token", error_description="The access token is invalid"`
231
- }
232
- case http .StatusForbidden :
233
- // Map 403 to insufficient_scope per RFC 6750
234
- wwwAuth = `Bearer realm="coder", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token"`
235
- default :
236
- wwwAuth = `Bearer realm="coder"`
237
- }
238
-
239
- rw .Header ().Set ("WWW-Authenticate" , wwwAuth )
223
+ rw .Header ().Set ("WWW-Authenticate" , buildWWWAuthenticateHeader (cfg .AccessURL , r , code , response ))
240
224
}
241
225
242
226
httpapi .Write (ctx , rw , code , response )
@@ -272,7 +256,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
272
256
273
257
// Validate OAuth2 provider app token audience (RFC 8707) if applicable
274
258
if key .LoginType == database .LoginTypeOAuth2ProviderApp {
275
- if err := validateOAuth2ProviderAppTokenAudience (ctx , cfg .DB , * key , r ); err != nil {
259
+ if err := validateOAuth2ProviderAppTokenAudience (ctx , cfg .DB , * key , cfg . AccessURL , r ); err != nil {
276
260
// Log the detailed error for debugging but don't expose it to the client
277
261
cfg .Logger .Debug (ctx , "oauth2 token audience validation failed" , slog .Error (err ))
278
262
return optionalWrite (http .StatusForbidden , codersdk.Response {
@@ -489,7 +473,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
489
473
490
474
// validateOAuth2ProviderAppTokenAudience validates that an OAuth2 provider app token
491
475
// is being used with the correct audience/resource server (RFC 8707).
492
- func validateOAuth2ProviderAppTokenAudience (ctx context.Context , db database.Store , key database.APIKey , r * http.Request ) error {
476
+ func validateOAuth2ProviderAppTokenAudience (ctx context.Context , db database.Store , key database.APIKey , accessURL * url. URL , r * http.Request ) error {
493
477
// Get the OAuth2 provider app token to check its audience
494
478
//nolint:gocritic // System needs to access token for audience validation
495
479
token , err := db .GetOAuth2ProviderAppTokenByAPIKeyID (dbauthz .AsSystemRestricted (ctx ), key .ID )
@@ -502,8 +486,8 @@ func validateOAuth2ProviderAppTokenAudience(ctx context.Context, db database.Sto
502
486
return nil
503
487
}
504
488
505
- // Extract the expected audience from the request
506
- expectedAudience := extractExpectedAudience (r )
489
+ // Extract the expected audience from the access URL
490
+ expectedAudience := extractExpectedAudience (accessURL , r )
507
491
508
492
// Normalize both audience values for RFC 3986 compliant comparison
509
493
normalizedTokenAudience := normalizeAudienceURI (token .Audience .String )
@@ -624,18 +608,59 @@ func normalizePathSegments(path string) string {
624
608
625
609
// Test export functions for testing package access
626
610
611
+ // buildWWWAuthenticateHeader constructs RFC 6750 + RFC 9728 compliant WWW-Authenticate header
612
+ func buildWWWAuthenticateHeader (accessURL * url.URL , r * http.Request , code int , response codersdk.Response ) string {
613
+ // Use the configured access URL for resource metadata
614
+ if accessURL == nil {
615
+ scheme := "https"
616
+ if r .TLS == nil {
617
+ scheme = "http"
618
+ }
619
+
620
+ // Use the Host header to construct the canonical audience URI
621
+ accessURL = & url.URL {
622
+ Scheme : scheme ,
623
+ Host : r .Host ,
624
+ }
625
+ }
626
+
627
+ resourceMetadata := accessURL .JoinPath ("/.well-known/oauth-protected-resource" ).String ()
628
+
629
+ switch code {
630
+ case http .StatusUnauthorized :
631
+ switch {
632
+ case strings .Contains (response .Message , "expired" ) || strings .Contains (response .Detail , "expired" ):
633
+ return fmt .Sprintf (`Bearer realm="coder", error="invalid_token", error_description="The access token has expired", resource_metadata=%q` , resourceMetadata )
634
+ case strings .Contains (response .Message , "audience" ) || strings .Contains (response .Message , "mismatch" ):
635
+ return fmt .Sprintf (`Bearer realm="coder", error="invalid_token", error_description="The access token audience does not match this resource", resource_metadata=%q` , resourceMetadata )
636
+ default :
637
+ return fmt .Sprintf (`Bearer realm="coder", error="invalid_token", error_description="The access token is invalid", resource_metadata=%q` , resourceMetadata )
638
+ }
639
+ case http .StatusForbidden :
640
+ return fmt .Sprintf (`Bearer realm="coder", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token", resource_metadata=%q` , resourceMetadata )
641
+ default :
642
+ return fmt .Sprintf (`Bearer realm="coder", resource_metadata=%q` , resourceMetadata )
643
+ }
644
+ }
645
+
627
646
// extractExpectedAudience determines the expected audience for the current request.
628
647
// This should match the resource parameter used during authorization.
629
- func extractExpectedAudience (r * http.Request ) string {
648
+ func extractExpectedAudience (accessURL * url. URL , r * http.Request ) string {
630
649
// For MCP compliance, the audience should be the canonical URI of the resource server
631
650
// This typically matches the access URL of the Coder deployment
632
- scheme := "https"
633
- if r .TLS == nil {
634
- scheme = "http"
635
- }
651
+ var audience string
652
+
653
+ if accessURL != nil {
654
+ audience = accessURL .String ()
655
+ } else {
656
+ scheme := "https"
657
+ if r .TLS == nil {
658
+ scheme = "http"
659
+ }
636
660
637
- // Use the Host header to construct the canonical audience URI
638
- audience := fmt .Sprintf ("%s://%s" , scheme , r .Host )
661
+ // Use the Host header to construct the canonical audience URI
662
+ audience = fmt .Sprintf ("%s://%s" , scheme , r .Host )
663
+ }
639
664
640
665
// Normalize the URI according to RFC 3986 for consistent comparison
641
666
return normalizeAudienceURI (audience )
0 commit comments