Files
pad/internal/oauth/audience.go
T
xarmian 5c53aae306 feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a) (#1715)
* feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a)

Ruling (i) on TASK-3321: the ChatGPT catalog is served at its own MCP
URL, which is a canonical OAuth resource of its own, and its tokens are
not interchangeable with /mcp's.

- config derives ChatGPTResourceURL from the /mcp resource: its MCP path
  plus /chatgpt, so https://mcp.getpad.dev becomes
  https://mcp.getpad.dev/mcp/chatgpt.
- The authorization server issues tokens for AllowedAudience plus
  AdditionalAudiences. A request must name exactly ONE of them, and every
  requested audience must be the same resource. A request for both, or for
  a foreign one, is refused before a code is issued, and the client's
  hydrated audience must include the one requested. A request naming no
  resource is still bound to /mcp's, the documented default.
- MCPBearerAuth checks the MOUNT's canonical. A route stamps it with
  WithMCPResource (never from the request); an unstamped route is /mcp's.
  A /mcp token is refused at the ChatGPT mount and the reverse.

Nothing is mounted yet (U2b). The cross-refusal test covers both
directions and the no-resource token, and goes red when the middleware
ignores the mount or the strategy allows two resources.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test(oauth): a refreshed token keeps its one resource (TASK-3321 U2a review)

