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>
This commit is contained in:
BorisTyshkevich 2026-06-16 15:20:09 +02:00 • committed by GitHub
commit 6a6ade920b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 145 additions and 0 deletions

44
oauthex/audience.go Normal file
View file

@ -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.TrimSuffix(resource, "/")
for _, c := range claims {
if c == resource {
return true
}
if strings.TrimSuffix(c, "/") == expected {
return true
}
}
return false
}

101
oauthex/audience_test.go Normal file
View file

@ -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)
}
})
}
}