Skip to content

feat(oauth): extract Protected Resource Metadata URL from WWW-Authenticate - #808

Closed
Dennisadira wants to merge 5 commits into
mark3labs:mainfrom
Dennisadira:feat/oauth-prm-www-authenticate
Closed

feat(oauth): extract Protected Resource Metadata URL from WWW-Authenticate#808
Dennisadira wants to merge 5 commits into
mark3labs:mainfrom
Dennisadira:feat/oauth-prm-www-authenticate

Conversation

@Dennisadira

@Dennisadira Dennisadira commented Apr 18, 2026

Copy link
Copy Markdown

Description

Adds RFC 9728 §5.1 support for extracting the Protected Resource Metadata URL from the WWW-Authenticate header on 401 responses.

This is the remaining piece of the OAuth RFC-compliance work tracked in #697 after #761 and #775 landed the path-preservation fixes, and closes the specific ask in #688.

Why

Per RFC 9728 §5.1, a resource server that requires OAuth advertises its PRM URL to clients via:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp", error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource/tenant"

Clients that honour this header can discover the PRM endpoint in deployments where origin-based /.well-known/oauth-protected-resource construction can't reach it — for example, MCP servers sitting behind a shared gateway with path-based routing (Smithery, Cloudflare, Kubernetes Ingress by path, etc.). Without this support, DCR fails with 404, the authorization URL ends up with an empty client_id, and users have to set AuthServerMetadataURL manually.

What changes

  • client/transport/oauth.go
    • New ProtectedResourceMetadataURL field on OAuthHandler, guarded by the existing mutex.
    • Exported setter/getter: SetProtectedResourceMetadataURL / ProtectedResourceMetadataURL.
    • Exported HandleUnauthorizedResponse(resp *http.Response) — extracts resource_metadata from WWW-Authenticate and stores it. Safe on nil responses / missing headers.
    • New extractResourceMetadataURL helper — a small RFC 9110 §5.6.2 token + quoted-string parser that matches parameter names case-insensitively (RFC 9110 §11.2) and handles both quoted-string and token value forms.
    • getServerMetadata now prefers the stored PRM URL over the origin-based /.well-known/oauth-protected-resource construction when one has been advertised.
  • client/transport/sse.go, client/transport/streamable_http.go — call HandleUnauthorizedResponse(resp) at all five 401 sites (SSE Start / SendRequest / SendNotification, StreamableHTTP SendRequest / SendNotification) so the PRM URL is captured wherever it's advertised.

Backward compatibility

Fully backward compatible. When the server doesn't send a resource_metadata parameter, HandleUnauthorizedResponse is a no-op and metadata discovery falls through to the existing buildWellKnownURL path. No public-API breakage. Test TestSSE_WithOAuth_NoWWWAuthenticate_LeavesPRMUnset and its streamable-HTTP twin pin this behaviour.

Tests

  • TestExtractResourceMetadataURL — 12 subtests covering: empty header, quoted value, unquoted token, case-insensitive parameter name, escaped quotes in quoted value, tabs/whitespace handling, multiple parameters in a Bearer challenge, word-substring rejection, missing =, truncated quoted value, first-occurrence preference.
  • TestOAuthHandler_ProtectedResourceMetadataURL_SetGet — setter/getter round-trip.
  • TestOAuthHandler_HandleUnauthorizedResponse — 4 subtests: nil response, no header, header without the parameter, header with the parameter.
  • TestOAuthHandler_GetServerMetadata_UsesAdvertisedPRMURL — end-to-end discovery: asserts the advertised URL is fetched instead of the well-known one.
  • TestSSE_WithOAuth_ExtractsPRMFromWWWAuthenticate + TestStreamableHTTP_WithOAuth_ExtractsPRMFromWWWAuthenticate — transport-level: 401 + header → PRM URL stored on handler.
  • TestSSE_WithOAuth_NoWWWAuthenticate_LeavesPRMUnset + streamable twin — backward-compat regression guard.

Validated locally with:

go test ./... -race -count=1           # all green
golangci-lint run ./client/transport/... # 0 issues

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • MCP spec compatibility implementation

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the documentation accordingly (godoc comments on all new exported symbols)

MCP Spec Compliance

Additional Information

The existing PR #718 attempted to cover this plus the path-preservation work in a single patch; this PR is scoped strictly to the WWW-Authenticate parsing that remained unmerged after #761 and #775. Happy to rebase or adjust scope on request.

