diff --git a/oauthex/audience.go b/oauthex/audience.go new file mode 100644 index 00000000..ad75abfa --- /dev/null +++ b/oauthex/audience.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import "strings" + +// MatchesResource reports whether any of claims matches resource under +// RFC 3986 §6.2.3 scheme-based normalization, narrowed to the empty-path +// case: a URI with an empty path is treated as equivalent to one with a +// path of "/". All other URI components (scheme, host case, port, query, +// fragment) must match exactly — this is intentionally narrower than the +// full §6.2.3 rung, which would also fold scheme/host case and default +// ports. +// +// RFC 9728 §3.3 normatively requires "simple string comparison" per +// RFC 3986 §6.2.1 (byte-equal). Callers that need strict byte-equal +// semantics should compare claims to resource directly: +// +// for _, c := range claims { if c == resource { return true } } +// +// Background: RFC 9728 §3.3 canonicalises the protected-resource +// identifier with a trailing slash, but RFC 8707 resource indicators +// sometimes omit it, and upstream IdPs vary in which form they emit in +// `aud` claims (Google trims, Auth0 retains, claude.ai round-trips +// whichever it received). Strict byte equality therefore fails routinely +// on legitimate setups; this helper is the most common pragmatic relaxation +// while keeping path/scheme/host strict so token confusion across distinct +// resources still fails closed. +// +// Returns false when claims is empty. +func MatchesResource(claims []string, resource string) bool { + expected := strings.TrimRight(resource, "/") + for _, c := range claims { + if c == resource { + return true + } + if strings.TrimRight(c, "/") == expected { + return true + } + } + return false +} diff --git a/oauthex/audience_test.go b/oauthex/audience_test.go new file mode 100644 index 00000000..7d5acfb2 --- /dev/null +++ b/oauthex/audience_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import "testing" + +func TestMatchesResource(t *testing.T) { + tests := []struct { + name string + claims []string + resource string + want bool + }{ + { + name: "exact match", + claims: []string{"https://mcp.example.com/"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "claim has trailing slash, resource does not", + claims: []string{"https://mcp.example.com/"}, + resource: "https://mcp.example.com", + want: true, + }, + { + name: "claim missing trailing slash, resource has one", + claims: []string{"https://mcp.example.com"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "multiple claims, one matches", + claims: []string{"https://other.example.com/", "https://mcp.example.com"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "no claims", + claims: nil, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "claim does not match resource", + claims: []string{"https://attacker.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "path mismatch is not tolerated", + claims: []string{"https://mcp.example.com/v2"}, + resource: "https://mcp.example.com", + want: false, + }, + { + name: "scheme mismatch is not tolerated", + claims: []string{"http://mcp.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + // The following document the intentional boundaries: §6.2.3 also + // permits scheme/host case folding and default-port elision, but + // MatchesResource deliberately does NOT, so two distinct + // registered resources cannot collide via these normalizations. + { + name: "host case difference is not tolerated", + claims: []string{"https://MCP.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "default-port elision is not tolerated", + claims: []string{"https://mcp.example.com:443/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "query string difference is not tolerated", + claims: []string{"https://mcp.example.com/?x=y"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "surrounding whitespace is not tolerated (malformed claim)", + claims: []string{" https://mcp.example.com/ "}, + resource: "https://mcp.example.com/", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchesResource(tt.claims, tt.resource) + if got != tt.want { + t.Errorf("MatchesResource(%v, %q) = %v, want %v", tt.claims, tt.resource, got, tt.want) + } + }) + } +}