Skip to content

Commit 6a6ade9

Browse files
BorisTyshkevichclaudeguglielmo-san
authored
oauthex: add MatchesResource helper (RFC 9728/8707 audience comparison) (#970)
## Summary - Add an exported \`oauthex.MatchesResource(claims []string, resource string) bool\` helper that compares an \`aud\` claim slice against an expected resource URL with the canonical-form (trailing slash) tolerance required by RFC 9728 and RFC 8707. - Tests cover the common shape combinations (canonical/trimmed in either direction, surrounding whitespace, multiple claims) plus negative cases ensuring scheme + path mismatches still fail. ## Motivation RFC 9728 §3.3 canonicalises the protected-resource identifier with a trailing slash, but RFC 8707 resource indicators frequently omit it. Upstream IdPs vary in which form they emit in the \`aud\` claim (Google trims, Auth0 retains, some clients round-trip whatever the IdP sent). Strict byte equality therefore fails routinely on legitimate setups. Resource servers writing this comparison by hand all converge on the same trim-trailing-slash + whitespace logic; exporting a canonical helper saves every consumer from re-deriving it. ## Test plan - [x] \`go test ./oauthex/... -run MatchesResource\` passes. - [x] Positive cases: exact match, trailing slash on either side, surrounding whitespace, multi-claim slice with one match. - [x] Negative cases: empty slice, no match, path mismatch, scheme mismatch (HTTPS vs HTTP must still fail). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
1 parent 79e1a13 commit 6a6ade9

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

oauthex/audience.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package oauthex
6+
7+
import "strings"
8+
9+
// MatchesResource reports whether any of claims matches resource under
10+
// RFC 3986 §6.2.3 scheme-based normalization, narrowed to the empty-path
11+
// case: a URI with an empty path is treated as equivalent to one with a
12+
// path of "/". All other URI components (scheme, host case, port, query,
13+
// fragment) must match exactly — this is intentionally narrower than the
14+
// full §6.2.3 rung, which would also fold scheme/host case and default
15+
// ports.
16+
//
17+
// RFC 9728 §3.3 normatively requires "simple string comparison" per
18+
// RFC 3986 §6.2.1 (byte-equal). Callers that need strict byte-equal
19+
// semantics should compare claims to resource directly:
20+
//
21+
// for _, c := range claims { if c == resource { return true } }
22+
//
23+
// Background: RFC 9728 §3.3 canonicalises the protected-resource
24+
// identifier with a trailing slash, but RFC 8707 resource indicators
25+
// sometimes omit it, and upstream IdPs vary in which form they emit in
26+
// `aud` claims (Google trims, Auth0 retains, claude.ai round-trips
27+
// whichever it received). Strict byte equality therefore fails routinely
28+
// on legitimate setups; this helper is the most common pragmatic relaxation
29+
// while keeping path/scheme/host strict so token confusion across distinct
30+
// resources still fails closed.
31+
//
32+
// Returns false when claims is empty.
33+
func MatchesResource(claims []string, resource string) bool {
34+
expected := strings.TrimSuffix(resource, "/")
35+
for _, c := range claims {
36+
if c == resource {
37+
return true
38+
}
39+
if strings.TrimSuffix(c, "/") == expected {
40+
return true
41+
}
42+
}
43+
return false
44+
}

oauthex/audience_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package oauthex
6+
7+
import "testing"
8+
9+
func TestMatchesResource(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
claims []string
13+
resource string
14+
want bool
15+
}{
16+
{
17+
name: "exact match",
18+
claims: []string{"https://mcp.example.com/"},
19+
resource: "https://mcp.example.com/",
20+
want: true,
21+
},
22+
{
23+
name: "claim has trailing slash, resource does not",
24+
claims: []string{"https://mcp.example.com/"},
25+
resource: "https://mcp.example.com",
26+
want: true,
27+
},
28+
{
29+
name: "claim missing trailing slash, resource has one",
30+
claims: []string{"https://mcp.example.com"},
31+
resource: "https://mcp.example.com/",
32+
want: true,
33+
},
34+
{
35+
name: "multiple claims, one matches",
36+
claims: []string{"https://other.example.com/", "https://mcp.example.com"},
37+
resource: "https://mcp.example.com/",
38+
want: true,
39+
},
40+
{
41+
name: "no claims",
42+
claims: nil,
43+
resource: "https://mcp.example.com/",
44+
want: false,
45+
},
46+
{
47+
name: "claim does not match resource",
48+
claims: []string{"https://attacker.example.com/"},
49+
resource: "https://mcp.example.com/",
50+
want: false,
51+
},
52+
{
53+
name: "path mismatch is not tolerated",
54+
claims: []string{"https://mcp.example.com/v2"},
55+
resource: "https://mcp.example.com",
56+
want: false,
57+
},
58+
{
59+
name: "scheme mismatch is not tolerated",
60+
claims: []string{"http://mcp.example.com/"},
61+
resource: "https://mcp.example.com/",
62+
want: false,
63+
},
64+
// The following document the intentional boundaries: §6.2.3 also
65+
// permits scheme/host case folding and default-port elision, but
66+
// MatchesResource deliberately does NOT, so two distinct
67+
// registered resources cannot collide via these normalizations.
68+
{
69+
name: "host case difference is not tolerated",
70+
claims: []string{"https://MCP.example.com/"},
71+
resource: "https://mcp.example.com/",
72+
want: false,
73+
},
74+
{
75+
name: "default-port elision is not tolerated",
76+
claims: []string{"https://mcp.example.com:443/"},
77+
resource: "https://mcp.example.com/",
78+
want: false,
79+
},
80+
{
81+
name: "query string difference is not tolerated",
82+
claims: []string{"https://mcp.example.com/?x=y"},
83+
resource: "https://mcp.example.com/",
84+
want: false,
85+
},
86+
{
87+
name: "surrounding whitespace is not tolerated (malformed claim)",
88+
claims: []string{" https://mcp.example.com/ "},
89+
resource: "https://mcp.example.com/",
90+
want: false,
91+
},
92+
}
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
got := MatchesResource(tt.claims, tt.resource)
96+
if got != tt.want {
97+
t.Errorf("MatchesResource(%v, %q) = %v, want %v", tt.claims, tt.resource, got, tt.want)
98+
}
99+
})
100+
}
101+
}

0 commit comments

Comments
 (0)