Summary by CodeRabbit

  • New Features
    • OAuth now extracts and stores protected-resource metadata advertised in 401 WWW-Authenticate headers and prefers a validated advertised PRM URL during protected-resource discovery.
  • Behavior Change
    • SSE and HTTP transports now invoke OAuth handling on 401 responses so advertised metadata is captured before returning unauthorized errors.
  • Tests
    • Added comprehensive tests covering PRM parsing, validation, discovery behavior, and transport handling on 401 responses.

…icate

Per RFC 9728 §5.1, a resource server indicates its Protected Resource
Metadata URL to clients by returning a 401 with a Bearer challenge
whose resource_metadata parameter carries the URL. The transport layer
now parses that header on every 401 and stores the advertised URL on
the OAuthHandler; metadata discovery then prefers it over the
origin-based /.well-known/oauth-protected-resource construction.

This is the remaining piece of the OAuth RFC compliance work tracked
in mark3labs#697 after mark3labs#761 and mark3labs#775 landed the path-preservation fixes, and
closes the specific ask in mark3labs#688. Backward compatible: when the server
does not send a resource_metadata parameter, the handler falls back
to the existing well-known URL construction.

- OAuthHandler: add ProtectedResourceMetadataURL field and
  SetProtectedResourceMetadataURL / ProtectedResourceMetadataURL /
  HandleUnauthorizedResponse methods
- oauth.go: extractResourceMetadataURL helper (RFC 9110 §5.6.2 token +
  quoted-string parser, case-insensitive parameter names)
- sse.go, streamable_http.go: call HandleUnauthorizedResponse at all
  five 401 sites so the PRM URL is captured wherever it's advertised

Closes mark3labs#688
@mark-iii-labs-huly

Copy link
Copy Markdown

Connected to Huly®: MCP_G-374

@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 617b335a-f02b-4e0d-8ede-153c6c9f74db

📥 Commits

Reviewing files that changed from the base of the PR and between 25ec92d and 29b1589.

📒 Files selected for processing (2)
  • client/transport/oauth.go
  • client/transport/oauth_test.go
✅ Files skipped from review due to trivial changes (1)
  • client/transport/oauth_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/transport/oauth.go

Walkthrough

OAuthHandler now captures RFC 9728 Protected Resource Metadata (PRM) URLs from WWW-Authenticate on 401 responses, exposes setter/getter and HandleUnauthorizedResponse, and prefers/validates the stored PRM during server metadata discovery. SSE and StreamableHTTP call the handler on 401. Tests added.

Changes

Cohort / File(s) Summary
OAuth Handler Core Implementation
client/transport/oauth.go
Added mutex-protected protectedResourceMetadataURL with SetProtectedResourceMetadataURL/ProtectedResourceMetadataURL, HandleUnauthorizedResponse (parses WWW-Authenticate Bearer challenges for resource_metadata values), parsing helpers (extractResourceMetadataURLs, parseAuthParamValue, isAuthTokenChar), PRM validators (validateAdvertisedPRMURL, resourceIdentifiersEqual), and updated getServerMetadata to prefer/validate advertised PRM (RFC 9728) and reject non-matching PRM responses.
OAuth Handler Tests
client/transport/oauth_test.go
Added extensive unit tests for WWW-Authenticate resource_metadata parsing (quoted/unquoted/escaped/token forms, multiple headers/params), handler getter/setter behavior, HandleUnauthorizedResponse validation/selection, resourceIdentifiersEqual rules, and PRM-driven metadata discovery acceptance/rejection.
SSE Transport Integration
client/transport/sse.go
Invoke oauthHandler.HandleUnauthorizedResponse(resp) on 401 in Start, SendRequest, and SendNotification before returning authorization errors; no other control-flow changes.
SSE Transport Tests
client/transport/sse_oauth_test.go
Added tests asserting PRM extraction from WWW-Authenticate on 401 and that PRM remains unset when header absent.
StreamableHTTP Transport Integration
client/transport/streamable_http.go
Invoke oauthHandler.HandleUnauthorizedResponse(resp) on 401 in SendRequest and SendNotification; preserves prior behavior when handler is nil.
StreamableHTTP Transport Tests
client/transport/streamable_http_oauth_test.go
Added tests asserting PRM extraction and empty-state handling for 401 responses with and without WWW-Authenticate.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels

type: enhancement, area: mcp spec

