From ca4f4ac41c65dc2291c5fc48cd1d8c066c8aa75c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:11:49 -0400 Subject: [PATCH] fix(oauth): parse resource_metadata per RFC 7235 auth-param rules --- internal/oauth/discovery.go | 75 +++++++++++++++---- .../oauth/resource_metadata_param_test.go | 25 +++++++ 2 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 internal/oauth/resource_metadata_param_test.go diff --git a/internal/oauth/discovery.go b/internal/oauth/discovery.go index a678aee41..ff26b5f47 100644 --- a/internal/oauth/discovery.go +++ b/internal/oauth/discovery.go @@ -278,25 +278,72 @@ func discoverAuthServerURL(env Env, serverURL string, timeout time.Duration, fet // ExtractResourceMetadataURL parses WWW-Authenticate header to extract resource_metadata URL // Format: Bearer error="invalid_request", resource_metadata="https://..." +// +// Parameters are scanned per RFC 7235 section 2.1: names are case-insensitive, +// whitespace may surround "=", and quoted values of other parameters are +// skipped rather than searched, so only a real resource_metadata parameter +// with a quoted-string value is returned. func ExtractResourceMetadataURL(wwwAuthHeader string) string { - // Look for resource_metadata parameter - if !strings.Contains(wwwAuthHeader, "resource_metadata") { - return "" + h := wwwAuthHeader + n := len(h) + skipWS := func(i int) int { + for i < n && (h[i] == ' ' || h[i] == '\t') { + i++ + } + return i } - - // Split on resource_metadata=" to find the URL - parts := strings.Split(wwwAuthHeader, "resource_metadata=\"") - if len(parts) < 2 { - return "" + i := 0 + for i < n { + for i < n && (h[i] == ' ' || h[i] == '\t' || h[i] == ',') { + i++ + } + start := i + for i < n && isAuthTokenChar(h[i]) { + i++ + } + name := h[start:i] + if name == "" { + i++ + continue + } + j := skipWS(i) + if j >= n || h[j] != '=' { + continue + } + j = skipWS(j + 1) + if j < n && h[j] == '"' { + var b strings.Builder + k := j + 1 + for ; k < n && h[k] != '"'; k++ { + if h[k] == '\\' && k+1 < n { + k++ + } + b.WriteByte(h[k]) + } + if k >= n { + return "" + } + if strings.EqualFold(name, "resource_metadata") { + return b.String() + } + i = k + 1 + continue + } + for j < n && h[j] != ',' && h[j] != ' ' && h[j] != '\t' { + j++ + } + i = j } + return "" +} - // Find the closing quote - endIdx := strings.Index(parts[1], "\"") - if endIdx == -1 { - return "" +// isAuthTokenChar reports whether c is an RFC 7230 tchar. +func isAuthTokenChar(c byte) bool { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + return true } - - return parts[1][:endIdx] + return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0 } // DiscoverProtectedResourceMetadata fetches RFC 9728 Protected Resource Metadata diff --git a/internal/oauth/resource_metadata_param_test.go b/internal/oauth/resource_metadata_param_test.go new file mode 100644 index 000000000..26c9090dd --- /dev/null +++ b/internal/oauth/resource_metadata_param_test.go @@ -0,0 +1,25 @@ +package oauth + +import "testing" + +func TestExtractResourceMetadataURL_RFC7235Params(t *testing.T) { + const want = "https://api.example.com/.well-known/oauth-protected-resource" + tests := []struct { + name string + header string + want string + }{ + {"case-insensitive name", `Bearer Resource_Metadata="` + want + `"`, want}, + {"whitespace around equals", `Bearer error="invalid_token", resource_metadata = "` + want + `"`, want}, + {"other param with similar suffix", `Bearer x_resource_metadata="https://wrong.example", resource_metadata="` + want + `"`, want}, + {"mention inside another quoted value", `Bearer error_description="see resource_metadata=\"https://wrong.example\"", resource_metadata="` + want + `"`, want}, + {"only a lookalike param", `Bearer x_resource_metadata="https://wrong.example"`, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ExtractResourceMetadataURL(tt.header); got != tt.want { + t.Errorf("ExtractResourceMetadataURL(%q) = %q, want %q", tt.header, got, tt.want) + } + }) + } +}