Codex confirmed fosite's refresh flow restores the original granted
audience; this pins it end to end: refreshing a ChatGPT token (with no
resource, or asking for /mcp's) yields a token refused at /mcp and accepted
at its own mount, and the test fails if no refresh succeeded at all.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 02:06:09 -04:00

232 lines
9.7 KiB
Go

package oauth
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/ory/fosite"
)
// RFC 8707 ("Resource Indicators for OAuth 2.0") audience-binding hook
// for pad's authorization server (PLAN-943 TASK-951 sub-PR B).
//
// fosite has no native RFC 8707 support — its built-in
// AudienceMatchingStrategy (DefaultAudienceMatchingStrategy /
// ExactAudienceMatchingStrategy) just checks that requested audiences
// are in a whitelist. PLAN-943 calls for a stricter rule: every token
// MUST be audience-bound to the canonical MCP URL
// (https://mcp.getpad.dev/mcp in production). A request with no
// audience or one that doesn't include the canonical URL fails the
// /authorize and /token endpoints.
//
// Why per-server (not per-client): TASK-951 v1 only serves one
// resource — the MCP transport. Future expansion (admin API, public
// REST, etc.) would either get its own AS or add an allow-list here.
// The single-string config keeps the policy obvious for now.
//
// Naming note: RFC 8707 calls it "resource"; fosite's surface uses
// "audience". They're the same concept (RFC 8707 §3 explicitly calls
// resource indicators "audience hints"). Sub-PR C's HTTP handlers
// translate the wire-form `resource=` param into fosite's
// `audience=` form before invoking the provider, so the flow lines
// up cleanly with this strategy.
// NormalizeAudience returns the canonical comparison form of an
// audience URI: trims a trailing "/" ONLY when the URI's path
// component is the root ("/" by itself), leaving every other
// trailing slash intact.
//
// Why exactly the root case: RFC 3986 §6.2.3 (Scheme-Based
// Normalization) declares that for HTTP-scheme URIs an empty path
// component is equivalent to a single "/". So
// "https://host" ≡ "https://host/" and we must accept both. But
// "https://host/foo" and "https://host/foo/" are NOT equivalent
// — they're distinct resources (one's a "file", the other a
// "directory" namespace). Collapsing those would let a token
// minted for "/foo/" pass an audience check expecting "/foo"
// (and vice versa), which is a real audience-confusion attack
// surface. Codex review #386 round 1 caught the over-broad trim
// that motivated this version.
//
// Why we need this at all: real OAuth clients vary in how they
// reconstruct the resource indicator. Claude Desktop's connector
// parses the URL the user pasted, URL-canonicalizes empty path → "/",
// and emits resource=https://mcp.example/ even when the operator's
// published canonical is https://mcp.example. A naive string compare
// rejects these as distinct audiences and the connector flow dies
// on "Requested audience X is not the canonical audience Y" — even
// though, per the spec, they ARE the same audience.
//
// We don't touch case, default ports, or percent-encoding —
// URI normalization can get arbitrarily thorny and each of those
// is its own decision. Root-path trailing slash is the one case
// both unambiguously safe per the spec AND observed in the wild.
//
// Exported so the resource-server-side audience check (in
// internal/server/middleware_mcp_auth.go) and the AS-side
// audienceMatchingStrategy share a single normalization rule. If
// the rule ever drifts between the two sides, tokens that the AS
// minted will start failing at the RS — silent breakage.
func NormalizeAudience(s string) string {
u, err := url.Parse(s)
// Only normalize when we have a parseable URL with a real host
// AND the path is exactly the root "/". Anything else (parse
// failures, hostless strings like "/" alone, paths like "/foo"
// or "/foo/", or URIs carrying a query/fragment) is returned
// byte-exact so we don't change semantics for cases the spec
// doesn't declare equivalent.
if err != nil || u.Host == "" || u.Path != "/" || u.RawQuery != "" || u.Fragment != "" {
return s
}
return strings.TrimSuffix(s, "/")
}
// audienceMatchingStrategy returns a fosite.AudienceMatchingStrategy
// that enforces "the requested audience must be exactly one of the
// configured canonical audiences, and no other audience is permitted."
// Closes over the canonical URLs because fosite expects a function value
// of type AudienceMatchingStrategy on its Config.
//
// Two-sided check:
//
// - If `needle` (the request's requested audience list) is empty,
// fail. RFC 8707 doesn't require `resource` on every request, but
// PLAN-943's TASK-950 + sub-PR C wiring guarantees /authorize and
// /token always carry it. Empty here means a misconfigured
// handler shipped a request without the param — fail loudly.
// - If any element of `needle` isn't equal to canonical (after
// trailing-slash normalization — see NormalizeAudience), fail.
// This rejects multi-audience tokens or audiences pointing
// elsewhere (the cross-server replay defense).
// - Else, succeed.
//
// `haystack` is the per-client allowed audience list set at DCR
// time. Sub-PR C populates it with [canonical] for every newly-
// registered client, which means the haystack-side check is a
// belt-and-suspenders safety net rather than a primary gate. We
// still verify haystack contains canonical — defends against admin
// scripts or fixtures that register a client without setting the
// audience.
//
// Returns errors wrapped via fosite.ErrInvalidRequest so the OAuth
// error response carries the right shape (RFC 6749 §4.1.2.1).
func audienceMatchingStrategy(canonicals []string) fosite.AudienceMatchingStrategy {
return func(haystack []string, needle []string) error {
if len(canonicals) == 0 || canonicals[0] == "" {
// Defensive: a server constructed without an audience is
// a configuration bug, not a per-request failure. Refuse
// the validation so the misconfigured handler errors
// loudly the first time it's hit.
return fosite.ErrServerError.WithHint("OAuth server has no canonical audience configured.")
}
// Needle (request.RequestedAudience) must name exactly ONE of the
// canonical resources (TASK-3321 U2a: the /mcp URL or the ChatGPT
// catalog's). Every element must normalize to that same resource,
// so a token is never valid for both: each mount checks its own
// canonical, and a two-resource token would pass both. Empty
// needle means the client didn't request any resource; per
// PLAN-943 every grant is audience-restricted, so it is refused.
if len(needle) == 0 {
return fosite.ErrInvalidRequest.WithHint(
"resource parameter is required (RFC 8707).",
)
}
target := ""
for _, n := range needle {
nn := NormalizeAudience(n)
match := ""
for _, c := range canonicals {
if NormalizeAudience(c) == nn {
match = NormalizeAudience(c)
break
}
}
if match == "" {
return fosite.ErrInvalidRequest.WithHintf(
"Requested audience %q is not a resource this server issues tokens for.", n,
)
}
if target != "" && target != match {
return fosite.ErrInvalidRequest.WithHint(
"A token is bound to one resource; request one resource at a time.",
)
}
target = match
}
// Haystack (client.Audience) must include the requested canonical
// (after trailing-slash normalization). Without this, a DCR client
// registered before audience-population shipped (sub-PR C work)
// could still drive flows. Reject.
if !audienceListContainsNormalized(haystack, target) {
return fosite.ErrInvalidRequest.WithHintf(
"Client is not authorized for the audience %q.", target,
)
}
return nil
}
}
// audienceListContainsNormalized reports whether haystack contains an
// element that compares equal to needleNorm after trailing-slash
// normalization. Caller is responsible for normalizing the needle
// before calling (the haystack side is normalized inline so the
// caller doesn't have to allocate a copy of the haystack).
func audienceListContainsNormalized(haystack []string, needleNorm string) bool {
for _, x := range haystack {
if NormalizeAudience(x) == needleNorm {
return true
}
}
return false
}
// ValidateAudienceParam is a one-off helper for sub-PR C's HTTP
// handlers: validates the incoming `resource` query param shape
// before fosite's internal validation runs. Returns nil iff the
// caller's resource is acceptable for this server.
//
// Exists separately from audienceMatchingStrategy because the HTTP
// handlers want a single-value check at request entry (parse error,
// scheme check, basic well-formedness) before they construct the
// fosite request. Keeps the fosite call site clean and the early-
// reject 400 response bodies meaningful.
func ValidateAudienceParam(canonical, raw string) error {
if canonical == "" {
return errors.New("oauth: server has no canonical audience configured")
}
if raw == "" {
return fmt.Errorf("oauth: resource parameter is required")
}
// RFC 8707 §2 says the value MUST be an absolute URI. We don't
// fully URI-parse here (fosite does that downstream); a string-
// equality check against the canonical is sufficient and the
// most restrictive policy. Any extension to allow-list multiple
// audiences happens in this function.
if raw != canonical {
return fmt.Errorf("oauth: resource %q does not match canonical %q", raw, canonical)
}
return nil
}
// audienceForNewClient is what sub-PR C's DCR handler will use to
// populate a freshly-registered client's audience list. Centralized
// here so the policy ("every new client gets exactly the canonical
// audience") lives next to the validator that enforces it.
//
// Returns a copy so the caller can mutate without affecting future
// calls.
func audienceForNewClient(canonical string) []string {
if canonical == "" {
return nil
}
return []string{canonical}
}
// Compile-time guard: both helpers consume strings, so a cfg.AllowedAudience
// rename downstream would surface here first.
var _ = strings.TrimSpace