Suggested reviewers

  • ezynda3
  • pottekkat
  • rwjblue-glean
  • giridhar-murthy-glean
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: extracting Protected Resource Metadata URLs from WWW-Authenticate headers in OAuth flows.
Description check ✅ Passed The PR description is comprehensive and fully compliant with the template, covering all required sections: description with context, type of change, complete checklist, MCP spec compliance details, and additional information.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
client/transport/oauth_test.go (1)

1759-1832: Parser test coverage is thorough.

Good mix of happy-path (quoted, unquoted, case-insensitive, multi-param), malformed (missing =, truncated quote), and adversarial (substring inside another quoted value, first-occurrence preference) cases.

One optional addition to consider: an assertion that a pathological non-quoted URL value is truncated at the first non-token char (e.g. resource_metadata=https://example.com/prm returns "https"), which is arguably surprising but matches RFC 9110 §5.6.2 tokens. Documenting this via a test would lock in the expected behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/oauth_test.go` around lines 1759 - 1832, Add a test case to
TestExtractResourceMetadataURL that documents the current RFC-9110 token
behavior for unquoted non-token characters: when extractResourceMetadataURL sees
a non-quoted URL like `resource_metadata=https://example.com/prm` it should
return the token up to the first non-token char (e.g. "https"), so add a case
(name something like "non-quoted URL truncated at first non-token char") with
header `Bearer resource_metadata=https://example.com/prm` and want `"https"` to
lock in and document this behavior in the test for the
extractResourceMetadataURL function.
client/transport/oauth.go (2)

422-433: Ordering caveat: metadataOnce may have already fired before any 401 arrives.

getServerMetadata is guarded by sync.Once, so the preference for the advertised PRM URL only takes effect if the first call to getServerMetadata happens after a 401 has populated it. In the common "401 → authorize flow" path this is fine, but if discovery is triggered earlier (e.g. during a proactive refreshToken on startup that fails for reasons other than 401, or via a direct GetAuthorizationURL call), the origin-based PRM URL is cached for the handler's lifetime and a subsequently advertised PRM URL is silently ignored.

No action needed if the intended contract is "PRM URL applies only to the first discovery attempt" — worth a sentence in the GoDoc for HandleUnauthorizedResponse / SetProtectedResourceMetadataURL so callers aren't surprised.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/oauth.go` around lines 422 - 433, The current use of
sync.Once (metadataOnce) in getServerMetadata causes an advertised Protected
Resource Metadata (PRM) URL from HandleUnauthorizedResponse to be ignored if
discovery ran earlier; update the logic so that an advertised PRM URL can
override a previously cached origin-built URL: either (a) stop caching the PRM
URL permanently in getServerMetadata and store only non-advertised defaults
unless an advertised value is later set, or (b) make metadataOnce
resettable/conditional so that SetProtectedResourceMetadataURL /
HandleUnauthorizedResponse can replace the previously cached value and trigger a
fresh metadata fetch; specifically modify getServerMetadata, metadataOnce usage,
and ProtectedResourceMetadataURL()/SetProtectedResourceMetadataURL() to prefer
an advertised URL when present even after an initial discovery, and add a GoDoc
sentence to HandleUnauthorizedResponse / SetProtectedResourceMetadataURL
documenting this override behavior.

565-647: Parser LGTM — one small edge case on truncated \ tail.

The RFC 9110 §5.6.2 token classification and quoted-string handling (with backslash unescape and case-insensitive parameter matching) look correct, and the table-driven tests cover the important forms well.

Minor: in parseAuthParamValue, if the header ends with a lone trailing backslash inside an unterminated quoted string (e.g. resource_metadata="abc\), the \ is written verbatim into the returned value because the guard i+1 < len(s) fails and the byte is then emitted via the fallthrough b.WriteByte(c). Low impact (only affects malformed headers), but dropping the trailing stray \ is probably closer to intent.

♻️ Optional tweak
 		if c == '\\' && i+1 < len(s) {
 			b.WriteByte(s[i+1])
 			i += 2
 			continue
 		}
+		if c == '\\' {
+			// Stray trailing backslash in a truncated quoted string — drop it.
+			return b.String(), i + 1
+		}
 		if c == '"' {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/oauth.go` around lines 565 - 647, In parseAuthParamValue,
when scanning a quoted-string in function parseAuthParamValue, the current logic
emits a lone trailing backslash if the input ends with `\` (e.g.
resource_metadata="abc\"); change the quoted-string loop to treat a backslash
with no following character as an unterminated escape and drop the trailing `\`
instead of writing it: detect when c == '\\' and i+1 >= len(s) and simply
break/return the accumulated b.String() (or advance to end without appending the
backslash), ensuring the returned value omits the stray backslash while
preserving existing behavior for valid escapes and closing quote handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/transport/oauth.go`:
- Around line 346-358: HandleUnauthorizedResponse only checks the first
WWW-Authenticate header via resp.Header.Get which can miss a later Bearer
challenge containing resource_metadata; change the implementation to iterate all
header values using resp.Header.Values("WWW-Authenticate"), call
extractResourceMetadataURL on each value, and call
h.SetProtectedResourceMetadataURL(u) with the first non-empty u returned (no-op
if none found), preserving the nil resp early return and existing helper names
(HandleUnauthorizedResponse, extractResourceMetadataURL,
SetProtectedResourceMetadataURL).
- Around line 346-358: Handle the attacker-controlled WWW-Authenticate PRM URL
and enforce RFC 9728 resource equality: in SetProtectedResourceMetadataURL
validate the parsed URL from extractResourceMetadataURL to ensure scheme ==
"https" and that host (and optionally port) matches h.baseURL's host before
storing it; in getServerMetadata, after decoding protectedResource.Resource,
verify it exactly equals h.baseURL (or its normalized form) and return an error
or ignore the fetched metadata if it does not match, rather than using the
unvalidated value.

---

Nitpick comments:
In `@client/transport/oauth_test.go`:
- Around line 1759-1832: Add a test case to TestExtractResourceMetadataURL that
documents the current RFC-9110 token behavior for unquoted non-token characters:
when extractResourceMetadataURL sees a non-quoted URL like
`resource_metadata=https://example.com/prm` it should return the token up to the
first non-token char (e.g. "https"), so add a case (name something like
"non-quoted URL truncated at first non-token char") with header `Bearer
resource_metadata=https://example.com/prm` and want `"https"` to lock in and
document this behavior in the test for the extractResourceMetadataURL function.

In `@client/transport/oauth.go`:
- Around line 422-433: The current use of sync.Once (metadataOnce) in
getServerMetadata causes an advertised Protected Resource Metadata (PRM) URL
from HandleUnauthorizedResponse to be ignored if discovery ran earlier; update
the logic so that an advertised PRM URL can override a previously cached
origin-built URL: either (a) stop caching the PRM URL permanently in
getServerMetadata and store only non-advertised defaults unless an advertised
value is later set, or (b) make metadataOnce resettable/conditional so that
SetProtectedResourceMetadataURL / HandleUnauthorizedResponse can replace the
previously cached value and trigger a fresh metadata fetch; specifically modify
getServerMetadata, metadataOnce usage, and
ProtectedResourceMetadataURL()/SetProtectedResourceMetadataURL() to prefer an
advertised URL when present even after an initial discovery, and add a GoDoc
sentence to HandleUnauthorizedResponse / SetProtectedResourceMetadataURL
documenting this override behavior.
- Around line 565-647: In parseAuthParamValue, when scanning a quoted-string in
function parseAuthParamValue, the current logic emits a lone trailing backslash
if the input ends with `\` (e.g. resource_metadata="abc\"); change the
quoted-string loop to treat a backslash with no following character as an
unterminated escape and drop the trailing `\` instead of writing it: detect when
c == '\\' and i+1 >= len(s) and simply break/return the accumulated b.String()
(or advance to end without appending the backslash), ensuring the returned value
omits the stray backslash while preserving existing behavior for valid escapes
and closing quote handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3b49171b-6417-4ac0-8fe7-65c657f491bf

📥 Commits

Reviewing files that changed from the base of the PR and between dee98dd and a36c798.

📒 Files selected for processing (6)
  • client/transport/oauth.go
  • client/transport/oauth_test.go
  • client/transport/sse.go
  • client/transport/sse_oauth_test.go
  • client/transport/streamable_http.go
  • client/transport/streamable_http_oauth_test.go

Comment thread client/transport/oauth.go Outdated
- Iterate every WWW-Authenticate header value (Values, not Get) so a
  Bearer challenge carrying resource_metadata is found even when a
  Basic challenge is emitted first (RFC 9110 §11.6.1 allows multiple
  WWW-Authenticate lines).
- Reject advertised PRM URLs whose scheme or host does not match the
  configured base URL. Prevents a compromised resource from
  redirecting clients to an attacker's metadata endpoint.
- Enforce RFC 9728 §3.3/§7.3 resource-identifier equality on metadata
  fetched via advertised PRM URLs: if the response declares a
  `resource` that does not match the base URL, the metadata MUST NOT
  be used. Scoped to the advertised path so existing origin-based
  discovery behaviour is unchanged.
- parseAuthParamValue now drops a lone trailing backslash in a
  truncated quoted-string instead of emitting it verbatim.
- Document the metadataOnce caveat on
  SetProtectedResourceMetadataURL: values set after the first metadata
  discovery are not applied retroactively.

Tests cover multi-header iteration, cross-host/scheme-downgrade/
unparseable rejection, resource-identifier mismatch (rejected) and
match (accepted), and the resource-identifier normalizer.
@Dennisadira

Copy link
Copy Markdown
Author

Addressed the review feedback in d12db0c:

  • HandleUnauthorizedResponse now iterates Header.Values("WWW-Authenticate") so a Bearer challenge is found even when preceded by a Basic one (RFC 9110 §11.6.1).
  • Advertised PRM URLs are validated against the base URL's scheme and host before being stored, rejecting cross-origin / scheme-downgraded / unparseable values.
  • RFC 9728 §3.3 / §7.3 resource-identifier equality is enforced on metadata fetched from an advertised PRM URL. Scoped to the advertised path so the pre-existing origin-based discovery tests stay unchanged.
  • Fixed a trailing-\ edge case in parseAuthParamValue.
  • Documented the metadataOnce caveat on SetProtectedResourceMetadataURL.

go test ./... -race passes and golangci-lint is clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
client/transport/oauth.go (1)

535-551: ⚠️ Potential issue | 🟠 Major

Reject advertised metadata unless resource is present and exactly matches.

Line 544 skips validation when resource is empty, so an advertised PRM response can omit the required resource field and still drive authorization_servers. Also, resourceIdentifiersEqual relaxes equality, but RFC 9728 requires the returned value to be identical before using the metadata. See RFC 9728 §2, §3.3, and §6: https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3

🛡️ Proposed fix
-		if prmFromAdvertisement && protectedResource.Resource != "" &&
-			!resourceIdentifiersEqual(protectedResource.Resource, baseURL) {
+		if prmFromAdvertisement && !resourceIdentifiersEqual(protectedResource.Resource, baseURL) {
 			h.metadataFetchErr = fmt.Errorf(
 				"advertised protected resource metadata declares resource %q which does not match base URL %q",
 				protectedResource.Resource, baseURL,
 			)
 			return
 		}
 func resourceIdentifiersEqual(a, b string) bool {
-	ua, errA := url.Parse(a)
-	ub, errB := url.Parse(b)
-	if errA != nil || errB != nil {
-		return a == b
-	}
-	if !strings.EqualFold(ua.Scheme, ub.Scheme) {
-		return false
-	}
-	if !strings.EqualFold(ua.Host, ub.Host) {
-		return false
-	}
-	if strings.TrimSuffix(ua.Path, "/") != strings.TrimSuffix(ub.Path, "/") {
-		return false
-	}
-	if ua.RawQuery != ub.RawQuery {
-		return false
-	}
-	if ua.Fragment != ub.Fragment {
-		return false
-	}
-	return ua.User.String() == ub.User.String()
+	return a == b
 }
RFC 9728 Section 2 Section 3.3 Section 6 protected resource metadata resource value identical comparison

Also applies to: 716-744

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/oauth.go` around lines 535 - 551, The advertised PRM
metadata is currently accepted when protectedResource.Resource is empty and uses
resourceIdentifiersEqual (a relaxed comparison); change the logic so that when
prmFromAdvertisement is true you reject the metadata unless
protectedResource.Resource is non-empty and exactly equals the expected baseURL
(use strict string equality instead of resourceIdentifiersEqual), setting
h.metadataFetchErr with a clear message (as done currently) and returning; apply
the same strict check wherever resourceIdentifiersEqual is used for advertised
metadata validation (e.g., the other block around lines 716-744) so
authorization_servers cannot be driven by missing or non-identical resource
fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/transport/oauth.go`:
- Around line 370-379: The loop in HandleUnauthorizedResponse currently uses
extractResourceMetadataURL which returns only the first resource_metadata from a
single WWW-Authenticate header value and can miss other candidates in the same
header; change extractResourceMetadataURL to collect and return all
resource_metadata values (e.g., extractResourceMetadataURLs -> []string) by
reusing the current scanner but appending every non-empty match, then update the
caller loop to iterate over each candidate returned and run
h.validateAdvertisedPRMURL(candidate) for each, calling
h.SetProtectedResourceMetadataURL(candidate) and returning on the first valid
one.

---

Duplicate comments:
In `@client/transport/oauth.go`:
- Around line 535-551: The advertised PRM metadata is currently accepted when
protectedResource.Resource is empty and uses resourceIdentifiersEqual (a relaxed
comparison); change the logic so that when prmFromAdvertisement is true you
reject the metadata unless protectedResource.Resource is non-empty and exactly
equals the expected baseURL (use strict string equality instead of
resourceIdentifiersEqual), setting h.metadataFetchErr with a clear message (as
done currently) and returning; apply the same strict check wherever
resourceIdentifiersEqual is used for advertised metadata validation (e.g., the
other block around lines 716-744) so authorization_servers cannot be driven by
missing or non-identical resource fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ca910242-2cb6-4ffd-8eca-2b858b010ed4

📥 Commits

Reviewing files that changed from the base of the PR and between a36c798 and d12db0c.

📒 Files selected for processing (4)
  • client/transport/oauth.go
  • client/transport/oauth_test.go
  • client/transport/sse_oauth_test.go
  • client/transport/streamable_http_oauth_test.go
✅ Files skipped from review due to trivial changes (2)
  • client/transport/oauth_test.go
  • client/transport/streamable_http_oauth_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/transport/sse_oauth_test.go

Comment thread client/transport/oauth.go Outdated
…urce field

Two follow-up refinements from review:

- Parser now returns every resource_metadata value it sees (plural
  extractResourceMetadataURLs) instead of only the first one. A single
  WWW-Authenticate header value can legitimately carry several Bearer
  challenges each with its own resource_metadata; HandleUnauthorizedResponse
  iterates all candidates and stores the first one that passes origin
  validation. Previously, if the first candidate was an attacker-controlled
  cross-origin URL, later valid candidates in the same header value were
  missed.
- Advertised PRM responses must now declare a non-empty resource field.
  Because the advertised PRM URL need not share an origin with the
  protected resource, omitting the resource field leaves no way to bind
  the metadata to the resource the client addressed. Origin-constructed
  well-known discovery remains unaffected (the origin binding is
  implicit there).
- resourceIdentifiersEqual retains RFC 3986 case-insensitive scheme/host
  matching and trailing-slash tolerance. Strict string equality would
  produce false positives on legitimate OAuth deployments that emit the
  same resource with and without a trailing slash. Rationale documented
  on the helper's godoc.

Tests updated for the new signature and cover: multiple candidates per
header value (bad first, good second), advertised PRM with missing
resource (rejected), and empty-header / no-match edge cases.
@Dennisadira

Copy link
Copy Markdown
Author

Addressed the second round of feedback in 6d104d5:

  • extractResourceMetadataURLs now returns every resource_metadata value in a header line. HandleUnauthorizedResponse iterates all candidates and keeps the first one that passes origin validation, so a bad-first / good-second ordering inside a single header value no longer drops the valid candidate. New test covers a single header value with two Bearer challenges.
  • Advertised PRM responses now require a non-empty resource field. Because the advertised PRM URL may not share an origin with the protected resource, omitting resource leaves no way to bind the metadata to the resource the client addressed. Added a regression test.

On the strict-equality suggestion: keeping the normalized resourceIdentifiersEqual comparison. It matches scheme and host case-insensitively per RFC 3986 §3.1 / §3.2.2, and tolerates a single trailing slash on the path because real-world OAuth deployments routinely emit https://example.com/mcp and https://example.com/mcp/ interchangeably for the same resource — strict string equality would produce false positives on correctly-configured servers. Query, fragment, and userinfo components remain significant. Rationale is now on the helper's godoc. Happy to tighten further if you prefer a specific canonicalization.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/transport/oauth.go`:
- Around line 649-728: The quoted-string parser parseAuthParamValue currently
returns a partial value on unterminated quotes or a trailing backslash; change
it to treat these cases as invalid by returning an empty string and advancing
the index to the end of the input (i.e., return "", len(s)) so
extractResourceMetadataURLs will ignore malformed parameters; update
parseAuthParamValue's two error branches (lone trailing backslash and
end-of-string without closing '"') to discard the accumulated builder and return
"" along with the appropriate next index instead of b.String().
- Around line 730-761: The path comparison in resourceIdentifiersEqual currently
uses ua.Path which is decoded by url.Parse and thus conflates percent-encoded
segments (e.g., %2F) with their decoded form; update the function to compare
ua.EscapedPath() and ub.EscapedPath() (after the same TrimSuffix("/")) so the
percent-encoded form is preserved when checking equality, and add unit tests
exercising reserved-character encodings (e.g., "https://example.com/a%2Fb" vs
"https://example.com/a/b") to prevent regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b83618c7-5dd8-4094-b9ae-3c891f956e39

📥 Commits

Reviewing files that changed from the base of the PR and between d12db0c and 6d104d5.

📒 Files selected for processing (2)
  • client/transport/oauth.go
  • client/transport/oauth_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/transport/oauth_test.go

Comment thread client/transport/oauth.go
Comment thread client/transport/oauth.go
Two follow-ups from review:

- parseAuthParamValue now returns an `ok` flag and treats unterminated
  quoted strings or lone trailing backslashes as malformed. The caller
  skips such entries so a truncated header can no longer set PRM
  state, consistent with HandleUnauthorizedResponse's documented
  behaviour. Token values yield ok=true only when non-empty.
- resourceIdentifiersEqual now compares EscapedPath rather than Path so
  percent-encoded reserved characters remain distinct from their
  decoded forms. url.Parse decodes %2F to `/`, which would otherwise
  allow `https://example.com/a%2Fb` and `https://example.com/a/b` to
  compare equal and bypass RFC 9728 §3.3 resource binding.

Tests cover the rejection paths and the %2F-vs-/ distinction.
@Dennisadira

Copy link
Copy Markdown
Author

Addressed both items in 25ec92d:

  • parseAuthParamValue now returns an ok bool and rejects unterminated quoted strings and lone trailing backslashes rather than producing partial values. The caller in extractResourceMetadataURLs skips malformed entries, so a truncated WWW-Authenticate can no longer set PRM state — consistent with HandleUnauthorizedResponse's documented behaviour. Tests cover both rejection paths.
  • resourceIdentifiersEqual now compares EscapedPath() rather than Path so percent-encoded reserved characters stay distinct from their decoded forms. Prevents https://example.com/a%2Fb from comparing equal to https://example.com/a/b, which would bypass the RFC 9728 §3.3 resource binding check. Added %2F-vs-/ regression test.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/transport/oauth.go`:
- Around line 392-410: In validateAdvertisedPRMURL, reject non-absolute URLs by
verifying both the parsed base (from h.baseURL) and the parsed candidate have
non-empty Scheme and Host before doing Equality checks; if base.Scheme=="" ||
base.Host=="" return a descriptive error about an invalid/non-absolute base URL,
and if parsed.Scheme=="" || parsed.Host=="" return an error that the advertised
PRM URL must be absolute, then proceed with the existing EqualFold scheme/host
comparisons (function: validateAdvertisedPRMURL, variables: base, parsed,
h.baseURL, candidate).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 34089bc8-731c-453e-ac45-69a5e4d26261

📥 Commits

Reviewing files that changed from the base of the PR and between 6d104d5 and 25ec92d.

📒 Files selected for processing (2)
  • client/transport/oauth.go
  • client/transport/oauth_test.go

Comment thread client/transport/oauth.go
url.Parse accepts relative references (e.g. "prm", "example.com/mcp")
without error, producing an empty Scheme and Host. Two empty strings
EqualFold each other, so a misconfigured base URL combined with a
relative advertised PRM URL would silently pass origin validation.

validateAdvertisedPRMURL now explicitly rejects either side when its
scheme or host is empty, with distinct error messages for the base and
candidate cases. Tests cover relative candidate URLs, schemeless
host-only candidates, and a relative base URL paired with a relative
candidate (which previously matched by double-empty EqualFold).
@Dennisadira

Copy link
Copy Markdown
Author

Addressed in 29b1589:

validateAdvertisedPRMURL now rejects URLs with an empty scheme or host on either side of the comparison. Without that check, url.Parse("prm") and url.Parse("example.com/mcp") both succeed with empty Scheme/Host, and EqualFold("", "") returns true — a misconfigured (relative) base URL combined with a relative advertised PRM would silently validate. Tests cover a relative candidate, a schemeless host-only candidate, and a relative base URL paired with a relative candidate.

@ezynda3

ezynda3 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

@Dennisadira thanks for the PR, could you please fix the merge conflicts and I will have a look again.

@ezynda3

ezynda3 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Hey @Dennisadira — heads up, #804 by @MariaChrysafis (opened 3 days earlier) implements the same RFC 9728 §5.1 feature. Both PRs solve the same core problem but take meaningfully different design paths, so I did a side-by-side comparison to help us decide how to move forward.

Where you both agree

  • Store a PRM URL on OAuthHandler, expose a setter
  • Prefer the stored PRM URL over origin-based .well-known construction in getServerMetadata
  • Hook into the 401 path in both SSE and StreamableHTTP transports
  • Parse resource_metadata from WWW-Authenticate

Where you diverge

Aspect #808 (this PR) #804
Header parser Custom minimal token + quoted-string parser (extractResourceMetadataURLs) Full RFC 9110 §11.6.1 challenge parser ported from the official go-sdk (parseWWWAuthenticate, splitWWWAuthChallenges, parseSingleWWWAuthChallenge)
Origin validation validateAdvertisedPRMURL rejects PRM URLs whose scheme/host differ from the base URL — prevents a compromised resource redirecting to an attacker's metadata endpoint ❌ No origin validation on the advertised URL
Resource binding (§3.3/§7.3) ✅ After fetching PRM from an advertised URL, validates that the resource field in the response matches the base URL. Rejects empty resource too ❌ No resource binding check
Metadata re-discovery ❌ Keeps sync.Once — PRM URL must be set before first metadata fetch ✅ Replaces sync.Once with sync.Mutex + metadataFetched bool, allowing SetResourceMetadataURL to reset and re-trigger discovery
WWWAuthenticate on error type Not surfaced — transport calls HandleUnauthorizedResponse internally and the caller doesn't see the raw headers ✅ Adds WWWAuthenticate []string field to OAuthAuthorizationRequiredError, giving callers access to the raw headers
Who calls the parser Transport calls handler.HandleUnauthorizedResponse(resp) automatically — zero caller involvement Two-step: transport populates WWWAuthenticate on the error, caller uses ParseResourceMetadataURL() + SetResourceMetadataURL() manually (or can be wired up)
Integration approach "Batteries included" — 401 handling is fully automatic "Building blocks" — exposes parsed headers and lets the caller decide

My take

Both PRs have pieces the other is missing. Ideally I'd like to see a combined version that has:

  1. feat: add WWW-Authenticate header parsing for OAuth metadata discovery #804's full RFC 9110 challenge parser — it's more rigorous and aligned with the official go-sdk. The minimal parser in this PR works for the common case but could break on edge cases the spec allows (multiple challenges on one line, token68 forms, etc.).
  2. feat(oauth): extract Protected Resource Metadata URL from WWW-Authenticate #808's security validations — origin validation and resource-binding checks are important. RFC 9728 §3.3/§7.3 specifically calls for the resource identifier check, and without origin validation a malicious server can redirect discovery. These shouldn't be optional.
  3. feat: add WWW-Authenticate header parsing for OAuth metadata discovery #804's re-discovery support (sync.Mutex replacing sync.Once) — in practice the 401 with WWW-Authenticate arrives after the transport is created, so the PRM URL will often be set after the first metadata fetch attempt. Without re-discovery, the advertised URL is silently ignored in that race.
  4. feat: add WWW-Authenticate header parsing for OAuth metadata discovery #804's WWWAuthenticate field on the error type — even if the transport handles PRM extraction automatically, surfacing the raw headers on the error is useful for debugging and for callers with custom OAuth flows.

Would you two be open to collaborating on a combined PR? Or if one of you wants to adopt the missing pieces from the other, that works too. Happy to help review either way.

cc @MariaChrysafis

@ezynda3

ezynda3 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Update: #730 by @sd2k predates all three PRs here and has the most complete design. I'm planning to merge that one (once rebased + your security validations are added) and close #794, #804, and this PR. Thanks everyone for the work on this — see #730 for the path forward.

@ezynda3 ezynda3 closed this Apr 21, 2026
@Dennisadira

Copy link
Copy Markdown
Author

Thanks @ezynda3 for the thoughtful comparison and for consolidating the three efforts — that was the right call. Glad the origin-validation and RFC 9728 §3.3/§7.3 resource-binding checks made it into #730. Happy to follow up with tests against the full RFC 9110 challenge parser in #730 if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: duplicate This issue or pull request already exists

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants