@@ -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 therequest
506
- expectedAudience := extractExpectedAudience (r )
489
+ // Extract the expected audience from theaccess 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="%s"` ,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="%s"` ,resourceMetadata )
636
+ default :
637
+ return fmt .Sprintf (`Bearer realm="coder", error="invalid_token", error_description="The access token is invalid", resource_metadata="%s"` ,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="%s"` ,resourceMetadata )
641
+ default :
642
+ return fmt .Sprintf (`Bearer realm="coder", resource_metadata="%s"` ,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 )