From 5882df93872774126331d322eacae7343ab95bef Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 29 Sep 2026 11:53:54 -0500 Subject: [PATCH] Resource gates, error taxonomy, and authserver 0.2.0 alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings main up to the current development line ahead of the 0.4.0 cut. The CHANGELOG's [Unreleased] section is the authoritative list of what changed for a caller; this body covers the mechanics a reviewer needs. - The no-credentials response no longer carries an error code that no RFC defines: RFC 6750 §3 describes a bare challenge for a request that presents no credentials, and an invented code is what a client would branch on. - Resource and issuer identifiers are gated where they are consumed, not only where the PRM URL is derived. - Internal error messages stay out of the WWW-Authenticate challenge. - resource_metadata can point at an AS-hosted PRM document. - authserver 0.2.0: access_denied and invalid_target are typed and excluded from the circuit breaker shared with introspection. may_act is deprecated — the AS no longer issues it. - The conformance catalog pin moves to 583a6d9, and the shared fetch script gains an overridable destination so the drift job and CI can use it alike. --- .conformance-catalog-ref | 2 +- .../scripts/conformance-case-body-drift.sh | 361 +++++++ .../conformance-case-body-drift.test.sh | 629 +++++++++++ .../conformance-registered-case-ids.sh | 70 ++ .github/scripts/fetch-conformance-catalog.sh | 64 +- .../scripts/fetch-conformance-catalog.test.sh | 333 ++++++ .../workflows/conformance-catalog-drift.yml | 106 ++ .github/workflows/workflows-lint.yml | 44 +- CHANGELOG.md | 27 + README.md | 11 +- core/authplane/circuit_breaker_test.go | 4 + core/authplane/client.go | 36 +- core/authplane/client_test.go | 229 ++++ core/authplane/types.go | 2 + core/conformancetests/catalog_loader_test.go | 196 ++++ .../catalog_parser_shapes_test.go | 492 +++++++++ core/conformancetests/catalog_parser_test.go | 333 ++++++ core/conformancetests/harness_test.go | 101 +- core/conformancetests/rfc8414_test.go | 533 +++++++++- core/conformancetests/rfc8707_test.go | 22 + core/conformancetests/rfc9728_test.go | 101 +- core/docs/user-guide.md | 66 +- core/internal/cache/cache.go | 160 ++- core/internal/cache/cache_test.go | 433 ++++++++ core/internal/oauth/client_test.go | 45 + core/internal/oauth/types.go | 12 + core/internal/oauth/types_test.go | 3 + core/resource/errors.go | 161 ++- core/resource/errors_test.go | 356 +++++++ core/resource/resource.go | 523 +++++++++- core/resource/resource_test.go | 985 +++++++++++++++++- core/resource/verifier/claims.go | 14 +- core/resource/verifier/claims_test.go | 10 +- core/resource/verifier/verifier.go | 6 +- core/resource/verifier/verifier_test.go | 31 + http/docs/user-guide.md | 33 +- http/pkg/authplanehttp/adapter.go | 103 +- http/pkg/authplanehttp/adapter_test.go | 443 +++++++- http/pkg/authplanehttp/helpers_test.go | 28 +- llm-full.txt | 2 +- mark3labs/docs/user-guide.md | 54 +- mark3labs/pkg/authplanemark3labs/adapter.go | 11 + .../pkg/authplanemark3labs/adapter_test.go | 107 ++ .../pkg/authplanemark3labs/helpers_test.go | 24 +- mcp/README.md | 60 +- mcp/docs/user-guide.md | 54 +- mcp/internal/httputil/scope_hint.go | 87 ++ mcp/internal/httputil/scope_hint_test.go | 121 +++ mcp/internal/httputil/www_authenticate.go | 6 + mcp/pkg/authplanemcp/adapter.go | 59 +- mcp/pkg/authplanemcp/adapter_test.go | 130 +++ mcp/pkg/authplanemcp/helpers_test.go | 24 +- scripts/manual-e2e-setup.sh | 12 +- 53 files changed, 7629 insertions(+), 230 deletions(-) create mode 100755 .github/scripts/conformance-case-body-drift.sh create mode 100755 .github/scripts/conformance-case-body-drift.test.sh create mode 100755 .github/scripts/conformance-registered-case-ids.sh create mode 100755 .github/scripts/fetch-conformance-catalog.test.sh create mode 100644 core/conformancetests/catalog_loader_test.go create mode 100644 core/conformancetests/catalog_parser_shapes_test.go create mode 100644 core/conformancetests/catalog_parser_test.go create mode 100644 mcp/internal/httputil/scope_hint.go create mode 100644 mcp/internal/httputil/scope_hint_test.go diff --git a/.conformance-catalog-ref b/.conformance-catalog-ref index efa9db0..bf2d5bd 100644 --- a/.conformance-catalog-ref +++ b/.conformance-catalog-ref @@ -1 +1 @@ -b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf +583a6d92412543ea352251c88f15f2c5a39d2593 diff --git a/.github/scripts/conformance-case-body-drift.sh b/.github/scripts/conformance-case-body-drift.sh new file mode 100755 index 0000000..07622fd --- /dev/null +++ b/.github/scripts/conformance-case-body-drift.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# +# Detect semantic drift in the conformance catalog: a case whose BODY changed +# under an unchanged id. +# +# Everything else in the alignment machinery compares case IDS. The pinned ref +# protects against new cases arriving unannounced, and the drift job's id-set +# comparison against the catalog tip reports cases added or removed. Neither +# looks at the body of a case, so a case that is re-tightened in place — same +# id, stricter requirement — is invisible end to end: the SDK bumps its pin and +# starts declaring conformance to a requirement nothing verified it against. +# That has already happened once, to the metadata jwks_uri rotation case. +# +# This script closes that gap. It compares the body of every case the SDK +# actually registers between two checkouts of the catalog — normally the pinned +# ref and the tip — and fails naming any case whose body changed. +# +# Scoped to the ids the SDK registers on purpose. Diffing the whole catalog, or +# asserting catalog_version, fires on every catalog edit including cases this +# SDK does not cover; the noise is what gets a guard ignored. +# +# Inputs (environment): +# PINNED_CATALOG path to the catalog YAML at the ref this repo pins +# TIP_CATALOG path to the catalog YAML to compare against +# REGISTERED_IDS path to a file holding one case id per line — the ids this +# SDK registers. Produced per-repo; for this repo, by +# conformance-registered-case-ids.sh. +# DRIFT_SUMMARY optional path to append a Markdown summary to +# COVERAGE_DIR optional directory to name in that summary as the place the +# conformance coverage lives, so the one human-facing pointer +# in this script is not the thing that has to be edited when +# it is dropped into a tree laid out differently. Defaults to +# this repo's core/conformancetests/. +# +# Exit status: +# 0 no body drift in any registered case +# 1 body drift found, OR this check could not do its job +# +# The second half of that exit code matters as much as the first. A guard that +# under-checks while reporting green is the defect class this script exists to +# close, so every input it cannot read, every catalog shape it cannot parse and +# every empty intermediate result is a hard failure rather than a quiet pass. + +set -euo pipefail + +: "${PINNED_CATALOG:?PINNED_CATALOG must be set}" +: "${TIP_CATALOG:?TIP_CATALOG must be set}" +: "${REGISTERED_IDS:?REGISTERED_IDS must be set}" + +DRIFT_SUMMARY="${DRIFT_SUMMARY:-}" +COVERAGE_DIR="${COVERAGE_DIR:-core/conformancetests/}" + +fail() { + echo "::error::$1" >&2 + exit 1 +} + +for input in "$PINNED_CATALOG" "$TIP_CATALOG" "$REGISTERED_IDS"; do + if [[ ! -f "$input" || ! -r "$input" ]]; then + fail "case-body drift check: '$input' is not a readable regular file. The check cannot run and is not reporting a clean result." + fi + if [[ ! -s "$input" ]]; then + fail "case-body drift check: '$input' is empty. The check cannot run and is not reporting a clean result." + fi +done + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# --------------------------------------------------------------------------- +# Case body extraction +# --------------------------------------------------------------------------- +# +# Emits one line per body line, as "", for every case in +# the catalog's top-level `cases:` block. The id prefix keeps each body +# addressable without opening a file per case, and leaves the body text after +# the tab byte-for-byte as the catalog has it. +# +# Only the `cases:` block is read. `standards_in_scope` earlier in the file +# also holds `- id:` entries, and matching those would compare bodies that are +# not cases at all. +# +# Normalization is deliberately minimal: trailing whitespace is stripped, blank +# lines are dropped, and comment lines are dropped only at structural positions +# — at or above the case-item indent. Nothing else is touched — no attempt is +# made to unfold YAML line continuations, because that needs real YAML semantics +# and is out of scope here. The consequence is stated rather than hidden: +# re-wrapping a folded scalar reports as drift even when the meaning is +# unchanged. That direction is the safe one. A re-wrap costs a human one look at +# the diff; the opposite error is the silent under-check that produced this +# check. +# +# The indent condition on comment dropping is that same trade-off. Below the +# case-item indent a leading `#` is not necessarily a comment: inside a block +# scalar (`use_case: |`) or a multi-line double-quoted scalar it is ordinary +# text, and dropping it would let an edit to that line report clean — the one +# normalization that erred toward silence rather than noise. Deeper lines are +# compared as body text instead, so a genuine comment edit there shows as drift. +# +# Any shape the extractor cannot read with certainty is a failure, not a skip. +extract_case_bodies() { + awk -v src="$1" ' + function die(lineno, msg) { + printf("%s:%s: %s\n", src, lineno, msg) > "/dev/stderr" + err = 1 + exit 1 + } + + BEGIN { + state = 0; itemind = -1; ncases = 0; casekeys = 0 + # A single quote, as an octal escape. Writing the character itself would + # mean breaking out of the shell quoting around this program for it. + SQ = "\047" + } + + # A column-0, non-blank, non-comment line either opens the cases block or, + # once inside it, closes it. + /^[^[:space:]#]/ { + if ($0 ~ /^cases:[[:space:]]*(#.*)?$/) { + casekeys++ + if (casekeys > 1) { + die(FNR, "a second top-level cases: key; the catalog shape is not what this check parses") + } + state = 1 + next + } + if (state == 1) { state = 2 } + next + } + + state != 1 { next } + + # A blank line carries nothing to compare wherever it sits. + /^[[:space:]]*$/ { next } + + { + match($0, /^[[:space:]]*/) + ind = RLENGTH + rest = substr($0, ind + 1) + isitem = (rest ~ /^-([[:space:]]|$)/) + + # A leading `#` is a comment only at a structural position: at or above + # the case-item indent, and anywhere before the first item has fixed that + # indent. Deeper than that it can be content — a line inside a block + # scalar or a multi-line quoted scalar — and dropping it would hide an + # edit to it. Those fall through and are compared as body text. + if (rest ~ /^#/ && (itemind < 0 || ind <= itemind)) { next } + + if (itemind < 0) { + if (!isitem) { + die(FNR, "content inside the cases: block before the first case item; the catalog shape is not what this check parses") + } + itemind = ind + } + + if (ind < itemind) { + die(FNR, "a line inside the cases: block indented less than the case items; the catalog shape is not what this check parses") + } + + # At the item indent, anything that is not an item start would be + # appended to the previous case body and mis-attributed to it. + if (ind == itemind && !isitem) { + die(FNR, "a non-item line at the case-item indent; the catalog shape is not what this check parses") + } + + if (ind == itemind) { + # New case. Its id must be the first key of the item: the id is what + # every other check keys on, and an item whose id sits further down is + # a shape this extractor would silently mis-attribute. + if (!match($0, /^[[:space:]]*-[[:space:]]+id:[[:space:]]*/)) { + die(FNR, "case item does not open with an id: key; the catalog shape is not what this check parses") + } + raw = substr($0, RLENGTH + 1) + sub(/[[:space:]]+$/, "", raw) + + if (substr(raw, 1, 1) == "\"") { + if (!match(raw, /^"[^"\\]+"([[:space:]]*#.*)?$/)) { + die(FNR, "case id is a double-quoted scalar this check will not read unambiguously (an escape, or an unterminated quote)") + } + id = raw + sub(/^"/, "", id) + sub(/"([[:space:]]*#.*)?$/, "", id) + } else if (substr(raw, 1, 1) == SQ) { + if (!match(raw, "^" SQ "[^" SQ "\\\\]+" SQ "([[:space:]]*#.*)?$")) { + die(FNR, "case id is a single-quoted scalar this check will not read unambiguously (an escape, or an unterminated quote)") + } + id = raw + sub("^" SQ, "", id) + sub(SQ "([[:space:]]*#.*)?$", "", id) + } else { + id = raw + sub(/[[:space:]]+#.*$/, "", id) + sub(/[[:space:]]+$/, "", id) + } + + # The id has to be a plain token: it names the case in every error + # message this check emits, and it is compared as an exact string. + if (id !~ /^[A-Za-z0-9][A-Za-z0-9._-]*$/) { + die(FNR, "case id is not a plain token; this check compares ids as exact strings and will not guess at this one") + } + if (id in seen) { + die(FNR, "duplicate case id " id "; ids key this comparison, so the second case would be invisible to it") + } + seen[id] = FNR + ncases++ + curid = id + } + + line = $0 + sub(/[[:space:]]+$/, "", line) + printf("%s\t%s\n", curid, line) + } + + END { + if (err) { exit 1 } + if (casekeys == 0) { + printf("%s: no top-level cases: key found\n", src) > "/dev/stderr" + exit 1 + } + if (ncases == 0) { + printf("%s: the cases: block parsed to zero cases; this check would compare nothing and report clean\n", src) > "/dev/stderr" + exit 1 + } + } + ' "$1" +} + +if ! extract_case_bodies "$PINNED_CATALOG" > "$WORK/pinned.tsv"; then + fail "case-body drift check: could not read the case bodies out of the pinned catalog '$PINNED_CATALOG' (see the parse error above). Not reporting a clean result." +fi + +if ! extract_case_bodies "$TIP_CATALOG" > "$WORK/tip.tsv"; then + fail "case-body drift check: could not read the case bodies out of the comparison catalog '$TIP_CATALOG' (see the parse error above). Not reporting a clean result." +fi + +# --------------------------------------------------------------------------- +# The registered ids to restrict the comparison to +# --------------------------------------------------------------------------- + +# Tolerate blank lines and surrounding whitespace in the id list; reject +# anything else, because an id this check silently drops is a case it silently +# stops guarding. +# The `|| true` is load-bearing. grep exits 1 when it selects no lines, so on an +# id list that is entirely blank the pipeline would fail under `pipefail` and +# `set -e` would end the script right here — exit 1 with nothing printed. The +# exit code would be the right one for the wrong reason, and the next +# maintainer would get a silent red with no message to act on. Let the pipeline +# succeed and let the explicit emptiness check below do the reporting. +{ tr -d '\r' < "$REGISTERED_IDS" \ + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ + | grep -v '^$' \ + | sort -u || true ; } > "$WORK/ids" + +if [[ ! -s "$WORK/ids" ]]; then + fail "case-body drift check: '$REGISTERED_IDS' holds no case ids. An empty id list makes this check vacuously green, which is the failure it exists to prevent." +fi + +if malformed="$(grep -vE '^[A-Za-z0-9][A-Za-z0-9._-]*$' "$WORK/ids" || true)"; [[ -n "$malformed" ]]; then + fail "case-body drift check: '$REGISTERED_IDS' holds entries that are not plain case ids: $(echo "$malformed" | tr '\n' ' '). Not reporting a clean result." +fi + +case_body() { + # $1 = stream, $2 = id + awk -F '\t' -v id="$2" '$1 == id { print substr($0, length(id) + 2) }' "$1" +} + +# --------------------------------------------------------------------------- +# Compare +# --------------------------------------------------------------------------- + +drifted=0 +missing_from_pin=0 +absent_from_tip=0 +compared=0 +: > "$WORK/report" + +while IFS= read -r id; do + case_body "$WORK/pinned.tsv" "$id" > "$WORK/body.pinned" + case_body "$WORK/tip.tsv" "$id" > "$WORK/body.tip" + + if [[ ! -s "$WORK/body.pinned" ]]; then + # The SDK registers a case its own pinned catalog does not hold. Whatever + # else is true, this check cannot vouch for that case, so it says so. + echo "::error::This SDK registers conformance case '$id', which the PINNED catalog does not contain. The case-body drift check cannot compare it." >&2 + missing_from_pin=$((missing_from_pin + 1)) + continue + fi + + if [[ ! -s "$WORK/body.tip" ]]; then + # Removal is id-level drift and the id-set check reports it. Named here so + # it is not mistaken for a compared-and-clean case, but not counted as body + # drift, to keep one catalog change from being reported twice. + echo "::warning::Conformance case '$id' is registered by this SDK and present in the pinned catalog, but absent from the comparison catalog. That is id-level drift; the alignment check is what reports it." >&2 + absent_from_tip=$((absent_from_tip + 1)) + continue + fi + + compared=$((compared + 1)) + + if ! diff -u -L "pinned/$id" -L "tip/$id" "$WORK/body.pinned" "$WORK/body.tip" > "$WORK/diff"; then + drifted=$((drifted + 1)) + echo "::error::Pinned conformance case '$id' changed shape under the same id. This SDK's coverage for it was written against the pinned wording and nothing has verified it against the new wording." >&2 + cat "$WORK/diff" >&2 + { + echo "" + echo "### \`$id\`" + echo "" + echo '```diff' + cat "$WORK/diff" + echo '```' + } >> "$WORK/report" + fi +done < "$WORK/ids" + +# A run that compared nothing is not a clean run. Reached when every registered +# id is missing from the pinned catalog, or when the id list and the catalog +# have no id in common at all — a mismatched pair of inputs, say, or an id +# source that produced plausible-looking nonsense. +if [[ "$compared" -eq 0 ]]; then + fail "case-body drift check: not one registered case id could be compared ($(wc -l < "$WORK/ids" | tr -d ' ') ids read). The inputs do not line up; this is not a clean result." +fi + +summary_head="" +if [[ "$drifted" -gt 0 ]]; then + summary_head="## Conformance case-body drift detected" +elif [[ "$missing_from_pin" -gt 0 ]]; then + summary_head="## Conformance case-body drift check could not verify every registered case" +fi + +if [[ -n "$DRIFT_SUMMARY" && -n "$summary_head" ]]; then + { + echo "$summary_head" + echo "" + echo "Compared $compared registered case(s) between the pinned catalog and the catalog tip." + if [[ "$drifted" -gt 0 ]]; then + echo "" + echo "$drifted registered case(s) changed body under an unchanged id. The id-level" + echo "alignment check cannot see this: the id is the same, so the case looks adopted" + echo "while its requirement has moved." + echo "" + echo "**Next steps:** re-read the coverage in \`$COVERAGE_DIR\` against the new" + echo "wording. Either the coverage still holds and the pin can be bumped, or it does not" + echo "and the registration should be downgraded to the level actually demonstrated." + cat "$WORK/report" + fi + if [[ "$missing_from_pin" -gt 0 ]]; then + echo "" + echo "$missing_from_pin registered case(s) are absent from the pinned catalog, so their" + echo "bodies could not be compared at all." + fi + } >> "$DRIFT_SUMMARY" +fi + +if [[ "$drifted" -gt 0 || "$missing_from_pin" -gt 0 ]]; then + exit 1 +fi + +echo "Conformance case bodies: compared $compared registered case(s) between the pinned catalog and the catalog tip; no case changed shape under an unchanged id." +if [[ "$absent_from_tip" -gt 0 ]]; then + echo "($absent_from_tip registered case(s) are absent from the comparison catalog; the alignment check reports those.)" +fi diff --git a/.github/scripts/conformance-case-body-drift.test.sh b/.github/scripts/conformance-case-body-drift.test.sh new file mode 100755 index 0000000..838d698 --- /dev/null +++ b/.github/scripts/conformance-case-body-drift.test.sh @@ -0,0 +1,629 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests for conformance-case-body-drift.sh and conformance-registered-case-ids.sh. +# +# Both scripts run only on the weekly drift schedule, so a break in either +# surfaces late and quietly — and the way it surfaces is a green run, because +# what they guard against is a check that under-reports. Shellcheck cannot see +# that class at all: a loosened id regex that silently drops cases, or a `diff` +# whose exit code stops being read, is valid shell. These tests pin the +# behaviour instead, so such an edit fails at PR time rather than the next time +# the catalog is re-tightened in place. +# +# The headline case is the real one the drift check exists for: the metadata +# jwks_uri rotation case was re-tightened under an unchanged id between two +# catalog revisions. The fixtures carry a trimmed form of both wordings, so the +# suite asserts against the change that actually happened rather than an +# invented one. +# +# Every fixture is a handful of YAML and JSON written to a temp dir. Nothing +# here clones the catalog, runs the conformance suite, or needs Go — the point +# is that these controls stay runnable and fast on a PR. +# +# Run: .github/scripts/conformance-case-body-drift.test.sh + +SCRIPTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DRIFT="$SCRIPTDIR/conformance-case-body-drift.sh" +IDSCRIPT="$SCRIPTDIR/conformance-registered-case-ids.sh" + +# conformance-registered-case-ids.sh reads its report with jq and fails cleanly +# when jq is absent — which would turn half this suite into a check that the +# absence message is right, silently dropping the cases it is here to cover. +# Refuse to run instead of passing for that reason. +if ! command -v jq > /dev/null 2>&1; then + echo "error: these tests need jq; conformance-registered-case-ids.sh reads its report with it" >&2 + exit 1 +fi + +failures=0 + +# One root that an EXIT trap removes, so a fixture still gets cleaned up when +# `set -e` kills the shell from inside a helper — the moment a leak is least +# welcome. The per-case RETURN traps below do not fire then. +TESTROOT="$(mktemp -d)" +trap 'rm -rf "$TESTROOT"' EXIT + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n %s\n' "$1" "$2"; failures=$((failures + 1)); } + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +# Writes a catalog to $1. $2 picks the wording of the jwks_uri rotation case: +# "pinned" for the one the SDK's coverage was written against, "tip" for the +# re-tightening that replaced it under the same id. Trimmed to the keys that +# carry the change — surface, requirement_summary, stimulus, rationale — because +# the point of the fixture is the shape of the edit, not its length. +# +# standards_in_scope is present on purpose. It holds `- id:` entries that are +# not cases, and a fixture without it would not exercise the scoping that keeps +# them out of the comparison. +write_catalog() { + local out="$1" jwks="$2" + + cat > "$out" <<'YAML' +--- +schema_version: "1.0" +catalog_id: "oauth-sdk-conformance-catalog" +catalog_version: "2026-08-04" + +standards_in_scope: + - id: "RFC8414" + title: "OAuth 2.0 Authorization Server Metadata" + - id: "RFC7009" + title: "OAuth 2.0 Token Revocation" + +cases: +YAML + + if [[ "$jwks" == "pinned" ]]; then + cat >> "$out" <<'YAML' + - id: "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache" + title: "Reconfigure JWKS resolution when metadata jwks_uri changes" + surface: "sdk-client.discovery" + priority: "medium" + requirement_summary: "When trusted metadata changes jwks_uri, the SDK SHOULD rebind JWKS fetching to the new URI." + stimulus: + operation: "client._on_metadata_changed" + expected: + outcome: "accept" + side_effect: + - "jwks_uri updated to new metadata value" + rationale: "Keeps key discovery aligned with metadata rotation without requiring client recreation." +YAML + else + cat >> "$out" <<'YAML' + - id: "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache" + title: "Follow a metadata jwks_uri rotation using only ordinary verification traffic" + surface: "sdk-verifier.jwks" + priority: "medium" + requirement_summary: "A verifier SHOULD re-read metadata on its configured refresh interval and rebind JWKS fetching\ + \ to the rotated jwks_uri. Following the rotation MUST require nothing beyond ordinary verification traffic: no\ + \ force-refresh argument, no test-only hook, and no reflective access to internals." + stimulus: + operation: "verifier.verify, repeated as ordinary traffic spanning the metadata refresh interval" + expected: + outcome: "accept" + side_effect: + - "metadata re-fetched after the refresh interval elapses, without an explicit refresh call" + - "JWKS fetched from new_metadata.jwks_uri" + rationale: "Keeps key discovery aligned with metadata rotation without requiring client recreation. The mechanism\ + \ restriction is the substance of the case: a verify-only resource server never repeats client-side discovery." +YAML + fi + + cat >> "$out" <<'YAML' + - id: "rfc7009-revocation-server-errors-must-surface" + title: "Surface revocation endpoint server errors as failures" + surface: "sdk-client.revocation" + priority: "high" + requirement_summary: "A 5xx from the revocation endpoint MUST surface as a failure rather than be swallowed." + expected: + outcome: "reject" + rationale: "A revocation the caller believes succeeded is worse than one that visibly failed." +YAML +} + +# A pinned/tip pair that differs only in the jwks case, in $1/pinned.yaml and +# $1/tip.yaml, with both case ids registered in $1/ids.txt. +make_pair() { + local root="$1" + write_catalog "$root/pinned.yaml" pinned + write_catalog "$root/tip.yaml" tip + cat > "$root/ids.txt" <<'IDS' +rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache +rfc7009-revocation-server-errors-must-surface +IDS +} + +# Runs the drift script against $1/{pinned,tip}.yaml and $1/ids.txt, capturing +# stdout and stderr together into $out and the exit status into $rc. Both are +# declared `local` by the caller. +run_drift() { + local root="$1" + rc=0 + out="$(PINNED_CATALOG="$root/pinned.yaml" \ + TIP_CATALOG="$root/tip.yaml" \ + REGISTERED_IDS="$root/ids.txt" \ + DRIFT_SUMMARY="$root/summary.md" \ + "$DRIFT" 2>&1)" || rc=$? +} + +# --------------------------------------------------------------------------- +# conformance-case-body-drift.sh +# --------------------------------------------------------------------------- + +# --- the positive control ------------------------------------------------------ +# Without this every assertion below could be satisfied by a script that fails +# unconditionally, and the suite would look green while guarding nothing. +t_identical_catalogs_are_clean() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + cp "$root/pinned.yaml" "$root/tip.yaml" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 0 ]]; then + fail "identical catalogs are clean" "exit $rc, want 0: ${out##*$'\n'}" + elif ! grep -q "compared 2 registered case(s)" <<<"$out"; then + fail "identical catalogs are clean" "it did not report comparing both cases: ${out##*$'\n'}" + else + pass "identical catalogs report clean, having compared both registered cases" + fi +} + +# --- the case this check exists for -------------------------------------------- +# The jwks_uri rotation case was re-tightened in place: same id, new surface, new +# requirement, new mechanism restriction. Every id-level check in the alignment +# machinery sees an unchanged id set and reports clean. This is the one that must +# not, and it must name the case — a red run that does not say which case sends +# the maintainer back into the catalog diff by hand. +t_retightened_case_is_drift() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "a re-tightened case is drift" "exit $rc, want 1" + elif ! grep -q "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache" <<<"$out"; then + fail "a re-tightened case is drift" "it failed without naming the case: ${out##*$'\n'}" + elif ! grep -q "sdk-verifier.jwks" <<<"$out"; then + fail "a re-tightened case is drift" "it named the case but printed no diff of the change" + elif ! grep -q "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache" "$root/summary.md"; then + fail "a re-tightened case is drift" "the case is missing from the drift summary" + else + pass "a case re-tightened under an unchanged id fails, names the case and diffs it" + fi +} + +# --- scoping to the ids this SDK registers ------------------------------------- +# The whole reason the check takes an id list: diffing the entire catalog fires +# on cases this SDK does not cover, and noise is what gets a guard ignored. Same +# drifted catalog as above, with only the untouched case registered. +t_unregistered_drift_is_ignored() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + echo "rfc7009-revocation-server-errors-must-surface" > "$root/ids.txt" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 0 ]]; then + fail "drift outside the registered ids is ignored" "exit $rc, want 0: ${out##*$'\n'}" + else + pass "a case that drifted but is not registered does not fail the check" + fi +} + +# --- standards_in_scope ids are not cases -------------------------------------- +# `standards_in_scope` earlier in the catalog carries its own `- id:` entries, and +# they are plain tokens that pass every id check. If the extractor ever reached +# them, "RFC8414" would compare clean against itself and this check would vouch +# for a case that does not exist. +t_standards_in_scope_is_not_a_case() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + cat > "$root/ids.txt" <<'IDS' +RFC8414 +rfc7009-revocation-server-errors-must-surface +IDS + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "standards_in_scope ids are not cases" "exit $rc, want 1" + elif ! grep -q "registers conformance case 'RFC8414', which the PINNED catalog does not contain" <<<"$out"; then + fail "standards_in_scope ids are not cases" "it did not report RFC8414 as absent: ${out##*$'\n'}" + else + pass "an id from standards_in_scope is not found as a case" + fi +} + +# --- a `#` line deep in a scalar is body, not a comment ------------------------ +# Inside a block scalar a leading `#` is text. Dropping such lines as comments +# made an edit to one report clean, which is the single normalization in the +# script that erred toward silence. Both fixtures here keep the structural shape +# identical so nothing but the scalar line can account for the result. +t_comment_shaped_scalar_line_is_body() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + cp "$root/pinned.yaml" "$root/tip.yaml" + + cat >> "$root/pinned.yaml" <<'YAML' + - id: "rfc8414-metadata-refresh-sequence" + use_case: | + Rotation sequence, in order: + # step 2: the AS begins serving new_metadata + expected: + outcome: "accept" +YAML + cat >> "$root/tip.yaml" <<'YAML' + - id: "rfc8414-metadata-refresh-sequence" + use_case: | + Rotation sequence, in order: + # step 2: the AS withdraws jwks-v1.json entirely + expected: + outcome: "accept" +YAML + echo "rfc8414-metadata-refresh-sequence" > "$root/ids.txt" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "a comment-shaped line inside a scalar is body text" "exit $rc, want 1 — the edit was dropped as a comment" + elif ! grep -q "withdraws jwks-v1.json" <<<"$out"; then + fail "a comment-shaped line inside a scalar is body text" "it failed without diffing the edited line" + else + pass "an edit to a #-leading line inside a block scalar reports as drift" + fi +} + +# --- a comment at a structural position stays a comment ------------------------ +# The other half of that trade-off. Comments around and between the case items +# are YAML comments by construction, and treating a re-worded one as drift would +# be the noise the scoping above exists to avoid. +t_structural_comment_is_not_body() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + cp "$root/pinned.yaml" "$root/tip.yaml" + + # At the case-item indent, and at column 0 inside the cases block. + cat >> "$root/tip.yaml" <<'YAML' + # Revisit this grouping once the verifier surfaces settle. +# Catalog maintainers: keep the cases sorted by RFC number. +YAML + + local out rc + run_drift "$root" + if [[ "$rc" -ne 0 ]]; then + fail "a structural comment is not body" "exit $rc, want 0: ${out##*$'\n'}" + else + pass "comments at and above the case-item indent do not register as drift" + fi +} + +# --- a registered case the pin does not hold ----------------------------------- +# Nothing can be said about that case's body either way, so the check says so +# rather than counting it as compared-and-clean. +t_registered_id_absent_from_pin() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + cp "$root/pinned.yaml" "$root/tip.yaml" + printf 'rfc9999-a-case-the-pin-never-had\n' >> "$root/ids.txt" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "a registered id absent from the pin fails" "exit $rc, want 1" + elif ! grep -q "rfc9999-a-case-the-pin-never-had" <<<"$out"; then + fail "a registered id absent from the pin fails" "it did not name the case: ${out##*$'\n'}" + else + pass "a registered case the pinned catalog does not hold fails, naming it" + fi +} + +# --- a case removed at the tip is id-level drift, reported elsewhere ----------- +# Removal is what the alignment check reports. Counting it here too would put one +# catalog change in two red steps, so it warns and stays out of the exit status — +# but it must still be named, not folded into the compared-and-clean count. +t_case_absent_from_tip_warns() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + cp "$root/pinned.yaml" "$root/tip.yaml" + # Drop the last case from the tip only — it runs to the end of the file, so + # deleting from its `- id:` line onward removes the whole item rather than + # orphaning its keys onto the case before it. + sed '/- id: "rfc7009-revocation-server-errors-must-surface"/,$d' "$root/tip.yaml" > "$root/tip.trimmed" + mv "$root/tip.trimmed" "$root/tip.yaml" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 0 ]]; then + fail "a case absent from the tip warns" "exit $rc, want 0: ${out##*$'\n'}" + elif ! grep -q "absent from the comparison catalog" <<<"$out"; then + fail "a case absent from the tip warns" "it passed without mentioning the removal" + else + pass "a case removed at the tip warns and leaves the exit status to the alignment check" + fi +} + +# --- an all-blank id list ------------------------------------------------------ +# An empty id list makes this check vacuously green, which is the failure it +# exists to prevent. The wording assertion is not decoration: the grep that +# filters blank lines exits 1 when it selects nothing, and under `pipefail` that +# used to end the script right here — the right exit code with nothing printed. +t_blank_id_list() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + printf '\n \n\t\n\n' > "$root/ids.txt" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "an all-blank id list fails" "exit $rc, want 1" + elif ! grep -q "holds no case ids" <<<"$out"; then + fail "an all-blank id list fails" "it failed silently, with no message to act on: ${out:-(empty)}" + else + pass "an all-blank id list fails with a message rather than a bare exit 1" + fi +} + +# --- an id list holding something that is not an id ---------------------------- +# An entry this check silently drops is a case it silently stops guarding. +t_malformed_id_list() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + echo 'rfc8414-jwks-uri rotation' > "$root/ids.txt" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "a malformed id list fails" "exit $rc, want 1" + elif ! grep -q "not plain case ids" <<<"$out"; then + fail "a malformed id list fails" "unexpected message: ${out##*$'\n'}" + else + pass "an id list entry that is not a plain case id fails" + fi +} + +# --- no id in common with the catalog ------------------------------------------ +# A mismatched pair of inputs, or an id source that produced plausible-looking +# nonsense. Nothing was compared, so nothing is clean. +t_nothing_compared() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + echo 'some-other-catalogs-case-id' > "$root/ids.txt" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "comparing nothing is not a clean run" "exit $rc, want 1" + elif ! grep -q "not one registered case id could be compared" <<<"$out"; then + fail "comparing nothing is not a clean run" "unexpected message: ${out##*$'\n'}" + else + pass "an id list with nothing in common with the catalog fails" + fi +} + +# --- a missing input ----------------------------------------------------------- +# The fetch that produces these files can fail; reading a clean result out of a +# file that is not there is the shape of failure this script refuses. +t_missing_input() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + rm -f "$root/pinned.yaml" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "a missing input fails" "exit $rc, want 1" + elif ! grep -q "is not a readable regular file" <<<"$out"; then + fail "a missing input fails" "unexpected message: ${out##*$'\n'}" + else + pass "a missing catalog fails rather than reporting a clean result" + fi +} + +# --- an empty input ------------------------------------------------------------ +# A truncated clone leaves a file that exists and parses to nothing. +t_empty_input() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_pair "$root" + : > "$root/tip.yaml" + + local out rc + run_drift "$root" + if [[ "$rc" -ne 1 ]]; then + fail "an empty input fails" "exit $rc, want 1" + elif ! grep -q "is empty" <<<"$out"; then + fail "an empty input fails" "unexpected message: ${out##*$'\n'}" + else + pass "an empty catalog file fails rather than reporting a clean result" + fi +} + +# --- catalog shapes the extractor will not guess at ----------------------------- +# Each of these would otherwise be mis-attributed to a neighbouring case, which +# is the quiet kind of wrong: the comparison still runs and still reports. +# Driven off one table because the contract is identical for all of them — fail, +# and say which line and why. +t_malformed_catalog_shapes() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + + local name shape expect + while IFS='|' read -r name expect shape; do + [[ -n "$name" ]] || continue + local dir; dir="$(mktemp -d "$root/XXXXXX")" + make_pair "$dir" + # Only the tip is malformed, so a pass here cannot come from both sides + # being equally unreadable. + printf '%b' "$shape" >> "$dir/tip.yaml" + + local out rc + run_drift "$dir" + if [[ "$rc" -ne 1 ]]; then + fail "$name" "exit $rc, want 1" + elif ! grep -q "$expect" <<<"$out"; then + fail "$name" "unexpected message: ${out##*$'\n'}" + else + pass "$name" + fi + done <<'SHAPES' +a second top-level cases: key fails|a second top-level cases: key|cases:\n - id: "rfc7009-a-second-block"\n title: "x"\n +a non-item line at the case-item indent fails|a non-item line at the case-item indent| title: "orphaned, and it would land on the previous case"\n +a case item whose first key is not id: fails|does not open with an id: key| - title: "id further down"\n id: "rfc7009-id-not-first"\n +a duplicate case id fails|duplicate case id| - id: "rfc7009-revocation-server-errors-must-surface"\n title: "the same id again"\n +a case id that is not a plain token fails|not a plain token| - id: "rfc7009 revocation errors"\n title: "spaces in the id"\n +SHAPES +} + +# --------------------------------------------------------------------------- +# conformance-registered-case-ids.sh +# --------------------------------------------------------------------------- +# +# The report lists one entry per CATALOG case, so presence in it is not +# registration — `nodeid` is. These fixtures are hand-written JSON rather than a +# suite run: the point is what the filter does with each shape, and running the +# suite to produce one would make these controls depend on Go, on the catalog +# clone, and on the suite passing. + +run_ids() { + rc=0 + out="$(CONFORMANCE_REPORT="$1" "$IDSCRIPT" 2>&1)" || rc=$? +} + +# --- only cases with a nodeid are registered ------------------------------------ +# The placeholder entries the report synthesizes for uncovered catalog cases +# carry a case_id and an empty nodeid. Letting one through would put a case this +# SDK never registered into the comparison, where its absence from the pin then +# fails the whole check for no reason. +t_ids_filters_unregistered() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + cat > "$root/report.json" <<'JSON' +{ + "cases": [ + {"case_id": "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache", "status": "passed", "nodeid": "TestRFC8414/jwks_rotation"}, + {"case_id": "rfc7009-revocation-server-errors-must-surface", "status": "failed", "nodeid": "TestRFC7009/server_errors"}, + {"case_id": "rfc9999-not-covered-here", "status": "not_run", "nodeid": ""} + ] +} +JSON + + local out rc + run_ids "$root/report.json" + if [[ "$rc" -ne 0 ]]; then + fail "only cases with a nodeid are registered" "exit $rc, want 0: ${out##*$'\n'}" + elif [[ "$out" != "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache +rfc7009-revocation-server-errors-must-surface" ]]; then + fail "only cases with a nodeid are registered" "printed: ${out//$'\n'/, }" + else + pass "an empty nodeid is filtered out and a failed test still counts as registered" + fi +} + +# --- a report where nothing registered ----------------------------------------- +# Every entry a placeholder: the suite was cached, or it died before any Case() +# fired. An empty list downstream is a vacuously green drift check. +t_ids_nothing_registered() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + cat > "$root/report.json" <<'JSON' +{"cases": [{"case_id": "rfc9999-not-covered-here", "status": "not_run", "nodeid": ""}]} +JSON + + local out rc + run_ids "$root/report.json" + if [[ "$rc" -ne 1 ]]; then + fail "a report with no registration fails" "exit $rc, want 1" + elif ! grep -q "records no case with a nodeid" <<<"$out"; then + fail "a report with no registration fails" "unexpected message: ${out##*$'\n'}" + else + pass "a report whose entries are all placeholders fails" + fi +} + +# --- an entry with no case_id --------------------------------------------------- +# It would drop out of the filter silently and take a real registration with it, +# so the count would be short by one with nothing to show for it. +t_ids_missing_case_id() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + cat > "$root/report.json" <<'JSON' +{ + "cases": [ + {"case_id": "rfc7009-revocation-server-errors-must-surface", "status": "passed", "nodeid": "TestRFC7009"}, + {"status": "passed", "nodeid": "TestSomethingElse"} + ] +} +JSON + + local out rc + run_ids "$root/report.json" + if [[ "$rc" -ne 1 ]]; then + fail "an entry with no case_id fails" "exit $rc, want 1" + elif ! grep -q "missing or non-string case_id" <<<"$out"; then + fail "an entry with no case_id fails" "unexpected message: ${out##*$'\n'}" + else + pass "a case entry without a case_id fails instead of being dropped" + fi +} + +# --- a report that is not there, not JSON, or has no cases ---------------------- +# The workflow deletes the previous report before the run that writes the one +# this reads, so "absent" is a state that really occurs and must not read as +# "nothing registered, carry on". +t_ids_unusable_reports() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + + local out rc + run_ids "$root/absent.json" + if [[ "$rc" -ne 1 ]] || ! grep -q "does not exist" <<<"$out"; then + fail "a missing report fails" "exit $rc: ${out##*$'\n'}" + else + pass "a missing report fails, naming the path" + fi + + echo 'not json at all' > "$root/bad.json" + run_ids "$root/bad.json" + if [[ "$rc" -ne 1 ]] || ! grep -q "not valid JSON" <<<"$out"; then + fail "an unparseable report fails" "exit $rc: ${out##*$'\n'}" + else + pass "a report that is not JSON fails" + fi + + echo '{"cases": []}' > "$root/empty.json" + run_ids "$root/empty.json" + if [[ "$rc" -ne 1 ]] || ! grep -q "no non-empty .cases array" <<<"$out"; then + fail "a report with no cases fails" "exit $rc: ${out##*$'\n'}" + else + pass "a report with an empty cases array fails" + fi +} + +echo "conformance-case-body-drift.sh — case body comparison" +t_identical_catalogs_are_clean +t_retightened_case_is_drift +t_unregistered_drift_is_ignored +t_standards_in_scope_is_not_a_case +t_comment_shaped_scalar_line_is_body +t_structural_comment_is_not_body +t_registered_id_absent_from_pin +t_case_absent_from_tip_warns +t_blank_id_list +t_malformed_id_list +t_nothing_compared +t_missing_input +t_empty_input +t_malformed_catalog_shapes + +echo "conformance-registered-case-ids.sh — registration filter" +t_ids_filters_unregistered +t_ids_nothing_registered +t_ids_missing_case_id +t_ids_unusable_reports + +if [[ "$failures" -gt 0 ]]; then + echo "$failures failing" + exit 1 +fi +echo "all passing" diff --git a/.github/scripts/conformance-registered-case-ids.sh b/.github/scripts/conformance-registered-case-ids.sh new file mode 100755 index 0000000..04f9ea6 --- /dev/null +++ b/.github/scripts/conformance-registered-case-ids.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# Print the conformance case ids THIS SDK registers, one per line. +# +# This is the repo-specific half of the case-body drift check: the id source +# depends on how this repo's harness records registrations, so it lives here and +# conformance-case-body-drift.sh stays generic. +# +# For this repo the ids come out of conformance-report.json, which the +# conformance suite writes from TestMain. That is the harness's own record of +# what registered, so it cannot disagree with what the suite actually did — the +# reason for reading the report rather than grepping Case(t, "...") calls out of +# the test sources, which would be a second, weaker id extractor that reports +# what it matched and stays silent about what it missed. +# +# The report lists one entry per CATALOG case, so presence in it is not +# registration. `nodeid` is: Case() sets it at registration time, and the +# placeholder entries the report synthesizes for uncovered catalog cases leave +# it empty. Reading `nodeid` rather than `status` matters — a registered case +# whose test failed or skipped is still registered, and still needs its body +# watched, but its status is not "passed". +# +# Requires the suite to have run, so the report on disk belongs to this commit. +# +# Inputs (environment): +# CONFORMANCE_REPORT path to conformance-report.json +# (default: $GITHUB_WORKSPACE/conformance-report.json) +# +# Exit status: +# 0 ids printed on stdout +# 1 the report is missing, unreadable, or holds no registered case + +set -euo pipefail + +REPORT="${CONFORMANCE_REPORT:-${GITHUB_WORKSPACE:-.}/conformance-report.json}" + +fail() { + echo "::error::$1" >&2 + exit 1 +} + +if ! command -v jq > /dev/null 2>&1; then + fail "registered case ids: jq is not available, so the conformance report cannot be read." +fi + +if [[ ! -f "$REPORT" ]]; then + fail "registered case ids: '$REPORT' does not exist. The conformance suite writes it from TestMain, so either the suite did not run or it failed before the report was written." +fi + +if ! jq -e . "$REPORT" > /dev/null 2>&1; then + fail "registered case ids: '$REPORT' is not valid JSON." +fi + +if ! jq -e '(.cases | type) == "array" and (.cases | length) > 0' "$REPORT" > /dev/null 2>&1; then + fail "registered case ids: '$REPORT' has no non-empty .cases array." +fi + +# An entry with a case_id that is not a string, or empty, would silently drop +# out of the filter below and take a real registration with it. +if ! jq -e 'all(.cases[]; (.case_id | type) == "string" and (.case_id | length) > 0)' "$REPORT" > /dev/null 2>&1; then + fail "registered case ids: '$REPORT' holds a case entry with a missing or non-string case_id." +fi + +ids="$(jq -r '.cases[] | select((.nodeid // "") != "") | .case_id' "$REPORT")" + +if [[ -z "$ids" ]]; then + fail "registered case ids: '$REPORT' records no case with a nodeid, so no conformance marker registered. Any check restricted to this list would be vacuously green." +fi + +printf '%s\n' "$ids" diff --git a/.github/scripts/fetch-conformance-catalog.sh b/.github/scripts/fetch-conformance-catalog.sh index 5652a5b..787f7cc 100755 --- a/.github/scripts/fetch-conformance-catalog.sh +++ b/.github/scripts/fetch-conformance-catalog.sh @@ -2,23 +2,33 @@ # # Fetch the conformance catalog at the revision this repo pins. # -# The catalog lives in github.com/AuthPlane/conformance and is updated -# independently of this repo, so cloning its default branch would let a catalog +# Generic: nothing here is specific to one workflow or one caller's layout. +# Every workflow in this repo that needs the pinned catalog runs this one +# script, so a guard tightened here is tightened for all of them. +# +# The catalog lives in github.com/AuthPlane/conformance — a public repo, updated +# independently of this one — so cloning its default branch would let a catalog # change turn an unrelated PR red here. The ref is pinned instead, single-sourced -# from the tracked .conformance-catalog-ref at the repo root — bump it there when +# from the tracked .conformance-catalog-ref at the repo root: bump it when # adopting new catalog cases, together with the coverage for them, so a catalog # change can never break CI on its own. # -# This script exists because the read/guard/fetch sequence is needed by more than -# one workflow (ci.yml and release.yml). Keeping it inline in both meant the -# guard could be tightened in one and not the other; the pin was single-sourced -# but the logic reading it was not. -# # Clones into $RUNNER_TEMP — outside $GITHUB_WORKSPACE — so the catalog stays out -# of the working tree: it must never trip `go list ./...` or a coverage glob, and -# `git add -A` in the release commit must never stage it as a gitlink. +# of the working tree: it must never be picked up by this repo's own build, test +# or coverage tooling, and `git add -A` in the release commit must never stage it +# as an embedded gitlink. +# +# Plain git over HTTPS is enough: the repo is public and read-only here, so there +# is no token to plumb and no third-party action surface to SHA-pin. # # Requires: GITHUB_WORKSPACE, RUNNER_TEMP. +# +# Optional: CONFORMANCE_CATALOG_DEST overrides the clone directory. A caller that +# needs the pinned catalog and the catalog tip side by side in the same job +# cannot let both land on the default path. Every other caller leaves it unset +# and gets $RUNNER_TEMP/conformance. It must be an absolute path outside +# $GITHUB_WORKSPACE: the out-of-tree rule above holds for every destination, not +# only the default, and is enforced below rather than left to the caller. set -euo pipefail @@ -26,8 +36,25 @@ set -euo pipefail : "${RUNNER_TEMP:?RUNNER_TEMP must be set}" REF_FILE="$GITHUB_WORKSPACE/.conformance-catalog-ref" -DEST="$RUNNER_TEMP/conformance" +DEST="${CONFORMANCE_CATALOG_DEST:-$RUNNER_TEMP/conformance}" CATALOG_REPO="https://github.com/AuthPlane/conformance.git" +CATALOG_FILE="oauth-sdk-conformance-catalog.yaml" + +# Hold the destination to the out-of-tree rule, override or not. A relative path +# resolves against the caller's working directory — $GITHUB_WORKSPACE for a +# `run:` step — and an in-workspace path puts the clone in the tree directly; a +# destination equal to the workspace root would have the checkout below replace +# this repo's own working tree with the catalog. The trailing slash is stripped +# so a destination under a $GITHUB_WORKSPACE written with one is still caught. +WORKSPACE="${GITHUB_WORKSPACE%/}" +if [[ "$DEST" != /* ]]; then + echo "::error::The conformance catalog destination must be an absolute path, got '$DEST'" + exit 1 +fi +if [[ "$DEST" == "$WORKSPACE" || "$DEST" == "$WORKSPACE"/* ]]; then + echo "::error::The conformance catalog destination must be outside \$GITHUB_WORKSPACE ($WORKSPACE), got '$DEST'" + exit 1 +fi if [[ ! -f "$REF_FILE" ]]; then echo "::error::$REF_FILE is missing; the conformance catalog revision is unpinned" @@ -36,8 +63,8 @@ fi CONFORMANCE_CATALOG_REF="$(tr -d '[:space:]' < "$REF_FILE")" -# Guard against un-pinning: the ref must be a full commit SHA, not a branch or -# tag name, either of which would silently track a moving target. +# Guard against un-pinning BEFORE the fetch: the ref must be a full commit SHA, +# not a branch or tag name, either of which would silently track a moving target. if ! grep -Eq '^[0-9a-f]{40}$' <<< "$CONFORMANCE_CATALOG_REF"; then echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA, got '$CONFORMANCE_CATALOG_REF'" exit 1 @@ -50,4 +77,13 @@ if ! git -C "$DEST" fetch --depth=1 "$CATALOG_REPO" "$CONFORMANCE_CATALOG_REF"; fi git -C "$DEST" checkout -q FETCH_HEAD -echo "Conformance catalog checked out at $CONFORMANCE_CATALOG_REF" +# The alignment assertion hard-fails when CONFORMANCE_CATALOG_PATH points at a +# missing file, but it reports that as a harness problem rather than drift. Fail +# here instead, where the cause is unambiguous: the fetch succeeded and the +# catalog still is not where every caller expects it. +if [[ ! -f "$DEST/$CATALOG_FILE" ]]; then + echo "::error::$CATALOG_FILE is not in the catalog at $CONFORMANCE_CATALOG_REF; the fetch succeeded but produced no catalog in $DEST" + exit 1 +fi + +echo "Conformance catalog checked out at $CONFORMANCE_CATALOG_REF in $DEST" diff --git a/.github/scripts/fetch-conformance-catalog.test.sh b/.github/scripts/fetch-conformance-catalog.test.sh new file mode 100755 index 0000000..48c524d --- /dev/null +++ b/.github/scripts/fetch-conformance-catalog.test.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests for fetch-conformance-catalog.sh. +# +# Every workflow that needs the catalog reads the pin through this one script, +# so a guard that stops holding here stops holding everywhere. An edit that +# drops a guard, mistypes the catalog filename or softens a refusal is still +# valid shell, so shellcheck — the only other gate on it — sees nothing. What +# changes is the diagnosis: an unpinned ref silently tracks a moving target, a +# destination in the working tree is staged as a gitlink or checked out over +# this repo, and a fetch that lands no catalog surfaces much later, in another +# job, as a harness problem. These tests pin each refusal to its own message so +# such an edit fails at PR time. +# +# Nothing here reaches the network. The cases that need a real fetch redirect +# the catalog URL at a local fixture repo through a per-invocation `insteadOf`, +# and GIT_ALLOW_PROTOCOL=file turns a redirect that failed to apply into a hard +# failure rather than a real clone. The ambient git configuration is neutralised +# for the same reason: a developer's own url rewrite must not get to decide what +# these tests exercise. +# +# Run: .github/scripts/fetch-conformance-catalog.test.sh + +SCRIPTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FETCH="$SCRIPTDIR/fetch-conformance-catalog.sh" +CATALOG_FILE="oauth-sdk-conformance-catalog.yaml" +CATALOG_URL="https://github.com/AuthPlane/conformance.git" + +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null + +failures=0 + +# One root that an EXIT trap removes, so a fixture still gets cleaned up when +# `set -e` kills the shell from inside a helper — the moment a leak is least +# welcome. The per-case RETURN traps below do not fire then. +TESTROOT="$(mktemp -d)" +trap 'rm -rf "$TESTROOT"' EXIT + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n %s\n' "$1" "$2"; failures=$((failures + 1)); } + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +# Builds a one-commit repo at $1 and prints its commit SHA. With $2 == "catalog" +# the commit carries the catalog file; otherwise it carries an unrelated file, +# which is the tree the missing-catalog refusal exists for. +make_repo() { + local dir="$1" contents="$2" + git init -q -b main "$dir" + # The script fetches a bare commit SHA rather than a ref, which the default + # upload-pack policy refuses. + git -C "$dir" config uploadpack.allowAnySHA1InWant true + if [[ "$contents" == "catalog" ]]; then + printf 'cases: []\n' > "$dir/$CATALOG_FILE" + else + printf 'this commit carries no catalog\n' > "$dir/README.md" + fi + git -C "$dir" add -A + git -C "$dir" -c user.name=fixture -c user.email=fixture@example.invalid \ + -c commit.gpgsign=false commit -q -m "fixture" + git -C "$dir" rev-parse HEAD +} + +WITH_CATALOG="$TESTROOT/fixture-with-catalog" +WITHOUT_CATALOG="$TESTROOT/fixture-without-catalog" +WITH_CATALOG_SHA="$(make_repo "$WITH_CATALOG" catalog)" +WITHOUT_CATALOG_SHA="$(make_repo "$WITHOUT_CATALOG" bare)" + +# Lays out the workspace and runner temp the script requires under $1, pinned to +# $2. The literal "none" writes no pin file at all. +make_case() { + local root="$1" ref="$2" + mkdir -p "$root/ws" "$root/tmp" + [[ "$ref" == "none" ]] || printf '%s\n' "$ref" > "$root/ws/.conformance-catalog-ref" +} + +# Runs the script against the workspace and runner temp under $1, with the +# catalog URL redirected at the fixture repo $2. Any further arguments are +# passed through as VAR=VALUE environment entries; they come after the ones set +# here, so a case that needs a different GITHUB_WORKSPACE can say so. The call +# is made from inside the workspace, which is where a `run:` step starts, so a +# relative destination resolves as it would on a runner. Captures stdout and +# stderr together into $out and the exit status into $rc — both declared `local` +# by the caller. +run_fetch() { + local root="$1" fixture="$2" + shift 2 + rc=0 + out="$(cd "$root/ws" && env \ + GITHUB_WORKSPACE="$root/ws" \ + RUNNER_TEMP="$root/tmp" \ + GIT_ALLOW_PROTOCOL=file \ + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="url.file://$fixture.insteadOf" \ + GIT_CONFIG_VALUE_0="$CATALOG_URL" \ + "$@" \ + "$FETCH" 2>&1)" || rc=$? +} + +# --------------------------------------------------------------------------- +# fetch-conformance-catalog.sh +# --------------------------------------------------------------------------- + +# --- the positive control ------------------------------------------------------ +# Without this every refusal below could be satisfied by a script that fails +# unconditionally, and the suite would look green while guarding nothing. +t_catalog_present_succeeds() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" "$WITH_CATALOG_SHA" + + local out rc + run_fetch "$root" "$WITH_CATALOG" + if [[ "$rc" -ne 0 ]]; then + fail "a fetch that lands the catalog succeeds" "exit $rc, want 0: ${out##*$'\n'}" + elif [[ ! -f "$root/tmp/conformance/$CATALOG_FILE" ]]; then + fail "a fetch that lands the catalog succeeds" "no catalog under the default destination" + elif ! grep -q "checked out at $WITH_CATALOG_SHA in $root/tmp/conformance" <<<"$out"; then + fail "a fetch that lands the catalog succeeds" "it did not report the ref and destination: ${out##*$'\n'}" + else + pass "a fetch that lands the catalog exits 0, reporting the ref and destination" + fi +} + +# --- the destination override -------------------------------------------------- +# The drift workflow holds the pinned catalog and the catalog tip side by side in +# one job, so it cannot let both land on the default path. An override that +# stopped being honoured would put the pinned checkout on top of the tip clone, +# where the two then compare equal and the check reports green forever. +t_destination_override_is_honoured() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" "$WITH_CATALOG_SHA" + + local out rc + run_fetch "$root" "$WITH_CATALOG" CONFORMANCE_CATALOG_DEST="$root/elsewhere" + if [[ "$rc" -ne 0 ]]; then + fail "CONFORMANCE_CATALOG_DEST is honoured" "exit $rc, want 0: ${out##*$'\n'}" + elif [[ ! -f "$root/elsewhere/$CATALOG_FILE" ]]; then + fail "CONFORMANCE_CATALOG_DEST is honoured" "no catalog under the override" + elif [[ -e "$root/tmp/conformance" ]]; then + fail "CONFORMANCE_CATALOG_DEST is honoured" "it wrote the default destination as well" + elif ! grep -q "checked out at $WITH_CATALOG_SHA in $root/elsewhere" <<<"$out"; then + fail "CONFORMANCE_CATALOG_DEST is honoured" "it did not report the override: ${out##*$'\n'}" + else + pass "CONFORMANCE_CATALOG_DEST moves the checkout and nothing lands on the default path" + fi +} + +# --- a destination that is not an absolute path -------------------------------- +# A relative destination resolves against the caller's working directory, which +# for a `run:` step is $GITHUB_WORKSPACE, so the clone lands in the tree and +# `git add -A` in the release commit stages it as a 160000 gitlink — all of it +# at exit 0. The refusal has to echo the value, because what the workflow author +# reads in the log is a path that looks nothing like where the clone went. +t_relative_destination_fails() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" "$WITH_CATALOG_SHA" + + local out rc + run_fetch "$root" "$WITH_CATALOG" CONFORMANCE_CATALOG_DEST=relative/path + if [[ "$rc" -ne 1 ]]; then + fail "a relative destination is rejected" "exit $rc, want 1: ${out##*$'\n'}" + elif ! grep -qF "must be an absolute path, got 'relative/path'" <<<"$out"; then + fail "a relative destination is rejected" "it did not name the rule and echo the value: ${out##*$'\n'}" + elif [[ -e "$root/ws/relative" ]]; then + fail "a relative destination is rejected" "it resolved it against the workspace and cloned into the tree" + elif [[ -e "$root/tmp/conformance" ]]; then + fail "a relative destination is rejected" "it initialised the default destination before refusing" + else + pass "a relative destination is rejected, naming the rule, before anything is initialised" + fi +} + +# --- a destination inside the workspace ---------------------------------------- +# The same out-of-tree rule, reached with an absolute path. A destination under +# the workspace puts the clone in the tree directly; a destination equal to the +# workspace root is worse, because the checkout then replaces this repo's own +# working tree with the catalog and still exits 0. The third pair pins the +# trailing-slash strip: a workspace written with one must still match, or the +# prefix test looks for a doubled slash and lets every destination through. +t_in_workspace_destination_fails() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" "$WITH_CATALOG_SHA" + + # Cases are labelled by the workspace-relative tail of each path: the roots + # are mktemp names, and the trailing slash is the whole point of the third. + local spec dest ws label out rc + for spec in "$root/ws/inside|$root/ws" "$root/ws|$root/ws" "$root/ws/inside|$root/ws/"; do + dest="${spec%%|*}" + ws="${spec##*|}" + label="'${dest#"$root/"}' under GITHUB_WORKSPACE '${ws#"$root/"}'" + rm -rf "${root:?}/tmp/conformance" "$root/ws/inside" "$root/ws/.git" + run_fetch "$root" "$WITH_CATALOG" \ + CONFORMANCE_CATALOG_DEST="$dest" GITHUB_WORKSPACE="$ws" + if [[ "$rc" -ne 1 ]]; then + fail "$label is rejected" "exit $rc, want 1: ${out##*$'\n'}" + elif ! grep -qF "must be outside \$GITHUB_WORKSPACE (${ws%/}), got '$dest'" <<<"$out"; then + fail "$label is rejected" "it did not name the workspace and echo the value: ${out##*$'\n'}" + elif [[ -e "$dest/.git" ]]; then + fail "$label is rejected" "it initialised a repo in the working tree" + elif [[ -e "$root/tmp/conformance" ]]; then + fail "$label is rejected" "it initialised the default destination before refusing" + else + pass "$label is rejected as inside the workspace, before anything is initialised" + fi + done +} + +# --- a fetch that produces no catalog ------------------------------------------ +# The branch this suite is here for. Downstream the miss surfaces as a failed +# alignment assertion, which reports a harness problem rather than the truth: +# the fetch succeeded and the catalog is not in the tree it produced. +t_fetch_without_catalog_fails() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" "$WITHOUT_CATALOG_SHA" + + local out rc + run_fetch "$root" "$WITHOUT_CATALOG" + if [[ "$rc" -ne 1 ]]; then + fail "a fetch that lands no catalog fails" "exit $rc, want 1: ${out##*$'\n'}" + elif ! grep -q "$CATALOG_FILE is not in the catalog at $WITHOUT_CATALOG_SHA" <<<"$out"; then + fail "a fetch that lands no catalog fails" "it did not name the file and the ref: ${out##*$'\n'}" + elif ! grep -q "produced no catalog in $root/tmp/conformance" <<<"$out"; then + fail "a fetch that lands no catalog fails" "it did not name the destination: ${out##*$'\n'}" + else + pass "a fetch that lands no catalog fails, naming the file, the ref and the destination" + fi +} + +# Same miss, reached through the override, because that is the call site where +# "which fetch failed" is a real question: one job runs the script twice. +t_fetch_without_catalog_fails_under_override() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" "$WITHOUT_CATALOG_SHA" + + local out rc + run_fetch "$root" "$WITHOUT_CATALOG" CONFORMANCE_CATALOG_DEST="$root/elsewhere" + if [[ "$rc" -ne 1 ]]; then + fail "the miss is reported against the override" "exit $rc, want 1: ${out##*$'\n'}" + elif ! grep -q "produced no catalog in $root/elsewhere" <<<"$out"; then + fail "the miss is reported against the override" "it named the wrong destination: ${out##*$'\n'}" + else + pass "the miss names the overridden destination, not the default one" + fi +} + +# --- the pin file is not there ------------------------------------------------- +# The workspace is the repo checkout, so an absent pin file means the pin was +# deleted rather than left unset — the catalog revision is then whatever the +# default branch happens to hold, which is the state this script exists to make +# impossible. +t_missing_ref_file_fails() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" none + + local out rc + run_fetch "$root" "$WITH_CATALOG" + if [[ "$rc" -ne 1 ]]; then + fail "a missing pin file fails" "exit $rc, want 1: ${out##*$'\n'}" + elif ! grep -q "$root/ws/.conformance-catalog-ref is missing" <<<"$out"; then + fail "a missing pin file fails" "it did not name the path: ${out##*$'\n'}" + elif ! grep -q "conformance catalog revision is unpinned" <<<"$out"; then + fail "a missing pin file fails" "wrong diagnosis: ${out##*$'\n'}" + elif [[ -e "$root/tmp/conformance" ]]; then + fail "a missing pin file fails" "it initialised a destination before refusing" + else + pass "a missing pin file fails before anything is fetched, naming the path" + fi +} + +# --- a pin that is not a full commit SHA --------------------------------------- +# A branch or tag name fetches fine and tracks a moving target, so the guard has +# to run before the fetch, not after it: the failure it prevents is a green run, +# not a red one. +t_unpinned_ref_fails_before_the_fetch() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + + local ref out rc + for ref in main v1.2.3 "${WITH_CATALOG_SHA:0:7}" "${WITH_CATALOG_SHA^^}"; do + rm -rf "${root:?}"/* + make_case "$root" "$ref" + run_fetch "$root" "$WITH_CATALOG" + if [[ "$rc" -ne 1 ]]; then + fail "'$ref' is rejected as a pin" "exit $rc, want 1: ${out##*$'\n'}" + elif ! grep -q "must be a 40-hex commit SHA, got '$ref'" <<<"$out"; then + fail "'$ref' is rejected as a pin" "it did not echo the rejected ref: ${out##*$'\n'}" + elif [[ -e "$root/tmp/conformance" ]]; then + fail "'$ref' is rejected as a pin" "it initialised a destination, so the guard ran after the fetch" + else + pass "'$ref' is rejected before anything is fetched" + fi + done +} + +# --- a pin that is well formed but not there ----------------------------------- +# The refusal the missing-catalog branch has to stay distinguishable from: a ref +# the catalog repo no longer serves is a different problem from a ref it serves +# without a catalog in it. +t_unreachable_ref_fails() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + make_case "$root" "0000000000000000000000000000000000000000" + + local out rc + run_fetch "$root" "$WITH_CATALOG" + if [[ "$rc" -ne 1 ]]; then + fail "an unreachable pin fails" "exit $rc, want 1: ${out##*$'\n'}" + elif ! grep -q "0000000000000000000000000000000000000000 is unreachable" <<<"$out"; then + fail "an unreachable pin fails" "wrong diagnosis: ${out##*$'\n'}" + elif grep -q "produced no catalog" <<<"$out"; then + fail "an unreachable pin fails" "it reported the miss as a missing catalog instead" + else + pass "an unreachable pin fails as unreachable, not as a missing catalog" + fi +} + +echo "fetch-conformance-catalog.sh — pin, fetch and catalog checks" +t_catalog_present_succeeds +t_destination_override_is_honoured +t_relative_destination_fails +t_in_workspace_destination_fails +t_fetch_without_catalog_fails +t_fetch_without_catalog_fails_under_override +t_missing_ref_file_fails +t_unpinned_ref_fails_before_the_fetch +t_unreachable_ref_fails + +if [[ "$failures" -gt 0 ]]; then + echo "$failures failing" + exit 1 +fi +echo "all passing" diff --git a/.github/workflows/conformance-catalog-drift.yml b/.github/workflows/conformance-catalog-drift.yml index 700135a..213514c 100644 --- a/.github/workflows/conformance-catalog-drift.yml +++ b/.github/workflows/conformance-catalog-drift.yml @@ -12,6 +12,20 @@ name: Conformance Catalog Drift # # When this job fails on drift, adopt the new cases in core/conformancetests/ # and bump .conformance-catalog-ref to the new catalog SHA in the same change. +# +# The job runs TWO checks, because they see different things: +# +# 1. Case-ID alignment against the tip. Reports cases ADDED to the catalog +# that this SDK does not yet cover. This is a set comparison over ids. +# +# 2. Case-BODY drift for the cases this SDK registers. Reports a case that was +# re-tightened IN PLACE — same id, changed requirement. Check 1 is blind to +# this by construction: the id set is unchanged, so the case still looks +# adopted while the requirement underneath it has moved, and the SDK keeps +# declaring conformance to wording nothing verified it against. That has +# already happened once, to the metadata jwks_uri rotation case. Check 2 +# needs the catalog at BOTH refs in the same job, which is why the pinned +# catalog is fetched here alongside the tip. on: schedule: @@ -66,9 +80,101 @@ jobs: shell: bash run: go test ./conformancetests/ -v 2>&1 | tee "$RUNNER_TEMP/align.log" + # The catalog at the ref this repo PINS, alongside the tip cloned above. + # The body check needs both to compare anything; with only the tip it + # would have nothing to compare against and could report nothing but + # green. Reuses the same fetch the pinned workflows use — including its + # 40-hex-SHA guard and its unreachable-ref failure — rather than a second + # copy of that logic that could be tightened in one place and not the + # other. CONFORMANCE_CATALOG_DEST keeps it off the default path, which + # the tip clone above already occupies. + # + # Placed AFTER the alignment check, with if: always(), so the two checks + # fail independently. Only check 2 reads the pinned catalog. Run before + # `align`, a failed fetch — a transient error on this second clone, or a + # pinned SHA that has become unreachable — would skip `Setup Go` and + # `align` (neither carries an `if:`, so both default to `success()`) and + # silently drop the id-level check that ran on its own before check 2 + # existed. `Report drift` would then find no align.log and report the + # skip as a build or harness problem, which is the wrong diagnosis for a + # check that never started. + - name: Fetch pinned conformance catalog (out of tree) + if: always() + env: + CONFORMANCE_CATALOG_DEST: ${{ runner.temp }}/conformance-pinned + run: .github/scripts/fetch-conformance-catalog.sh + + # The ids for check 2. They come from the harness's own report rather than + # from a grep over the test sources, so they cannot disagree with what + # actually registered. + # + # Run against the PINNED catalog, not the tip. The question the body check + # asks is "which cases does this SDK declare conformance to under its + # current pin", and anchoring the list to the pin keeps it stable while + # the tip moves. It also keeps this step independent of the step above, + # which is EXPECTED to go red whenever the tip has drifted. + # + # if: always() because of that: the id-level check failing is the normal + # way this job reports, and the body check must still run after it. + - name: Collect the case ids this SDK registers + if: always() + working-directory: core + env: + CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance-pinned/oauth-sdk-conformance-catalog.yaml + shell: bash + run: | + # Delete the report the step above wrote against the TIP catalog. If + # the run below fails to produce a new one, the id script must find + # nothing rather than silently read the previous step's report and + # describe the wrong catalog. + rm -f "$GITHUB_WORKSPACE/conformance-report.json" + + # -count=1 defeats the test cache. A cached run does not execute + # TestMain, so it writes no report at all — and the check downstream + # would then be reading a stale artifact or nothing. + status=0 + go test -count=1 ./conformancetests/ > "$RUNNER_TEMP/pinned-suite.log" 2>&1 || status=$? + if [ "$status" -ne 0 ]; then + # Not this job's signal to raise: a suite that fails against its own + # pinned catalog is red on every PR in ci.yml already. Surfaced, and + # the id list still comes from THIS run's report, never an older one. + echo "::warning::The conformance suite did not pass against the pinned catalog (exit $status). This job reports catalog drift, not suite health — see ci.yml. Log tail follows." + tail -n 40 "$RUNNER_TEMP/pinned-suite.log" + fi + + "$GITHUB_WORKSPACE/.github/scripts/conformance-registered-case-ids.sh" \ + > "$RUNNER_TEMP/registered-case-ids.txt" + echo "This SDK registers $(wc -l < "$RUNNER_TEMP/registered-case-ids.txt") conformance case(s)." + + # Check 2. Fails the job when a case this SDK registers changed body + # between the pinned ref and the tip, and fails just as loudly when it + # cannot do that comparison at all. + - name: Check pinned case bodies against the catalog tip + id: bodies + if: always() + shell: bash + env: + PINNED_CATALOG: ${{ runner.temp }}/conformance-pinned/oauth-sdk-conformance-catalog.yaml + TIP_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml + REGISTERED_IDS: ${{ runner.temp }}/registered-case-ids.txt + run: | + DRIFT_SUMMARY="$GITHUB_STEP_SUMMARY" \ + .github/scripts/conformance-case-body-drift.sh + - name: Report drift if: always() run: | + # The body check writes its own detail into the step summary when it + # finds something. Recorded here either way, so a green run states + # that both checks ran rather than only the one that prints on + # success — a check whose silence is indistinguishable from its + # absence is how this class went unnoticed in the first place. + if [ "${{ steps.bodies.outcome }}" = "success" ]; then + echo "Conformance case bodies: no registered case changed shape under an unchanged id." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::The pinned case-body check did not pass (outcome: ${{ steps.bodies.outcome }}). Either a case this SDK registers was re-tightened under the same id, or the check could not run. Read its step log — it names the case." + fi + if [ "${{ steps.align.outcome }}" = "success" ]; then echo "Conformance catalog alignment: no drift against the latest catalog." >> "$GITHUB_STEP_SUMMARY" elif ! grep -qE "has no conformance test|registers unknown case" "$RUNNER_TEMP/align.log" 2>/dev/null; then diff --git a/.github/workflows/workflows-lint.yml b/.github/workflows/workflows-lint.yml index 48cfbda..cd46ae5 100644 --- a/.github/workflows/workflows-lint.yml +++ b/.github/workflows/workflows-lint.yml @@ -7,22 +7,33 @@ name: Release tooling # release, which is the worst moment to discover it — so they are linted # and tested here too. # -# Scoped to `.github/workflows/**` and `scripts/*.sh` to keep CI overhead -# off unrelated PRs. `.github/scripts/*.sh` is deliberately not in scope: -# it is driven by conformance-catalog-drift.yml, not by the release flow -# this job guards, and pulling it in would widen the trigger to every PR -# touching `.github/**`. +# Scoped to `.github/workflows/**`, `scripts/*.sh` and `.github/scripts/*.sh` +# to keep CI overhead off unrelated PRs. +# +# `.github/scripts/*.sh` was previously out of scope on the grounds that it +# serves the drift workflow rather than the release flow this job guards. It is +# in scope now: those scripts stopped being thin fetch wrappers once the +# conformance case-body drift check landed there, and they run only on a weekly +# schedule, so a break in them surfaces late and quietly — the same "surfaces +# only when someone reaches for it" argument that put `scripts/*.sh` here. The +# trigger stays narrow: the added path matches PRs touching those scripts, not +# every PR touching `.github/**`. +# +# Linting alone would not have been enough for them, so they carry their own +# tests here as well, next to backport-fixes.test.sh. on: pull_request: paths: - ".github/workflows/**" + - ".github/scripts/*.sh" - "scripts/*.sh" push: branches: - main paths: - ".github/workflows/**" + - ".github/scripts/*.sh" - "scripts/*.sh" permissions: @@ -76,9 +87,32 @@ jobs: - name: Shellcheck the release scripts run: shellcheck scripts/*.sh + - name: Shellcheck the workflow support scripts + run: shellcheck .github/scripts/*.sh + # backport-fixes.sh accepts a branch or a tag as --from, and only the # branch form has a remote-tracking ref. The tag form is what the release # flow tells you to use once release.yml has deleted the branch, so it is # the form least likely to be exercised before it is needed. - name: Test backport-fixes.sh run: scripts/backport-fixes.test.sh + + # Shellcheck above is the only other gate on the conformance drift + # scripts, and it cannot see the way they break: a loosened id regex that + # drops cases, or a `diff` whose exit code stops being read, is valid + # shell. The result is a check that reports green while guarding nothing, + # on a weekly schedule where nobody is watching — and it would stay + # unnoticed until a real re-tightening slipped through, which is the + # failure that check exists to prevent. These controls pin the detection + # itself. They need neither Go nor the catalog clone, so they run here. + - name: Test conformance-case-body-drift.sh + run: .github/scripts/conformance-case-body-drift.test.sh + + # Same argument, for the script every catalog-consuming workflow reads the + # pin through. Its refusals are what keep an unpinned ref or a fetch that + # lands no catalog from being diagnosed somewhere else, much later — and + # dropping one of them is valid shell. It is also a copy carried between + # repositories, so this is what a copy has to still satisfy. Nothing here + # touches the network: the fetch is redirected at a local fixture repo. + - name: Test fetch-conformance-catalog.sh + run: .github/scripts/fetch-conformance-catalog.test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b7b9e..e1bb75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `core/resource`: `WithResourceMetadataURL(string)` option and `Resource.ResourceMetadataURL()` accessor — point the `WWW-Authenticate` `resource_metadata` parameter at an AS-hosted RFC 9728 document instead of the derived resource-hosted one; the adapters (`http`, `mcp` and `mark3labs` via `Options.ResourceMetadataURL`) advertise it on every challenge. +- `core/resource`: `AuthErrorResponseWithMetadata(err, resourceMetadataURL, realm...)` emits the RFC 9728 §5.1 `resource_metadata` parameter that the `http` adapter used to append itself; the header is byte-identical. +- `core/authplane`: `ErrAccessDenied` and `ErrInvalidTarget` sentinels for the token-endpoint errors `access_denied` (403, cross-client exchange not allowlisted on the target Resource) and `invalid_target` (400, RFC 8707 §2.2); match with `errors.Is`. +- `core/authplane`: the auto-wired introspection checker logs one warning per resource when the AS answers `active: false` for a token that passed local JWT verification, pointing at the runtime-client requirement (authserver ≥ 0.1.2 answers `active: false` to any client that is not the issuing client or a runtime-client of the Resource). +- `core/resource/verifier`: the fail-open revocation branch logs a `log/slog` warning carrying the checker error instead of accepting the token silently. +- `http`, `mcp`, `mark3labs`: every 401 `WWW-Authenticate` challenge now carries `scope="…"` listing the resource's configured scopes (RFC 6750 §3; MCP authorization spec SHOULD), omitted when none are configured. 403 challenges are unchanged. +- `core/resource`: `AuthErrorResponseVerbose(err error, realm ...string)` — `AuthErrorResponse` with the error's own message restored in the JSON `error_description`. A development aid: it discloses SDK-internal detail to unauthenticated callers, so do not use it in production. + +### Fixed +- `core/resource`: a resource URI rejected at construction no longer reaches the error verbatim when it carries an `@` — `net/url.Error` prints its URL field unredacted, so a password in the identifier landed in whatever log the error did. The fragment, query, space and quote branches now always redact to scheme and host, those being the components that carry credentials (`#access_token=…`, `?access_token=…`); the scheme/host branch redacts only when the identifier holds an `@`, so the misconfigurations it exists to diagnose (`/mcp`, `://no-scheme`) still name themselves, and an identifier with no parseable scheme and host redacts to `(unparseable resource URI)` rather than to scheme and host. `errors.As(err, new(*url.Error))` keeps working. +- `core/internal/cache`: a forced refresh now has a retry floor, so an unknown `kid` — a token header an attacker chooses, decoded before anything is authenticated — no longer costs one JWKS fetch per verification. The cost: when a forced fetch within the last `min(jwksCacheTTL, 1m)` did not return the requested `kid`, a newly rotated `kid` is rejected until that floor elapses. A failed fetch now holds the cached document for a backoff window instead of leaving it expired. +- `core/internal/cache`: a server expiry at or before the moment a document was cached — a stale `Expires:` header is the realistic source — is now treated as no preference, letting the configured refresh interval govern, instead of caching the document already expired and putting every subsequent read on the synchronous fetch path. A server expiry *longer* than the configured interval is clamped to it, so `WithJWKSCacheTTL` / `RefreshInterval` is an upper bound and an authorization server cannot pin a key set for longer than the operator asked. A usable server expiry shorter than the configured interval still wins. +- `core/resource`: the JSON error body for a request that presented no credentials no longer names `invalid_request` while the challenge beside it omits `error` entirely. RFC 6750 §3.1 ties `invalid_request` to a malformed request answered with 400, not to a 401 asking the caller to authenticate, so a client reading the body could treat the response as non-retryable instead of starting RFC 9728 discovery, while a client reading the header saw no error at all. The body now omits the member too, carrying only `error_description`; every other code is unchanged, and the challenge is untouched. + +### Changed +- `core/authplane`: `access_denied` and `invalid_target` no longer count toward the circuit breaker — both are policy answers about the request, not an AS outage. +- **BREAKING** `core/resource`: `AuthErrorResponse` no longer copies the error's message into the JSON `error_description`; it emits a fixed sentence chosen by the error code. The `http` adapter's `RequireScopes` changes with it (the verifier's `(*VerifiedClaims).RequireScopes` is unchanged): the missing scopes still travel in the `scope=` challenge parameter, but no longer in the body. **Migration:** the `net/http` adapter now logs the diagnostic to `slog.Default()` at DEBUG; log `err` yourself if you verify through `resource.VerifyToken`, or call `AuthErrorResponseVerbose` in development. +- **BREAKING** `core/resource`: `resource.New` now rejects a literal space in the resource URI path. `net/url` accepts `0x20`, but the derived PRM URL escapes it to `%20` while the document's `resource` member keeps it raw, so RFC 9728 §3.3 makes every client discard the document. **Migration:** percent-encode the space. +- **BREAKING** `core/resource`: `resource.New` now rejects a literal `"` in the resource URI host. `net/url` passes it through unescaped, and it closes the `WWW-Authenticate` quoted-string carrying `resource_metadata` early (RFC 9110 §11.2). **Migration:** remove the quote from the host. +- **BREAKING** `core/resource`: `PRMURL()` now preserves the resource identifier's query in the derived PRM URL — RFC 9728 §3 inserts the well-known string ahead of the path and query, so identifiers differing only by query no longer collapse onto one URL. A bare trailing `?` still derives the query-less form, and `WellKnownPRMPath()` is unchanged. **Migration:** update any hard-coded expectation of the old query-less URL. +- **BREAKING** `core/resource`: `resource.New` now rejects a resource URI whose query is not a valid RFC 3986 §3.4 query. The query now reaches the `resource_metadata` quoted-string, where a literal `"`, a space or a malformed `%zz` makes the 401 challenge unparseable. An identifier that worked in 0.3.0 can now fail at startup. **Migration:** percent-encode the offending octets. +- **BREAKING** `core/resource`: `resource.New` now rejects a resource URI carrying userinfo (RFC 9110 §4.2.4). Such an identifier published the credential three times over: in the PRM document, in the 401 challenge, and in the origin the DPoP `htu` comparison is built from. The empty form `https://@host/mcp` counts; an `@` in a path or query does not. **Migration:** move the credentials out of the identifier. + +### Deprecated +- `core/resource/verifier`: `(*VerifiedClaims).MayAct()` — authserver 0.2.0 no longer issues `may_act`; removed in the next minor. Parsing is unchanged until then. + ## [0.3.0] - 2026-08-27 ### Added diff --git a/README.md b/README.md index 7184048..fd41d8e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,11 @@ func main() { } ``` -That's a complete, secure, standards-compliant MCP resource server. For a plain HTTP resource server, see the [`http` adapter](http/README.md); for `mark3labs/mcp-go` servers, see the [`mark3labs` adapter](mark3labs/README.md). +That's a complete, standards-compliant MCP resource server: every request needs a valid token for this resource, and an unauthenticated one gets a 401 pointing at the metadata document. + +One thing it does *not* do is gate individual tools. `Options.Scopes` is only advertised in the metadata document; the middleware accepts any valid token for the resource, so a token without `tools/query` still reaches every handler. Gate them with `ClaimsFromContext(ctx).RequireScope(...)` — see [Scope-gated tools](mcp/docs/user-guide.md#43-enforce-scope-inside-tool-handlers). + +For a plain HTTP resource server, see the [`http` adapter](http/README.md); for `mark3labs/mcp-go` servers, see the [`mark3labs` adapter](mark3labs/README.md). ## Packages @@ -69,6 +73,11 @@ Each package has its own quickstart and user guide; start at the package README - Go 1.24+ (`core`, `http`) / Go 1.25+ (`mcp`, forced by `github.com/modelcontextprotocol/go-sdk`; `mark3labs`, forced by `github.com/mark3labs/mcp-go`) +## Compatibility + +- Tested against [authserver](https://github.com/AuthPlane/authserver) 0.2.0. +- Introspection-based revocation requires authserver ≥ 0.1.2, and the introspecting client must be confidential and either the issuing client or a runtime-client of the Resource — a public client, or the wrong client, gets `active: false` for every token. + ## Capabilities ### Standards and RFCs diff --git a/core/authplane/circuit_breaker_test.go b/core/authplane/circuit_breaker_test.go index 5d136db..568782e 100644 --- a/core/authplane/circuit_breaker_test.go +++ b/core/authplane/circuit_breaker_test.go @@ -41,6 +41,8 @@ func TestShouldTripCircuitBreaker(t *testing.T) { {"invalid_grant", oauth.ErrInvalidGrant, false}, {"invalid_scope", oauth.ErrInvalidScope, false}, {"use_dpop_nonce", oauth.ErrUseDPoPNonce, false}, + {"access_denied", oauth.ErrAccessDenied, false}, + {"invalid_target", oauth.ErrInvalidTarget, false}, // SSRF — should NOT trip. {"ssrf_blocked", ssrf.ErrSSRFBlocked, false}, @@ -54,6 +56,8 @@ func TestShouldTripCircuitBreaker(t *testing.T) { Cause: oauth.ErrConsentRequired, }), false}, {"wrapped ssrf", fmt.Errorf("metadata: %w", ssrf.ErrSSRFBlocked), false}, + {"wrapped access_denied", fmt.Errorf("exchange: %w: client not allowlisted", oauth.ErrAccessDenied), false}, + {"wrapped invalid_target", fmt.Errorf("exchange: %w: resource mismatch", oauth.ErrInvalidTarget), false}, } for _, tt := range tests { diff --git a/core/authplane/client.go b/core/authplane/client.go index 8885d24..93553c4 100644 --- a/core/authplane/client.go +++ b/core/authplane/client.go @@ -9,8 +9,10 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "os" "strings" + "sync" "time" "github.com/authplane/go-sdk/core/internal/cache" @@ -186,14 +188,39 @@ func NewClient(ctx context.Context, issuer string, opts ...Option) (*Client, err // WithClientAuthentication), RFC 7662 introspection is automatically wired as // the default revocation checker. Any WithRevocationChecker supplied in opts // takes precedence — pass verifier.NullRevocationChecker to explicitly disable it. +// +// The introspecting client must be confidential and either the client the +// token was issued to or a runtime-client of the Resource named in the token's +// aud: authserver ≥ 0.1.2 answers {"active": false} to anyone else, so a +// resource server introspecting with the wrong credentials rejects every +// token as revoked. The checker logs a warning the first time that happens. func (c *Client) Resource(resourceURI string, opts ...resource.Option) (*resource.Resource, error) { if c.auth != nil { + // inactiveWarn gates the ownership warning below. It is declared here, + // not on Client, because runtime-client registration is per resource: a + // Client backing two Resources must be able to warn about each. + var inactiveWarn sync.Once introspectOpt := resource.WithVerifierOptions( - verifier.WithRevocationChecker(func(ctx context.Context, _ *verifier.VerifiedClaims, rawToken string) (bool, error) { + verifier.WithRevocationChecker(func(ctx context.Context, claims *verifier.VerifiedClaims, rawToken string) (bool, error) { resp, err := c.Introspect(ctx, rawToken) if err != nil { return false, err } + if !resp.Active { + // The token passed signature, issuer, audience and + // expiry locally, so active:false is either a real + // revocation or the AS not treating this client as + // the token's owner. Once per resource is enough to + // point at the second cause without flooding logs + // on genuinely revoked tokens. + inactiveWarn.Do(func() { + slog.WarnContext(ctx, "authplane: introspection returned active=false for a token that passed local JWT verification; "+ + "if the token is not revoked, the authorization server does not recognize this client as the token's owner — "+ + "authserver >= 0.1.2 only answers the issuing client or a runtime-client of the resource in aud "+ + "(authserver admin resource runtime-client add --client-id --slug )", + "client_id", c.auth.ClientID, "aud", claims.Audience(), "jti", claims.JTI()) + }) + } return !resp.Active, nil }), ) @@ -413,12 +440,17 @@ func shouldTripCircuitBreaker(err error) bool { } // Per-request or per-token errors — the AS responded correctly. + // access_denied (cross-client exchange not allowlisted on the target + // resource) and invalid_target (resource does not match a granted one) + // are policy answers about this request, not an AS outage. switch { case errors.Is(err, oauth.ErrInvalidGrant), errors.Is(err, oauth.ErrInvalidScope), errors.Is(err, oauth.ErrConsentRequired), errors.Is(err, oauth.ErrInteractionRequired), - errors.Is(err, oauth.ErrUseDPoPNonce): + errors.Is(err, oauth.ErrUseDPoPNonce), + errors.Is(err, oauth.ErrAccessDenied), + errors.Is(err, oauth.ErrInvalidTarget): return false } diff --git a/core/authplane/client_test.go b/core/authplane/client_test.go index f08877f..99d13e5 100644 --- a/core/authplane/client_test.go +++ b/core/authplane/client_test.go @@ -1,9 +1,11 @@ package authplane_test import ( + "bytes" "context" "encoding/json" "errors" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -12,6 +14,7 @@ import ( "time" "github.com/authplane/go-sdk/core/authplane" + "github.com/authplane/go-sdk/core/resource/verifier" "github.com/authplane/go-sdk/core/testutil" "github.com/go-jose/go-jose/v4" ) @@ -481,6 +484,166 @@ func TestClient_Resource(t *testing.T) { } } +// TestClient_Resource_IntrospectionInactive_WarnsOnce pins the auto-wired +// introspection checker's behavior when the AS answers active:false for a +// token that already passed local JWT verification: the token is rejected as +// revoked, and a single WARN names the ownership cause (authserver >= 0.1.2 +// answers active:false to any client that is not the issuing client or a +// runtime-client of the resource in aud) so the operator can tell a +// misconfigured resource server from a genuinely revoked token. +func TestClient_Resource_IntrospectionInactive_WarnsOnce(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + key, err := testutil.GenerateES256Key() + if err != nil { + t.Fatalf("generate key: %v", err) + } + jwksData, err := testutil.BuildJWKSWithKID(&key.PublicKey, "kid") + if err != nil { + t.Fatalf("build JWKS: %v", err) + } + + var serverURL string + var introspectCalls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": serverURL, + "token_endpoint": serverURL + "/token", + "jwks_uri": serverURL + "/jwks", + "introspection_endpoint": serverURL + "/introspect", + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksData) + }) + mux.HandleFunc("/introspect", func(w http.ResponseWriter, r *http.Request) { + introspectCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"active":false}`)) + }) + + server := httptest.NewServer(mux) + defer server.Close() + serverURL = server.URL + + client, err := authplane.NewClient(context.Background(), serverURL, + authplane.WithClientCredentials("rs-client", "rs-secret"), + authplane.WithFetchSettings(authplane.DevModeFetchSettings()), + ) + if err != nil { + t.Fatalf("create client: %v", err) + } + defer client.Close() + + const resourceURI = "https://api.example.com/mcp" + res, err := client.Resource(resourceURI) + if err != nil { + t.Fatalf("create resource: %v", err) + } + + for i := range 2 { + token, err := testutil.SignTokenWithClaims(key, jose.ES256, "kid", serverURL, resourceURI, "user", "issuing-client", nil) + if err != nil { + t.Fatalf("sign token: %v", err) + } + if _, err := res.VerifyToken(context.Background(), token); !errors.Is(err, verifier.ErrTokenRevoked) { + t.Fatalf("call %d: expected ErrTokenRevoked, got %v", i, err) + } + } + if got := introspectCalls.Load(); got != 2 { + t.Fatalf("expected 2 introspection calls, got %d", got) + } + + logged := buf.String() + if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, "runtime-client add") { + t.Fatalf("expected one ownership WARN pointing at the runtime-client requirement, got %q", logged) + } + if n := strings.Count(logged, "active=false"); n != 1 { + t.Errorf("expected the warning exactly once per resource, got %d in %q", n, logged) + } + if !strings.Contains(logged, "client_id=rs-client") { + t.Errorf("expected the introspecting client_id in the log, got %q", logged) + } +} + +// TestClient_Resource_IntrospectionInactive_WarnsPerResource pins the scope of +// the once-only gate: runtime-client registration is per resource, so a Client +// backing two Resources must warn about each. Gating on the Client would leave +// the second resource rejecting every token with nothing in the log. +func TestClient_Resource_IntrospectionInactive_WarnsPerResource(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + key, err := testutil.GenerateES256Key() + if err != nil { + t.Fatalf("generate key: %v", err) + } + jwksData, err := testutil.BuildJWKSWithKID(&key.PublicKey, "kid") + if err != nil { + t.Fatalf("build JWKS: %v", err) + } + + var serverURL string + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": serverURL, + "token_endpoint": serverURL + "/token", + "jwks_uri": serverURL + "/jwks", + "introspection_endpoint": serverURL + "/introspect", + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksData) + }) + mux.HandleFunc("/introspect", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"active":false}`)) + }) + + server := httptest.NewServer(mux) + defer server.Close() + serverURL = server.URL + + client, err := authplane.NewClient(context.Background(), serverURL, + authplane.WithClientCredentials("rs-client", "rs-secret"), + authplane.WithFetchSettings(authplane.DevModeFetchSettings()), + ) + if err != nil { + t.Fatalf("create client: %v", err) + } + defer client.Close() + + for _, resourceURI := range []string{"https://api.example.com/mcp", "https://api.example.com/admin"} { + res, err := client.Resource(resourceURI) + if err != nil { + t.Fatalf("create resource %s: %v", resourceURI, err) + } + token, err := testutil.SignTokenWithClaims(key, jose.ES256, "kid", serverURL, resourceURI, "user", "issuing-client", nil) + if err != nil { + t.Fatalf("sign token for %s: %v", resourceURI, err) + } + if _, err := res.VerifyToken(context.Background(), token); !errors.Is(err, verifier.ErrTokenRevoked) { + t.Fatalf("%s: expected ErrTokenRevoked, got %v", resourceURI, err) + } + } + + logged := buf.String() + if n := strings.Count(logged, "active=false"); n != 2 { + t.Errorf("expected one warning per resource (2), got %d in %q", n, logged) + } +} + func TestClient_CircuitBreaker(t *testing.T) { key, err := testutil.GenerateES256Key() if err != nil { @@ -535,6 +698,72 @@ func TestClient_CircuitBreaker(t *testing.T) { } } +// TestClient_CircuitBreaker_AccessDeniedDoesNotTrip pins that a 403 +// access_denied (cross-client exchange not allowlisted on the target +// resource) and a 400 invalid_target are policy answers, not outages: the +// breaker stays closed however many of them the AS returns. +func TestClient_CircuitBreaker_AccessDeniedDoesNotTrip(t *testing.T) { + tests := []struct { + name string + status int + code string + want error + }{ + {"access_denied", http.StatusForbidden, "access_denied", authplane.ErrAccessDenied}, + {"invalid_target", http.StatusBadRequest, "invalid_target", authplane.ErrInvalidTarget}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": serverURL, + "token_endpoint": serverURL + "/token", + "jwks_uri": serverURL + "/jwks", + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(`{"error":"` + tt.code + `"}`)) + }) + + server := httptest.NewServer(mux) + defer server.Close() + serverURL = server.URL + + client, err := authplane.NewClient(context.Background(), serverURL, + authplane.WithClientCredentials("id", "secret"), + authplane.WithFetchSettings(authplane.DevModeFetchSettings()), + authplane.WithCircuitBreaker(2, 10*time.Second), + ) + if err != nil { + t.Fatalf("create client: %v", err) + } + defer client.Close() + + for i := range 5 { + _, err := client.TokenExchange(context.Background(), authplane.TokenExchangeInput{ + SubjectToken: "subject-token", + Resources: []string{"https://downstream.example.com/"}, + }) + if errors.Is(err, authplane.ErrCircuitOpen) { + t.Fatalf("call %d: circuit opened on %s", i, tt.code) + } + if !errors.Is(err, tt.want) { + t.Fatalf("call %d: expected %v, got %v", i, tt.want, err) + } + } + }) + } +} + func TestClient_Close(t *testing.T) { server, serverURL := mockAS(t) defer server.Close() diff --git a/core/authplane/types.go b/core/authplane/types.go index 8f85702..9b690d4 100644 --- a/core/authplane/types.go +++ b/core/authplane/types.go @@ -69,4 +69,6 @@ var ( ErrUseDPoPNonce = oauth.ErrUseDPoPNonce ErrConsentRequired = oauth.ErrConsentRequired ErrInteractionRequired = oauth.ErrInteractionRequired + ErrAccessDenied = oauth.ErrAccessDenied + ErrInvalidTarget = oauth.ErrInvalidTarget ) diff --git a/core/conformancetests/catalog_loader_test.go b/core/conformancetests/catalog_loader_test.go new file mode 100644 index 0000000..3a7757e --- /dev/null +++ b/core/conformancetests/catalog_loader_test.go @@ -0,0 +1,196 @@ +package conformancetests + +// Tests for loadCatalogMetadata's fail-loudly guards and for the exit-code +// raise TestMain applies when one of them fires. +// +// These guards are the reason the catalog parser exists: a guard that +// under-checks while reporting green is worse than a red run, because the +// report then says "passed" for a contract the suite never fully compared +// itself against. An untested guard is the same failure one level up — it can +// stop firing and nothing notices. So each of the three is pinned here. +// +// The fixtures go through CONFORMANCE_CATALOG_PATH, which loadCatalogMetadata +// reads at call time. That is the documented way to point a run at a specific +// catalog, and t.Setenv restores the previous value when the test ends, so the +// post-run alignment check and report generation in TestMain still see the +// pinned catalog. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeCatalog writes body to a file in a temp dir and points +// CONFORMANCE_CATALOG_PATH at it for the duration of the test. +func writeCatalog(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "catalog.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write fixture catalog: %v", err) + } + t.Setenv("CONFORMANCE_CATALOG_PATH", path) + return path +} + +// A catalog that parses to zero cases must fail the run. Without this guard +// verifyCatalogAlignment's first loop has nothing to iterate, so it reports no +// drift for a catalog it never read — the guard passes precisely because it +// checked nothing. The likely causes are a wrong path or a parser that stopped +// recognizing the cases block, and both look identical to "the catalog is +// fine" from the outside. +func TestLoadCatalogMetadata_RejectsZeroCases(t *testing.T) { + path := writeCatalog(t, "catalog_version: \"1.2.3\"\ncases:\n") + + _, ids, err := loadCatalogMetadata() + if err == nil { + t.Fatalf("loadCatalogMetadata: expected an error for a catalog with no cases, got nil and %d ids", len(ids)) + } + if !strings.Contains(err.Error(), "no cases parsed") { + t.Errorf("error = %v, want it to report that no cases were parsed", err) + } + if !strings.Contains(err.Error(), path) { + t.Errorf("error = %v, want it to name the catalog it read (%s) so a wrong path is diagnosable", err, path) + } +} + +// Two cases sharing an id must fail the run. Ids key both the alignment map and +// the report rows, so the second case would overwrite the first: the suite +// would report on one row where the catalog has two, and the case that lost the +// collision would never be asked for. That is a silent shrink of the contract, +// which is exactly what these guards exist to make loud. +func TestLoadCatalogMetadata_RejectsDuplicateCaseID(t *testing.T) { + writeCatalog(t, `catalog_version: "1.2.3" +cases: + - id: "rfc9728-prm-1" + title: "First" + - id: "rfc8707-resource-1" + title: "Second" + - id: "rfc9728-prm-1" + title: "Third, colliding with the first" +`) + + _, ids, err := loadCatalogMetadata() + if err == nil { + t.Fatalf("loadCatalogMetadata: expected an error for a duplicate case id, got nil and ids %v", ids) + } + msg := err.Error() + if !strings.Contains(msg, "duplicate case id") { + t.Errorf("error = %v, want it to report a duplicate case id", err) + } + // The id and both positions are what make the error actionable in a + // 100+-case catalog; naming only "a duplicate" leaves the operator to find + // it by hand. + if !strings.Contains(msg, "rfc9728-prm-1") { + t.Errorf("error = %v, want it to name the colliding id", err) + } + if !strings.Contains(msg, "1") || !strings.Contains(msg, "3") { + t.Errorf("error = %v, want it to name both colliding case positions", err) + } +} + +// The happy path, as the control for the two guards above: a well-formed +// catalog must load, so a test that fails there fails for the shape it names +// and not because the fixture route itself is broken. +func TestLoadCatalogMetadata_ReadsFixtureCatalog(t *testing.T) { + writeCatalog(t, `catalog_version: "1.2.3" +cases: + - id: "rfc9728-prm-1" + title: "First" + - id: "rfc8707-resource-1" + title: "Second" +`) + + version, ids, err := loadCatalogMetadata() + if err != nil { + t.Fatalf("loadCatalogMetadata: unexpected error: %v", err) + } + if version != "1.2.3" { + t.Errorf("version = %q, want %q", version, "1.2.3") + } + want := []string{"rfc9728-prm-1", "rfc8707-resource-1"} + if len(ids) != len(want) { + t.Fatalf("ids = %v, want %v", ids, want) + } + for i := range want { + // Catalog order is the report's row order, so it is part of the + // contract, not an incidental. + if ids[i] != want[i] { + t.Errorf("ids[%d] = %q, want %q", i, ids[i], want[i]) + } + } +} + +// generateReports must surface a catalog it cannot load as an error rather than +// writing a report anyway. It is the only caller of loadCatalogMetadata that +// runs on every invocation, so this is the path by which a broken catalog +// reaches TestMain — and it must reach it as an error, because a report written +// from a catalog that did not load would carry a summary computed over no +// cases. +func TestGenerateReports_FailsOnUnloadableCatalog(t *testing.T) { + root := projectRoot() + reports := []string{ + filepath.Join(root, "conformance-report.json"), + filepath.Join(root, "conformance-report.md"), + } + before := make([]os.FileInfo, len(reports)) + for i, path := range reports { + if fi, statErr := os.Stat(path); statErr == nil { + before[i] = fi + } + } + + missing := filepath.Join(t.TempDir(), "does-not-exist.yaml") + t.Setenv("CONFORMANCE_CATALOG_PATH", missing) + + err := generateReports(0) + if err == nil { + t.Fatal("generateReports: expected an error for a catalog that cannot be read, got nil") + } + if !strings.Contains(err.Error(), "load catalog") { + t.Errorf("error = %v, want it to report a catalog load failure", err) + } + + // The reports on disk must be left untouched rather than truncated or + // rewritten from a catalog that did not load. A report written from a + // failed load would carry a summary computed over no cases at all, which + // reads as a clean run; a visibly stale report does not. + for i, path := range reports { + fi, statErr := os.Stat(path) + if before[i] == nil { + if statErr == nil { + t.Errorf("generateReports created %s after failing to load the catalog", path) + } + continue + } + if statErr != nil { + t.Errorf("generateReports removed %s after failing to load the catalog: %v", path, statErr) + continue + } + if !fi.ModTime().Equal(before[i].ModTime()) || fi.Size() != before[i].Size() { + t.Errorf("generateReports rewrote %s after failing to load the catalog", path) + } + } +} + +// A guard that fires after m.Run has already returned has no *testing.T to fail, +// so the exit code is the only channel it has. failRun is where that happens: +// TestMain routes both the alignment failure and the report-generation failure +// through it. Before this release a failure there printed to stderr and left +// the exit code alone, so a run whose catalog would not parse still reported +// success — the green-while-under-checking failure the whole guard exists to +// prevent. +func TestFailRun_RaisesGreenAndPreservesRed(t *testing.T) { + if got := failRun(0); got != 1 { + t.Errorf("failRun(0) = %d, want 1: a guard failure must turn a green run red", got) + } + // A non-zero code already names which tests failed; flattening it to 1 + // would throw that away for no gain, since the guards print their own + // detail to stderr. + for _, code := range []int{1, 2, 3} { + if got := failRun(code); got != code { + t.Errorf("failRun(%d) = %d, want %d: an existing failure code must be preserved", code, got, code) + } + } +} diff --git a/core/conformancetests/catalog_parser_shapes_test.go b/core/conformancetests/catalog_parser_shapes_test.go new file mode 100644 index 0000000..508cb5e --- /dev/null +++ b/core/conformancetests/catalog_parser_shapes_test.go @@ -0,0 +1,492 @@ +package conformancetests + +// Crafted-shape tests for parseCatalogCases. +// +// The catalog is shared, it changes independently of this repo, and the +// alignment guard keys on the ids this parser produces. So the property under +// test is not "the current catalog parses" — it does, and a regex managed that +// too. It is that a shape the parser cannot read fails the run rather than +// disappearing from the guard's view. Every negative case below is a shape that +// a match-what-you-can extractor returns quietly and incompletely. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// mustParse parses fixture text and fails the test on error. +func mustParse(t *testing.T, yamlText string) []conformanceCase { + t.Helper() + cases, err := parseCatalogCases(yamlText) + if err != nil { + t.Fatalf("parseCatalogCases: unexpected error: %v", err) + } + return cases +} + +// mustFail parses fixture text, requires an error, and requires the message to +// mention want — the message is the deliverable here, since its whole job is to +// tell a maintainer which catalog line the parser refused and why. +func mustFail(t *testing.T, yamlText, want string) { + t.Helper() + cases, err := parseCatalogCases(yamlText) + if err == nil { + t.Fatalf("parseCatalogCases: expected an error, got %d cases: %+v", len(cases), cases) + } + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to mention %q", err.Error(), want) + } +} + +func assertCases(t *testing.T, got []conformanceCase, want []conformanceCase) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d cases, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("case %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +// An id must accept every scalar style a title does. The regex this replaced +// matched one shape — a double-quoted value alone on its line — and skipped the +// rest in silence, so a plain or single-quoted id, or one carrying an escaped +// quote, was a case the alignment guard never asked about. +func TestParseCatalogCases_IDScalarStyles(t *testing.T) { + tests := []struct { + name string + yaml string + wantID string + wantTit string + }{ + { + name: "double-quoted", + yaml: "cases:\n - id: \"a-b\"\n title: \"T\"\n", + wantID: "a-b", + wantTit: "T", + }, + { + name: "plain unquoted", + yaml: "cases:\n - id: a-b\n title: T\n", + wantID: "a-b", + wantTit: "T", + }, + { + name: "single-quoted", + yaml: "cases:\n - id: 'a-b'\n title: 'T'\n", + wantID: "a-b", + wantTit: "T", + }, + { + // The ticket's own example: an apostrophe is outside the character + // class the old regex allowed on an unquoted value, and inside a + // single-quoted scalar it is written doubled. + name: "single-quoted with doubled apostrophe", + yaml: "cases:\n - id: 'client''s-id'\n title: 'the client''s title'\n", + wantID: "client's-id", + wantTit: "the client's title", + }, + { + name: "double-quoted containing an apostrophe", + yaml: "cases:\n - id: \"client's-id\"\n title: \"the client's title\"\n", + wantID: "client's-id", + wantTit: "the client's title", + }, + { + name: "double-quoted with an escaped quote", + yaml: "cases:\n - id: \"a\\\"b\"\n title: \"say \\\"hi\\\"\"\n", + wantID: `a"b`, + wantTit: `say "hi"`, + }, + { + name: "double-quoted with escaped backslash and solidus", + yaml: "cases:\n - id: \"a\\\\b\"\n title: \"a\\/b\"\n", + wantID: `a\b`, + wantTit: "a/b", + }, + { + name: "double-quoted with tab and newline escapes", + yaml: "cases:\n - id: \"a-b\"\n title: \"one\\ttwo\\nthree\"\n", + wantID: "a-b", + wantTit: "one\ttwo\nthree", + }, + { + name: "double-quoted with a unicode escape", + yaml: "cases:\n - id: \"a-b\"\n title: \"RFC 9068 \\u00a72.2\"\n", + wantID: "a-b", + wantTit: "RFC 9068 §2.2", + }, + { + // The code points immediately below and above the surrogate block, + // which the surrogate rejection must not swallow: the guard is on + // D800-DFFF exactly, not on "high code points". + name: "unicode escapes bracketing the surrogate block", + yaml: "cases:\n - id: \"a-b\"\n title: \"\\ud7ff|\\ue000\"\n", + wantID: "a-b", + wantTit: "\ud7ff|\ue000", + }, + { + name: "plain scalar loses its inline comment", + yaml: "cases:\n - id: a-b # the id\n title: T # the title\n", + wantID: "a-b", + wantTit: "T", + }, + { + // A "#" not preceded by whitespace is data, not a comment. + name: "hash inside a plain scalar is data", + yaml: "cases:\n - id: a#b\n title: T\n", + wantID: "a#b", + wantTit: "T", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertCases(t, mustParse(t, tt.yaml), []conformanceCase{{ID: tt.wantID, Title: tt.wantTit}}) + }) + } +} + +// The catalog emitter wraps every long scalar with an escaped line break plus an +// escaped leading space on the continuation line. A parser that stops at the end +// of the line returns a truncated title, and one that hunts for the next quote +// swallows the lines in between. +func TestParseCatalogCases_WrappedQuotedScalars(t *testing.T) { + t.Run("escaped line break joins without a space", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " title: \"Follow a metadata rotation using only ordinary\\\n" + + " \\ verification traffic\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{ + {ID: "a-b", Title: "Follow a metadata rotation using only ordinary verification traffic"}, + }) + }) + + t.Run("unescaped line break folds to a single space", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " title: \"first part\n" + + " second part\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{ + {ID: "a-b", Title: "first part second part"}, + }) + }) + + t.Run("a wrapped id is joined the same way", func(t *testing.T) { + y := "cases:\n" + + " - id: \"very-long-case\\\n" + + " \\ -id\"\n" + + " title: \"T\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{ + {ID: "very-long-case -id", Title: "T"}, + }) + }) +} + +// A case with an id but no title must stay in the list. The guard keys on ids, +// so dropping the case for want of a field the guard never reads would hide it +// from the only check that matters — and the case would read as "not in the +// catalog" rather than "unimplemented". +func TestParseCatalogCases_TitleFallsBackToID(t *testing.T) { + tests := []struct { + name string + yaml string + }{ + {"no title key at all", "cases:\n - id: \"a-b\"\n surface: \"x\"\n"}, + {"empty double-quoted title", "cases:\n - id: \"a-b\"\n title: \"\"\n"}, + {"title key with no value", "cases:\n - id: \"a-b\"\n title:\n"}, + {"whitespace-only title", "cases:\n - id: \"a-b\"\n title: \" \"\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertCases(t, mustParse(t, tt.yaml), []conformanceCase{{ID: "a-b", Title: "a-b"}}) + }) + } +} + +// Structure the parser must read correctly rather than guess at. +func TestParseCatalogCases_BlockStructure(t *testing.T) { + t.Run("title in a later position is still found", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " surface: \"sdk-verifier\"\n" + + " priority: \"high\"\n" + + " title: \"T\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{{ID: "a-b", Title: "T"}}) + }) + + t.Run("a title nested deeper is not the case title", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " setup:\n" + + " title: \"nested, not mine\"\n" + + " title: \"T\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{{ID: "a-b", Title: "T"}}) + }) + + t.Run("nested list items are not new cases", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " title: \"T\"\n" + + " standard_refs:\n" + + " - \"RFC8414\"\n" + + " - \"RFC8725\"\n" + + " prohibited_mechanisms:\n" + + " - \"force-refresh\"\n" + + " - id: \"c-d\"\n" + + " title: \"U\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{ + {ID: "a-b", Title: "T"}, + {ID: "c-d", Title: "U"}, + }) + }) + + t.Run("field indentation is derived, not assumed", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " title: \"T\"\n" + + " - id: \"c-d\"\n" + + " title: \"U\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{ + {ID: "a-b", Title: "T"}, + {ID: "c-d", Title: "U"}, + }) + }) + + t.Run("blank lines inside the block are skipped", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + "\n" + + " title: \"T\"\n" + + "\n" + + " - id: \"c-d\"\n" + + " title: \"U\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{ + {ID: "a-b", Title: "T"}, + {ID: "c-d", Title: "U"}, + }) + }) + + // A column-0 comment is not a top-level key. Ending the block on the first + // unindented line would drop every case after a banner comment — and the + // report would show those cases as absent from the catalog, not as + // unimplemented. + t.Run("a column-0 comment does not end the block", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " title: \"T\"\n" + + "# ---- RFC 9449 cases ----\n" + + " - id: \"c-d\"\n" + + " title: \"U\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{ + {ID: "a-b", Title: "T"}, + {ID: "c-d", Title: "U"}, + }) + }) + + t.Run("an indented comment does not end the block", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " # a note about this case\n" + + " title: \"T\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{{ID: "a-b", Title: "T"}}) + }) + + // The block ends at the next top-level key, so an "- id:" in a later + // top-level section is not a case. The regex this replaced took everything + // after the first "cases:" in the file, so any later block using the same + // key would have contributed phantom ids. + t.Run("a top-level key ends the block", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " title: \"T\"\n" + + "usage_guidance:\n" + + " - id: \"not-a-case\"\n" + + " title: \"nor this\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{{ID: "a-b", Title: "T"}}) + }) + + t.Run("the document-end marker ends the block", func(t *testing.T) { + y := "cases:\n" + + " - id: \"a-b\"\n" + + " title: \"T\"\n" + + "...\n" + + " - id: \"not-a-case\"\n" + assertCases(t, mustParse(t, y), []conformanceCase{{ID: "a-b", Title: "T"}}) + }) + + t.Run("CRLF line endings", func(t *testing.T) { + y := "cases:\r\n - id: \"a-b\"\r\n title: \"T\"\r\n" + assertCases(t, mustParse(t, y), []conformanceCase{{ID: "a-b", Title: "T"}}) + }) +} + +// Every shape below must fail the run. A parser that skips instead leaves the +// alignment guard green while it checks less than it reports. +func TestParseCatalogCases_UnreadableShapesFailLoudly(t *testing.T) { + tests := []struct { + name string + yaml string + want string + }{ + { + // A case item whose first key is not "id" would otherwise be folded + // into the previous case, and its own id — wherever it sits — never + // seen. + name: "list item that does not start with id", + yaml: "cases:\n - title: \"T\"\n id: \"a-b\"\n", + want: "unrecognized case list item", + }, + { + name: "bare list item", + yaml: "cases:\n - id: \"a-b\"\n title: \"T\"\n -\n", + want: "unrecognized case list item", + }, + { + name: "empty double-quoted id", + yaml: "cases:\n - id: \"\"\n title: \"T\"\n", + want: "empty case id", + }, + { + name: "id key with no value", + yaml: "cases:\n - id:\n title: \"T\"\n", + want: "empty case id", + }, + { + // Bounded to the case item: the next "- id:" sits at the case + // indent, so the quote demonstrably never closed. Consuming to the + // next quote later in the file would swallow whole cases into this + // one value. + name: "unterminated quote bounded by the next case", + yaml: "cases:\n - id: \"a-b\n - id: \"c-d\"\n title: \"U\"\n", + want: "leaves the case item before the closing quote", + }, + { + name: "unterminated quote bounded by a top-level key", + yaml: "cases:\n - id: \"a-b\"\n title: \"unclosed\n usage_guidance:\n", + want: "leaves the case item before the closing quote", + }, + { + name: "unterminated quote at end of file", + yaml: "cases:\n - id: \"a-b\"\n title: \"unclosed\n", + want: "unterminated double-quoted scalar", + }, + { + name: "unterminated single quote at end of file", + yaml: "cases:\n - id: \"a-b\"\n title: 'unclosed\n", + want: "unterminated single-quoted scalar", + }, + { + name: "unsupported escape", + yaml: "cases:\n - id: \"a\\qb\"\n title: \"T\"\n", + want: "unsupported escape", + }, + { + name: "malformed unicode escape", + yaml: "cases:\n - id: \"a-b\"\n title: \"bad \\u00zz escape\"\n", + want: "malformed \\uXXXX escape", + }, + { + name: "truncated unicode escape", + yaml: "cases:\n - id: \"a-b\"\n title: \"bad \\u00\"\n", + want: "malformed \\uXXXX escape", + }, + { + // The last silent-corruption path: ParseUint accepts D83D and + // WriteRune encodes every surrogate as U+FFFD, so this used to + // return a title the catalog never contained. YAML writes a + // non-BMP code point as \UXXXXXXXX, which default: already + // rejects, so no valid catalog reaches this arm. + name: "lone high surrogate", + yaml: "cases:\n - id: \"a-b\"\n title: \"half an emoji \\ud83d\"\n", + want: "surrogate code point", + }, + { + name: "lone low surrogate", + yaml: "cases:\n - id: \"a-b\"\n title: \"half an emoji \\ude00\"\n", + want: "surrogate code point", + }, + { + // A surrogate pair is not a valid escape sequence in YAML either, + // and it must not be reassembled here: the parser refuses shapes + // it does not understand rather than guessing at an encoding. + name: "surrogate pair in an id", + yaml: "cases:\n - id: \"a\\ud83d\\ude00b\"\n title: \"T\"\n", + want: "surrogate code point", + }, + { + name: "literal block scalar indicator", + yaml: "cases:\n - id: |\n a-b\n title: \"T\"\n", + want: "block scalar", + }, + { + name: "folded block scalar indicator", + yaml: "cases:\n - id: \"a-b\"\n title: >\n folded\n", + want: "block scalar", + }, + { + // Not a key, not a comment, not the document-end marker. Reading it + // as the end of the block would hide every case below it. + name: "unrecognized column-0 line", + yaml: "cases:\n - id: \"a-b\"\n title: \"T\"\n%TAG !x!\n - id: \"c-d\"\n", + want: "unrecognized column-0 line", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mustFail(t, tt.yaml, tt.want) + }) + } +} + +// The pinned catalog itself must parse cleanly, every case must carry a +// non-empty id and title, and ids must be unique. This is the smoke test the +// crafted shapes above cannot be: it runs against whatever the pin currently +// points at, so a catalog whose shape drifts past the parser fails here rather +// than quietly shrinking the guard's coverage. +func TestParseCatalogCases_PinnedCatalogParsesCleanly(t *testing.T) { + root := projectRoot() + path := filepath.Join(root, "..", "conformance", "oauth-sdk-conformance-catalog.yaml") + if p := os.Getenv("CONFORMANCE_CATALOG_PATH"); p != "" { + path = p + } else if p := os.Getenv("AUTHPLANE_CONFORMANCE_CATALOG"); p != "" { + path = p + } + + data, err := os.ReadFile(path) + if err != nil { + t.Skipf("catalog not available at %s: %v", path, err) + } + + cases, err := parseCatalogCases(string(data)) + if err != nil { + t.Fatalf("parseCatalogCases over the pinned catalog: %v", err) + } + if len(cases) == 0 { + t.Fatal("parsed 0 cases from the pinned catalog") + } + + seen := make(map[string]int, len(cases)) + for i, c := range cases { + if strings.TrimSpace(c.ID) == "" { + t.Errorf("case %d has an empty id", i) + } + if strings.TrimSpace(c.Title) == "" { + t.Errorf("case %d (%q) has an empty title", i, c.ID) + } + // An id that still carries a quote or a stray backslash means the + // scalar grammar mis-read it rather than parsed it. + if strings.ContainsAny(c.ID, "\"'\\") { + t.Errorf("case %d id %q carries quoting artifacts; the scalar was not parsed", i, c.ID) + } + if prev, dup := seen[c.ID]; dup { + t.Errorf("duplicate id %q at cases %d and %d", c.ID, prev, i) + } + seen[c.ID] = i + } +} diff --git a/core/conformancetests/catalog_parser_test.go b/core/conformancetests/catalog_parser_test.go new file mode 100644 index 0000000..2f2de0e --- /dev/null +++ b/core/conformancetests/catalog_parser_test.go @@ -0,0 +1,333 @@ +package conformancetests + +// Catalog parsing for the drift guard. +// +// The guard this feeds is a contract, not a convenience: verifyCatalogAlignment +// asserts that every case in the shared catalog has a test here, and the report +// marks anything unregistered as "not_run". Both read the parser's output, so a +// case the parser fails to see is a case the guard cannot ask about — and the +// guard stays green while it under-checks. That is strictly worse than a red +// run: the report says "passed" for a catalog the suite never fully compared +// itself against. +// +// So this file has one rule, and every error below is an instance of it: a +// catalog shape the parser does not understand must fail the run, never be +// skipped. A single regex over the file cannot honor that rule — it matches +// what it matches and is silent about the rest, which is how a case with an +// unquoted id, a single-quoted id, an id carrying an escaped quote, or a +// trailing comment could sit in the catalog and never be asked for. The +// line-oriented state machine here reads the same shapes a YAML parser would +// and refuses the ones it cannot: an unparseable list item, a quoted scalar +// that never closes, an unknown escape, a block scalar indicator, an +// unrecognized column-0 line. +// +// This file deliberately contains no Test functions — it is the parser; the +// crafted shapes that exercise it live in catalog_parser_shapes_test.go. + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// conformanceCase is one catalog entry. +// +// The title is carried even though the alignment guard keys on ids alone. It is +// what makes the id trustworthy: both go through the same scalar grammar, so a +// quoting style the parser gets wrong shows up in a title long before it +// silently mangles an id, and the report can name a case rather than only +// identify it. +type conformanceCase struct { + ID string + Title string +} + +var ( + // A case list item's "- id:" key. The value is handed to parseScalar + // rather than captured by this regex, so an id accepts exactly the + // quoting a title does. + reCaseIDKey = regexp.MustCompile(`^(\s*)-\s*id:\s*(.*)$`) + + // A top-level key (e.g. "usage_guidance:"), which is the only thing + // besides the document-end marker that ends the cases block. + reTopLevelKey = regexp.MustCompile(`^[A-Za-z_][\w-]*:`) + + // The start of any list item, so a case item whose first key is not "id" + // is detected rather than folded into the previous case. + reListItem = regexp.MustCompile(`^(\s*)-(\s|$)`) + + // A case's "title:" key. As with the id, the value needs the scalar + // grammar, not a regex. + reTitleKey = regexp.MustCompile(`^(\s*)title:\s*(.*)$`) +) + +// parseCatalogCases parses the "cases:" block of the conformance catalog into +// id/title pairs, in catalog order. +// +// Every failure is a shape the parser cannot make sense of. Returning a partial +// list instead would hand the alignment guard a catalog with holes in it and +// nothing to indicate they were there. +func parseCatalogCases(text string) ([]conformanceCase, error) { + lines := strings.Split(text, "\n") + + var cases []conformanceCase + var currentID, currentTitle string + inCases := false + // -1 means "not yet established". The first list item after "cases:" + // fixes the case-item indent; the first field line of a case fixes that + // case's field indent. Both are read off the file rather than assumed to + // be two spaces, so the parser stays correct for any valid indent width. + caseIndent, fieldIndent := -1, -1 + + flush := func() { + if strings.TrimSpace(currentID) != "" { + title := currentTitle + if strings.TrimSpace(title) == "" { + // The guard keys on ids, so a case whose title is missing or + // empty must stay visible to it. Falling back to the id keeps + // the case in the list instead of dropping it for want of a + // field the guard never reads. + title = currentID + } + cases = append(cases, conformanceCase{ID: currentID, Title: title}) + } + currentID, currentTitle = "", "" + } + + for i := 0; i < len(lines); i++ { + line := strings.TrimRight(lines[i], "\r") + + if !inCases { + if strings.TrimSpace(line) == "cases:" { + inCases = true + } + continue + } + + if strings.TrimSpace(line) == "" { + continue + } + + // A full-line comment carries no content at any indent. A column-0 + // comment in particular is not a top-level key and must not end the + // cases block — splitting on the first unindented line would drop + // every case after it. + if strings.HasPrefix(strings.TrimLeft(line, " \t"), "#") { + continue + } + + if line[0] != ' ' && line[0] != '\t' && line[0] != '-' { + // Only a top-level key or the document-end marker ends the block. + // Anything else at column 0 is a shape this parser does not + // understand, and reading it as the end of the block would + // silently drop every remaining case. + if reTopLevelKey.MatchString(line) || strings.TrimRight(line, " \t") == "..." { + break + } + return nil, fmt.Errorf("unrecognized column-0 line at line %d inside the cases block: %q; treating it as the end of the block would hide every remaining case from the catalog-alignment guard", i+1, strings.TrimSpace(line)) + } + + if m := reListItem.FindStringSubmatch(line); m != nil { + indent := len(m[1]) + if caseIndent < 0 { + caseIndent = indent + } + // Deeper items belong to a nested list (standard_refs, notes, + // prohibited_mechanisms, ...), not to a new case. + if indent > caseIndent { + continue + } + + idm := reCaseIDKey.FindStringSubmatch(line) + if idm == nil { + return nil, fmt.Errorf("unrecognized case list item at line %d: %q; every catalog case starts with `- id:`, and skipping this item would hide it from the catalog-alignment guard", i+1, strings.TrimSpace(line)) + } + + flush() + fieldIndent = -1 + id, err := parseScalar(lines, &i, strings.TrimSpace(idm[2]), caseIndent) + if err != nil { + return nil, err + } + if strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("empty case id at line %d; a case without an id is invisible to the catalog-alignment guard", i+1) + } + currentID = id + continue + } + + lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) + if currentID != "" && fieldIndent < 0 && caseIndent >= 0 && lineIndent > caseIndent { + fieldIndent = lineIndent + } + + // Only a title at the case's own field indent belongs to the case. An + // identically named key nested deeper — inside setup:, or in a + // variants: entry — does not, and taking it would attach the wrong + // title to the case. + if tm := reTitleKey.FindStringSubmatch(line); tm != nil && + currentID != "" && currentTitle == "" && len(tm[1]) == fieldIndent { + title, err := parseScalar(lines, &i, strings.TrimSpace(tm[2]), caseIndent) + if err != nil { + return nil, err + } + currentTitle = title + } + } + + flush() + return cases, nil +} + +// parseScalar parses a YAML flow scalar — double-quoted, single-quoted or plain +// — beginning at rest, consuming the continuation lines of a wrapped quoted +// scalar and advancing i past them. +// +// It covers the styles the catalog actually uses. Double-quoted values may +// carry apostrophes, the JSON escape set plus "\ " and "\uXXXX", and — because +// the catalog emitter writes every long scalar that way — wrap with an escaped +// line break followed by an escaped leading space on the continuation line. +// Single-quoted values escape an apostrophe by doubling it. A plain scalar +// loses its inline comment. +// +// Everything else is an error rather than a degraded value: an escape outside +// that set, a block scalar indicator (the emitter pins double-quoted style, so +// "|" and ">" never legitimately arrive), a quoted scalar that never closes, +// and a continuation line that leaves the case item — an indent at or below +// caseIndent, which also covers the next "- id:" — instead of swallowing every +// line up to the next quote in the file. +func parseScalar(lines []string, i *int, rest string, caseIndent int) (string, error) { + if rest == "" { + return "", nil + } + + if rest[0] == '|' || rest[0] == '>' { + return "", fmt.Errorf("block scalar (%q) at line %d is not supported; returning the indicator as the value would silently corrupt it, and the catalog emitter pins double-quoted style", rest, *i+1) + } + + if rest[0] != '"' && rest[0] != '\'' { + // Plain scalar. A "#" at the start of the value, or preceded by + // whitespace, begins a comment (RFC-independent YAML rule) and is not + // part of the value. + for p := 0; p < len(rest); p++ { + if rest[p] == '#' && (p == 0 || rest[p-1] == ' ' || rest[p-1] == '\t') { + return strings.TrimRight(rest[:p], " \t"), nil + } + } + return rest, nil + } + + quote := rest[0] + var value strings.Builder + startLine := *i + 1 + chunk := rest[1:] + + for { + closed := false + joinNextWithoutSpace := false + + for p := 0; p < len(chunk); p++ { + c := chunk[p] + + if quote == '"' && c == '\\' { + if p == len(chunk)-1 { + // A trailing backslash is an escaped line break: the + // continuation line joins with no folded space. + joinNextWithoutSpace = true + break + } + + switch esc := chunk[p+1]; esc { + case '"', '\\', '/', ' ': + value.WriteByte(esc) + case 'n': + value.WriteByte('\n') + case 't': + value.WriteByte('\t') + case 'r': + value.WriteByte('\r') + case '0': + value.WriteByte(0) + case 'u': + if p+6 > len(chunk) { + return "", fmt.Errorf("malformed \\uXXXX escape in double-quoted scalar at line %d: %q", *i+1, chunk[p:]) + } + cp, err := strconv.ParseUint(chunk[p+2:p+6], 16, 32) + if err != nil { + return "", fmt.Errorf("malformed \\uXXXX escape in double-quoted scalar at line %d: %q", *i+1, chunk[p:p+6]) + } + // A lone surrogate is the last silent-corruption path left + // in this parser: ParseUint returns 0xD83D happily and + // strings.Builder.WriteRune encodes every surrogate as + // U+FFFD, so the value would come back with a replacement + // character the catalog never contained — the exact silent + // substitution this file exists to refuse. YAML 1.2 has no + // surrogate-pair escape either (a non-BMP code point is + // written \UXXXXXXXX, which this switch already rejects + // through default:), so there is no valid catalog in which + // D800-DFFF appears here. + if cp >= 0xD800 && cp <= 0xDFFF { + return "", fmt.Errorf("malformed \\uXXXX escape in double-quoted scalar at line %d: %q is a surrogate code point, which has no encoding and would be substituted with U+FFFD", *i+1, chunk[p:p+6]) + } + value.WriteRune(rune(cp)) + // The loop's own p++ accounts for the sixth byte. + p += 4 + default: + return "", fmt.Errorf("unsupported escape %q in double-quoted scalar at line %d; passing the character through unescaped would silently corrupt the value", `\`+string(esc), *i+1) + } + + // Step over the escaped byte; the loop's p++ does the rest. + p++ + continue + } + + if c == quote { + if quote == '\'' && p < len(chunk)-1 && chunk[p+1] == '\'' { + // Single-quoted style escapes an apostrophe by doubling it. + value.WriteByte('\'') + p++ + continue + } + closed = true + break + } + + value.WriteByte(c) + } + + if closed { + return value.String(), nil + } + + if *i+1 >= len(lines) { + return "", fmt.Errorf("unterminated %s-quoted scalar starting at line %d; refusing to return a truncated value", quoteName(quote), startLine) + } + + // A continuation line must stay inside the case item. An indent at or + // below the case-item indent means the quote never closed — the next + // "- id:" is such a line — and consuming lines until the next quote + // somewhere later in the file would swallow whole cases into this + // value, which is exactly the silent drop this parser exists to + // prevent. + next := strings.TrimRight(lines[*i+1], "\r") + if len(next)-len(strings.TrimLeft(next, " \t")) <= caseIndent { + return "", fmt.Errorf("unterminated %s-quoted scalar starting at line %d: line %d leaves the case item before the closing quote; refusing to return a truncated value", quoteName(quote), startLine, *i+2) + } + + if !joinNextWithoutSpace { + // An unescaped line break inside a quoted scalar folds to a space. + value.WriteByte(' ') + } + + *i++ + chunk = strings.TrimLeft(strings.TrimRight(lines[*i], "\r"), " \t") + } +} + +func quoteName(quote byte) string { + if quote == '"' { + return "double" + } + return "single" +} diff --git a/core/conformancetests/harness_test.go b/core/conformancetests/harness_test.go index 2b96f91..bb29617 100644 --- a/core/conformancetests/harness_test.go +++ b/core/conformancetests/harness_test.go @@ -78,13 +78,28 @@ var registry = &conformanceRegistry{ // caseRegistration holds metadata supplied via CaseOption functions. type caseRegistration struct { - gaps []string - note string + level string + gaps []string + note string } // CaseOption configures optional metadata on a conformance case. type CaseOption func(*caseRegistration) +// Full marks a case as fully covered and records what the test demonstrated. +// +// A green row says only that a test passed; it cannot say how much of the case +// that test drove, so a reader has no way to tell full coverage from a check +// that happened to be green. The note carries what the row cannot: the +// mechanism the case was satisfied through and the bound it was demonstrated +// within. +func Full(note string) CaseOption { + return func(r *caseRegistration) { + r.level = "full" + r.note = note + } +} + // Partial marks a case as having partial coverage with a single gap. func Partial(gap, note string) CaseOption { return func(r *caseRegistration) { @@ -115,9 +130,12 @@ func Case(t *testing.T, caseID string, opts ...CaseOption) { } coverage := map[string]any{} - if len(reg.gaps) > 0 { + switch { + case len(reg.gaps) > 0: coverage["level"] = "partial" coverage["gaps"] = reg.gaps + case reg.level != "": + coverage["level"] = reg.level } if reg.note != "" { coverage["note"] = reg.note @@ -168,13 +186,17 @@ func projectRoot() string { return filepath.Dir(filepath.Dir(filepath.Dir(thisFile))) } -var ( - reCatalogVersion = regexp.MustCompile(`(?m)^catalog_version:\s*"([^"]+)"\s*$`) - reCaseID = regexp.MustCompile(`(?m)^\s+- id: "([^"]+)"\s*$`) -) - -// loadCatalogMetadata reads the conformance catalog YAML and extracts the -// catalog version and the list of case IDs using regex. +var reCatalogVersion = regexp.MustCompile(`(?m)^catalog_version:\s*"([^"]+)"\s*$`) + +// loadCatalogMetadata reads the conformance catalog YAML and returns the +// catalog version and the list of case IDs. +// +// The case IDs come from parseCatalogCases, not from a regex over the file. A +// regex reports what it matched and says nothing about what it did not, so a +// case in a shape it did not anticipate was dropped silently — and a dropped +// case is one the alignment guard never asks about, leaving the guard green +// while it under-checks the catalog it claims to enforce. The parser fails the +// run on any shape it cannot read instead. func loadCatalogMetadata() (version string, caseIDs []string, err error) { root := projectRoot() catalogPath := os.Getenv("CONFORMANCE_CATALOG_PATH") @@ -197,16 +219,34 @@ func loadCatalogMetadata() (version string, caseIDs []string, err error) { } version = vMatch[1] - // Split at "cases:" and extract IDs from the cases section only. - parts := strings.SplitN(text, "cases:", 2) - if len(parts) < 2 { + if !strings.Contains(text, "cases:") { return "", nil, fmt.Errorf("cases section not found in %s", catalogPath) } - casesSection := parts[1] - matches := reCaseID.FindAllStringSubmatch(casesSection, -1) - caseIDs = make([]string, 0, len(matches)) - for _, m := range matches { - caseIDs = append(caseIDs, m[1]) + + cases, err := parseCatalogCases(text) + if err != nil { + return "", nil, fmt.Errorf("parse catalog %s: %w", catalogPath, err) + } + + // A catalog that parses to nothing is a parser or path failure wearing a + // success. Without this check the alignment guard's first loop has nothing + // to iterate and the suite passes having compared itself against an empty + // contract. + if len(cases) == 0 { + return "", nil, fmt.Errorf("no cases parsed from %s; the catalog-alignment guard would have nothing to check", catalogPath) + } + + caseIDs = make([]string, 0, len(cases)) + seen := make(map[string]int, len(cases)) + for _, c := range cases { + // Ids key the registry and the report, so a duplicate would silently + // collapse two catalog cases into one row and let the second go + // unchecked. + if prev, dup := seen[c.ID]; dup { + return "", nil, fmt.Errorf("duplicate case id %q in %s (cases %d and %d); ids key the alignment guard, so the later case would be invisible to it", c.ID, catalogPath, prev+1, len(caseIDs)+1) + } + seen[c.ID] = len(caseIDs) + caseIDs = append(caseIDs, c.ID) } return version, caseIDs, nil @@ -457,14 +497,33 @@ func TestMain(m *testing.M) { for _, e := range alignmentErrs { fmt.Fprintf(os.Stderr, "CATALOG ALIGNMENT: %s\n", e) } - if exitCode == 0 { - exitCode = 1 - } + exitCode = failRun(exitCode) } + // A failure here used to print and leave the exit code alone, so a run + // whose catalog would not parse — or whose report never got written — + // still reported success, and the report on disk stayed at whatever the + // last good run left there. Both are the silent-under-check failure the + // alignment guard exists to prevent, so they fail the run. if err := generateReports(exitCode); err != nil { fmt.Fprintf(os.Stderr, "conformance report generation failed: %v\n", err) + exitCode = failRun(exitCode) } os.Exit(exitCode) } + +// failRun turns a guard failure discovered after m.Run into a red run. +// +// It raises a passing exit code to 1 and leaves a failing one alone: the code +// m.Run returned already identifies which tests failed, and overwriting it with +// a flat 1 would discard that. The guards this serves report their own detail +// to stderr, so the only thing the exit code has to carry for them is "not +// green". Extracted from TestMain so the raise is assertable — TestMain itself +// ends in os.Exit and cannot be called from a test. +func failRun(exitCode int) int { + if exitCode == 0 { + return 1 + } + return exitCode +} diff --git a/core/conformancetests/rfc8414_test.go b/core/conformancetests/rfc8414_test.go index 1b92882..da7abbd 100644 --- a/core/conformancetests/rfc8414_test.go +++ b/core/conformancetests/rfc8414_test.go @@ -2,7 +2,10 @@ package conformancetests import ( "context" + "crypto/ecdsa" "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -10,8 +13,14 @@ import ( "testing" "time" + "github.com/authplane/go-sdk/core/authplane" + "github.com/authplane/go-sdk/core/internal/cache" "github.com/authplane/go-sdk/core/internal/metadata" "github.com/authplane/go-sdk/core/internal/ssrf" + "github.com/authplane/go-sdk/core/resource" + "github.com/authplane/go-sdk/core/resource/verifier" + "github.com/authplane/go-sdk/core/testutil" + "github.com/go-jose/go-jose/v4" ) // helper: create a test server that serves AS metadata JSON. @@ -413,66 +422,508 @@ func TestRFC8414RevocationEndpointRequiredWhenRevocationIsUsed(t *testing.T) { } } -func TestRFC8414JWKSURIRotationMustReconfigureJWKSCache(t *testing.T) { - Case(t, "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache") +// --------------------------------------------------------------------------- +// jwks_uri rotation +// --------------------------------------------------------------------------- + +// rotationKID is the key id published on *both* of the rotating authorization +// server's key sets. +// +// One kid across the withdrawn and the rotated document is what gives the +// rotation case its edge. With two distinct kids, a token signed by the new +// key presents a kid the cached key set does not hold; that miss escalates +// into a forced JWKS re-fetch, which resolves jwks_uri again and lands on the +// rotated document. The token then verifies whether or not the interval-driven +// rebind ever happened, and the case reports a pass having measured the +// unknown-kid path instead of the one it is about. Under a single kid a cache +// still bound to the withdrawn URI finds a usable key, never escalates, and +// fails on the *signature* — so only a real rebind can turn the rejection into +// an acceptance. TestRFC8414RotationControlSameKIDBlocksTheKIDMissShortcut +// measures both halves of that claim. +const rotationKID = "as-signing-key" + +// rotatingAS is an authorization server that publishes its signing key at one +// of two jwks_uri values and can be switched from the first to the second. +// +// Both JWKS documents are served by this server, and every request to the +// three paths is counted. That is what makes the case's side effects +// observable: jwks_uri values pointing at some other host are never fetched by +// the test's own client, so clauses about which document was fetched, and +// about the withdrawn one no longer being fetched, could not be checked at all. +type rotatingAS struct { + // issuer is the server's base URL, and doubles as the metadata "issuer" + // value — RFC 8414 §3.3 requires the two to be identical. + issuer string + + rotated atomic.Bool + + metadataReads atomic.Int32 + v1Reads atomic.Int32 + v2Reads atomic.Int32 +} - var callCount atomic.Int32 - var changed atomic.Int32 +// newRotatingAS starts a rotating authorization server. It serves metadata +// with a max-age of metadataInterval, which is the effective metadata refresh +// interval for a client talking to it: the SDK honors the document's own cache +// headers, and its public client exposes no metadata-refresh-interval option, +// so the AS's cache header is the ordinary way to shorten the interval for a +// test. metadataInterval must be a whole number of seconds. +func newRotatingAS(t *testing.T, v1JWKS, v2JWKS []byte, metadataInterval time.Duration) *rotatingAS { + t.Helper() - var ts *httptest.Server - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := callCount.Add(1) - jwksURI := "https://auth.example.com/jwks-v1" - if n > 1 { - jwksURI = "https://auth.example.com/jwks-v2" + as := &rotatingAS{} + + // The handlers need the server's own base URL. Read it from the listener + // before Start rather than from srv.URL afterwards: the serving goroutine + // exists from the moment the server is created, so a field assigned after + // that point and read inside a handler is an unsynchronized access. + srv := httptest.NewUnstartedServer(http.NotFoundHandler()) + as.issuer = "http://" + srv.Listener.Addr().String() + + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + as.metadataReads.Add(1) + jwksURI := as.issuer + "/jwks-v1.json" + if as.rotated.Load() { + jwksURI = as.issuer + "/jwks-v2.json" } w.Header().Set("Content-Type", "application/json") - // Short cache so refresh happens quickly. - w.Header().Set("Cache-Control", "max-age=1") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": ts.URL, + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", int(metadataInterval.Seconds()))) + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": as.issuer, "jwks_uri": jwksURI, }) - })) - defer ts.Close() + }) - mc := metadata.New(metadata.Config{ - IssuerURL: ts.URL, - FetchSettings: ssrf.DevModeFetchSettings(), - RefreshInterval: 1 * time.Second, - OnJWKSURIChange: func(old, newURI string) { - changed.Add(1) - if old != "https://auth.example.com/jwks-v1" { - t.Errorf("old jwks_uri = %q, want v1", old) - } - if newURI != "https://auth.example.com/jwks-v2" { - t.Errorf("new jwks_uri = %q, want v2", newURI) - } - }, + mux.HandleFunc("/jwks-v1.json", func(w http.ResponseWriter, _ *http.Request) { + as.v1Reads.Add(1) + w.Header().Set("Content-Type", "application/jwk-set+json") + _, _ = w.Write(v1JWKS) }) - defer mc.Close() + + mux.HandleFunc("/jwks-v2.json", func(w http.ResponseWriter, _ *http.Request) { + // The rotated key is published as part of the rotation, so before it + // the document does not exist and nothing can reach the new key early. + if !as.rotated.Load() { + w.WriteHeader(http.StatusNotFound) + return + } + as.v2Reads.Add(1) + w.Header().Set("Content-Type", "application/jwk-set+json") + _, _ = w.Write(v2JWKS) + }) + + srv.Config.Handler = mux + srv.Start() + t.Cleanup(srv.Close) + + return as +} + +// rotate moves jwks_uri to the second document and publishes the rotated key +// there. +// +// The first document is not taken down; it keeps serving the key it always +// served, now retired. The catalog allows either ("withdrawn or serves only +// retired keys") and this is the harder of the two to follow: a verifier still +// bound to the old URI keeps getting a well-formed key set with a key under +// the rotation kid, so nothing about the response tells it that it is looking +// in the wrong place. +func (as *rotatingAS) rotate() { as.rotated.Store(true) } + +// metadataJWKSURI reads the jwks_uri the AS currently advertises, over plain +// HTTP and outside the SDK. The controls below use it to anchor themselves: a +// control that shows a verifier failing to follow a rotation proves nothing +// unless the AS is known to have rotated. +func (as *rotatingAS) metadataJWKSURI(t *testing.T) string { + t.Helper() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, as.issuer+"/.well-known/oauth-authorization-server", nil) + if err != nil { + t.Fatalf("build AS metadata request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("read AS metadata: %v", err) + } + defer resp.Body.Close() + var doc struct { + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + t.Fatalf("decode AS metadata: %v", err) + } + return doc.JWKSURI +} + +// rotationToken signs an access token for the rotating AS with the given key +// and kid. +func rotationToken(t *testing.T, key *ecdsa.PrivateKey, kid, issuer, audience string) string { + t.Helper() + claims := testutil.StandardClaims(issuer, audience, "user-1", "client-1") + token, err := testutil.SignToken(claims, key, jose.ES256, kid) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return token +} + +// rotationKeyPair generates a signing key and the single-key JWKS document +// that publishes it under kid. +func rotationKeyPair(t *testing.T, kid string) (*ecdsa.PrivateKey, []byte) { + t.Helper() + key, err := testutil.GenerateES256Key() + if err != nil { + t.Fatalf("generate key: %v", err) + } + jwks, err := testutil.BuildJWKSWithKID(&key.PublicKey, kid) + if err != nil { + t.Fatalf("build JWKS: %v", err) + } + return key, jwks +} + +func TestRFC8414JWKSURIRotationMustReconfigureJWKSCache(t *testing.T) { + Case(t, "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache", Full( + "Driven entirely through the public API — authplane.NewClient, then repeated "+ + "Resource.VerifyToken calls — with no force-refresh argument, no test-only hook and "+ + "no access to cache internals. The rotated signing key is published only at the new "+ + "jwks_uri and both key sets carry one kid, so a cache still bound to the withdrawn "+ + "URI fails on the signature rather than on a missing key, and only a real rebind can "+ + "make the token verify. All three side effects are asserted against request counts on "+ + "the serving test AS. Lateness bound: the JWKS fetch re-resolves jwks_uri through the "+ + "metadata cache on every fetch, so a rotation is followed within one metadata refresh "+ + "interval plus one JWKS cache TTL. That is inside the case's two-metadata-interval "+ + "bound whenever the effective JWKS TTL (the JWKS response's max-age, else "+ + "WithJWKSCacheTTL, default 5m) does not exceed the effective metadata interval (the "+ + "metadata response's max-age, else 1h). Both TTLs are read off the served document "+ + "and fall back to the configured value only when the response carries no cache "+ + "headers, so the AS decides whether the bound holds: one serving JWKS max-age=86400 "+ + "against metadata max-age=3600 sits outside it whatever the SDK is configured with. "+ + "This test reproduces the case that satisfies it, shortening the metadata interval "+ + "through the AS's own cache header and the JWKS TTL through WithJWKSCacheTTL.")) + + const ( + metadataInterval = 2 * time.Second + jwksTTL = 500 * time.Millisecond + resourceURI = "https://api.example.com/mcp" + ) + // The bound the catalog sets: the rotated key must verify within two + // metadata refresh intervals of the rotation. + const rotationBound = 2 * metadataInterval ctx := context.Background() - // First fetch seeds the cache. - meta, err := mc.Get(ctx) + retiredKey, v1JWKS := rotationKeyPair(t, rotationKID) + rotatedKey, v2JWKS := rotationKeyPair(t, rotationKID) + + as := newRotatingAS(t, v1JWKS, v2JWKS, metadataInterval) + + client, err := authplane.NewClient(ctx, as.issuer, + authplane.WithFetchSettings(authplane.DevModeFetchSettings()), + authplane.WithJWKSCacheTTL(jwksTTL), + ) + if err != nil { + t.Fatalf("new client: %v", err) + } + defer client.Close() + + res, err := client.Resource(resourceURI) + if err != nil { + t.Fatalf("new resource: %v", err) + } + + // Ordinary traffic before the rotation: a token signed by the key the AS + // publishes at the first jwks_uri verifies. + claims, err := res.VerifyToken(ctx, rotationToken(t, retiredKey, rotationKID, as.issuer, resourceURI)) if err != nil { - t.Fatalf("first fetch: %v", err) + t.Fatalf("token signed by the pre-rotation key must verify: %v", err) } - if meta.JWKSURI != "https://auth.example.com/jwks-v1" { - t.Fatalf("first jwks_uri = %q, want v1", meta.JWKSURI) + if claims.KID() != rotationKID { + t.Fatalf("kid = %q, want %q", claims.KID(), rotationKID) + } + if as.v1Reads.Load() == 0 { + t.Fatal("the pre-rotation key set was never fetched") + } + if n := as.v2Reads.Load(); n != 0 { + t.Fatalf("the rotated key set was fetched %d times before the rotation", n) + } + + metadataReadsAtRotation := as.metadataReads.Load() + as.rotate() + rotatedAt := time.Now() + + rotatedToken := rotationToken(t, rotatedKey, rotationKID, as.issuer, resourceURI) + + // Immediately after the rotation nothing has re-read metadata, so the JWKS + // cache is still bound to the withdrawn URI. Both documents publish + // rotationKID, so the key lookup succeeds and the rejection is a signature + // failure — this is the state the rebind has to get the verifier out of, + // and asserting it here is what stops the acceptance below from being + // something the verifier could have reached without following anything. + _, err = res.VerifyToken(ctx, rotatedToken) + if got := as.metadataReads.Load(); got != metadataReadsAtRotation { + t.Fatalf("metadata was re-read %d times within %v of the rotation, so the pre-rebind state could not be observed; the run stalled", + got-metadataReadsAtRotation, time.Since(rotatedAt)) + } + if !errors.Is(err, verifier.ErrInvalidSignature) { + t.Fatalf("before the rebind the rotated token must be rejected on the signature, got: %v", err) } - // Wait for background refresh to pick up the rotated URI. - deadline := time.Now().Add(5 * time.Second) + // Ordinary verification traffic, repeated across the interval. Nothing in + // this loop asks for a refresh: it calls one public method with one + // argument, and the rebind has to come from the SDK's own cadence. + var ( + accepted bool + acceptedIn time.Duration + lastErr error + ) + deadline := rotatedAt.Add(rotationBound) for time.Now().Before(deadline) { - if changed.Load() > 0 { + _, err := res.VerifyToken(ctx, rotatedToken) + if err == nil { + accepted = true + acceptedIn = time.Since(rotatedAt) break } - time.Sleep(200 * time.Millisecond) + // Any other rejection would mean the run measured something other than + // the rotation — a missing key, an unreachable JWKS — so fail on it + // rather than letting the loop spin to its deadline and report the + // rotation as unfollowed. + if !errors.Is(err, verifier.ErrInvalidSignature) { + t.Fatalf("rotated token rejected for an unexpected reason: %v", err) + } + lastErr = err + time.Sleep(25 * time.Millisecond) + } + if !accepted { + t.Fatalf("a token signed by the key published only at the rotated jwks_uri did not verify within %v (two metadata refresh intervals); last rejection: %v", + rotationBound, lastErr) + } + t.Logf("rotation followed after %v (bound %v)", acceptedIn.Round(time.Millisecond), rotationBound) + + // side_effect: metadata re-fetched after the refresh interval elapses, + // without an explicit refresh call. The test issued none. + if got := as.metadataReads.Load(); got <= metadataReadsAtRotation { + t.Errorf("metadata was not re-fetched after the rotation: %d reads, %d at the rotation", got, metadataReadsAtRotation) + } + // side_effect: JWKS fetched from the new jwks_uri. + if as.v2Reads.Load() == 0 { + t.Error("the rotated jwks_uri was never fetched") + } + + // side_effect: no further fetch of the withdrawn document once the rebind + // has happened. Keep verifying for several JWKS cache lifetimes — had the + // verifier gone on resolving the old URI, its background refresh would + // have returned there well inside this window. + v1ReadsAtRebind := as.v1Reads.Load() + settle := time.Now().Add(3 * jwksTTL) + for time.Now().Before(settle) { + if _, err := res.VerifyToken(ctx, rotatedToken); err != nil { + t.Fatalf("verification regressed after the rebind: %v", err) + } + time.Sleep(25 * time.Millisecond) + } + if got := as.v1Reads.Load(); got != v1ReadsAtRebind { + t.Errorf("the withdrawn jwks_uri was fetched %d more times after the rebind", got-v1ReadsAtRebind) + } +} + +// TestRFC8414RotationControlPinnedJWKSURIMustFailOnSignature is a negative +// control for the case above. +// +// It runs the identical rotation against a verifier that captures jwks_uri +// once and only ever refreshes keys from that URI — the implementation the +// catalog case exists to catch, described in the case's own notes as "a +// verifier that only refreshes keys from a URI captured at construction". The +// rotation must not be followed, and the rejection must be a signature failure +// rather than a missing key: that is the single-kid design doing its job, and +// without it this control would be indistinguishable from a lookup miss. +func TestRFC8414RotationControlPinnedJWKSURIMustFailOnSignature(t *testing.T) { + const ( + metadataInterval = 1 * time.Second + jwksTTL = 250 * time.Millisecond + resourceURI = "https://api.example.com/mcp" + ) + + ctx := context.Background() + + retiredKey, v1JWKS := rotationKeyPair(t, rotationKID) + rotatedKey, v2JWKS := rotationKeyPair(t, rotationKID) + + as := newRotatingAS(t, v1JWKS, v2JWKS, metadataInterval) + + pinnedURI := as.issuer + "/jwks-v1.json" + jwksCache := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + resp, err := ssrf.SSRFSafeGet(ctx, pinnedURI, ssrf.DevModeFetchSettings(), nil, ssrf.MaxJWKSSize) + if err != nil { + return nil, nil, err + } + if resp.Status != http.StatusOK { + return nil, nil, fmt.Errorf("JWKS fetch returned HTTP %d", resp.Status) + } + return resp.Body, nil, nil + }, + DefaultTTL: jwksTTL, + }) + defer jwksCache.Close() + if err := jwksCache.Prime(ctx); err != nil { + t.Fatalf("prime JWKS cache: %v", err) + } + + res, err := resource.New(resourceURI, as.issuer, jwksCache) + if err != nil { + t.Fatalf("new resource: %v", err) } - if changed.Load() == 0 { - t.Error("OnJWKSURIChange callback was never invoked after JWKS URI rotation") + // Anchor: the control is wired correctly before the rotation. + if _, err := res.VerifyToken(ctx, rotationToken(t, retiredKey, rotationKID, as.issuer, resourceURI)); err != nil { + t.Fatalf("control setup is broken: the pre-rotation token must verify, got: %v", err) + } + + as.rotate() + rotatedAt := time.Now() + + // Anchor: the AS really did rotate. Read outside the SDK, so a verifier + // that follows nothing cannot make this look true. + if got, want := as.metadataJWKSURI(t), as.issuer+"/jwks-v2.json"; got != want { + t.Fatalf("control setup is broken: AS advertises jwks_uri %q after the rotation, want %q", got, want) } + + rotatedToken := rotationToken(t, rotatedKey, rotationKID, as.issuer, resourceURI) + + deadline := rotatedAt.Add(2 * metadataInterval) + for time.Now().Before(deadline) { + _, err := res.VerifyToken(ctx, rotatedToken) + if err == nil { + t.Fatalf("a verifier pinned to the withdrawn jwks_uri accepted the rotated key after %v; the case would pass without following anything", + time.Since(rotatedAt)) + } + if !errors.Is(err, verifier.ErrInvalidSignature) { + t.Fatalf("the pinned verifier must fail on the signature, not on key resolution: %v", err) + } + time.Sleep(25 * time.Millisecond) + } + + if n := as.v2Reads.Load(); n != 0 { + t.Errorf("the pinned verifier fetched the rotated jwks_uri %d times", n) + } +} + +// TestRFC8414RotationControlSameKIDBlocksTheKIDMissShortcut measures the claim +// that publishing one kid on both key sets is what makes the rotation case +// sharp, rather than an incidental detail of the fixture. +// +// Both halves run the same configuration: metadata rotates promptly, but the +// JWKS cache TTL is an hour, so the interval-driven rebind cannot happen inside +// the test at all. The only remaining route to the rotated document is the +// unknown-kid escalation, which forces a JWKS re-fetch. +// +// - With distinct kids the route is open in principle, but the forced-refresh +// floor holds the kid-miss re-fetch for the whole window, so the rotated +// token stays rejected on key resolution — and nothing in that half measures +// the refresh interval either. +// - With one kid the route is closed, the cached key is used, and the token +// is rejected on the signature for as long as the cache stands. +func TestRFC8414RotationControlSameKIDBlocksTheKIDMissShortcut(t *testing.T) { + const ( + metadataInterval = 1 * time.Second + jwksTTL = 1 * time.Hour + resourceURI = "https://api.example.com/mcp" + ) + + newRotatedClient := func(t *testing.T, rotatedKID string) (*rotatingAS, *resource.Resource, *ecdsa.PrivateKey) { + t.Helper() + ctx := context.Background() + + retiredKey, v1JWKS := rotationKeyPair(t, rotationKID) + rotatedKey, v2JWKS := rotationKeyPair(t, rotatedKID) + as := newRotatingAS(t, v1JWKS, v2JWKS, metadataInterval) + + client, err := authplane.NewClient(ctx, as.issuer, + authplane.WithFetchSettings(authplane.DevModeFetchSettings()), + authplane.WithJWKSCacheTTL(jwksTTL), + ) + if err != nil { + t.Fatalf("new client: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + + res, err := client.Resource(resourceURI) + if err != nil { + t.Fatalf("new resource: %v", err) + } + if _, err := res.VerifyToken(ctx, rotationToken(t, retiredKey, rotationKID, as.issuer, resourceURI)); err != nil { + t.Fatalf("control setup is broken: the pre-rotation token must verify, got: %v", err) + } + as.rotate() + return as, res, rotatedKey + } + + t.Run("distinct kid is held by the forced-refresh floor", func(t *testing.T) { + const rotatedKID = rotationKID + "-2" + as, res, rotatedKey := newRotatedClient(t, rotatedKID) + ctx := context.Background() + + rotatedToken := rotationToken(t, rotatedKey, rotatedKID, as.issuer, resourceURI) + rotatedAt := time.Now() + + // Before the forced-refresh floor existed this half could go either way, + // so it ended in a t.Skipf. It no longer can: the floor is + // min(DefaultForcedRefreshFloor, jwksTTL) = 1m here, and this window is + // 4s, so the kid-miss escalation is refused for the whole window and the + // outcome is deterministic. Assert it rather than skip on it — a skip + // that can never not fire is dead coverage propping up the half below. + var lastErr error + deadline := rotatedAt.Add(4 * metadataInterval) + for time.Now().Before(deadline) { + _, err := res.VerifyToken(ctx, rotatedToken) + if err == nil { + t.Fatalf("the rotated kid was accepted %v after rotation, inside a forced-refresh floor of %v: the floor is not holding the kid-miss re-fetch", + time.Since(rotatedAt).Round(time.Millisecond), cache.DefaultForcedRefreshFloor) + } + // The cached key set holds nothing under the rotated kid, so + // every rejection here has to come from key resolution. A + // signature failure would mean the kid stopped selecting the key, + // and the contrast this control draws would be measuring something + // else entirely. + if errors.Is(err, verifier.ErrInvalidSignature) { + t.Fatalf("a distinct kid must be rejected on key resolution, not on the signature: %v", err) + } + lastErr = err + time.Sleep(25 * time.Millisecond) + } + if lastErr == nil { + t.Fatal("the window closed without a single verification attempt") + } + // The single-kid half below is the contrast: there the rebind carries the + // rotation on the refresh interval, with no forced re-fetch involved. + t.Logf("distinct kid still rejected after %v, as the forced-refresh floor requires; last rejection: %v", + 4*metadataInterval, lastErr) + }) + + t.Run("one kid closes the shortcut", func(t *testing.T) { + as, res, rotatedKey := newRotatedClient(t, rotationKID) + ctx := context.Background() + + rotatedToken := rotationToken(t, rotatedKey, rotationKID, as.issuer, resourceURI) + rotatedAt := time.Now() + + deadline := rotatedAt.Add(2 * metadataInterval) + for time.Now().Before(deadline) { + _, err := res.VerifyToken(ctx, rotatedToken) + if err == nil { + t.Fatalf("the rotated token verified after %v although the JWKS cache TTL (%v) had not elapsed; the single-kid fixture is not closing the unknown-kid route", + time.Since(rotatedAt), jwksTTL) + } + if !errors.Is(err, verifier.ErrInvalidSignature) { + t.Fatalf("with one kid the rejection must be a signature failure, got: %v", err) + } + time.Sleep(25 * time.Millisecond) + } + if n := as.v2Reads.Load(); n != 0 { + t.Errorf("the rotated jwks_uri was fetched %d times with no escalation to trigger it", n) + } + }) } diff --git a/core/conformancetests/rfc8707_test.go b/core/conformancetests/rfc8707_test.go index 5db88c8..f9926d1 100644 --- a/core/conformancetests/rfc8707_test.go +++ b/core/conformancetests/rfc8707_test.go @@ -6,8 +6,10 @@ import ( "net/http" "net/http/httptest" "slices" + "strings" "testing" + "github.com/authplane/go-sdk/core/resource" "github.com/authplane/go-sdk/core/testutil" "github.com/go-jose/go-jose/v4" ) @@ -70,6 +72,26 @@ func TestRFC8707ClientCredentialsMultipleResourceParametersMustBeEmitted(t *test } } +func TestRFC8707ResourceIndicatorMustNotContainAFragment(t *testing.T) { + Case(t, "rfc8707-resource-indicator-must-not-contain-a-fragment") + + // RFC 8707 §2 states of the resource parameter that "The URI MUST NOT + // include a fragment component", and RFC 9728 §1.2 defines the resource + // identifier as a URL with no fragment. The rejection has to be observable + // from the constructor call itself: accepting the value and stripping the + // fragment later, while deriving the well-known URL, would publish a + // metadata document whose resource field differs from the identifier the + // client derived that URL from, which §3.3 requires the client to discard. + const uri = "https://api.example.com/mcp#section" + r, err := resource.New(uri, "https://auth.example.com", newPRMTestJWKSCache(t)) + if err == nil { + t.Fatalf("resource.New(%q) = %q, want rejection", uri, r.URI()) + } + if !strings.Contains(err.Error(), "fragment") { + t.Errorf("resource.New(%q) error = %v, want it to name the fragment", uri, err) + } +} + func TestRFC8707VerifierMustAcceptResourceWhenPresentInAudArray(t *testing.T) { Case(t, "rfc8707-verifier-must-accept-resource-when-present-in-aud-array") ctx := context.Background() diff --git a/core/conformancetests/rfc9728_test.go b/core/conformancetests/rfc9728_test.go index de536e0..9e5b71a 100644 --- a/core/conformancetests/rfc9728_test.go +++ b/core/conformancetests/rfc9728_test.go @@ -3,6 +3,7 @@ package conformancetests import ( "context" "crypto/ecdsa" + "strings" "testing" "time" @@ -16,14 +17,22 @@ func newPRMTestResource(t *testing.T, uri, issuer string, scopes ...string) *res return newPRMTestResourceWithOpts(t, uri, issuer, scopes) } -// newPRMTestResourceWithOpts is like newPRMTestResource but accepts extra resource options. -func newPRMTestResourceWithOpts(t *testing.T, uri, issuer string, scopes []string, extra ...resource.Option) *resource.Resource { +// newPRMTestJWKSCache builds a primed JWKS cache for tests that call +// resource.New directly — the rejection cases below need the constructor's +// error, which newPRMTestResource turns into t.Fatalf. +func newPRMTestJWKSCache(t *testing.T) *verifier.JWKSCache { t.Helper() key, err := testutil.GenerateES256Key() if err != nil { t.Fatalf("generate key: %v", err) } - jc := newJWKSCacheForKey(t, key) + return newJWKSCacheForKey(t, key) +} + +// newPRMTestResourceWithOpts is like newPRMTestResource but accepts extra resource options. +func newPRMTestResourceWithOpts(t *testing.T, uri, issuer string, scopes []string, extra ...resource.Option) *resource.Resource { + t.Helper() + jc := newPRMTestJWKSCache(t) opts := []resource.Option{} if len(scopes) > 0 { @@ -141,6 +150,92 @@ func TestRFC9728WellKnownPathMustDeriveFromResourceURI(t *testing.T) { } } +func TestRFC9728WellKnownURLMustPreserveTheResourceQueryComponent(t *testing.T) { + Case(t, "rfc9728-well-known-url-must-preserve-the-resource-query-component") + + // The stimulus is the full derived URL rather than the path alone, since a + // path-only accessor cannot express a query. RFC 9728 §3 inserts the + // well-known string "between the host component and the path and/or query + // components", so the query survives the derivation. + cases := []struct { + resourceURI string + wantURL string + }{ + {"https://api.example.com/mcp?tenant=a", "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"}, + {"https://api.example.com/mcp?tenant=b", "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=b"}, + // No path and no terminating slash: §3.1 has no slash to remove, so the + // suffix goes directly after the host and the query follows it. + {"https://api.example.com?x=1", "https://api.example.com/.well-known/oauth-protected-resource?x=1"}, + } + + var derived []string + for _, tc := range cases { + r := newPRMTestResource(t, tc.resourceURI, "https://auth.example.com") + got := r.PRMURL() + if got != tc.wantURL { + t.Errorf("PRMURL(%q) = %q, want %q", tc.resourceURI, got, tc.wantURL) + } + derived = append(derived, got) + } + + // Identifiers differing only by their query must not collapse onto one + // metadata document URL — that is the multi-tenant harm the case names, in + // which a client asking for tenant a's metadata is served tenant b's. + // Checked as uniqueness over every derived URL rather than by index, so + // reordering or extending the case table cannot silently drop the check. + seen := make(map[string]struct{}, len(derived)) + for i, url := range derived { + if _, dup := seen[url]; dup { + t.Errorf("distinct identifiers collapsed onto one PRM URL %q (case %d)", url, i) + } + seen[url] = struct{}{} + } +} + +func TestRFC9728ResourceIdentifierMustBeAnAbsoluteURLWithSchemeAndHost(t *testing.T) { + Case(t, "rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host") + + jc := newPRMTestJWKSCache(t) + + // Each value is exercised independently and each must reject on its own. + // The two are not redundant: a scheme-relative reference supplies an + // authority, so a guard that only asks whether the identifier is opaque or + // authority-less would accept it while still rejecting the plain relative + // form. The scheme is the component missing from both. + rejected := []struct { + name string + uri string + }{ + {"relative reference", "/mcp"}, + {"scheme-relative reference", "//api.example.com/mcp"}, + } + for _, tc := range rejected { + t.Run(tc.name, func(t *testing.T) { + r, err := resource.New(tc.uri, "https://auth.example.com", jc) + if err == nil { + t.Fatalf("resource.New(%q) = %q, want rejection", tc.uri, r.URI()) + } + // The catalog's error_hint names the absoluteness requirement, so + // pin the rejection to it: an error from an unrelated gate (query + // grammar, parse failure) must not keep this case green. + if !strings.Contains(err.Error(), "absolute with scheme and host") { + t.Errorf( + "resource.New(%q) error = %v, want it to name the absoluteness requirement", + tc.uri, err, + ) + } + }) + } + + // The requirement is scheme-and-host, not https-only: an identifier that + // supplies both components is acceptable on these grounds, so a loopback + // http identifier must still be accepted. Any https policy is a separate + // requirement this case neither tests nor licenses folding in here. + if _, err := resource.New("http://localhost:8080/mcp", "https://auth.example.com", jc); err != nil { + t.Errorf("resource.New(%q): unexpected rejection: %v", "http://localhost:8080/mcp", err) + } +} + func TestRFC9728PRMDPoPFieldsShouldBeAdvertisedWhenDPoPIsSupported(t *testing.T) { Case(t, "rfc9728-prm-dpop-fields-should-be-advertised-when-dpop-is-supported") diff --git a/core/docs/user-guide.md b/core/docs/user-guide.md index f93a811..d2b6cfb 100644 --- a/core/docs/user-guide.md +++ b/core/docs/user-guide.md @@ -276,10 +276,11 @@ claims.Claim("email") // any or nil ```go claims.Act() // map[string]any for "act" claim (delegation chain) -claims.MayAct() // map[string]any for "may_act" claim (authorized actors) claims.AgentChain() // []string for "agent_chain" claim (Authplane extension) ``` +`MayAct()` is deprecated: authserver 0.2.0 no longer issues the `may_act` claim (RFC 8693 §4.4), and the accessor is removed in the next minor. + ### DPoP confirmation ```go @@ -310,6 +311,14 @@ By default, verification only uses the JWT and JWKS. Revocation checking adds a ### Built-in introspection-based revocation +The introspecting client must be **confidential** (client ID and secret) **and** either the client the token was issued to or a runtime-client of the Resource named in the token's `aud`. authserver ≥ 0.1.2 answers `{"active": false}` to anyone else — a public (secret-less) client cannot introspect at all, and a resource server introspecting with the wrong client rejects every token as revoked. Register the resource server as a runtime-client of its Resource: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +When introspection answers `active: false` for a token that already passed local JWT verification, the SDK logs one warning per resource pointing at this requirement. The warning is written to `slog.Default()`; install a handler with `slog.SetDefault` to route it into your own logging setup, or to silence it. + Use the facade's `Introspect` method as a revocation checker: ```go @@ -418,6 +427,22 @@ resp, err := client.TokenExchange(ctx, authplane.TokenExchangeInput{ | `Resources` | no | Target audience URIs (RFC 8707), multiple values supported | | `Audiences` | no | Target audience strings, multiple values supported | +**Operator step for cross-client exchanges.** For each MCP server that exchanges for a downstream resource it does not act as, allowlist the exchanging client on the target Resource: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token issued to itself, fronted exchanges and Broker resources need nothing. + +Two exchange errors look like consent problems but are not: + +- `access_denied` (HTTP 403, `ErrAccessDenied`) on a cross-client exchange means the operator has not allowlisted the exchanging client on the target Resource (`policy.exchange.allowed_client_ids` / `policy.runtime.client_ids`). Re-prompting the user will not fix it — unlike `consent_required`, which the user resolves. +- `invalid_target` (HTTP 400, `ErrInvalidTarget`, RFC 8707 §2.2) means the `resource` string does not match a granted resource exactly — byte for byte, a trailing slash counts. + +Neither counts toward the circuit breaker: the AS answered, it just said no. + ## 7. Handling Consent-Required Errors When a token exchange fails because the user has not yet consented to a third-party service, the AS returns `consent_required` or `interaction_required`. The SDK wraps these into a `*ConsentRequiredError` with the consent URL and description from the AS response. @@ -450,6 +475,8 @@ The `ConsentURL` field may be empty if the AS does not yet include it in its err MCP adapters can use this error to return a `URLElicitationRequiredError` (MCP spec SEP-1036, JSON-RPC code `-32042`) to prompt the user to complete an out-of-band consent flow. +Do not map `ErrAccessDenied` or `ErrInvalidTarget` to a consent prompt — see the notes under [TokenExchange](#tokenexchange). `access_denied` is an operator allowlist gap on the target Resource; `invalid_target` is a `resource` string that does not match a granted resource exactly. + ## 8. DPoP for Outbound Calls Use `DPoPSigner` when your MCP server needs sender-constrained tokens or when the AS/downstream service requires DPoP proofs. @@ -621,12 +648,25 @@ status := resource.HTTPStatus(err) // 500 for ErrSSRFBlocked, ErrProtocolError, and unknown errors ``` -For generating `WWW-Authenticate` headers: +For generating `WWW-Authenticate` headers (note this returns the status too — do not call `HTTPStatus` as well): ```go +// Without the RFC 9728 pointer: status, headers, body := resource.AuthErrorResponse(err) + +// With `resource_metadata="…"` appended to the challenge, which is what the +// adapters emit — pass res.ResourceMetadataURL(): +status, headers, body = resource.AuthErrorResponseWithMetadata(err, res.ResourceMetadataURL()) ``` +The parameter goes last in the challenge, after `realm`, `error` and `scope`, separated by a space when it is the only auth-param (the no-token case, `Bearer resource_metadata="…"`) and by `, ` otherwise. An empty URL emits the challenge unchanged. + +The JSON body's `error_description` is a fixed sentence chosen by the error code — `err`'s own message never reaches it. That body goes to a caller who has not authenticated, and the verifier's messages name the failing detail (the unknown `kid`, the audience the resource expects, the rejected `typ`). Log `err` for the diagnostic; it is unchanged. + +A request that presented no credentials at all is the one case with no `error` on either side: RFC 6750 §3 has the challenge omit it — the codes describe a request that did authenticate and failed — and the body omits the member for the same reason, carrying only `{"error_description":"The request did not carry an access token"}`. Read that absence the way the challenge reads: begin discovery and authenticate. + +`resource.AuthErrorResponseVerbose(err, realm...)` puts the message back in `error_description`. It is a development aid — it discloses that detail to unauthenticated callers, so do not use it in production. + ### OAuth errors (`authplane`) ```go @@ -634,6 +674,10 @@ resp, err := client.ClientCredentials(ctx, scope, resource) if err != nil { if errors.Is(err, authplane.ErrInvalidGrant) { // subject token invalid + } else if errors.Is(err, authplane.ErrAccessDenied) { + // cross-client exchange: this client is not allowlisted on the target Resource + } else if errors.Is(err, authplane.ErrInvalidTarget) { + // "resource" does not match a granted resource byte for byte (RFC 8707 §2.2) } else if errors.Is(err, authplane.ErrCircuitOpen) { // AS unavailable, circuit breaker tripped } @@ -667,6 +711,22 @@ Example output: Serve it at `/.well-known/oauth-protected-resource` in your framework of choice. The SDK does not include HTTP middleware -- integration with `net/http`, gRPC, or other frameworks is left to adapter packages or application code. +### Where the document lives + +`res.PRMURL()` is the URL the SDK derives for the document and `res.WellKnownPRMPath()` the path to serve it on. `res.ResourceMetadataURL()` is what adapters advertise in the `WWW-Authenticate` `resource_metadata` parameter — the same derived URL, unless you point it elsewhere: + +```go +res, err := client.Resource("https://api.example.com/mcp", + resource.WithScopes("read", "write"), + // The document authserver >= 0.2.0 publishes for every registered Resource. + resource.WithResourceMetadataURL("https://auth.example.com/.well-known/oauth-protected-resource/mcp"), +) +``` + +Use it when the resource server cannot host well-known paths. Only the advertisement moves: `PRMURL()` and `WellKnownPRMPath()` keep returning the derived values, so the local endpoint stays serveable. The URL is validated at construction — absolute, `https` or `http`, no fragment, no userinfo — and a bad value fails `client.Resource(...)` rather than the first 401. + +Either way RFC 9728 §3.3 applies: the `resource` member of whatever document you point at must equal the URL clients call, byte for byte, or a conformant client discards it. + ## 13. Advanced Notes ### Circuit breaker behavior @@ -684,6 +744,8 @@ The circuit breaker protects AS-bound operations from cascading failure. It clas - `invalid_grant` — per-token condition (expired, revoked) - `invalid_scope` — per-request (wrong scopes requested) - `use_dpop_nonce` — per-request (nonce retry) +- `access_denied` — cross-client exchange not allowlisted on the target Resource (operator fix) +- `invalid_target` — `resource` does not match a granted resource (RFC 8707 §2.2) - SSRF validation failures — local configuration, not AS issue After cooldown expiry, the next call is allowed as a half-open probe. Successful probes reset the circuit to closed. diff --git a/core/internal/cache/cache.go b/core/internal/cache/cache.go index 8391839..8c4be6c 100644 --- a/core/internal/cache/cache.go +++ b/core/internal/cache/cache.go @@ -14,6 +14,18 @@ import ( // ErrCacheClosed is returned by Get when the cache has been closed. var ErrCacheClosed = errors.New("cache: closed") +const ( + // DefaultFailureBackoff is the default minimum interval between fetch attempts + // after a failed one. The effective backoff is capped at DefaultTTL, so a cache + // asked to refresh every few seconds is not pinned to a longer retry floor. + DefaultFailureBackoff = 30 * time.Second + + // DefaultForcedRefreshFloor caps how far apart forced refreshes are spaced. The + // effective floor is min(DefaultTTL, this), so a deployment that wants fresher + // documents than a minute still gets them. + DefaultForcedRefreshFloor = 1 * time.Minute +) + // FetchFunc fetches the document. Returns the raw bytes and response headers (or an error). type FetchFunc func(ctx context.Context) (data []byte, headers map[string][]string, err error) @@ -26,20 +38,31 @@ type Config struct { FetchFn FetchFunc DefaultTTL time.Duration OnChange OnChangeFunc + // FailureBackoff is the minimum interval between fetch attempts after a failed + // one. Zero or less means DefaultFailureBackoff; the value is capped at + // DefaultTTL. + FailureBackoff time.Duration + // ForcedRefreshFloor is the minimum interval between ForceRefresh fetches. Zero + // or less means DefaultForcedRefreshFloor; the value is capped at DefaultTTL. + ForcedRefreshFloor time.Duration } // DocumentCache provides thread-safe caching with background refresh and stale fallback. // It fetches raw bytes using FetchFunc and caches them with TTL-based expiration. // Background refresh fires at 80% of the effective TTL. type DocumentCache struct { - fetchFn FetchFunc - onChange OnChangeFunc - defaultTTL time.Duration + fetchFn FetchFunc + onChange OnChangeFunc + defaultTTL time.Duration + failureBackoff time.Duration + forcedFloor time.Duration - mu sync.RWMutex - data []byte - expiry time.Time - lastErr error + mu sync.RWMutex + data []byte + expiry time.Time + lastErr error + lastFailureAt time.Time + lastForcedAt time.Time stopCh chan struct{} wg sync.WaitGroup @@ -51,11 +74,26 @@ func New(cfg Config) *DocumentCache { if cfg.DefaultTTL <= 0 { cfg.DefaultTTL = 5 * time.Minute } + if cfg.FailureBackoff <= 0 { + cfg.FailureBackoff = DefaultFailureBackoff + } + if cfg.ForcedRefreshFloor <= 0 { + cfg.ForcedRefreshFloor = DefaultForcedRefreshFloor + } c := &DocumentCache{ fetchFn: cfg.FetchFn, onChange: cfg.OnChange, defaultTTL: cfg.DefaultTTL, - stopCh: make(chan struct{}), + // Both floors are capped at the refresh interval: a cache configured to + // re-read every few seconds must not be held back by a floor measured in + // tens of seconds. The one-second lower clamp keeps that cap from + // collapsing them: DefaultTTL is unvalidated, and a TTL of a few + // milliseconds would otherwise leave an unreachable server re-attempted + // on essentially every call, which is the amplification the floors exist + // to stop. + failureBackoff: max(time.Second, min(cfg.FailureBackoff, cfg.DefaultTTL)), + forcedFloor: max(time.Second, min(cfg.ForcedRefreshFloor, cfg.DefaultTTL)), + stopCh: make(chan struct{}), } c.wg.Add(1) go c.backgroundRefresh() @@ -100,23 +138,58 @@ func (c *DocumentCache) cachedData() ([]byte, bool) { } // refresh fetches fresh data and updates the cache. Must be called under the write lock. -// On fetch failure with stale data it returns the stale data. On fetch failure with no -// data it returns the error. +// While the failure floor holds it reaches nothing upstream and returns what a +// suppressed attempt would have produced. On fetch failure with stale data it returns +// the stale data. On fetch failure with no data it returns the error. func (c *DocumentCache) refresh(ctx context.Context) ([]byte, error) { + if c.withinFailureFloor(time.Now()) { + return c.lastKnown() + } + data, rawHeaders, err := c.fetchFn(ctx) if err != nil { + // Stamp the floor after the attempt, not before it: a fetch that ran into + // its own timeout has already spaced the next one by that much. + c.lastFailureAt = time.Now() + c.lastErr = err if c.data != nil { - // Stale fallback. + // Stale fallback, held for the backoff window rather than left expired. + // An expiry that a failure never moves puts every subsequent read back + // on the synchronous fetch path, so an unreachable authorization server + // turns into one outbound attempt per request, indefinitely. + // + // Extend only. A background refresh that fails at 80% of a still-valid + // document must not shorten its life: overwriting unconditionally would + // expire it early and send every read down the blocking fetch path for + // the rest of the window the document was still good for. The floor's + // own job is done without touching expiry — refresh returns lastKnown + // without reaching the network — so the write is only needed when + // expiry has already passed. + if e := c.lastFailureAt.Add(c.failureBackoff); e.After(c.expiry) { + c.expiry = e + } return c.data, nil } - c.lastErr = err return nil, err } headers := toHTTPHeaders(rawHeaders) + cachedAt := time.Now() expiry := ParseCacheExpiry(headers) - if expiry.IsZero() { - expiry = time.Now().Add(c.defaultTTL) + // A server expiry at or before the moment the document was cached — a stale + // `Expires:` header is the realistic source — is not a shorter TTL, it is an + // unusable one: honoring it caches the document already expired, so every + // read takes the synchronous fetch path for as long as the header stays + // stale. Treat it as no preference and let the configured interval govern. + // The zero time ParseCacheExpiry returns for "no cache headers" lands here too. + // + // A server expiry *longer* than the configured interval is rejected for the + // opposite reason: it would make the effective TTL server-controlled, so an + // AS answering `max-age=86400` would pin a cache configured for five minutes + // to a day and keep a retired key trusted for that long. The configured + // interval is an upper bound; the server can only ask for less. + if !expiry.After(cachedAt) || expiry.After(cachedAt.Add(c.defaultTTL)) { + expiry = cachedAt.Add(c.defaultTTL) } old := c.data @@ -126,12 +199,55 @@ func (c *DocumentCache) refresh(ctx context.Context) ([]byte, error) { c.data = data c.expiry = expiry + // Any successful attempt closes the failure floor: the upstream answered. + c.lastFailureAt = time.Time{} c.lastErr = nil return c.data, nil } -// ForceRefresh bypasses the cache and fetches fresh data immediately. -// The cache is updated on success. On failure, stale data is returned if available. +// withinFailureFloor reports whether the retry floor opened by the last failed fetch +// is still holding. Must be called under at least a read lock. +func (c *DocumentCache) withinFailureFloor(now time.Time) bool { + return !c.lastFailureAt.IsZero() && now.Sub(c.lastFailureAt) < c.failureBackoff +} + +// admitForcedRefresh reports whether a forced refresh may reach upstream, stamping the +// window when it may. Must be called under the write lock. +func (c *DocumentCache) admitForcedRefresh(now time.Time) bool { + if !c.lastForcedAt.IsZero() && now.Sub(c.lastForcedAt) < c.forcedFloor { + return false + } + c.lastForcedAt = now + return true +} + +// lastKnown returns what a suppressed fetch would have produced: the last known good +// document if there is one, otherwise the error that opened the floor. +// Must be called under at least a read lock. +func (c *DocumentCache) lastKnown() ([]byte, error) { + if c.data != nil { + return c.data, nil + } + return nil, c.lastErr +} + +// ForceRefresh bypasses the cache TTL and fetches fresh data immediately, subject to +// the refresh floors. The cache is updated on success. On failure, stale data is +// returned if available. +// +// The caller that reaches it is a JWKS kid miss, and nothing upstream of that has +// authenticated anything — only the token header has been decoded — so a well-formed +// header carrying an attacker-chosen kid would otherwise cost the authorization server +// one fetch per request, unthrottled. The floor caps that at one forced fetch per +// interval while still following a real rotation promptly: the first miss after it +// elapses fetches immediately, and a refused one degrades to an ordinary read. +// +// "Ordinary read" is literal: a refusal serves the cached document only while it +// is still within its TTL, exactly as Get would. Returning an expired document +// would be worse than the pre-floor behavior — after a transient upstream +// failure the forced window stays spent for the rest of the floor, and every +// legitimate token carrying a rotated kid would be rejected against a key set +// that an ordinary read would have re-fetched. func (c *DocumentCache) ForceRefresh(ctx context.Context) ([]byte, error) { if c.closed.Load() != 0 { return nil, ErrCacheClosed @@ -139,6 +255,18 @@ func (c *DocumentCache) ForceRefresh(ctx context.Context) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() + + // The failure floor is consulted first so a forced refresh that it is going to + // refuse anyway does not spend the forced window: burning it on a refusal would + // downgrade the first miss after the floor elapses, which is exactly the one + // that follows a key rotation. + now := time.Now() + if !c.withinFailureFloor(now) && !c.admitForcedRefresh(now) { + if data, ok := c.cachedData(); ok { + return data, nil + } + } + // refresh handles the within-floor and no-data cases via lastKnown. return c.refresh(ctx) } diff --git a/core/internal/cache/cache_test.go b/core/internal/cache/cache_test.go index 230ca33..15f0b9b 100644 --- a/core/internal/cache/cache_test.go +++ b/core/internal/cache/cache_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "sync" "sync/atomic" "testing" @@ -475,3 +476,435 @@ func TestHeadersWithMaxAge_Helper(t *testing.T) { t.Errorf("expected ~300s, got %.1f", diff) } } + +// --------------------------------------------------------------------------- +// Refresh floors +// --------------------------------------------------------------------------- + +func TestForceRefresh_FloorThrottlesUnknownKIDBurst(t *testing.T) { + // Every verification of a token carrying an unknown kid reaches ForceRefresh, + // and the attacker picks the kid: without a floor a burst of N verifications + // is N outbound fetches, all of them pre-authentication. + fn, count := counterFetcher("keys") + c := newTestCache(fn, time.Hour) + defer c.Close() + + const burst = 50 + for range burst { + if _, err := c.ForceRefresh(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + if n := count.Load(); n != 1 { + t.Errorf("expected 1 fetch for a burst of %d forced refreshes, got %d", burst, n) + } +} + +func TestForceRefresh_RefusedServesLastKnownGood(t *testing.T) { + var n atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + i := n.Add(1) + return fmt.Appendf(nil, "v%d", i), nil, nil + } + + c := newTestCache(fn, time.Hour) + defer c.Close() + + first, err := c.ForceRefresh(context.Background()) // admitted + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := c.ForceRefresh(context.Background()) // refused by the floor + if err != nil { + t.Errorf("a refused forced refresh must serve the cached document, got error: %v", err) + } + if string(second) != string(first) { + t.Errorf("expected the cached document %q, got %q", string(first), string(second)) + } +} + +func TestForceRefresh_FloorElapsesAndRefetches(t *testing.T) { + var n atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + i := n.Add(1) + return fmt.Appendf(nil, "v%d", i), nil, nil + } + + // The floors carry a one-second lower clamp, so the window a test waits out + // is a real second rather than the 20ms this used to ask for. + c := New(Config{FetchFn: fn, DefaultTTL: time.Hour, ForcedRefreshFloor: time.Second}) + defer c.Close() + + _, _ = c.ForceRefresh(context.Background()) + _, _ = c.ForceRefresh(context.Background()) // refused + time.Sleep(1200 * time.Millisecond) + _, _ = c.ForceRefresh(context.Background()) // admitted again + + if got := n.Load(); got != 2 { + t.Errorf("expected 2 fetches (one per elapsed floor window), got %d", got) + } +} + +func TestForceRefresh_RefusedButExpiredStillRefetches(t *testing.T) { + // A refused forced refresh must still behave like an ordinary read: Get would + // have re-fetched an expired document, and serving the expired one instead + // rejects every legitimate token carrying a rotated kid for the rest of the + // forced floor. + // + // Both floors are capped at DefaultTTL, so a refusal can only coincide with an + // expired document when the expiry came from somewhere other than DefaultTTL — + // a server-supplied expiry shorter than the configured interval, which this + // cache honors. WithJWKSCacheTTL(1h) against an AS serving max-age=30 is the + // live shape: forcedFloor is a minute, the document expires at 30s, and every + // kid miss in between takes this branch. Expiry is moved directly rather than + // slept for, so the case is reached deterministically and the failure path is + // not needed to spend the window — one admitted ForceRefresh spends it too. + var n atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + i := n.Add(1) + return fmt.Appendf(nil, "v%d", i), nil, nil + } + + c := New(Config{FetchFn: fn, DefaultTTL: time.Hour, ForcedRefreshFloor: time.Minute}) + defer c.Close() + + if _, err := c.Get(context.Background()); err != nil { // fetch 1, populates + t.Fatalf("unexpected error: %v", err) + } + if _, err := c.ForceRefresh(context.Background()); err != nil { // fetch 2, spends the forced window + t.Fatalf("unexpected error: %v", err) + } + + // Expire the document without touching lastForcedAt: the forced floor is a + // minute and nothing here waits it out, so the next forced call is refused. + c.mu.Lock() + c.expiry = time.Now().Add(-time.Second) + c.mu.Unlock() + + // A TTL this long keeps the background loop out of it (it sleeps to 80% of + // the interval), but count around the call anyway: the claim is about this + // call reaching upstream. + before := n.Load() + got, err := c.ForceRefresh(context.Background()) // refused by the floor, expired → must fetch + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := n.Load(); calls != before+1 { + t.Errorf("a refusal must degrade to an ordinary read and re-fetch an expired document; fetches went %d -> %d", before, calls) + } + if want := fmt.Appendf(nil, "v%d", n.Load()); string(got) != string(want) { + t.Errorf("ForceRefresh returned %q, want the re-fetched document %q", got, want) + } +} + +func TestFailedRefresh_ExtendsAnAlreadyExpiredDocument(t *testing.T) { + // The extension write only fires when expiry has already passed — while the + // document is still good the failure must not shorten its life, so the + // extend-only guard skips it. Drive the case directly: the sibling test above + // runs with an hour of TTL, where the write is a no-op and every assertion in + // it passes whether or not the extension exists. + fetchErr := errors.New("server down") + var calls atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + if calls.Add(1) == 1 { + return []byte("original"), nil, nil + } + return nil, nil, fetchErr + } + + c := newTestCache(fn, time.Hour) // failureBackoff clamps to one second + defer c.Close() + + if _, err := c.Get(context.Background()); err != nil { + t.Fatalf("initial fetch failed: %v", err) + } + c.mu.Lock() + c.expiry = time.Now().Add(-time.Hour) + c.mu.Unlock() + + // Expired, so this read takes the fetch path; the fetch fails and the stale + // document is served. Without the extension expiry stays in the past and + // every later read goes back down that same blocking path. + if _, err := c.Get(context.Background()); err != nil { + t.Fatalf("expected the stale document, got %v", err) + } + + c.mu.RLock() + expiry := c.expiry + c.mu.RUnlock() + if !expiry.After(time.Now()) { + t.Errorf("a failed refresh must extend an expired document into the backoff window, expiry = %v", expiry) + } + if d := c.nextRefreshIn(); d <= 0 { + t.Errorf("expected a positive background refresh delay after a failure, got %v", d) + } +} + +func TestFailedRefresh_OpensRetryFloor(t *testing.T) { + fetchErr := errors.New("server down") + var calls atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + calls.Add(1) + return nil, nil, fetchErr + } + + c := newTestCache(fn, time.Hour) + defer c.Close() + + const attempts = 20 + for range attempts { + if _, err := c.Get(context.Background()); !errors.Is(err, fetchErr) { + t.Fatalf("expected the retained failure, got %v", err) + } + } + + if n := calls.Load(); n != 1 { + t.Errorf("expected 1 fetch across %d attempts against an unreachable server, got %d", attempts, n) + } +} + +func TestFailedRefresh_HoldsTheDocumentAndSpacesReads(t *testing.T) { + fetchErr := errors.New("server down") + var calls atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + if calls.Add(1) == 1 { + return []byte("original"), nil, nil + } + return nil, nil, fetchErr + } + + c := newTestCache(fn, time.Hour) + defer c.Close() + + if _, err := c.Get(context.Background()); err != nil { + t.Fatalf("initial fetch failed: %v", err) + } + if _, err := c.ForceRefresh(context.Background()); err != nil { + t.Fatalf("expected stale fallback, not error: %v", err) + } + + // A failure that leaves expiry in the past keeps nextRefreshIn at 0, which + // leaves the background loop polling and every read on the fetch path. + c.mu.RLock() + expiry := c.expiry + c.mu.RUnlock() + if !expiry.After(time.Now()) { + t.Errorf("a failed refresh must hold the document for the backoff window, expiry = %v", expiry) + } + if d := c.nextRefreshIn(); d <= 0 { + t.Errorf("expected a positive background refresh delay after a failure, got %v", d) + } + + // The floor also spaces the reads themselves. + for range 20 { + if _, err := c.Get(context.Background()); err != nil { + t.Fatalf("expected the stale document, got %v", err) + } + } + if n := calls.Load(); n != 2 { + t.Errorf("expected 2 fetches (one success, one failure) while the floor holds, got %d", n) + } +} + +func TestSuccessfulRefresh_ClosesRetryFloor(t *testing.T) { + fetchErr := errors.New("server down") + var calls atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + if calls.Add(1) == 1 { + return nil, nil, fetchErr + } + return []byte("recovered"), nil, nil + } + + // The floor's one-second lower clamp is the shortest window a test can wait out. + c := New(Config{FetchFn: fn, DefaultTTL: time.Hour, FailureBackoff: time.Second}) + defer c.Close() + + if _, err := c.Get(context.Background()); !errors.Is(err, fetchErr) { + t.Fatalf("expected the fetch error, got %v", err) + } + time.Sleep(1200 * time.Millisecond) + + data, err := c.Get(context.Background()) + if err != nil { + t.Fatalf("expected recovery once the floor elapsed, got %v", err) + } + if string(data) != "recovered" { + t.Errorf("expected %q, got %q", "recovered", string(data)) + } +} + +// --------------------------------------------------------------------------- +// Non-future server expiry +// --------------------------------------------------------------------------- + +func headersWithExpires(at time.Time) map[string][]string { + return map[string][]string{ + "Expires": {at.UTC().Format(http.TimeFormat)}, + } +} + +// expiryAfterFetch runs one Get against a fetcher serving the given headers and +// reports the expiry the cache recorded, plus the number of fetches a second Get +// then costs. +func expiryAfterFetch(t *testing.T, headers map[string][]string, ttl time.Duration) (time.Time, int32) { + t.Helper() + + var calls atomic.Int32 + c := newTestCache(func(ctx context.Context) ([]byte, map[string][]string, error) { + calls.Add(1) + return []byte("data"), headers, nil + }, ttl) + defer c.Close() + + if _, err := c.Get(context.Background()); err != nil { + t.Fatalf("initial fetch failed: %v", err) + } + c.mu.RLock() + expiry := c.expiry + c.mu.RUnlock() + + if _, err := c.Get(context.Background()); err != nil { + t.Fatalf("second read failed: %v", err) + } + return expiry, calls.Load() +} + +func TestZeroServerExpiryFallsBackToDefaultTTL(t *testing.T) { + // `Expires:` at the moment the document is cached — a zero TTL is not a + // shorter TTL, it is an unusable one. + expiry, calls := expiryAfterFetch(t, headersWithExpires(time.Now()), time.Minute) + + if remaining := time.Until(expiry); remaining < 50*time.Second { + t.Errorf("expected the configured TTL (~1m) to govern a zero server expiry, got %v", remaining) + } + if calls != 1 { + t.Errorf("expected the second read to be served from cache, got %d fetches", calls) + } +} + +func TestPastServerExpiryFallsBackToDefaultTTL(t *testing.T) { + // A stale `Expires:` header leaves the document expired on arrival, so every + // read re-fetches — an unauthenticated caller's per-request cost against the + // authorization server, since the metadata read precedes signature checking. + expiry, calls := expiryAfterFetch(t, headersWithExpires(time.Now().Add(-time.Hour)), time.Minute) + + if remaining := time.Until(expiry); remaining < 50*time.Second { + t.Errorf("expected the configured TTL (~1m) to govern a past server expiry, got %v", remaining) + } + if calls != 1 { + t.Errorf("expected the second read to be served from cache, got %d fetches", calls) + } +} + +func TestFutureServerExpiryStillGoverns(t *testing.T) { + // Negative control: a usable server expiry shorter than the configured + // interval must still win, so the fix above is not "ignore the header". + expiry, _ := expiryAfterFetch(t, headersWithExpires(time.Now().Add(10*time.Second)), time.Hour) + + if remaining := time.Until(expiry); remaining > 15*time.Second { + t.Errorf("expected the server expiry (~10s) to govern, got %v", remaining) + } +} + +func TestServerExpiryLongerThanTTLIsClamped(t *testing.T) { + // The configured interval is an upper bound: an AS answering with a day-long + // expiry must not pin a cache configured for a minute, or a key retired at the + // AS stays trusted for a day and the effective TTL becomes server-controlled. + expiry, _ := expiryAfterFetch(t, headersWithExpires(time.Now().Add(24*time.Hour)), time.Minute) + + if remaining := time.Until(expiry); remaining > 2*time.Minute { + t.Errorf("expected the configured TTL (1m) to cap the server expiry, got %v", remaining) + } +} + +func TestFailedRefreshNeverShortensALiveDocument(t *testing.T) { + // The failure floor extends expiry, never pulls it in: a background refresh + // that fails at 80% of a still-valid document must leave the rest of its life + // intact, or every read for the remainder takes the blocking fetch path. + var n atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + if n.Add(1) == 1 { + return []byte("v1"), nil, nil + } + return nil, nil, errors.New("server down") + } + + c := New(Config{FetchFn: fn, DefaultTTL: time.Hour, FailureBackoff: time.Second}) + defer c.Close() + + if _, err := c.Get(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + c.mu.RLock() + before := c.expiry + c.mu.RUnlock() + if _, err := c.ForceRefresh(context.Background()); err != nil { + t.Fatalf("a failed refresh must serve the stale document, got %v", err) + } + c.mu.RLock() + after := c.expiry + c.mu.RUnlock() + + if after.Before(before) { + t.Errorf("a failed refresh shortened a live document: expiry %v -> %v", before, after) + } +} + +func TestFailureFloorRefusalDoesNotStampTheForcedWindow(t *testing.T) { + // Ordering invariant: the failure floor is consulted first, so a forced refresh + // it is going to refuse anyway does not spend the forced window — burning it on + // a refusal would downgrade the first miss after the floor elapses, which is + // exactly the one that follows a key rotation. + // + // Asserted on the stamp rather than on a later fetch: the fetch version needs + // three sleeps straddling two floors to tell the orders apart, and the stamp is + // the thing the ordering actually protects. With the conditions swapped, + // admitForcedRefresh runs first, sees its own floor elapsed, and moves the mark. + var n atomic.Int32 + fn := func(ctx context.Context) ([]byte, map[string][]string, error) { + i := n.Add(1) + if i == 2 { + return nil, nil, errors.New("server down") + } + return fmt.Appendf(nil, "v%d", i), nil, nil + } + + // Forced floor shorter than the failure floor, so there is a window in which + // the forced floor has elapsed and the failure floor still holds. + c := New(Config{ + FetchFn: fn, + DefaultTTL: time.Hour, + FailureBackoff: 3 * time.Second, + ForcedRefreshFloor: time.Second, + }) + defer c.Close() + + if _, err := c.Get(context.Background()); err != nil { // fetch 1, populates + t.Fatalf("unexpected error: %v", err) + } + if _, err := c.ForceRefresh(context.Background()); err != nil { // fetch 2, fails + t.Fatalf("a failed forced refresh must serve the stale document, got: %v", err) + } + c.mu.RLock() + stamped := c.lastForcedAt + c.mu.RUnlock() + + // Forced floor (1s) elapsed, failure floor (3s) still holding. + time.Sleep(1500 * time.Millisecond) + if _, err := c.ForceRefresh(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + c.mu.RLock() + after := c.lastForcedAt + c.mu.RUnlock() + if !after.Equal(stamped) { + t.Errorf("a refusal spent the forced window: lastForcedAt moved %v -> %v", stamped, after) + } + if got := n.Load(); got != 2 { + t.Errorf("the refused call must not reach upstream, got %d fetches", got) + } +} diff --git a/core/internal/oauth/client_test.go b/core/internal/oauth/client_test.go index d48869f..08cdf81 100644 --- a/core/internal/oauth/client_test.go +++ b/core/internal/oauth/client_test.go @@ -644,6 +644,51 @@ func TestTokenExchange_ConsentRequiredWithoutConsentURL(t *testing.T) { } } +// TestTokenExchange_AccessDenied pins the typed mapping of the HTTP 403 +// access_denied answer an AS gives a cross-client exchange whose client is +// not allowlisted on the target resource. +func TestTokenExchange_AccessDenied(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusForbidden, map[string]any{ + "error": "access_denied", + "error_description": "client is not allowed to exchange for this resource", + }) + })) + defer srv.Close() + + _, err := TokenExchange(context.Background(), srv.URL, testClientAuth(), testFetchSettings(), TokenExchangeInput{ + SubjectToken: "subject-token", + Resources: []string{"https://downstream.example.com/"}, + }, nil) + if !errors.Is(err, ErrAccessDenied) { + t.Fatalf("expected ErrAccessDenied, got %T: %v", err, err) + } + var consentErr *ConsentRequiredError + if errors.As(err, &consentErr) { + t.Error("access_denied must not be surfaced as *ConsentRequiredError") + } +} + +// TestTokenExchange_InvalidTarget pins the typed mapping of invalid_target +// (RFC 8707 §2.2): the requested resource does not match a granted one. +func TestTokenExchange_InvalidTarget(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "invalid_target", + "error_description": "resource does not match a granted resource", + }) + })) + defer srv.Close() + + _, err := TokenExchange(context.Background(), srv.URL, testClientAuth(), testFetchSettings(), TokenExchangeInput{ + SubjectToken: "subject-token", + Resources: []string{"https://downstream.example.com"}, + }, nil) + if !errors.Is(err, ErrInvalidTarget) { + t.Fatalf("expected ErrInvalidTarget, got %T: %v", err, err) + } +} + func TestTokenExchange_MissingIssuedTokenType(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ diff --git a/core/internal/oauth/types.go b/core/internal/oauth/types.go index e1e8f78..eb91bef 100644 --- a/core/internal/oauth/types.go +++ b/core/internal/oauth/types.go @@ -158,6 +158,14 @@ var ( ErrUseDPoPNonce = errors.New("auth: use_dpop_nonce") ErrConsentRequired = errors.New("auth: consent_required") ErrInteractionRequired = errors.New("auth: interaction_required") + // ErrAccessDenied is returned when the AS refuses a cross-client token + // exchange (HTTP 403): the exchanging client is not allowlisted on the + // target resource. Operator-fixable, not user-fixable — re-prompting the + // user does not help, unlike consent_required. + ErrAccessDenied = errors.New("auth: access_denied") + // ErrInvalidTarget is returned when the requested resource does not match + // a granted resource byte for byte (RFC 8707 §2.2; a trailing slash counts). + ErrInvalidTarget = errors.New("auth: invalid_target") ) // mapOAuthError maps an OAuth 2.0 error code string to a sentinel error. @@ -183,6 +191,10 @@ func mapOAuthError(errorCode string) error { return ErrConsentRequired case "interaction_required": return ErrInteractionRequired + case "access_denied": + return ErrAccessDenied + case "invalid_target": + return ErrInvalidTarget default: return errors.New("auth: " + errorCode) } diff --git a/core/internal/oauth/types_test.go b/core/internal/oauth/types_test.go index 0244d50..8705245 100644 --- a/core/internal/oauth/types_test.go +++ b/core/internal/oauth/types_test.go @@ -21,6 +21,8 @@ func TestMapOAuthError_KnownCodes(t *testing.T) { {"server_error", ErrServerError}, {"consent_required", ErrConsentRequired}, {"interaction_required", ErrInteractionRequired}, + {"access_denied", ErrAccessDenied}, + {"invalid_target", ErrInvalidTarget}, } for _, tt := range tests { t.Run(tt.code, func(t *testing.T) { @@ -131,6 +133,7 @@ func TestSentinelErrors_AreDistinct(t *testing.T) { ErrUnauthorizedClient, ErrUnsupportedGrantType, ErrInvalidRequest, ErrServerError, ErrCircuitOpen, ErrConsentRequired, ErrInteractionRequired, + ErrAccessDenied, ErrInvalidTarget, } for i, a := range sentinels { for j, b := range sentinels { diff --git a/core/resource/errors.go b/core/resource/errors.go index f8e8bfb..4a4c3d3 100644 --- a/core/resource/errors.go +++ b/core/resource/errors.go @@ -2,6 +2,7 @@ package resource import ( + "encoding/json" "errors" "fmt" "net/http" @@ -63,10 +64,134 @@ func HTTPStatus(err error) int { } } +// safeErrorDescriptions maps an error code to the error_description emitted +// for it. +// +// The body is served to a caller who by definition has not authenticated, so +// error_description is built from the RFC 6750 §3.1 / RFC 9449 §7.1 error code, +// never from the error's own message. The SDK's messages name the failing +// detail — the unknown `kid`, the claim that did not validate, the `typ` that +// was rejected — and an audience mismatch in particular would hand the caller +// the exact `aud` the resource expects, which is the value they need in order +// to request a token for it. RFC 6750 §3 does not require error_description to +// be diagnostic: the error code already carries everything a conforming client +// needs in order to decide what to do next. +// +// The descriptions carry no comma, so that the same text stays safe to emit as +// a WWW-Authenticate quoted-string, where a comma separates challenge +// parameters and is what a lenient client-side parser splits on. +var safeErrorDescriptions = map[string]string{ + "invalid_token": "The access token is missing or not valid for this resource", + "insufficient_scope": "The access token does not carry the scope this operation requires", + "invalid_dpop_proof": "The DPoP proof is missing or not valid for this request", +} + +// marshalFallbackErrorCode is the body's `error` in the unreachable branch that +// answers a failed marshal. It names a code even where the response it replaces +// would omit one: a body carrying neither a code nor a description says less +// than a generic one. +const marshalFallbackErrorCode = "invalid_token" + +// fallbackErrorDescription covers an error code with no entry in +// safeErrorDescriptions — any code added without a matching row. Kept +// deliberately contentless for the same reason the table exists. +const fallbackErrorDescription = "The request could not be authenticated" + +// missingCredentialsDescription is the error_description for a request that +// presented no credentials at all. +// +// That case carries no `error` on either side. RFC 6750 §3.1 defines the codes +// for a request that did present credentials and failed, and ties +// invalid_request to a malformed request answered with 400 — a plain +// "please authenticate" 401 is neither. §3 has the challenge omit `error` +// accordingly, and the body omits it for the same reason, so a client reading +// either half of the response gets the same answer: authenticate. +const missingCredentialsDescription = "The request did not carry an access token" + +// errorDescription returns the error_description value to emit for errorCode. +// +// A nil err is a programmer error — every exported entry point is documented as +// taking the error the verifier returned — but it must not panic: the +// non-verbose branch never touches err, so the verbose one guarding it keeps +// AuthErrorResponse and AuthErrorResponseVerbose agreeing on the same input. +func errorDescription(errorCode string, err error, verbose bool) string { + if verbose && err != nil { + return err.Error() + } + if errorCode == "" { + return missingCredentialsDescription + } + if description, ok := safeErrorDescriptions[errorCode]; ok { + return description + } + return fallbackErrorDescription +} + // AuthErrorResponse returns the HTTP status, headers, and body for an auth error. // An optional realm string may be passed; if non-empty it is included in the // WWW-Authenticate challenge per RFC 6750 §3. +// +// The JSON body's error_description is a fixed, caller-safe sentence chosen by +// the error code; err's own message never reaches the wire. Log err for the +// diagnostic — it is unchanged. +// +// The challenge carries no resource_metadata parameter. Use +// AuthErrorResponseWithMetadata to advertise the RFC 9728 document, which is +// what every adapter in this SDK does. func AuthErrorResponse(err error, realm ...string) (status int, headers map[string]string, body string) { + return authErrorResponse(err, "", false, realm...) +} + +// AuthErrorResponseVerbose is AuthErrorResponse with err's own message restored +// in the JSON error_description, which is what this package emitted before the +// description became a fixed per-code sentence. +// +// It is a development aid. The body reaches a caller who has not +// authenticated, and the SDK's messages name the failing detail — the unknown +// `kid`, the claim that did not validate, the audience the resource expects — +// so do not enable it in production. Status, headers and error code are +// identical to AuthErrorResponse either way. +func AuthErrorResponseVerbose(err error, realm ...string) (status int, headers map[string]string, body string) { + return authErrorResponse(err, "", true, realm...) +} + +// sanitizeChallengeValue strips the octets that cannot appear inside an +// RFC 9110 §11.2 quoted-string: a bare '"' terminates the parameter early and a +// '\' opens a quoted-pair a conforming client unescapes into something else, +// so either lets a caller-supplied value append auth-params of its own — a +// second resource_metadata naming a different authorization server, for +// instance. CR and LF go with them so no value can split the header. +// +// The gate in resource.New covers only what arrives through +// WithResourceMetadataURL; AuthErrorResponseWithMetadata is exported and the +// guides route custom middleware straight to it, so the emitter has to hold the +// invariant for values that never passed a constructor — the multi-tenant case +// computes the URL per request. Stripping is a no-op for anything that did pass +// the gate, so the byte-for-byte equivalence with the previous adapter +// composition is unchanged. +func sanitizeChallengeValue(v string) string { + return strings.NewReplacer(`"`, "", "\\", "", "\r", "", "\n", "").Replace(v) +} + +// AuthErrorResponseWithMetadata is AuthErrorResponse with the RFC 9728 §5.1 +// resource_metadata parameter appended to the WWW-Authenticate challenge, so a +// client can discover the authorization server straight from the 401. +// +// Pass Resource.ResourceMetadataURL(); an empty resourceMetadataURL emits the +// challenge unchanged, making this a drop-in for AuthErrorResponse. +// +// The parameter goes last, after realm, error and scope, and is separated by a +// space when it is the first auth-param (the no-token case, where the challenge +// so far is the bare scheme) and by ", " otherwise — RFC 9110 §11.1 spells the +// challenge "auth-scheme 1*SP auth-param", with commas only between params. +// Adapters composed this parameter themselves until the emitter moved here; +// the bytes are unchanged, and errors_test.go pins that against the previous +// composition. +func AuthErrorResponseWithMetadata(err error, resourceMetadataURL string, realm ...string) (status int, headers map[string]string, body string) { + return authErrorResponse(err, resourceMetadataURL, false, realm...) +} + +func authErrorResponse(err error, resourceMetadataURL string, verboseDescription bool, realm ...string) (status int, headers map[string]string, body string) { status = HTTPStatus(err) scheme := "Bearer" errorCode := "invalid_token" @@ -97,7 +222,7 @@ func AuthErrorResponse(err error, realm ...string) (status int, headers map[stri // RFC 6750 §3: realm SHOULD be included in challenges. if len(realm) > 0 && realm[0] != "" { - wwwAuth += fmt.Sprintf(` realm="%s"`, realm[0]) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes in WWW-Authenticate parameters + wwwAuth += fmt.Sprintf(` realm="%s"`, sanitizeChallengeValue(realm[0])) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes in WWW-Authenticate parameters } if errorCode != "" { @@ -106,7 +231,15 @@ func AuthErrorResponse(err error, realm ...string) (status int, headers map[stri var scopeErr *ScopeError if errors.As(err, &scopeErr) && len(scopeErr.RequiredScopes) > 0 { - wwwAuth += fmt.Sprintf(`, scope="%s"`, scopeErr.ScopeString()) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes in WWW-Authenticate parameters + wwwAuth += fmt.Sprintf(`, scope="%s"`, sanitizeChallengeValue(scopeErr.ScopeString())) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes in WWW-Authenticate parameters + } + + if resourceMetadataURL != "" { + sep := " " + if strings.Contains(wwwAuth, "=") { + sep = ", " + } + wwwAuth += fmt.Sprintf(`%sresource_metadata="%s"`, sep, sanitizeChallengeValue(resourceMetadataURL)) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes in WWW-Authenticate parameters } headers = map[string]string{ @@ -114,11 +247,27 @@ func AuthErrorResponse(err error, realm ...string) (status int, headers map[stri "Content-Type": "application/json", } - if errorCode == "" { - body = fmt.Sprintf(`{"error":"invalid_request","error_description":%q}`, err.Error()) - } else { - body = fmt.Sprintf(`{"error":%q,"error_description":%q}`, errorCode, err.Error()) + // Marshaled rather than formatted with %q: strconv.Quote renders control + // bytes as Go escapes (\a, \v, \x7f), none of which are JSON escapes + // (RFC 8259 §7). The safe descriptions are fixed ASCII, but the verbose + // form puts err.Error() back on the wire, and the verifier interpolates + // token-controlled header values into those messages. + // `error` is omitted when the challenge omits it — the missing-token case — + // rather than being filled in with a code the header does not carry. + bodyBytes, marshalErr := json.Marshal(struct { + Error string `json:"error,omitempty"` + Description string `json:"error_description"` + }{ + Error: errorCode, + Description: errorDescription(errorCode, err, verboseDescription), + }) + if marshalErr != nil { + // Unreachable for two strings; fall back to the fixed pair rather than + // serve an empty body. Built from the constants so this copy cannot + // drift from them — no test can reach this branch to catch it if it did. + bodyBytes = fmt.Appendf(nil, `{"error":%q,"error_description":%q}`, marshalFallbackErrorCode, fallbackErrorDescription) } + body = string(bodyBytes) return status, headers, body } diff --git a/core/resource/errors_test.go b/core/resource/errors_test.go index fa720a8..5cc499a 100644 --- a/core/resource/errors_test.go +++ b/core/resource/errors_test.go @@ -1,6 +1,8 @@ package resource_test import ( + "encoding/json" + "fmt" "strings" "testing" @@ -30,3 +32,357 @@ func TestAuthErrorResponse_MultipleDpopProofs_MapsToInvalidDpopProof(t *testing. t.Errorf("WWW-Authenticate = %q, want invalid_dpop_proof error code", wwwAuth) } } + +// legacyChallengeWithMetadata reproduces the composition the http adapter +// performed before AuthErrorResponseWithMetadata existed: take the challenge +// AuthErrorResponse emits, then append resource_metadata with a space when no +// auth-param is present yet and ", " otherwise. It is the reference the tests +// below compare against, so a change to the emitter that alters a single byte +// of the wire format fails here rather than on a client. +func legacyChallengeWithMetadata(challenge, metadataURL string) string { + if metadataURL == "" { + return challenge + } + if challenge == "" { + challenge = "Bearer" + } + sep := " " + if strings.Contains(challenge, "=") { + sep = ", " + } + return challenge + sep + `resource_metadata="` + metadataURL + `"` +} + +// TestAuthErrorResponseWithMetadata_MatchesPreviousAdapterComposition pins the +// emitter move: carrying the resource_metadata parameter here instead of in the +// http adapter must not change one byte of the emitted header. Every challenge shape +// the adapter can produce is covered, the bare-Bearer no-token case included — +// that is the one whose separator is a space rather than a comma, and the one +// MCP clients parse on the very first unauthenticated request. +func TestAuthErrorResponseWithMetadata_MatchesPreviousAdapterComposition(t *testing.T) { + const metadataURL = "https://api.example.com/.well-known/oauth-protected-resource/mcp" + tests := []struct { + name string + err error + }{ + {"no token (bare Bearer)", verifier.ErrTokenMissing}, + {"invalid token", verifier.ErrInvalidSignature}, + {"expired token", verifier.ErrTokenExpired}, + {"insufficient scope", &resource.ScopeError{RequiredScopes: []string{"tools/admin"}, Err: verifier.ErrInsufficientScope}}, + {"insufficient scope, several", &resource.ScopeError{RequiredScopes: []string{"tools/admin", "tools/superuser"}, Err: verifier.ErrInsufficientScope}}, + {"DPoP required", verifier.ErrDPoPRequired}, + {"DPoP invalid", verifier.ErrDPoPInvalid}, + {"multiple DPoP proofs", verifier.ErrMultipleDpopProofs}, + {"DPoP not supported (Bearer scheme)", verifier.ErrDPoPNotSupported}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldStatus, oldHeaders, oldBody := resource.AuthErrorResponse(tt.err) + want := legacyChallengeWithMetadata(oldHeaders["WWW-Authenticate"], metadataURL) + + status, headers, body := resource.AuthErrorResponseWithMetadata(tt.err, metadataURL) + if got := headers["WWW-Authenticate"]; got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } + if status != oldStatus { + t.Errorf("status = %d, want %d", status, oldStatus) + } + if body != oldBody { + t.Errorf("body = %q, want %q", body, oldBody) + } + if got := headers["Content-Type"]; got != oldHeaders["Content-Type"] { + t.Errorf("Content-Type = %q, want %q", got, oldHeaders["Content-Type"]) + } + }) + } +} + +// descriptionOf decodes the error_description member of an AuthErrorResponse +// body, failing the test if the body is not the JSON object this package +// documents. +func descriptionOf(t *testing.T, body string) string { + t.Helper() + var parsed struct { + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("body is not valid JSON: %v — body: %s", err, body) + } + return parsed.ErrorDescription +} + +// TestAuthErrorResponse_DescriptionNeverCarriesTheErrorMessage is the point of +// the fixed description table: the body is served to a caller who has not +// authenticated, so the detail the verifier names — the unknown `kid`, the +// audience the resource expects, the rejected `typ` — must not travel with it. +// The assertion is on the wire body rather than on the error, because the +// error deliberately keeps its message for the resource server to log. +func TestAuthErrorResponse_DescriptionNeverCarriesTheErrorMessage(t *testing.T) { + tests := []struct { + name string + err error + secret string + }{ + { + name: "audience mismatch does not disclose the expected aud", + err: fmt.Errorf("%w: token aud does not include %q", verifier.ErrAudienceMismatch, "https://api.example.com/internal"), + secret: "https://api.example.com/internal", + }, + { + name: "signature failure does not disclose the unknown kid", + err: fmt.Errorf("%w: no key for kid %q", verifier.ErrInvalidSignature, "2026-09-rotation-key"), + secret: "2026-09-rotation-key", + }, + { + name: "claim rejection does not disclose the rejected typ", + err: fmt.Errorf("%w: unexpected typ %q", verifier.ErrInvalidClaims, "application/vnd.internal+jwt"), + secret: "application/vnd.internal+jwt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, body := resource.AuthErrorResponse(tt.err) + if strings.Contains(body, tt.secret) { + t.Errorf("body = %s, want it not to disclose %q", body, tt.secret) + } + if got, want := descriptionOf(t, body), "The access token is missing or not valid for this resource"; got != want { + t.Errorf("error_description = %q, want %q", got, want) + } + if !strings.Contains(tt.err.Error(), tt.secret) { + t.Errorf("err = %q, want the detail kept on the error for the server to log", tt.err) + } + }) + } +} + +// TestAuthErrorResponseVerbose_BodyIsValidJSONWithControlBytes: the verbose +// form puts the underlying error message back on the wire, and the verifier +// interpolates token-controlled header values into those messages. Go's %q +// would render a control byte as a Go escape that is not a JSON escape, so the +// body has to be marshaled. +func TestAuthErrorResponseVerbose_BodyIsValidJSONWithControlBytes(t *testing.T) { + err := fmt.Errorf("%w: token type must be \"at+jwt\", got \"a\x7fb\"", verifier.ErrInvalidClaims) + + _, _, body := resource.AuthErrorResponseVerbose(err) + + var decoded struct { + Error string `json:"error"` + Description string `json:"error_description"` + } + if jsonErr := json.Unmarshal([]byte(body), &decoded); jsonErr != nil { + t.Fatalf("body is not valid JSON: %v (body = %s)", jsonErr, body) + } + if !strings.Contains(decoded.Description, "a\x7fb") { + t.Errorf("error_description = %q, want it to carry the offending value", decoded.Description) + } +} + +// TestAuthErrorResponse_DescriptionIsFixedPerErrorCode pins the exact sentence +// emitted for each error code. The strings are a wire contract, so a rewording +// here is a wire change and should fail this test first. +func TestAuthErrorResponse_DescriptionIsFixedPerErrorCode(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {"invalid_token", verifier.ErrTokenExpired, "The access token is missing or not valid for this resource"}, + {"insufficient_scope", verifier.ErrInsufficientScope, "The access token does not carry the scope this operation requires"}, + {"invalid_dpop_proof", verifier.ErrMultipleDpopProofs, "The DPoP proof is missing or not valid for this request"}, + // No challenge `error` parameter (RFC 6750 §3.1), and none in the body + // either; the description comes from missingCredentialsDescription + // rather than from a row keyed by a code neither half names. + {"no credentials", verifier.ErrTokenMissing, "The request did not carry an access token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, body := resource.AuthErrorResponse(tt.err) + if got := descriptionOf(t, body); got != tt.want { + t.Errorf("error_description = %q, want %q", got, tt.want) + } + }) + } +} + +// TestAuthErrorResponseWithMetadata_RealmAndSeparators pins the parameter order +// and separators directly, rather than against the reference composition: the +// metadata parameter goes last, after realm, error and scope. +func TestAuthErrorResponseWithMetadata_RealmAndSeparators(t *testing.T) { + const metadataURL = "https://as.example.com/.well-known/oauth-protected-resource/mcp" + tests := []struct { + name string + err error + realm []string + want string + }{ + { + name: "no token — space separator after the bare scheme", + err: verifier.ErrTokenMissing, + want: `Bearer resource_metadata="` + metadataURL + `"`, + }, + { + name: "invalid token — comma after the error param", + err: verifier.ErrInvalidSignature, + want: `Bearer error="invalid_token", resource_metadata="` + metadataURL + `"`, + }, + { + name: "insufficient scope — metadata last, after scope", + err: &resource.ScopeError{RequiredScopes: []string{"tools/admin"}, Err: verifier.ErrInsufficientScope}, + want: `Bearer error="insufficient_scope", scope="tools/admin", resource_metadata="` + metadataURL + `"`, + }, + { + name: "realm present — metadata still last", + err: verifier.ErrTokenMissing, + realm: []string{"api"}, + want: `Bearer realm="api", resource_metadata="` + metadataURL + `"`, + }, + { + name: "multiple DPoP proofs — DPoP scheme keeps the parameter", + err: verifier.ErrMultipleDpopProofs, + want: `DPoP error="invalid_dpop_proof", resource_metadata="` + metadataURL + `"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, headers, _ := resource.AuthErrorResponseWithMetadata(tt.err, metadataURL, tt.realm...) + if got := headers["WWW-Authenticate"]; got != tt.want { + t.Errorf("WWW-Authenticate = %q, want %q", got, tt.want) + } + }) + } +} + +// TestAuthErrorResponseWithMetadata_EmptyURLIsAuthErrorResponse: an empty URL +// emits the challenge unchanged, so the metadata-carrying function is a drop-in +// for callers that have no document to advertise. +func TestAuthErrorResponseWithMetadata_EmptyURLIsAuthErrorResponse(t *testing.T) { + for _, err := range []error{verifier.ErrTokenMissing, verifier.ErrInvalidSignature, verifier.ErrDPoPRequired} { + wantStatus, wantHeaders, wantBody := resource.AuthErrorResponse(err) + status, headers, body := resource.AuthErrorResponseWithMetadata(err, "") + if headers["WWW-Authenticate"] != wantHeaders["WWW-Authenticate"] || status != wantStatus || body != wantBody { + t.Errorf("AuthErrorResponseWithMetadata(%v, \"\") = (%d, %q, %q), want (%d, %q, %q)", + err, status, headers["WWW-Authenticate"], body, wantStatus, wantHeaders["WWW-Authenticate"], wantBody) + } + } +} + +// TestAuthErrorResponseWithMetadata_SanitizesCallerSuppliedValues: the +// construction-time gate in resource.New only covers values that arrive through +// WithResourceMetadataURL. This function is exported and the user guide routes +// custom middleware straight to it, so a deployment computing the URL per +// request — the multi-tenant case, where the tenant slug lands in the PRM path — +// reaches the emitter with a value nothing validated. A bare '"' would terminate +// the quoted-string and let the rest of the value append auth-params of its own. +func TestAuthErrorResponseWithMetadata_SanitizesCallerSuppliedValues(t *testing.T) { + tests := []struct { + name string + url string + realm []string + want string + }{ + { + name: "quote in the metadata URL cannot close the parameter", + url: `https://as.example.com/prm", resource_metadata="https://evil.example.com/prm`, + want: `Bearer error="invalid_token", resource_metadata="https://as.example.com/prm, resource_metadata=https://evil.example.com/prm"`, + }, + { + name: "backslash in the metadata URL cannot open a quoted-pair", + url: `https://as.example.com/a\"b`, + want: `Bearer error="invalid_token", resource_metadata="https://as.example.com/ab"`, + }, + { + name: "quote in the realm cannot close the parameter", + url: "https://as.example.com/prm", + realm: []string{`api", error="insufficient_scope`}, + want: `Bearer realm="api, error=insufficient_scope" error="invalid_token", resource_metadata="https://as.example.com/prm"`, + }, + { + name: "CR and LF cannot split the header", + url: "https://as.example.com/prm\r\nX-Injected: 1", + want: `Bearer error="invalid_token", resource_metadata="https://as.example.com/prmX-Injected: 1"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, headers, _ := resource.AuthErrorResponseWithMetadata(verifier.ErrInvalidSignature, tt.url, tt.realm...) + got := headers["WWW-Authenticate"] + if got != tt.want { + t.Errorf("WWW-Authenticate = %q, want %q", got, tt.want) + } + if strings.ContainsAny(got[len("Bearer "):], "\r\n") { + t.Errorf("challenge carries a bare CR/LF: %q", got) + } + }) + } +} + +// TestAuthErrorResponse_ScopeNamesReachTheChallengeNotTheBody records where the +// missing scopes went. RFC 6750 §3 defines `scope="..."` for exactly this, so +// the client still learns what to step up to; the enriched message naming the +// scopes the token *does* carry stays on the error. +func TestAuthErrorResponse_ScopeNamesReachTheChallengeNotTheBody(t *testing.T) { + err := &resource.ScopeError{ + RequiredScopes: []string{"tools/admin", "tools/superuser"}, + Err: fmt.Errorf(`%w: required scopes "tools/admin", "tools/superuser"; token has scopes: tools/add`, verifier.ErrInsufficientScope), + } + + _, headers, body := resource.AuthErrorResponse(err) + + if want := `scope="tools/admin tools/superuser"`; !strings.Contains(headers["WWW-Authenticate"], want) { + t.Errorf("WWW-Authenticate = %q, want it to contain %q", headers["WWW-Authenticate"], want) + } + if strings.Contains(body, "tools/add") { + t.Errorf("body = %s, want it not to disclose the scopes the token carries", body) + } +} + +// TestAuthErrorResponseVerbose_RestoresTheErrorMessage covers the escape hatch, +// including that it changes nothing but the description. +func TestAuthErrorResponseVerbose_RestoresTheErrorMessage(t *testing.T) { + err := fmt.Errorf("%w: no key for kid %q", verifier.ErrInvalidSignature, "2026-09-rotation-key") + + safeStatus, safeHeaders, safeBody := resource.AuthErrorResponse(err, "https://api.example.com") + status, headers, body := resource.AuthErrorResponseVerbose(err, "https://api.example.com") + + if got := descriptionOf(t, body); got != err.Error() { + t.Errorf("error_description = %q, want %q", got, err.Error()) + } + if status != safeStatus { + t.Errorf("status = %d, want %d", status, safeStatus) + } + if headers["WWW-Authenticate"] != safeHeaders["WWW-Authenticate"] { + t.Errorf("WWW-Authenticate = %q, want %q", headers["WWW-Authenticate"], safeHeaders["WWW-Authenticate"]) + } + if strings.Contains(safeBody, "2026-09-rotation-key") { + t.Errorf("safe body = %s, want the verbose detail to stay out of it", safeBody) + } +} + +// TestAuthErrorResponse_NilErrorDoesNotPanic: a nil error is a programmer +// error, but the two entry points must agree on it rather than one returning a +// body and the other panicking on err.Error(). +func TestAuthErrorResponse_NilErrorDoesNotPanic(t *testing.T) { + for _, tt := range []struct { + name string + call func() (int, map[string]string, string) + }{ + {"AuthErrorResponse", func() (int, map[string]string, string) { return resource.AuthErrorResponse(nil) }}, + {"AuthErrorResponseVerbose", func() (int, map[string]string, string) { + return resource.AuthErrorResponseVerbose(nil) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("panicked on a nil error: %v", r) + } + }() + if _, _, body := tt.call(); body == "" { + t.Error("expected a body, got none") + } + }) + } +} diff --git a/core/resource/resource.go b/core/resource/resource.go index 0c042c3..61fd779 100644 --- a/core/resource/resource.go +++ b/core/resource/resource.go @@ -3,6 +3,7 @@ package resource import ( "context" "encoding/json" + "errors" "fmt" "maps" "net/url" @@ -28,6 +29,12 @@ type Resource struct { prmMap map[string]any prmConfig PRMConfig prmURL string + // resourceMetadataURL is what adapters advertise in the + // WWW-Authenticate resource_metadata parameter: the value passed to + // WithResourceMetadataURL, or prmURL when no override was given. + // Resolved once at construction so the accessor is a plain read and no + // adapter has to know which of the two topologies it is serving. + resourceMetadataURL string } // PRMConfig is the typed view of the Protected Resource Metadata document @@ -59,8 +66,9 @@ type PRMConfig struct { type Option func(*resourceConfig) type resourceConfig struct { - scopes []string - verifierOpts []verifier.Option + scopes []string + verifierOpts []verifier.Option + resourceMetadataURL string } // WithScopes sets the scopes supported by this resource. @@ -70,6 +78,33 @@ func WithScopes(scopes ...string) Option { } } +// WithResourceMetadataURL overrides the URL advertised in the RFC 9728 §5.1 +// resource_metadata parameter of every WWW-Authenticate challenge. Without it +// the challenge points at the document this SDK derives and serves itself +// (PRMURL); with it the challenge points wherever the document actually lives — +// typically the authorization server, which since 0.2.0 publishes one for every +// registered Resource at "/.well-known/oauth-protected-resource/{ref}". +// Use it when the resource server cannot serve its own well-known paths. +// +// The value is advertised, never fetched: the SDK does not resolve this URL, so +// it carries no SSRF surface and is validated for shape only. It must be an +// absolute http or https URL with a host, must carry no fragment and no +// userinfo, and must hold nothing that would break the +// quoted-string it is interpolated into (RFC 9110 §11.2). A value failing any +// of these is rejected by resource.New, the same construction-time boundary +// that rejects a malformed resource identifier. +// +// RFC 9728 §3.3 constrains what the document at that URL may say: its +// "resource" member must equal the identifier the client derived the request +// from, byte for byte, or the client must discard it. So the Resource URI +// registered at the authorization server, the identifier passed to +// client.Resource, and the public URL clients call must all be the same string. +func WithResourceMetadataURL(rawURL string) Option { + return func(cfg *resourceConfig) { + cfg.resourceMetadataURL = rawURL + } +} + // WithVerifierOptions passes options to the underlying TokenVerifier. func WithVerifierOptions(opts ...verifier.Option) Option { return func(cfg *resourceConfig) { @@ -98,6 +133,9 @@ func (r *Resource) URI() string { // PRMURL returns the absolute Protected Resource Metadata URL (RFC 9728 §3), // e.g. "https://api.example.com/.well-known/oauth-protected-resource/mcp". +// A query component on the resource identifier is preserved, so +// "https://api.example.com/mcp?tenant=a" yields +// "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a". // // The value is precomputed at construction time from the already-validated // resource URI, so this accessor is infallible — adapters should consume it @@ -106,10 +144,33 @@ func (r *Resource) PRMURL() string { return r.prmURL } +// ResourceMetadataURL returns the URL to advertise in the RFC 9728 §5.1 +// resource_metadata parameter of a WWW-Authenticate challenge: the value passed +// to WithResourceMetadataURL, or the derived PRMURL when none was. +// +// Adapters consume this rather than PRMURL, so an operator serving the document +// from the authorization server instead of the resource gets the challenge +// pointed at the document that exists. PRMURL keeps returning the derived URL +// regardless — it is where PRMHandler serves, and routing does not move when +// the advertisement does. +func (r *Resource) ResourceMetadataURL() string { + return r.resourceMetadataURL +} + // WellKnownPRMPath returns the RFC 9728 well-known path for this resource. // The path is formed by inserting "/.well-known/oauth-protected-resource" // between the host and the path component of the resource URI. // +// A query component on the resource identifier does not appear here: RFC 9728 +// §3 inserts the well-known string "between the host component and the path +// and/or query components", and the query half of that derivation lives in +// PRMURL. This accessor keys routing, so identifiers differing only by query +// share one path-registered handler serving one document. Serving distinct +// documents per query value is not supported: RFC 9728 §3.3 requires the +// client to discard a response whose "resource" value differs from the +// identifier it derived the request from, so any query value the shared +// document's "resource" was not built for fails that client-side check. +// // Per RFC 9728 §3.1 the terminating slash following the host component is // removed before insertion, so a resource identifier and its trailing-slash // variant resolve to the same well-known path. The section says "any @@ -132,6 +193,7 @@ func (r *Resource) PRMURL() string { // resource URI "https://api.example.com/mcp//" → "/.well-known/oauth-protected-resource/mcp" // resource URI "https://api.example.com/mcp%2F" → "/.well-known/oauth-protected-resource/mcp%2F" // resource URI "https://api.example.com/v2/mcp" → "/.well-known/oauth-protected-resource/v2/mcp" +// resource URI "https://api.example.com/mcp?t=a" → "/.well-known/oauth-protected-resource/mcp" (query surfaces in PRMURL) func (r *Resource) WellKnownPRMPath() string { return wellKnownPRMPath(r.parsedURI) } @@ -159,13 +221,6 @@ func wellKnownPRMPath(u *url.URL) string { // derives. This strips only a genuine delimiter slash (a "%2F" is left // intact) and only from the derived URL; the resource identifier is // unchanged. - // - // TODO(AuthPlane/go-sdk#24): RFC 9728 §3.1 defines the derivation over the - // resource identifier's "path and/or query components"; only the path half - // is handled here, so two identifiers differing only by query collapse onto - // one document. Whether to preserve the query or reject a query-bearing - // identifier at New is an open cross-implementation decision — see the - // issue. return "/.well-known/oauth-protected-resource" + strings.TrimRight(escPath, "/") } @@ -177,20 +232,226 @@ func wellKnownPRMPath(u *url.URL) string { // or a PRM well-known URL. Rejecting them at this boundary keeps the // invariant that downstream consumers (the HTTP adapter, the PRM emitter) // rely on consistent. +// +// It must also carry no userinfo subcomponent, no literal `"` in the host and +// no literal space in the path — see the gates below. func New(uri, issuer string, jwksCache *verifier.JWKSCache, opts ...Option) (*Resource, error) { parsed, err := url.ParseRequestURI(uri) if err != nil { + // url.Error.Error() prints its URL field verbatim and does not redact, + // so a parse failure on a credential-bearing identifier would echo the + // credential into whatever log the construction error lands in — either + // because the failure is elsewhere in the URI + // ("https://svc:pw@api.example.com/x%zz", rejected for the path escape) + // or because it is in the userinfo itself, which net/url validates + // before this function gets to reject it. Substitute a redacted URL, + // keeping %w so errors.As(err, new(*url.Error)) still works for callers + // that unwrap. + var uerr *url.Error + if errors.As(err, &uerr) { + err = &url.Error{Op: uerr.Op, URL: redactURI(uri), Err: uerr.Err} + } return nil, fmt.Errorf("resource: invalid resource URI: %w", err) } + // RFC 9110 §4.2.4: in an http or https URI "a sender MUST NOT generate the + // userinfo subcomponent (and its '@' delimiter)". Reject rather than + // redact: the identifier is stored verbatim and fanned out to three sinks, + // and redacting at each one only covers the sinks that remember to. Those + // sinks are the "resource" member of the Protected Resource Metadata + // document RFC 9728 §3 serves to unauthenticated callers, the + // resource_metadata parameter of the 401 WWW-Authenticate challenge, and + // the origin the DPoP htu comparison is built from — which no honest client + // proof could ever match, since RFC 9449 §4.3 derives htu from a target URI + // that carries no userinfo. + // + // The check reads the authority off the original string rather than off + // parsed.User, which is the narrower signal in two ways. It is blind to a + // scheme-relative reference — "//svc:pw@api.example.com/mcp" parses with + // User nil and the whole string folded into Path — so a parsed-field gate + // would let that fall through to the scheme/host rejection below, and the + // credential would leak out of the very branch that rejected it. And + // reading the string keeps the rule independent of how net/url chooses to + // distribute a malformed authority across Host, User and Path. Everything + // parsed.User can see, the scan sees. + // + // The empty form counts: "https://@api.example.com/mcp" is a userinfo + // component, because the "@" delimiter is what §4.2.4 names alongside the + // subcomponent — the credentials being empty does not make it absent. A + // truthiness test on a parsed username would let that URI through and carry + // the "@" into the derived well-known URL. + // + // The scan is bounded to the authority (RFC 3986 §3.2), so an "@" elsewhere + // stays data — it is legal pchar in a path (§3.3) and in a query (§3.4) — + // and a host with a port, "https://api.example.com:8443/mcp", is untouched. + // Scanning the whole identifier for ":" or "@" instead is the obvious way + // to get this wrong, and it breaks the commonest non-default identifier + // there is. + // + // The rejection message is redacted, or the gate leaks exactly what it + // exists to stop. + // + // Out of scope for this gate: an identifier with no authority at all. RFC + // 3986 §3.2 requires "//" for an authority, so neither + // "https:svc:pw@api.example.com/mcp" (opaque) nor + // "https:///svc:pw@api.example.com/mcp" (an extra slash, which net/url + // parses with an empty host and the rest folded into the path) has a + // userinfo subcomponent to reject. They are still rejected, by the + // scheme/host branch below, which is where their credential used to leak: + // see the redaction there. + if hasUserinfoDelimiter(uri) { + return nil, fmt.Errorf("resource: resource URI must not contain a userinfo component (RFC 9110 §4.2.4), got %s", redactURI(uri)) + } if parsed.Scheme == "" || parsed.Host == "" { + // Echo the identifier only when it holds no "@". An identifier that + // reaches here is malformed by definition, but "malformed" includes + // shapes that carry credentials past the userinfo gate above precisely + // because they have no authority for it to read — "https:u:p@host/mcp", + // "https:///u:p@host/mcp" — and %q would print the password into + // whatever log the construction error lands in. The common + // misconfigurations this branch exists to diagnose ("/mcp", "", + // "file:///tmp/mcp", "://no-scheme") contain no "@", so they still name + // themselves in the message. + if strings.Contains(uri, "@") { + return nil, fmt.Errorf("resource: resource URI must be absolute with scheme and host, got %s", redactURI(uri)) + } return nil, fmt.Errorf("resource: resource URI must be absolute with scheme and host, got %q", uri) } + // RFC 9110 §11.2 gives the auth-param value of a WWW-Authenticate challenge + // as a quoted-string, and §5.6.4 gives qdtext: every visible octet except + // the two delimiters `"` and `\`. The adapters interpolate the derived PRM + // URL into that quoted-string as the resource_metadata value, and the URL + // carries the host verbatim, so a host holding a literal `"` closes the + // quoted-string at the host and the remainder of the URL is re-read as + // garbage auth-params: + // + // Bearer resource_metadata="https://api"example.com/.well-known/oauth-protected-resource/mcp" + // + // net/url does not stop this. url.ParseRequestURI("https://api\"example.com/mcp") + // parses with Host == `api"example.com`, and url.URL.String() writes the + // quote back out unescaped, so the octet survives every step between this + // boundary and the header. + // + // The gate is deliberately one character wide rather than a general + // "invalid host character" sweep, because one character is the whole live + // gap. Of the octets outside qdtext, the C0 controls and DEL are rejected + // by ParseRequestURI above ("net/url: invalid control character in URL") + // and `\` is rejected by its host-name check, while every octet net/url + // does admit into a host is qdtext — obs-text covers %x80-FF. A broader + // sweep would be dead code on every byte but this one. + // + // This is a different defect from the path gate below and needs a different + // rule: that one is about the identifier no longer comparing equal to + // itself (RFC 9728 §3.3), this one is about a header a client cannot parse + // at all (RFC 9110 §11.2). The query gate further below rejects the same + // `"` in the query component for this same reason; its comment named the + // host as deliberately deferred, and this is that follow-up. + // + // Placed here, immediately after the authority is known to exist and ahead + // of the fragment, path and query gates, because the authority anchors + // every value derived from the identifier — the origin of the PRM URL and + // the origin the DPoP htu comparison is built from — so a host defect is + // the one to name first when an identifier is broken on more than one axis. + // + // The message echoes redactURI, which keeps exactly the scheme and host and + // drops userinfo, path, query and fragment. That is safe and it is the + // point: the host is the component at fault, so the operator is shown the + // defect itself and nothing credential-shaped. + if strings.Contains(parsed.Host, `"`) { + return nil, fmt.Errorf(`resource: resource URI host must not contain a literal '"' — it closes the WWW-Authenticate quoted-string carrying resource_metadata (RFC 9110 §11.2), got %s`, redactURI(uri)) + } // RFC 8707 §2 forbids a fragment in a resource indicator. url.ParseRequestURI // does not split a fragment, so "https://api.example.com/mcp#frag" parses with // the "#frag" folded into Path and would otherwise pass the scheme/host check // and leak into the derived PRM URL. Reject it explicitly. + // + // The message is redacted for the same reason the parse-error and userinfo + // branches above are: the fragment is where an implicit-flow response puts + // its credential ("https://api.example.com/mcp#access_token=..."), and that + // shape carries no "@" for the userinfo gate to catch. This branch fires on + // every "#", so it is the branch that shape reaches — and %q would print the + // token into whatever log the construction error lands in. The scheme and + // host that redactURI keeps are the whole of the identifier worth naming + // here anyway; the caller knows which URI it passed. if strings.Contains(uri, "#") { - return nil, fmt.Errorf("resource: resource URI must not contain a fragment (RFC 8707 §2), got %q", uri) + return nil, fmt.Errorf("resource: resource URI must not contain a fragment (RFC 8707 §2), got %s", redactURI(uri)) + } + // A literal space in the path component makes the identifier fail RFC 9728 + // §3.3 against itself. net/url's control-character screen rejects only + // b < 0x20 and b == 0x7f, so 0x20 slips through: + // url.ParseRequestURI("https://api.example.com/m cp") succeeds with + // Path == "/m cp" and EscapedPath() == "/m%20cp". The identifier is stored + // verbatim, so the "resource" member of the PRM document and URI() keep the + // raw space, while PRMURL() and WellKnownPRMPath() derive from the escaped + // path and advertise the %20 form. A client therefore fetches + // ".../oauth-protected-resource/m%20cp", reads back a document whose + // "resource" is "https://api.example.com/m cp", and §3.3 requires it to + // discard a document whose "resource" value does not match the identifier + // it derived the request from. Discovery fails for every conformant client, + // and only at discovery time. + // + // The rule is the space and not "whitespace and control characters", + // because the broader phrasing would be dead code on all of it but the + // space: a tab, a newline, a carriage return, a vertical tab, a form feed, + // NUL and DEL in the path all fail at ParseRequestURI above with + // "net/url: invalid control character in URL". 0x20 is the only octet of + // that class this line can ever see. + // + // Other octets net/url escapes in a path — `"`, `<`, `>`, `\`, `^`, '`', + // `{`, `|`, `}` and %x80-FF — diverge the same way and are the same §3.3 + // defect. They are not gated here: unlike the space they are not plausible + // typos in a configured identifier, and widening this into a path + // character-set gate is a separate change with its own migration cost. + // + // The scan reads the original string, not parsed.Path, because parsed.Path + // is decoded: "https://api.example.com/m%20cp" also yields Path == "/m cp", + // and that identifier is correct — its escaped and raw forms agree, so + // nothing diverges and it must keep constructing. Only the raw text + // distinguishes the two. + // + // Everything before the first "?" is the path region by this point. RFC + // 3986 §3.3 forbids "?" in a path, so the first one opens the query (which + // the gate below validates on its own terms, space included). A "#" cannot + // be present — the fragment gate immediately above rejected it, which is + // also why that gate must run first: ParseRequestURI folds "#frag" into + // Path, so "https://api.example.com/mcp#a b" would otherwise be reported as + // a path defect when the real defect is the fragment. And no space can + // reach the authority: net/url rejects one in a host name ('invalid + // character " " in host name') and in a port ("invalid port"), a space in + // the userinfo fails validUserinfo, and the userinfo gate above has already + // rejected any authority carrying an "@" regardless. + // + // Redacted for the same reason as the fragment and query branches: the path + // is a place a token gets put ("/mcp/t/s3cr3t"), and this branch fires on a + // shape that carries no "@" for the userinfo gate. The offset is the + // diagnostic and leaks no bytes. + rawPath := uri + if i := strings.IndexByte(uri, '?'); i >= 0 { + rawPath = uri[:i] + } + if i := strings.IndexByte(rawPath, ' '); i >= 0 { + return nil, fmt.Errorf("resource: resource URI path must not contain a literal space (at offset %d in the identifier) — the derived PRM URL escapes it to %%20 and RFC 9728 §3.3 then makes the client discard the document; percent-encode it, got %s", i, redactURI(uri)) + } + // RFC 3986 §3.4 defines query = *( pchar / "/" / "?" ). net/url keeps + // RawQuery verbatim and never validates or escapes it — unlike the path, + // which EscapedPath() normalizes and ParseRequestURI rejects on a malformed + // escape. The raw query is carried into the derived PRM URL (buildPRM) and + // from there interpolated into the WWW-Authenticate quoted-string by the + // adapters, so an octet outside the query production — a literal `"`, a + // space, a malformed percent-escape — would ship a 401 whose + // resource_metadata value is unparseable. Reject it at the same boundary + // that rejects a fragment, so no query byte that passes construction can + // corrupt the challenge. The host half of the same hazard — net/url passes + // a literal `"` in a host, so a pathological host reached the identical + // quoted-string by a different route — is gated above, where the authority + // is validated; this gate stays scoped to the query. + // + // Redacted for the same reason as the branches above: a query is a place + // credentials are put ("?access_token=..."), the shape carries no "@" for + // the userinfo gate, and this branch fires on the query. The reason string + // still names the offending octet and its offset, which is the diagnostic + // the operator needs and is bounded to the escape that failed. + if reason := invalidQueryReason(parsed.RawQuery); reason != "" { + return nil, fmt.Errorf("resource: resource URI query must be a valid query per RFC 3986 §3.4 (%s), got %s", reason, redactURI(uri)) } cfg := &resourceConfig{} @@ -198,6 +459,12 @@ func New(uri, issuer string, jwksCache *verifier.JWKSCache, opts ...Option) (*Re opt(cfg) } + if cfg.resourceMetadataURL != "" { + if err := validateResourceMetadataURL(cfg.resourceMetadataURL); err != nil { + return nil, err + } + } + tv, err := verifier.NewTokenVerifier(issuer, uri, jwksCache, cfg.verifierOpts...) if err != nil { return nil, err @@ -212,9 +479,232 @@ func New(uri, issuer string, jwksCache *verifier.JWKSCache, opts ...Option) (*Re } r.buildPRM() + // Resolved after buildPRM, which is what computes prmURL — the default the + // override stands in for. + r.resourceMetadataURL = cfg.resourceMetadataURL + if r.resourceMetadataURL == "" { + r.resourceMetadataURL = r.prmURL + } return r, nil } +// validateResourceMetadataURL gates the value WithResourceMetadataURL puts into +// the WWW-Authenticate quoted-string. The SDK never fetches this URL, so the +// gate is about what a client can parse and what the header can carry, not +// about where the request would go. +// +// The rules are the issuer's, plus the two the resource identifier already +// enforces for the same sink. Absolute with a scheme and host is +// ValidateIssuer's rule verbatim. A fragment is rejected because the document +// is fetched by URL and a fragment is never sent, so it can only be a +// misconfiguration; a query is not, because the derived default may carry one +// (RFC 9728 §3 keeps the identifier's query). Userinfo and a literal '"' are +// the two shapes resource.New rejects on the identifier: this value reaches the +// identical quoted-string by a different route, so leaving them ungated here +// would reopen the credential leak and the header break that gate closes. A +// space is rejected for the same reason the identifier's path rejects one — it +// is not a URI character (RFC 3986 §2), and net/url does not stop it. +// +// https or http only: those are the two schemes the SDK's fetch layer accepts, +// and advertising anything else names a document no OAuth client will retrieve. +// http is accepted on any host: the derived PRM URL this override replaces is +// not scheme-narrowed either, and DevMode already relaxes HTTP and private +// networks together — a loopback-only carve-out here would refuse the +// in-cluster and docker-compose topologies DevMode exists to serve. +// +// Messages redact for the same reason every other branch in New does: an +// operator can paste a credential into any URL-shaped setting, and this one is +// echoed by whatever log the construction error lands in. +func validateResourceMetadataURL(rawURL string) error { + if strings.Contains(rawURL, "#") { + return fmt.Errorf("resource: resource metadata URL must not contain a fragment, got %s", redactURI(rawURL)) + } + if hasUserinfoDelimiter(rawURL) { + return fmt.Errorf("resource: resource metadata URL must not contain a userinfo subcomponent (RFC 9110 §4.2.4) — it would be published in every WWW-Authenticate challenge, got %s", redactURI(rawURL)) + } + parsed, err := url.ParseRequestURI(rawURL) + if err != nil { + var uerr *url.Error + if errors.As(err, &uerr) { + err = &url.Error{Op: uerr.Op, URL: redactURI(rawURL), Err: uerr.Err} + } + return fmt.Errorf("resource: resource metadata URL is not a valid URL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("resource: resource metadata URL must be absolute with a scheme and host, got %s", redactURI(rawURL)) + } + if strings.ContainsAny(rawURL, "\"\\") { + return fmt.Errorf(`resource: resource metadata URL must not contain a literal '"' or '\\' — the first closes the WWW-Authenticate quoted-string carrying resource_metadata and the second is a quoted-pair escape a conforming client unescapes into a different URL (RFC 9110 §5.6.4, §11.2), got %s`, redactURI(rawURL)) + } + if strings.Contains(rawURL, " ") { + return fmt.Errorf("resource: resource metadata URL must not contain a literal space (RFC 3986 §2); percent-encode it, got %s", redactURI(rawURL)) + } + // Same gate the resource identifier gets: an out-of-grammar octet or a + // malformed percent-escape in the query would otherwise ship in the + // challenge and name a document the client cannot fetch. + if reason := invalidQueryReason(parsed.RawQuery); reason != "" { + return fmt.Errorf("resource: resource metadata URL query must be a valid query per RFC 3986 §3.4 (%s), got %s", reason, redactURI(rawURL)) + } + // Scheme rule and its rationale are on the doc comment above. + switch parsed.Scheme { + case "https", "http": + default: + return fmt.Errorf("resource: resource metadata URL scheme must be https or http, got %s", redactURI(rawURL)) + } + return nil +} + +// hasUserinfoDelimiter reports whether the authority component of uri, read off +// the original string rather than off a parsed URL, contains the "@" userinfo +// delimiter. +// +// Reading the original string rather than a parsed field is the point: net/url +// only populates url.URL.User when it recognized an authority, which a +// scheme-relative reference such as "//svc:pw@api.example.com/mcp" does not +// produce — that parses with User nil and the credentials folded into Path. +// +// The authority is what sits between "//" and the next "/", "?" or "#" (RFC +// 3986 §3.2), so the scan is bounded to it: an "@" in a path ("/a@b") or a +// query ("?x=u@h") is legal pchar data (§3.3, §3.4) and is not matched, and +// neither is a percent-encoded "%40" anywhere. A host with a port has no "@" at +// all, so "https://api.example.com:8443/mcp" is unaffected — scanning the whole +// string for ":" or "@" instead would break it. +// +// The "//" that opens the authority is only the one at offset 0 or immediately +// after the scheme's ":". RFC 3986 §3 admits "//" authority only at the start +// of the hier-part, and §3.3 forbids a path from beginning with "//" when there +// is no authority — so a "//" anywhere else in the reference is path data. +// Taking the first "//" in the string instead would read an authority out of an +// identifier that has none: "https:/a//u@h" and "/mcp//u@h" would both be +// reported as carrying a userinfo component when what they are actually missing +// is a host. Both are still rejected, one branch further down; this only +// decides which message names the real defect. Anchoring this way keeps the +// scheme-relative form, whose "//" is at offset 0, deliberately caught — and it +// is where net/url anchors too, which is what keeps the two readings of +// "authority" from drifting apart. +// +// This scan is the only userinfo gate; there is no companion check on +// parsed.User, and none is needed. net/url cuts the query off before it splits +// the authority and bounds the authority only at "/", so its authority and the +// slice scanned here agree on every identifier that parses — the one axis where +// they could differ is "#", which this scan treats as a boundary and net/url +// does not, and a "#" inside what net/url would take for an authority never +// reaches a populated User: "https://a#b@c/d" fails validUserinfo and +// "https://u@h#f" fails host validation, both erroring at ParseRequestURI and +// landing in the redacted parse-error path above. So there is no shape where +// parsed.User is set and this scan comes back false. +func hasUserinfoDelimiter(uri string) bool { + // Locate the "//" that opens the authority: at offset 0 (a scheme-relative + // reference) or directly after the scheme's ":". + rest := uri + if !strings.HasPrefix(rest, "//") { + colon := strings.Index(uri, ":") + if colon < 0 || !isScheme(uri[:colon]) || !strings.HasPrefix(uri[colon+1:], "//") { + return false + } + rest = uri[colon+1:] + } + authority := rest[2:] + if end := strings.IndexAny(authority, "/?#"); end >= 0 { + authority = authority[:end] + } + return strings.Contains(authority, "@") +} + +// isScheme reports whether s is a well-formed URI scheme per RFC 3986 §3.1: +// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). Used only to decide +// whether a ":" is the scheme delimiter, so that a ":" appearing in a path +// ("/a:b//u@h") is not mistaken for one. This is the same production net/url's +// own getScheme applies, deliberately: the userinfo gate and the parse it runs +// alongside must agree on where the scheme ends. +func isScheme(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': + case i > 0 && (c >= '0' && c <= '9' || c == '+' || c == '-' || c == '.'): + default: + return false + } + } + return true +} + +// redactURI renders a resource identifier for an error message without echoing +// anything credential-shaped. +// +// Only the scheme and host survive: url.URL keeps userinfo in User, the query +// in RawQuery and the fragment in Fragment, so Host alone is safe to print. +// When the identifier does not parse into a scheme and host — which includes +// the scheme-relative form the userinfo gate rejects — nothing is echoed at +// all, since there is no parse to read a safe subset off of. This mirrors the +// issuer-side redaction the verifier package applies at its own construction +// boundary. +func redactURI(uri string) string { + parsed, err := url.Parse(uri) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "(unparseable resource URI)" + } + return parsed.Scheme + "://" + parsed.Host + " (userinfo, path, query and fragment redacted)" +} + +// invalidQueryReason reports why rawQuery is not a valid RFC 3986 §3.4 query, +// or "" when it is. The grammar is query = *( pchar / "/" / "?" ) with +// pchar = unreserved / pct-encoded / sub-delims / ":" / "@", so every octet +// must be a query character or part of a well-formed two-hex-digit +// percent-escape. This is validation only — nothing is escaped on the +// operator's behalf, since silently rewriting the query would change the +// resource's identity (RFC 3986 §6.2.2.2 permits decoding only unreserved +// octets when comparing). +func invalidQueryReason(rawQuery string) string { + for i := 0; i < len(rawQuery); i++ { + c := rawQuery[i] + if c == '%' { + if i+2 >= len(rawQuery) || !isHexDigit(rawQuery[i+1]) || !isHexDigit(rawQuery[i+2]) { + end := min(i+3, len(rawQuery)) + return fmt.Sprintf("malformed percent-escape %q at offset %d", rawQuery[i:end], i) + } + i += 2 + continue + } + if !isQueryChar(c) { + if 0x20 <= c && c < 0x7f { + return fmt.Sprintf("invalid character %q at offset %d", c, i) + } + // %q on a byte prints the rune of that value, which for a + // non-ASCII octet is a character that never appeared in the + // input (the 0xC3 of a UTF-8 "ü" would render as 'Ã'). Hex + // names the actual octet at that offset. + return fmt.Sprintf("invalid byte 0x%02x at offset %d", c, i) + } + } + return "" +} + +// isQueryChar reports whether c may appear literally in a URI query: +// unreserved / sub-delims / ":" / "@" (the pchar set) plus "/" and "?". +func isQueryChar(c byte) bool { + switch { + case 'A' <= c && c <= 'Z', 'a' <= c && c <= 'z', '0' <= c && c <= '9': + return true + } + switch c { + case '-', '.', '_', '~', // unreserved (RFC 3986 §2.3) + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', // sub-delims (§2.2) + ':', '@', // pchar extras (§3.3) + '/', '?': // query extras (§3.4) + return true + } + return false +} + +func isHexDigit(c byte) bool { + return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F' +} + // VerifyToken validates an access token for this resource. func (r *Resource) VerifyToken(ctx context.Context, token string, opts ...VerifyOption) (*verifier.VerifiedClaims, error) { cfg := &verifyConfig{} @@ -317,5 +807,18 @@ func (r *Resource) buildPRM() { // fail and ref is never nil. The discarded error is therefore safe to ignore. u := r.parsedURI ref, _ := url.Parse(wellKnownPRMPath(r.parsedURI)) + // RFC 9728 §3 inserts the well-known string "between the host component and + // the path and/or query components, if any" — a query on the resource + // identifier is preserved in the derived URL, so identifiers differing only + // by query derive distinct PRM URLs. A query-bearing identifier is legal: + // RFC 8707 §2 states the SHOULD NOT and its exception in the same sentence, + // and RFC 9728 §1.2 carries that definition forward. The raw (still-escaped) + // query is copied so String() does not re-escape it. ForceQuery is + // deliberately not copied: an identifier with a bare trailing "?" (an empty + // query) derives the query-less URL. RFC 3986 §3.4 does distinguish an + // empty query from an absent one, but a conformant client and this + // derivation land on the same URL only when the empty and absent readings + // agree, so the query-less form is the one chosen here. + ref.RawQuery = u.RawQuery r.prmURL = u.ResolveReference(ref).String() } diff --git a/core/resource/resource_test.go b/core/resource/resource_test.go index a47cfdb..7b30094 100644 --- a/core/resource/resource_test.go +++ b/core/resource/resource_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "testing" "time" @@ -187,6 +188,39 @@ func TestPRMURL(t *testing.T) { resourceURI: "https://api.example.com/mcp/", want: "https://api.example.com/.well-known/oauth-protected-resource/mcp", }, + { + // RFC 9728 §3 inserts the well-known string "between the host + // component and the path and/or query components, if any" — the + // query is part of the derivation, not discarded. A query-bearing + // identifier is legal per RFC 8707 §2's stated exception. + name: "query preserved after path", + resourceURI: "https://api.example.com/mcp?tenant=a", + want: "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", + }, + { + // No terminating slash exists, so nothing is removed: the suffix + // lands directly after the host and the query follows. + name: "query with no path", + resourceURI: "https://api.example.com?x=1", + want: "https://api.example.com/.well-known/oauth-protected-resource?x=1", + }, + { + // RFC 9728 §3.1: the terminating slash following the host is + // removed when a path or query is present, so "/?x=1" derives the + // same URL as "?x=1". + name: "query with terminating slash removed", + resourceURI: "https://api.example.com/?x=1", + want: "https://api.example.com/.well-known/oauth-protected-resource?x=1", + }, + { + // A bare trailing "?" (an empty query) derives the query-less URL. + // RFC 3986 §3.4 distinguishes an empty query from an absent one; + // the query-less reading is the one pinned here, so a client + // deriving the URL from the identifier reaches a single document. + name: "bare trailing question mark dropped", + resourceURI: "https://api.example.com/mcp?", + want: "https://api.example.com/.well-known/oauth-protected-resource/mcp", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -294,6 +328,50 @@ func TestWellKnownPRMPath_EncodedSlashPreserved(t *testing.T) { } } +// TestPRMURL_QueryDifferingIdentifiersDistinct locks in that identifiers +// differing only by query derive distinct PRM URLs (previously both collapsed +// onto the query-less URL) while sharing one well-known path — routing stays +// path-keyed, so a single registered handler serves both derived URLs. +func TestPRMURL_QueryDifferingIdentifiersDistinct(t *testing.T) { + key, err := testutil.GenerateES256Key() + if err != nil { + t.Fatalf("generate key: %v", err) + } + jwksData, err := testutil.BuildJWKSWithKID(&key.PublicKey, testKID) + if err != nil { + t.Fatalf("build jwks: %v", err) + } + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return jwksData, nil, nil + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + newRes := func(uri string) *resource.Resource { + t.Helper() + res, err := resource.New(uri, testIssuer, jc) + if err != nil { + t.Fatalf("resource.New(%q): %v", uri, err) + } + return res + } + a := newRes("https://api.example.com/mcp?tenant=a") + b := newRes("https://api.example.com/mcp?tenant=b") + plain := newRes("https://api.example.com/mcp") + + if a.PRMURL() == b.PRMURL() { + t.Errorf("PRMURL collapsed query-differing identifiers: both %q", a.PRMURL()) + } + if a.PRMURL() == plain.PRMURL() { + t.Errorf("PRMURL dropped the query: %q equals query-less %q", a.PRMURL(), plain.PRMURL()) + } + if got, want := a.WellKnownPRMPath(), plain.WellKnownPRMPath(); got != want { + t.Errorf("WellKnownPRMPath() = %q, want %q (routing path must not carry the query)", got, want) + } +} + func TestPRMResponse_DPoPNotConfigured_OmitsDPoPFields(t *testing.T) { res, _ := makeResource(t) prm := res.PRMResponse() @@ -649,13 +727,17 @@ func TestAuthErrorResponse_TokenMissing(t *testing.T) { if headers["Content-Type"] != "application/json" { t.Errorf("Content-Type = %q, want application/json", headers["Content-Type"]) } - // body should use "invalid_request" error code when token missing + // The challenge above carries no `error` parameter, and the body makes the + // same omission rather than naming a code the header does not. var parsed map[string]any if err := json.Unmarshal([]byte(body), &parsed); err != nil { t.Fatalf("body is not valid JSON: %v — body: %s", err, body) } - if parsed["error"] != "invalid_request" { - t.Errorf("error = %v, want invalid_request", parsed["error"]) + if _, ok := parsed["error"]; ok { + t.Errorf("error = %v, want the member absent — body: %s", parsed["error"], body) + } + if parsed["error_description"] != "The request did not carry an access token" { + t.Errorf("error_description = %v, want the no-credentials sentence", parsed["error_description"]) } } @@ -906,6 +988,9 @@ func TestNew_RejectsInvalidResourceURI(t *testing.T) { uri string }{ {"scheme-less absolute path", "/mcp"}, + // A scheme-relative reference supplies an authority, so a guard that + // only asks for one would accept it; the scheme is what is missing. + {"scheme-relative reference", "//api.example.com/mcp"}, {"authority-less scheme", "file:///tmp/mcp"}, {"empty", ""}, {"malformed", "://no-scheme"}, @@ -923,3 +1008,897 @@ func TestNew_RejectsInvalidResourceURI(t *testing.T) { }) } } + +// resource.New must reject a query that is not a valid RFC 3986 §3.4 query +// production (query = *( pchar / "/" / "?" )). net/url keeps RawQuery verbatim +// — it neither validates nor escapes it, unlike the path — and the raw query +// is carried into PRMURL() and from there interpolated into the +// WWW-Authenticate quoted-string by every adapter. A literal `"` terminates +// the quoted-string early (RFC 9110 §11.2), a space or malformed +// percent-escape makes the resource_metadata value unparseable as a URL — all +// of which would surface as a broken 401 at discovery time. The gate fails +// construction instead, with an error citing the RFC. +func TestNew_RejectsInvalidQuery(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + invalid := []struct { + name string + uri string + }{ + {"literal double quote", `https://api.example.com/mcp?a="b"`}, + {"space after comma", "https://api.example.com/mcp?a=1, 2"}, + {"malformed percent-escape", "https://api.example.com/mcp?a=%zz"}, + {"literal space", "https://api.example.com/mcp?a=b c"}, + } + for _, tt := range invalid { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + if !strings.Contains(err.Error(), "RFC 3986 §3.4") { + t.Errorf("error = %v, want it to cite RFC 3986 §3.4", err) + } + }) + } + + // A non-ASCII octet must be reported as the raw byte in hex, not as the + // rune of its value: %q on the 0xC3 of a UTF-8 "ü" would print 'Ã' — a + // character nowhere in the input. + t.Run("non-ASCII octet reported as hex", func(t *testing.T) { + _, err := resource.New("https://api.example.com/mcp?tenant=münchen", testIssuer, jc) + if err == nil { + t.Fatal("expected error for non-ASCII octet, got nil") + } + if !strings.Contains(err.Error(), "invalid byte 0xc3 at offset 8") { + t.Errorf("error = %v, want it to report the octet as \"invalid byte 0xc3 at offset 8\"", err) + } + }) + + // A legal query exercising the full literal set — unreserved, sub-delims, + // the pchar extras ":" / "@", the query extras "/" / "?", and a + // well-formed percent-escape — must construct, and the query must survive + // into the derived PRM URL verbatim. + t.Run("legal query constructs and survives into the PRM URL", func(t *testing.T) { + const legal = "https://api.example.com/mcp?a=b&c:d@e/f?g='h'!$()*+,;~-._%2F" + res, err := resource.New(legal, testIssuer, jc) + if err != nil { + t.Fatalf("resource.New(%q): unexpected error: %v", legal, err) + } + want := "https://api.example.com/.well-known/oauth-protected-resource/mcp?a=b&c:d@e/f?g='h'!$()*+,;~-._%2F" + if got := res.PRMURL(); got != want { + t.Errorf("PRMURL() = %q, want %q", got, want) + } + }) +} + +// resource.New must reject a resource identifier carrying a userinfo +// subcomponent. RFC 9110 §4.2.4: in an http or https URI "a sender MUST NOT +// generate the userinfo subcomponent (and its '@' delimiter)". The identifier +// is stored verbatim and republished — as the "resource" member of the PRM +// document RFC 9728 §3 serves to unauthenticated callers, as the +// resource_metadata parameter of the 401 challenge, and as the origin the DPoP +// htu comparison is built from — so an operator who configures +// "https://svc:pw@api.example.com/mcp" publishes the credential rather than +// merely storing it. Rejecting at construction is the only place that covers +// every sink at once. +func TestNew_RejectsUserinfo(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + // asUserinfo is true when the identifier has an authority, so the + // userinfo gate itself is what must reject it and the operator must be + // told that — not merely that construction failed. Rejection alone is a + // weak assertion here: every URI in this table is malformed on some + // other axis too, so a test that only checks for a non-nil error passes + // even when the userinfo gate is gone. + asUserinfo bool + }{ + {"user and password", "https://svc:pw@api.example.com/mcp", true}, + {"user only", "https://svc@api.example.com/mcp", true}, + // The "@" delimiter marks the subcomponent as present even with no + // credentials in it, so the empty form is a userinfo component and RFC + // 9110 §4.2.4 forbids generating it. A truthiness test on the parsed + // username would let this through; the "@" would then ride into the + // derived well-known URL. + {"empty userinfo", "https://@api.example.com/mcp", true}, + {"empty userinfo with colon", "https://:@api.example.com/mcp", true}, + {"percent-encoded user", "https://user%40x@api.example.com/mcp", true}, + // A host that looks like an authority is still userinfo: net/url splits + // at the last "@", so the real host here is evil.example.com. + {"host-shaped userinfo", "https://api.example.com:8443@evil.example.com/mcp", true}, + // A scheme-relative reference parses with User nil and the whole string + // folded into Path, so the parsed-field check cannot see it — only the + // raw authority scan can, and without it the operator is told the + // scheme is missing rather than that they configured a credential. + {"scheme-relative reference", "//svc:pw@api.example.com/mcp", true}, + // Userinfo alongside a fragment: the userinfo gate must fire first, or + // the fragment branch reports the failure and echoes the URI verbatim. + {"userinfo with fragment", "https://svc:pw@api.example.com/mcp#frag", true}, + // Userinfo alongside an invalid query, same ordering argument. + {"userinfo with invalid query", `https://svc:pw@api.example.com/mcp?a="b"`, true}, + // These three have no authority at all, so there is no userinfo + // subcomponent to reject and net/url leaves User nil — they are + // rejected for having no host. They belong in this table anyway, + // because the credential is still in the string the rejection reports: + // the scheme/host branch echoed it verbatim until it learned to redact + // an "@"-bearing identifier. + {"opaque with credentials", "https:svc:pw@api.example.com/mcp", false}, + {"extra slash before credentials", "https:///svc:pw@api.example.com/mcp", false}, + {"double extra slash before credentials", "https:////svc:pw@api.example.com/mcp", false}, + // Same category, and the reason the scan anchors its "//" to offset 0 + // or the byte after the scheme's ":" rather than taking the first "//" + // anywhere in the string. These have a "//" in the path, not an + // authority, so what they are missing is a host — reporting them as + // carrying a userinfo component would name the wrong defect. The + // credential must still not be echoed. + {"double slash inside the path", "https:/a//svc:pw@api.example.com/mcp", false}, + {"relative path with a double slash", "/mcp//svc:pw@api.example.com", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + msg := err.Error() + // Whatever branch rejects, the message must not carry the + // credential — a gate whose rejection echoes the secret leaks + // exactly what it exists to stop. + if strings.Contains(msg, "pw") || strings.Contains(msg, "svc") { + t.Errorf("resource.New(%q) error echoes userinfo: %s", tt.uri, msg) + } + if tt.asUserinfo && !strings.Contains(msg, "RFC 9110 §4.2.4") { + t.Errorf("resource.New(%q): want the userinfo gate to reject it citing RFC 9110 §4.2.4, got %s", tt.uri, msg) + } + // And the converse: an identifier with no authority has no + // userinfo subcomponent, so blaming one misnames the defect. The + // operator is chasing a missing host and the message has to say + // so. + if !tt.asUserinfo { + if strings.Contains(msg, "RFC 9110 §4.2.4") { + t.Errorf("resource.New(%q) has no authority, so it must not be reported as a userinfo component, got %s", tt.uri, msg) + } + if !strings.Contains(msg, "absolute with scheme and host") { + t.Errorf("resource.New(%q): want the missing host named, got %s", tt.uri, msg) + } + } + }) + } +} + +// The userinfo rejection must name the RFC and the host — so the operator can +// tell which identifier failed — while echoing nothing credential-shaped. +func TestNew_UserinfoRejectionIsRedactedButDiagnosable(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + _, err := resource.New("https://svc:s3cr3t@api.example.com/mcp?tenant=a", testIssuer, jc) + if err == nil { + t.Fatal("expected error for a userinfo-bearing resource URI, got nil") + } + msg := err.Error() + if strings.Contains(msg, "s3cr3t") || strings.Contains(msg, "svc") { + t.Errorf("error echoes userinfo: %s", msg) + } + if !strings.Contains(msg, "RFC 9110 §4.2.4") { + t.Errorf("error = %v, want it to cite RFC 9110 §4.2.4", err) + } + if !strings.Contains(msg, "api.example.com") { + t.Errorf("expected the host to survive redaction so the operator can identify the identifier, got %s", msg) + } + // The path and query are redacted too, so nothing a caller put in the + // query string rides out either. + if strings.Contains(msg, "tenant=a") { + t.Errorf("error echoes the query: %s", msg) + } +} + +// The fragment rejection must be redacted. The branch fires on every "#", and +// the shape it exists to catch is the one an implicit-flow response produces — +// "https://api.example.com/mcp#access_token=..." — which carries no "@" and so +// never reaches the userinfo gate. Echoing the identifier with %q here would +// print the token into whatever log the construction error lands in, out of +// the very branch that rejected it. +func TestNew_FragmentRejectionIsRedacted(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + _, err := resource.New("https://api.example.com/mcp#access_token=s3cr3t", testIssuer, jc) + if err == nil { + t.Fatal("expected error for a resource URI with a fragment, got nil") + } + msg := err.Error() + if strings.Contains(msg, "s3cr3t") || strings.Contains(msg, "access_token") { + t.Errorf("fragment rejection echoes the fragment: %s", msg) + } + if !strings.Contains(msg, "RFC 8707 §2") { + t.Errorf("error = %v, want it to cite RFC 8707 §2", err) + } + // The host survives redaction, or the operator cannot tell which + // identifier failed. + if !strings.Contains(msg, "api.example.com") { + t.Errorf("expected the host to survive redaction, got %s", msg) + } + if strings.Contains(msg, "/mcp") { + t.Errorf("fragment rejection echoes the path: %s", msg) + } +} + +// The invalid-query rejection must be redacted, for the same reason: a query +// is a place credentials get put, the shape carries no "@" for the userinfo +// gate, and this is the branch it reaches. The reason string may still name +// the offending octet — that is the diagnostic, and it is bounded to the +// escape that failed rather than the whole identifier. +func TestNew_InvalidQueryRejectionIsRedacted(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + _, err := resource.New(`https://api.example.com/mcp?access_token=s3cr3t&a="b"`, testIssuer, jc) + if err == nil { + t.Fatal("expected error for a resource URI with an invalid query, got nil") + } + msg := err.Error() + if strings.Contains(msg, "s3cr3t") || strings.Contains(msg, "access_token") { + t.Errorf("invalid-query rejection echoes the query: %s", msg) + } + if !strings.Contains(msg, "RFC 3986 §3.4") { + t.Errorf("error = %v, want it to cite RFC 3986 §3.4", err) + } + if !strings.Contains(msg, "api.example.com") { + t.Errorf("expected the host to survive redaction, got %s", msg) + } +} + +// A parse failure on a credential-bearing identifier must not echo the +// credential: url.Error.Error() prints its URL field verbatim, so the URL has +// to be substituted before the error is wrapped. Both shapes matter — a failure +// elsewhere in the URI, and a failure inside the userinfo itself, which net/url +// rejects before the userinfo gate is reached. +func TestNew_ParseFailureDoesNotEchoUserinfo(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + }{ + {"malformed path escape", "https://svc:s3cr3t@api.example.com/x%zz"}, + {"invalid octet in userinfo", "https://svc:s3c r3t@api.example.com/mcp"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + if msg := err.Error(); strings.Contains(msg, "s3c") { + t.Errorf("resource.New(%q) error echoes userinfo: %s", tt.uri, msg) + } + // The wrapped *url.Error must still be reachable for callers that + // unwrap it; substituting the URL must not cost that. + var uerr *url.Error + if !errors.As(err, &uerr) { + t.Errorf("resource.New(%q): expected a wrapped *url.Error, got %v", tt.uri, err) + } + }) + } +} + +// The userinfo gate is bounded to the authority component (RFC 3986 §3.2), so +// every shape that merely looks credential-adjacent must still construct. A +// naive scan of the whole identifier for ":" or "@" is the obvious way to get +// this wrong, and it would reject a host with a port — the single most common +// non-default resource identifier there is. +func TestNew_AcceptsAtSignAndColonOutsideUserinfo(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + wantPRM string + }{ + { + "host with port", + "https://api.example.com:8443/mcp", + "https://api.example.com:8443/.well-known/oauth-protected-resource/mcp", + }, + { + "at sign in path is pchar data", + "https://api.example.com/a@b", + "https://api.example.com/.well-known/oauth-protected-resource/a@b", + }, + { + "at sign in query is pchar data", + "https://api.example.com/mcp?x=u@h", + "https://api.example.com/.well-known/oauth-protected-resource/mcp?x=u@h", + }, + { + "percent-encoded at sign in path is not a delimiter", + "https://api.example.com/%40mcp", + "https://api.example.com/.well-known/oauth-protected-resource/%40mcp", + }, + { + "host with port and an at sign in the path", + "https://api.example.com:8443/a@b", + "https://api.example.com:8443/.well-known/oauth-protected-resource/a@b", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := resource.New(tt.uri, testIssuer, jc) + if err != nil { + t.Fatalf("resource.New(%q): unexpected error: %v", tt.uri, err) + } + // The identifier must survive verbatim into the document and the + // derived URL — the gate rejects, it never rewrites. + if got := res.URI(); got != tt.uri { + t.Errorf("URI() = %q, want %q", got, tt.uri) + } + if got := res.PRMResponse()["resource"]; got != tt.uri { + t.Errorf("PRM resource = %v, want %q", got, tt.uri) + } + if got := res.PRMURL(); got != tt.wantPRM { + t.Errorf("PRMURL() = %q, want %q", got, tt.wantPRM) + } + }) + } +} + +// resource.New must reject a literal space in the path component. net/url's +// control-character screen rejects only b < 0x20 and b == 0x7f, so 0x20 parses: +// "https://api.example.com/m cp" constructs, URI() and the PRM document's +// "resource" member keep the raw space, and PRMURL() — derived from +// EscapedPath() — advertises ".../oauth-protected-resource/m%20cp". A client +// fetches the %20 URL, gets back a document whose "resource" is a different +// string, and RFC 9728 §3.3 requires it to discard the document. Every +// conformant client fails discovery, and only at discovery time. +func TestNew_RejectsSpaceInPath(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + }{ + {"space inside a segment", "https://api.example.com/m cp"}, + {"leading space in the first segment", "https://api.example.com/ mcp"}, + {"trailing space", "https://api.example.com/mcp "}, + {"space in a middle segment", "https://api.example.com/v2/m cp/x"}, + {"space in the path of a query-bearing identifier", "https://api.example.com/m cp?tenant=a"}, + // The whole authority is a bare origin here, so the space is the only + // defect and the path is a single space. + {"path is a single space", "https://api.example.com/ "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + // Rejection alone is too weak: the identifier must be rejected by + // *this* gate, naming the RFC whose comparison it breaks, or a + // future refactor could drop the gate and let another branch take + // the credit. + if !strings.Contains(err.Error(), "RFC 9728 §3.3") { + t.Errorf("resource.New(%q) error = %v, want it to cite RFC 9728 §3.3", tt.uri, err) + } + }) + } +} + +// The path gate is the space and nothing wider, because nothing wider reaches +// it. Every other whitespace or control octet fails earlier, at +// url.ParseRequestURI, with "net/url: invalid control character in URL" — so a +// gate phrased as "whitespace and control characters" would be dead code on +// all of these. Pin that split: these must keep failing at the parse, and must +// NOT be attributed to the RFC 9728 §3.3 gate. A future refactor that widens +// the gate, or that moves the parse, changes one of these assertions. +func TestNew_PathControlCharactersRejectedAtParse(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + }{ + {"horizontal tab", "https://api.example.com/m\tcp"}, + {"line feed", "https://api.example.com/m\ncp"}, + {"carriage return", "https://api.example.com/m\rcp"}, + {"vertical tab", "https://api.example.com/m\vcp"}, + {"form feed", "https://api.example.com/m\fcp"}, + {"NUL", "https://api.example.com/m\x00cp"}, + {"DEL", "https://api.example.com/m\x7fcp"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + msg := err.Error() + if !strings.Contains(msg, "invalid control character in URL") { + t.Errorf("resource.New(%q) error = %v, want the net/url control-character rejection", tt.uri, err) + } + if strings.Contains(msg, "RFC 9728 §3.3") { + t.Errorf("resource.New(%q) was attributed to the path gate, but it never reaches it: %s", tt.uri, msg) + } + // The parse failure is still wrapped, so callers that unwrap keep + // working. + var uerr *url.Error + if !errors.As(err, &uerr) { + t.Errorf("resource.New(%q): expected a wrapped *url.Error, got %v", tt.uri, err) + } + }) + } +} + +// resource.New must reject a literal `"` in the host. RFC 9110 §11.2 carries +// the WWW-Authenticate auth-param value as a quoted-string, and §5.6.4's qdtext +// excludes `"` — so a host holding one closes the quoted-string early and the +// rest of the resource_metadata URL is re-read as garbage auth-params. +// net/url passes the octet through: ParseRequestURI yields Host == `api"example.com` +// and url.URL.String() writes it back unescaped, so nothing between this +// boundary and the header catches it. This is a different defect from the path +// gate — a header no client can parse, rather than an identifier that no longer +// compares equal to itself — so it gets its own rule and its own RFC. +func TestNew_RejectsQuoteInHost(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + }{ + {"quote inside the host", `https://api"example.com/mcp`}, + {"quote in a host with a port", `https://api"example.com:8443/mcp`}, + {"quote at the start of the host", `https://"api.example.com/mcp`}, + {"bare origin with a quoted host", `https://api"example.com`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + msg := err.Error() + if !strings.Contains(msg, "RFC 9110 §11.2") { + t.Errorf("resource.New(%q) error = %v, want it to cite RFC 9110 §11.2", tt.uri, err) + } + // Two axes, two gates: the host defect must not be reported as the + // path's identity-comparison defect. + if strings.Contains(msg, "RFC 9728 §3.3") { + t.Errorf("resource.New(%q) host defect reported as the path defect: %s", tt.uri, msg) + } + }) + } +} + +// The host gate is one character wide because one character is the live gap. +// Every other octet outside qdtext — the C0 controls, DEL, `\` — plus the +// punctuation net/url refuses in a host name is already rejected at +// url.ParseRequestURI, so widening the gate to "whitespace" or to a host +// character-set sweep would be dead code. Pin the split so a refactor cannot +// silently move these across the boundary in either direction. +func TestNew_HostCharactersRejectedAtParse(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + }{ + // A space in the host never reaches either new gate, which is why the + // host needs a different rule from the path rather than the same one. + {"space", "https://api example.com/mcp"}, + {"horizontal tab", "https://api\texample.com/mcp"}, + {"DEL", "https://api\x7fexample.com/mcp"}, + // The other octet qdtext excludes. + {"backslash", `https://api\example.com/mcp`}, + {"caret", "https://api^example.com/mcp"}, + {"backtick", "https://api`example.com/mcp"}, + {"pipe", "https://api|example.com/mcp"}, + {"opening brace", "https://api{example.com/mcp"}, + {"space in the port", "https://api.example.com:84 43/mcp"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + msg := err.Error() + if strings.Contains(msg, "RFC 9110 §11.2") { + t.Errorf("resource.New(%q) was attributed to the host gate, but it never reaches it: %s", tt.uri, msg) + } + if strings.Contains(msg, "RFC 9728 §3.3") { + t.Errorf("resource.New(%q) was attributed to the path gate, but it never reaches it: %s", tt.uri, msg) + } + var uerr *url.Error + if !errors.As(err, &uerr) { + t.Errorf("resource.New(%q): expected a wrapped *url.Error, got %v", tt.uri, err) + } + }) + } +} + +// Neither gate may reject the correctly-written forms. The percent-encoded +// space is the whole point of the path gate — it is what the operator is being +// told to write — so it must construct and must survive verbatim into the +// document and the derived URL. A percent-encoded quote is likewise data, in +// the path and in the query alike, and the octets that merely look adjacent +// (a port's ":", an IPv6 literal's brackets) are untouched by both rules. +func TestNew_AcceptsEncodedAndUnaffectedShapes(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + wantPRM string + }{ + { + "percent-encoded space in the path", + "https://api.example.com/m%20cp", + "https://api.example.com/.well-known/oauth-protected-resource/m%20cp", + }, + { + "percent-encoded space with a query", + "https://api.example.com/m%20cp?tenant=a", + "https://api.example.com/.well-known/oauth-protected-resource/m%20cp?tenant=a", + }, + { + "percent-encoded quote in the path", + "https://api.example.com/m%22cp", + "https://api.example.com/.well-known/oauth-protected-resource/m%22cp", + }, + { + "percent-encoded quote in the query", + "https://api.example.com/mcp?a=%22b%22", + "https://api.example.com/.well-known/oauth-protected-resource/mcp?a=%22b%22", + }, + { + "host with a port", + "https://api.example.com:8443/mcp", + "https://api.example.com:8443/.well-known/oauth-protected-resource/mcp", + }, + { + "IPv6 literal with a port", + "https://[::1]:8443/mcp", + "https://[::1]:8443/.well-known/oauth-protected-resource/mcp", + }, + { + "bare origin", + "https://api.example.com", + "https://api.example.com/.well-known/oauth-protected-resource", + }, + { + "multi-segment path", + "https://api.example.com/v2/mcp", + "https://api.example.com/.well-known/oauth-protected-resource/v2/mcp", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := resource.New(tt.uri, testIssuer, jc) + if err != nil { + t.Fatalf("resource.New(%q): unexpected error: %v", tt.uri, err) + } + if got := res.URI(); got != tt.uri { + t.Errorf("URI() = %q, want %q", got, tt.uri) + } + if got := res.PRMResponse()["resource"]; got != tt.uri { + t.Errorf("PRM resource = %v, want %q", got, tt.uri) + } + if got := res.PRMURL(); got != tt.wantPRM { + t.Errorf("PRMURL() = %q, want %q", got, tt.wantPRM) + } + }) + } +} + +// Both new rejections are redacted, like every other branch in New. The path is +// a place a token gets put and the identifier carries no "@" for the userinfo +// gate, so %q here would print the token into whatever log the construction +// error lands in. The host is the one component redactURI keeps — and it is the +// component at fault in the quote case, so naming it is the diagnostic. +func TestNew_CharacterGateRejectionsAreRedacted(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + t.Run("path space", func(t *testing.T) { + _, err := resource.New("https://api.example.com/mcp/t/s3cr3t x?tenant=a", testIssuer, jc) + if err == nil { + t.Fatal("expected error for a literal space in the path, got nil") + } + msg := err.Error() + if strings.Contains(msg, "s3cr3t") { + t.Errorf("path-space rejection echoes the path: %s", msg) + } + if strings.Contains(msg, "tenant=a") { + t.Errorf("path-space rejection echoes the query: %s", msg) + } + if !strings.Contains(msg, "api.example.com") { + t.Errorf("expected the host to survive redaction so the operator can identify the identifier, got %s", msg) + } + // The offset is the diagnostic and leaks no bytes. + if !strings.Contains(msg, "at offset 36 in the identifier") { + t.Errorf("expected the offset of the space, got %s", msg) + } + }) + + t.Run("host quote", func(t *testing.T) { + _, err := resource.New(`https://api"example.com/mcp/t/s3cr3t`, testIssuer, jc) + if err == nil { + t.Fatal("expected error for a literal quote in the host, got nil") + } + msg := err.Error() + if strings.Contains(msg, "s3cr3t") { + t.Errorf("host-quote rejection echoes the path: %s", msg) + } + // The offending host is what redactURI keeps, and it is the component + // at fault — the operator has to see it to fix it. + if !strings.Contains(msg, `api"example.com`) { + t.Errorf("expected the offending host to survive redaction, got %s", msg) + } + }) +} + +// The gates sit in a deliberate order, and an identifier broken on more than +// one axis must be reported by the branch naming its most fundamental defect. +// Two of these orderings are load-bearing rather than cosmetic: a "#" is folded +// into Path by url.ParseRequestURI, so a space after it would look like a path +// defect unless the fragment gate runs first; and a scheme-relative reference +// has no host for net/url to validate, so a space in it would look like a path +// defect unless the absoluteness gate runs first. +func TestNew_CharacterGateOrdering(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + wantCite string + denyCite string + }{ + { + "host quote beats path space", + `https://api"example.com/m cp`, + "RFC 9110 §11.2", + "RFC 9728 §3.3", + }, + { + "fragment beats the space it folds into the path", + "https://api.example.com/mcp#a b", + "RFC 8707 §2", + "RFC 9728 §3.3", + }, + { + "userinfo beats path space", + "https://svc:pw@api.example.com/m cp", + "RFC 9110 §4.2.4", + "RFC 9728 §3.3", + }, + { + "absoluteness beats path space", + "//api.example.com/m cp", + "must be absolute with scheme and host", + "RFC 9728 §3.3", + }, + { + "a space in the query stays a query defect", + "https://api.example.com/mcp?a=b c", + "RFC 3986 §3.4", + "RFC 9728 §3.3", + }, + { + "a quote in the query stays a query defect", + `https://api.example.com/mcp?a="b"`, + "RFC 3986 §3.4", + "RFC 9110 §11.2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + msg := err.Error() + if !strings.Contains(msg, tt.wantCite) { + t.Errorf("resource.New(%q) error = %v, want it to name %q", tt.uri, err, tt.wantCite) + } + if strings.Contains(msg, tt.denyCite) { + t.Errorf("resource.New(%q) error = %v, must not be attributed to %q", tt.uri, err, tt.denyCite) + } + }) + } +} + +// TestResourceMetadataURL_DefaultsToDerivedPRMURL: with no override the +// advertised URL is the derived one, so the challenge a resource emits today is +// the challenge it emitted before the option existed. +func TestResourceMetadataURL_DefaultsToDerivedPRMURL(t *testing.T) { + res, _ := makeResource(t) + if got, want := res.ResourceMetadataURL(), res.PRMURL(); got != want { + t.Errorf("ResourceMetadataURL() = %q, want the derived PRMURL %q", got, want) + } +} + +// TestResourceMetadataURL_Override: the configured value is returned verbatim, +// and PRMURL keeps returning the derived URL — the advertisement moves, the +// route the SDK serves does not. +func TestResourceMetadataURL_Override(t *testing.T) { + const asHosted = "https://auth.example.com/.well-known/oauth-protected-resource/api" + res, _ := makeResource(t, resource.WithResourceMetadataURL(asHosted)) + if got := res.ResourceMetadataURL(); got != asHosted { + t.Errorf("ResourceMetadataURL() = %q, want %q", got, asHosted) + } + if got, want := res.PRMURL(), testResource+"/.well-known/oauth-protected-resource"; got != want { + t.Errorf("PRMURL() = %q, want the derived %q — the override must not move it", got, want) + } + if got, want := res.WellKnownPRMPath(), "/.well-known/oauth-protected-resource"; got != want { + t.Errorf("WellKnownPRMPath() = %q, want %q — routing must not move either", got, want) + } +} + +// TestResourceMetadataURL_Rejected pins the construction-time gate. Each shape +// either breaks the client that fetches the URL or the WWW-Authenticate +// quoted-string that carries it, and every one of them is a configuration +// mistake that would otherwise surface only when a client attempted discovery. +func TestResourceMetadataURL_Rejected(t *testing.T) { + tests := []struct { + name string + url string + }{ + {"relative reference", "/.well-known/oauth-protected-resource/api"}, + {"scheme but no host", "https:///.well-known/oauth-protected-resource"}, + {"no scheme", "auth.example.com/.well-known/oauth-protected-resource"}, + {"unsupported scheme", "ftp://auth.example.com/.well-known/oauth-protected-resource"}, + {"fragment", "https://auth.example.com/.well-known/oauth-protected-resource#frag"}, + {"userinfo", "https://svc:pw@auth.example.com/.well-known/oauth-protected-resource"}, + {"empty userinfo", "https://@auth.example.com/.well-known/oauth-protected-resource"}, + {"literal quote", `https://auth"example.com/.well-known/oauth-protected-resource`}, + {"literal backslash", `https://auth.example.com/.well-known/oauth-protected-resource\api`}, + {"literal space", "https://auth.example.com/.well-known/oauth protected resource"}, + {"truncated percent-escape in query", "https://auth.example.com/.well-known/oauth-protected-resource?tenant=%a"}, + {"out-of-grammar octet in query", "https://auth.example.com/.well-known/oauth-protected-resource?tenant=a\x7f"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, errors.New("not reached") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + _, err := resource.New(testResource, testIssuer, jc, resource.WithResourceMetadataURL(tt.url)) + if err == nil { + t.Fatalf("resource.New accepted resource metadata URL %q, want a construction error", tt.url) + } + if !strings.Contains(err.Error(), "resource metadata URL") { + t.Errorf("error = %q, want it to name the resource metadata URL", err) + } + }) + } +} + +// TestResourceMetadataURL_AcceptedShapes: the values an operator legitimately +// configures must construct — the AS-hosted document authserver publishes, a +// query-bearing URL (the derived default can carry one, so the override must be +// allowed to), and the http forms local and in-cluster development run on — +// loopback and a service hostname alike, since DevMode relaxes HTTP and private +// networks together. +func TestResourceMetadataURL_AcceptedShapes(t *testing.T) { + for _, u := range []string{ + "https://auth.example.com/.well-known/oauth-protected-resource/api", + "https://auth.example.com/.well-known/oauth-protected-resource/api?tenant=a", + "http://localhost:9000/.well-known/oauth-protected-resource/api", + "http://127.0.0.1:9000/.well-known/oauth-protected-resource/api", + "http://authserver:8080/.well-known/oauth-protected-resource/api", + } { + res, _ := makeResource(t, resource.WithResourceMetadataURL(u)) + if got := res.ResourceMetadataURL(); got != u { + t.Errorf("ResourceMetadataURL() = %q, want %q", got, u) + } + } +} + +// TestResourceMetadataURL_ErrorRedactsCredentials: the gate exists partly to +// keep a credential out of every 401, so its own error must not put one in a +// log instead. +func TestResourceMetadataURL_ErrorRedactsCredentials(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, errors.New("not reached") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + _, err := resource.New(testResource, testIssuer, jc, + resource.WithResourceMetadataURL("https://svc:s3cr3t@auth.example.com/.well-known/oauth-protected-resource")) + if err == nil { + t.Fatal("resource.New accepted a userinfo-bearing resource metadata URL") + } + if strings.Contains(err.Error(), "s3cr3t") { + t.Errorf("error = %q, want the credential redacted", err) + } +} diff --git a/core/resource/verifier/claims.go b/core/resource/verifier/claims.go index a55b246..e02017b 100644 --- a/core/resource/verifier/claims.go +++ b/core/resource/verifier/claims.go @@ -83,7 +83,9 @@ func (c *VerifiedClaims) Act() map[string]any { return cloneMap(c.act) } -// MayAct returns a copy of the may_act claim. +// MayAct returns a copy of the may_act claim (RFC 8693 §4.4). +// +// Deprecated: authserver 0.2.0 no longer issues may_act; removed in the next minor. func (c *VerifiedClaims) MayAct() map[string]any { return cloneMap(c.mayAct) } @@ -118,9 +120,11 @@ func (c *VerifiedClaims) RequireScope(scope string) error { // semantic. // // On failure the returned error names every missing scope and the scopes -// the token does carry, so the adapter can surface it verbatim in the -// `error_description` of the `WWW-Authenticate` challenge without an -// out-of-band log lookup. The error wraps ErrInsufficientScope, so +// the token does carry, so the resource server has the whole diagnostic in +// one log line. It is not put on the wire: the 403 names the missing scopes +// through the RFC 6750 §3 `scope="..."` parameter of the WWW-Authenticate +// header, and the body carries only the fixed sentence for the error code. +// The error wraps ErrInsufficientScope, so // adapters that already branch on `errors.Is(err, ErrInsufficientScope)` // (e.g. `resource.HTTPStatus`) keep producing a 403 with the right // `scope="..."` parameter when the surrounding ScopeError carries the @@ -156,7 +160,7 @@ func (c *VerifiedClaims) RequireScopes(scopes ...string) error { } // quoteAll wraps each entry in %q-style double-quotes so the rendered -// error_description visually delimits each scope and stays unambiguous +// message visually delimits each scope and stays unambiguous // even if a malformed token carries scope tokens containing control // characters. RFC 6749 §3.3 (`scope-token = 1*( %x21 / %x23-5B / // %x5D-7E )`) explicitly forbids whitespace inside a scope, so this is diff --git a/core/resource/verifier/claims_test.go b/core/resource/verifier/claims_test.go index eb944d4..a9e30b6 100644 --- a/core/resource/verifier/claims_test.go +++ b/core/resource/verifier/claims_test.go @@ -136,9 +136,9 @@ func TestVerifiedClaims_RequireScope(t *testing.T) { func TestVerifiedClaims_RequireScope_EnrichedErrorString(t *testing.T) { // RequireScope now delegates to RequireScopes, which means the singular // path also carries the `required scope "X"; token has scopes: …` - // rich shape. Pin the wire body so a future refactor that reverts the - // delegation (or changes the message format) fails this test instead - // of silently regressing the adapter's WWW-Authenticate error_description. + // rich shape. Pin the message so a future refactor that reverts the + // delegation (or changes the format) fails this test instead of + // silently regressing what the resource server logs. c := ParseClaims(testClaims(), "kid") err := c.RequireScope("delete") if err == nil { @@ -306,9 +306,11 @@ func TestVerifiedClaims_Act(t *testing.T) { } } +// TestVerifiedClaims_MayAct keeps the deprecated accessor honest until it +// is removed: a token that still carries may_act is parsed unchanged. func TestVerifiedClaims_MayAct(t *testing.T) { c := ParseClaims(testClaims(), "kid") - mayAct := c.MayAct() + mayAct := c.MayAct() //nolint:staticcheck // deprecated accessor kept until the next minor if mayAct["sub"] != "potential-actor" { t.Errorf("expected may_act.sub = 'potential-actor', got %v", mayAct["sub"]) } diff --git a/core/resource/verifier/verifier.go b/core/resource/verifier/verifier.go index 6cdfd45..0a759fa 100644 --- a/core/resource/verifier/verifier.go +++ b/core/resource/verifier/verifier.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/url" "slices" "strings" @@ -198,7 +199,10 @@ func (v *TokenVerifier) VerifyToken(ctx context.Context, rawToken string, dpop * if v.failClosed { return nil, fmt.Errorf("%w: revocation check failed: %v", ErrTokenRevoked, err) } - // fail-open: accept the token + // fail-open: accept the token. Say so — a silently failing + // checker is indistinguishable from a passing one otherwise. + slog.WarnContext(ctx, "verifier: revocation check failed; accepting token (fail-open)", + "jti", claims.JTI(), "error", err) } else if revoked { return nil, ErrTokenRevoked } diff --git a/core/resource/verifier/verifier_test.go b/core/resource/verifier/verifier_test.go index 693fce4..05b397b 100644 --- a/core/resource/verifier/verifier_test.go +++ b/core/resource/verifier/verifier_test.go @@ -1,12 +1,15 @@ package verifier_test import ( + "bytes" "context" "crypto/ecdsa" "crypto/rsa" "encoding/json" "errors" "fmt" + "log/slog" + "strings" "testing" "time" @@ -356,6 +359,34 @@ func TestVerifyToken_RevocationCheckerError_FailOpen(t *testing.T) { } } +// TestVerifyToken_RevocationCheckerError_FailOpen_LogsWarning pins that +// the fail-open branch is not silent: a checker error that lets a token +// through is reported on the default slog logger. +func TestVerifyToken_RevocationCheckerError_FailOpen_LogsWarning(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + checker := func(ctx context.Context, claims *verifier.VerifiedClaims, rawToken string) (bool, error) { + return false, fmt.Errorf("revocation service unavailable") + } + + v, key := setupES256Verifier(t, verifier.WithRevocationChecker(checker)) + token := signStandardToken(t, key) + + if _, err := v.VerifyToken(context.Background(), token, nil); err != nil { + t.Fatalf("fail-open should accept token, got error: %v", err) + } + logged := buf.String() + if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, "fail-open") { + t.Errorf("expected a fail-open WARN log, got %q", logged) + } + if !strings.Contains(logged, "revocation service unavailable") { + t.Errorf("expected the checker error in the log, got %q", logged) + } +} + func TestVerifyToken_RevocationCheckerError_FailClosed(t *testing.T) { checker := func(ctx context.Context, claims *verifier.VerifiedClaims, rawToken string) (bool, error) { return false, fmt.Errorf("revocation service unavailable") diff --git a/http/docs/user-guide.md b/http/docs/user-guide.md index 50943d6..d3b1468 100644 --- a/http/docs/user-guide.md +++ b/http/docs/user-guide.md @@ -128,7 +128,8 @@ Standard `net/http` middleware. - For `DPoP`, also consumes the `DPoP` request header and constructs a `*verifier.DPoPContext` with the request method, reconstructed URL (scheme + host + request URI), proof, and the adapter's replay store. - Calls `resource.VerifyToken(ctx, token, opts...)`. - On success, injects `*verifier.VerifiedClaims` and the raw token into the request context. -- On failure, writes an RFC 6750 response via `resource.AuthErrorResponse`. +- On failure, writes an RFC 6750 response via `resource.AuthErrorResponseWithMetadata`, whose challenge carries `resource_metadata="…"` (RFC 9728 §5.1) pointing at `res.ResourceMetadataURL()` — see §6.1. +- A 401 also carries `scope="…"` listing the resource's configured scopes (`resource.WithScopes`), per RFC 6750 §3 and the MCP authorization spec's SHOULD, so a client learns what to request before it holds any token — e.g. `Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", scope="tools/add tools/multiply"`. The param is omitted when no scopes are configured. A 403 from `RequireScopes` keeps its own route-specific `scope=` (below) and is not touched. - Requests whose **escaped** path (`r.URL.EscapedPath()`) equals `WellKnownPRMPath()` are passed through unauthenticated. The comparison is on the escaped form on both sides: a resource identifier carrying a percent-encoded octet (e.g. `%2F`) derives a well-known path that keeps it, and comparing the decoded `r.URL.Path` would let `%2F` collapse to `/`, disagree, and return 401 for the discovery endpoint RFC 9728 §3.2 requires to be publicly reachable. ### `(a *Adapter) RequireScopes(scopes ...string) func(http.Handler) http.Handler` @@ -168,6 +169,26 @@ res, err := client.Resource("https://api.example.com", adapter := authplanehttp.New(res) ``` +### 6.1 Where the PRM document lives + +RFC 9728 admits two topologies, and the adapter serves both. + +**(a) Resource-hosted — the default.** The SDK derives `/.well-known/oauth-protected-resource[/path]` from the resource identifier, `PRMHandler()` serves the document, and the `WWW-Authenticate` challenge advertises that URL. Nothing to configure. + +**(b) AS-hosted.** authserver 0.2.0 and later publishes a document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the RFC 9728 §3.1 path suffix of the Resource URI (or its slug). Point the challenge there when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that will not forward it: + +```go +res, err := client.Resource("https://api.example.com/mcp", + resource.WithScopes("read", "write"), + resource.WithResourceMetadataURL("https://auth.example.com/.well-known/oauth-protected-resource/mcp"), +) +adapter := authplanehttp.New(res) +``` + +Only the advertisement moves. `WellKnownPRMPath()` and `PRMHandler()` keep serving the derived route, so you can switch the pointer first and retire the local endpoint afterwards. The URL is validated at construction: absolute, `https` or `http`, no fragment, no userinfo. + +Whichever topology you use, RFC 9728 §3.3 pins the same constraint: the `resource` member inside the document must equal the URL clients call, byte for byte — a client must discard a document whose `resource` differs from the identifier it derived the request from. So the Resource URI registered at the authorization server, the identifier you configure here, and the public URL your server is reached on must be one and the same string, trailing slash and port included. + ## 7. DPoP (sender-constrained tokens) The adapter auto-detects the `DPoP` authorization scheme. When it sees `Authorization: DPoP `, it: @@ -241,15 +262,17 @@ client.Resource(uri, ## 9. Error handling -The middleware maps every verifier error to an RFC 6750 response via `resource.AuthErrorResponse`. If you need to handle errors yourself inside a custom middleware chain, call the same helpers: +The middleware maps every verifier error to an RFC 6750 response via `resource.AuthErrorResponseWithMetadata`. If you need to handle errors yourself inside a custom middleware chain, call the same helper — it returns the status too, so there is no need to call `HTTPStatus` as well: ```go import "github.com/authplane/go-sdk/core/resource" -status := resource.HTTPStatus(err) -status, headers, body := resource.AuthErrorResponse(err) +// status, WWW-Authenticate + Content-Type headers, and the JSON body: +status, headers, body := resource.AuthErrorResponseWithMetadata(err, res.ResourceMetadataURL()) ``` +`AuthErrorResponseWithMetadata` appends the RFC 9728 §5.1 `resource_metadata` parameter to the challenge — pass `res.ResourceMetadataURL()` and your custom middleware advertises the same document the adapter does. `resource.AuthErrorResponse(err)` is the same call without that parameter. + Status mapping (from `resource.HTTPStatus`): | Error | HTTP | @@ -259,7 +282,7 @@ Status mapping (from `resource.HTTPStatus`): | `ErrJWKSUnavailable`, `ErrMetadataUnavailable` | 503 | | `ErrSSRFBlocked`, `ErrProtocolError`, other | 500 | -`WWW-Authenticate` scheme is `DPoP` for any DPoP error and `Bearer` otherwise. The full error reference lives in the [core user guide](../../core/docs/user-guide.md). +`WWW-Authenticate` scheme is `DPoP` for any DPoP error and `Bearer` otherwise. The JSON body's `error_description` is a fixed sentence chosen by the error code, never the verifier's own message. The adapter owns the error at that point and no longer returns it, so it writes the diagnostic to `slog.Default()` at DEBUG (`authplane: rejecting request`, with `err` and `status`); enable debug logging on your default handler to see it, or call `resource.VerifyToken` directly if you want the error in hand. The full error reference lives in the [core user guide](../../core/docs/user-guide.md). ## 10. Lifecycle diff --git a/http/pkg/authplanehttp/adapter.go b/http/pkg/authplanehttp/adapter.go index 32347f6..ff225d6 100644 --- a/http/pkg/authplanehttp/adapter.go +++ b/http/pkg/authplanehttp/adapter.go @@ -3,8 +3,10 @@ package authplanehttp import ( "context" "fmt" + "log/slog" "net/http" "net/url" + "regexp" "strings" "github.com/authplane/go-sdk/core/resource" @@ -19,17 +21,23 @@ import ( // flag) is configured on the wrapped Resource via verifier.WithInboundDPoP; // the adapter does not own DPoP policy. type Adapter struct { - resource *resource.Resource - prmURL string // full URL advertised in the WWW-Authenticate resource_metadata param (RFC 9728 §5.1) - resourceOrigin string // scheme + "://" + authority from the configured resource URI; precomputed for DPoP htu binding + resource *resource.Resource + // resourceMetadataURL is the full URL advertised in the WWW-Authenticate + // resource_metadata param (RFC 9728 §5.1) — the derived PRM URL, or the + // override from resource.WithResourceMetadataURL when the document is + // hosted elsewhere (typically by the authorization server). + resourceMetadataURL string + scopeHint string // space-joined resource scopes advertised in the 401 scope param (RFC 6750 §3); empty when none configured + resourceOrigin string // scheme + "://" + authority from the configured resource URI; precomputed for DPoP htu binding } // New creates an Adapter wrapping the given resource.Resource. func New(res *resource.Resource) *Adapter { return &Adapter{ - resource: res, - prmURL: res.PRMURL(), - resourceOrigin: resourceOrigin(res.URI()), + resource: res, + resourceMetadataURL: res.ResourceMetadataURL(), + scopeHint: strings.Join(res.PRMConfig().ScopesSupported, " "), + resourceOrigin: resourceOrigin(res.URI()), } } @@ -71,30 +79,35 @@ func (a *Adapter) WellKnownPRMPath() string { } // writeAuthError writes the HTTP error response for an auth failure using -// resource.AuthErrorResponse, which generates RFC 6750 compliant status, -// WWW-Authenticate header, and JSON error body. +// resource.AuthErrorResponseWithMetadata, which generates RFC 6750 compliant +// status, WWW-Authenticate header, and JSON error body, with +// `resource_metadata="..."` (RFC 9728 §5.1) appended so clients can +// auto-discover the authorization server from a 401. // -// When the adapter has a non-empty prmURL, `resource_metadata="..."` is -// appended to the WWW-Authenticate header per RFC 9728 §5.1, so clients can -// auto-discover the authorization server from a 401. The separator is space -// when no auth-param is yet present (e.g. the no-token case where -// resource.AuthErrorResponse returns just `Bearer`) and `, ` otherwise — RFC -// 9110 §11.1 requires `auth-scheme 1*SP auth-param`, with commas only -// *between* params. +// The parameter used to be appended here, which meant core could emit a +// challenge this adapter would then have to repair; the emitter now lives in +// one place and this adapter only supplies the URL. +// +// A 401 additionally carries `scope="..."` listing the resource's configured +// scopes (RFC 6750 §3) when any are set: the MCP authorization spec says the +// server SHOULD name the scopes to request on the first challenge, before the +// client holds any token at all. A 403 is left alone — its scope param +// already names the route's required scopes via resource.ScopeError, which is +// the more specific answer. func (a *Adapter) writeAuthError(w http.ResponseWriter, err error) { - status, headers, body := resource.AuthErrorResponse(err) - if a.prmURL != "" { - wwwAuth := headers["WWW-Authenticate"] - if wwwAuth == "" { - wwwAuth = "Bearer" - } - sep := " " - if strings.Contains(wwwAuth, "=") { - sep = ", " - } - wwwAuth += fmt.Sprintf(`%sresource_metadata="%s"`, sep, a.prmURL) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes - headers["WWW-Authenticate"] = wwwAuth + status, headers, body := resource.AuthErrorResponseWithMetadata(err, a.resourceMetadataURL) + if status == http.StatusUnauthorized && a.scopeHint != "" { + headers["WWW-Authenticate"] = appendChallengeParam(headers["WWW-Authenticate"], "scope", a.scopeHint) } + + // The response body no longer carries the diagnostic, and this function owns + // the last reference to err: every middleware failure site funnels through + // here and the adapter exposes no error hook. Without this line a + // misconfigured aud, iss or kid rotation is undebuggable on the adapter path + // — the operator sees the generic sentence and nothing anywhere else. DEBUG + // because it is per-request and only useful while diagnosing; slog.Default() + // is the sink, so slog.SetDefault routes or silences it. + slog.Default().Debug("authplane: rejecting request", "err", err, "status", status) for k, v := range headers { w.Header().Set(k, v) } @@ -102,6 +115,33 @@ func (a *Adapter) writeAuthError(w http.ResponseWriter, err error) { _, _ = w.Write([]byte(body)) } +// appendChallengeParam appends one quoted auth-param to a WWW-Authenticate +// challenge. The separator is a space when no auth-param is yet present +// (e.g. the no-token case where resource.AuthErrorResponse returns just +// `Bearer`) and `, ` otherwise — RFC 9110 §11.1 requires +// `auth-scheme 1*SP auth-param`, with commas only *between* params. +func appendChallengeParam(challenge, name, value string) string { + sep := " " + if strings.Contains(challenge, "=") { + sep = ", " + } + return challenge + sep + fmt.Sprintf(`%s="%s"`, name, sanitizeParamValue(value)) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes +} + +// sanitizeParamValue makes a value safe to splice into a WWW-Authenticate +// quoted-string (RFC 9110 §5.6.4): CR, LF, `"` and `\` are each replaced by a +// space, and the result is trimmed. Substitution rather than deletion is what +// keeps `a"b` two scope tokens instead of silently fusing it into one — none +// of these octets is legal in a scope-token (RFC 6749 §3.3) or in the URI +// derived for resource_metadata, so no valid value is altered. +func sanitizeParamValue(v string) string { + return strings.TrimSpace(paramValueSanitizer.ReplaceAllString(v, " ")) +} + +// A run collapses to one space: `\"` is one offense, not two, and should not +// widen the value by an extra space. +var paramValueSanitizer = regexp.MustCompile(`[\r\n"\\]+`) + // buildRequestURL reconstructs the absolute URL used as the request side of // the RFC 9449 §4.3 `htu` comparison. The scheme and authority come from the // **operator-configured resource origin**, never from the inbound `Host` @@ -218,9 +258,12 @@ func (a *Adapter) Middleware() func(http.Handler) http.Handler { // (including scope= parameter) if any scope is missing. Returns 401 if no claims // are in context (i.e., Middleware was not applied upstream). // -// On failure the error_description names every missing scope (not just the first), -// matching the shape produced by a direct claims.RequireScopes call so middleware- -// enforced and code-enforced paths surface the same diagnostic to clients. +// On failure the `scope="..."` challenge parameter names every missing scope +// (not just the first), so a client can step up in one round trip. The fuller +// diagnostic produced by a direct claims.RequireScopes call does not reach the +// caller — the JSON body carries a fixed error_description, not the message. +// The adapter logs it to slog.Default() at DEBUG instead, since it owns the +// error here and hands it back to nobody. func (a *Adapter) RequireScopes(scopes ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/http/pkg/authplanehttp/adapter_test.go b/http/pkg/authplanehttp/adapter_test.go index 836f471..e61edc9 100644 --- a/http/pkg/authplanehttp/adapter_test.go +++ b/http/pkg/authplanehttp/adapter_test.go @@ -1,14 +1,18 @@ package authplanehttp_test import ( + "bytes" "context" "encoding/json" + "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" + "github.com/authplane/go-sdk/core/resource" "github.com/authplane/go-sdk/core/resource/verifier" "github.com/authplane/go-sdk/http/pkg/authplanehttp" ) @@ -240,6 +244,219 @@ func TestMiddlewareInvalidTokenWWWAuthenticateExact(t *testing.T) { } } +// Scope hint on 401 (RFC 6750 §3; MCP authorization spec: the server SHOULD +// name the scopes to request on the first challenge). The resource in +// newTestEnv is configured with "tools/add" and "tools/multiply". + +// TestMiddlewareNoTokenCarriesScopeHint pins the exact no-token challenge: +// resource_metadata first, then scope, both quoted and comma-separated, with +// a space (never a comma) after the scheme. +func TestMiddlewareNoTokenCarriesScopeHint(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.Middleware()(okHandler()) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestMiddlewareInvalidTokenCarriesScopeHint covers the 401 that already has +// error="invalid_token": the scope param must still be present, after +// resource_metadata. +func TestMiddlewareInvalidTokenCarriesScopeHint(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.Middleware()(okHandler()) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil) + req.Header.Set("Authorization", "Bearer not.a.valid.jwt") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + want := `Bearer error="invalid_token", resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestMiddlewareDPoPErrorCarriesScopeHint: a DPoP-scheme 401 is still a 401, +// so it carries the hint too (RFC 9449 §7.1 reuses the RFC 6750 §3 params). +func TestMiddlewareDPoPErrorCarriesScopeHint(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.Middleware()(okHandler()) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil) + req.Header.Set("Authorization", "DPoP some.access.token") + req.Header.Add("DPoP", "proof-one") + req.Header.Add("DPoP", "proof-two") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + if !strings.HasPrefix(got, "DPoP ") { + t.Errorf("WWW-Authenticate = %q, want DPoP scheme", got) + } + if !strings.HasSuffix(got, `, scope="tools/add tools/multiply"`) { + t.Errorf("WWW-Authenticate = %q, want trailing scope hint", got) + } +} + +// TestMiddlewareNoConfiguredScopesOmitsScopeHint: a resource with no scopes +// has nothing to hint, and RFC 6750 §3 forbids an empty scope value, so the +// param is absent and the challenge is byte-identical to the pre-hint shape. +func TestMiddlewareNoConfiguredScopesOmitsScopeHint(t *testing.T) { + e := newTestEnvWithScopes(t) + handler := e.adapter.Middleware()(okHandler()) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestRequireScopesMissingKeepsRouteScopesOnly pins that the 403 is untouched +// by the 401 hint: its scope param names the route's required scopes from +// resource.ScopeError, not the resource-wide list, and appears exactly once. +func TestRequireScopesMissingKeepsRouteScopesOnly(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.Middleware()(e.adapter.RequireScopes("tools/admin")(okHandler())) + token := e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/admin", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + want := `Bearer error="insufficient_scope", scope="tools/admin", resource_metadata="` + e.adapter.Resource().PRMURL() + `"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestMiddlewareNoTokenQueryReachesResourceMetadata pins the end-to-end claim +// behind PRMURL's query preservation: for a query-bearing resource identifier, +// the 401 challenge's resource_metadata value carries the query verbatim with +// no adapter changes — RFC 9728 §5.1 is where a client actually reads this +// URL, and the quoted-string interpolation here is where an unvalidated query +// would break the header, so the assertion lives at this layer rather than +// only on core/resource.PRMURL. +func TestMiddlewareNoTokenQueryReachesResourceMetadata(t *testing.T) { + e := newTestEnvForResource(t, "https://api.example.com/mcp?tenant=a") + handler := e.adapter.Middleware()(okHandler()) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + const want = `resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"` + if !strings.Contains(got, want) { + t.Errorf("WWW-Authenticate = %q; want it to contain %s", got, want) + } +} + +// TestMiddlewareAdvertisedQueryURLIsReachable closes the round trip the previous +// test only opens: it fetches the URL that was actually advertised in the 401's +// resource_metadata and asserts the PRM document comes back rather than another +// challenge. That is the migration promise behind the query-preserving +// derivation — the newly-advertised URL carries a query the route was never +// registered with, and the discovery bypass must still let it through, because +// RFC 9728 §3.2 requires the metadata endpoint publicly reachable. +// +// The bypass compares the request's EscapedPath against the path-keyed +// WellKnownPRMPath, so the query is ignored and any query value reaches the one +// registered handler. Asserting it here means tightening that comparison to the +// full RequestURI — which would break discovery for every query-bearing +// resource — cannot pass with a green suite. +func TestMiddlewareAdvertisedQueryURLIsReachable(t *testing.T) { + e := newTestEnvForResource(t, "https://api.example.com/mcp?tenant=a") + + mux := http.NewServeMux() + mux.Handle(e.adapter.WellKnownPRMPath(), e.adapter.PRMHandler()) + mux.Handle("/", okHandler()) + handler := e.adapter.Middleware()(mux) + + // Take the advertised URL from a real challenge rather than recomputing it, + // so the request below is literally what a client would follow. + challengeRec := httptest.NewRecorder() + handler.ServeHTTP(challengeRec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil)) + if challengeRec.Code != http.StatusUnauthorized { + t.Fatalf("challenge status = %d, want 401", challengeRec.Code) + } + advertised := resourceMetadataParam(t, challengeRec.Header().Get("WWW-Authenticate")) + parsed, err := url.Parse(advertised) + if err != nil { + t.Fatalf("parse advertised resource_metadata %q: %v", advertised, err) + } + if parsed.RawQuery != "tenant=a" { + t.Fatalf("advertised URL query = %q, want %q", parsed.RawQuery, "tenant=a") + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, parsed.RequestURI(), nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200 (discovery must be bypassed)", parsed.RequestURI(), rec.Code) + } + if challenge := rec.Header().Get("WWW-Authenticate"); challenge != "" { + t.Errorf("GET %s returned WWW-Authenticate = %q; want none", parsed.RequestURI(), challenge) + } + var doc map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("decode PRM document: %v (body %q)", err, rec.Body.String()) + } + if got := doc["resource"]; got != e.resourceURI { + t.Errorf("PRM document resource = %v, want %q", got, e.resourceURI) + } + + // A query value the shared document was not built for still reaches the same + // handler: routing is path-keyed, so the bypass does not depend on the value. + otherRec := httptest.NewRecorder() + other := e.adapter.WellKnownPRMPath() + "?tenant=zzz" + handler.ServeHTTP(otherRec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, other, nil)) + if otherRec.Code != http.StatusOK { + t.Errorf("GET %s status = %d, want 200", other, otherRec.Code) + } +} + +// resourceMetadataParam extracts the resource_metadata quoted-string value from +// a WWW-Authenticate challenge. +func resourceMetadataParam(t *testing.T, challenge string) string { + t.Helper() + const key = `resource_metadata="` + i := strings.Index(challenge, key) + if i < 0 { + t.Fatalf("WWW-Authenticate = %q; want a resource_metadata param", challenge) + } + rest := challenge[i+len(key):] + j := strings.Index(rest, `"`) + if j < 0 { + t.Fatalf("WWW-Authenticate = %q; resource_metadata value is unterminated", challenge) + } + return rest[:j] +} + func TestMiddlewareMalformedHeader(t *testing.T) { e := newTestEnv(t) handler := e.adapter.Middleware()(okHandler()) @@ -387,10 +604,12 @@ func TestRequireScopesMultipleOneMissing(t *testing.T) { } // TestRequireScopesMultipleAllMissingNamesEveryScope verifies the middleware -// surfaces all missing scopes (not just the first) in the error_description, -// matching the shape of a direct claims.RequireScopes call. The -// WWW-Authenticate header carries the scopes space-separated per RFC 6750 §3; -// the enriched quoted-list shape lives in the JSON body's error_description. +// surfaces all missing scopes (not just the first), so a client can step up in +// one round trip rather than discovering them one 403 at a time. They travel in +// the RFC 6750 §3 `scope="..."` challenge parameter, space-separated. The JSON +// body must not repeat them: its error_description is a fixed sentence, and the +// enriched message naming the scopes the token already carries stays on the +// error for the resource server to log. func TestRequireScopesMultipleAllMissingNamesEveryScope(t *testing.T) { e := newTestEnv(t) handler := e.adapter.Middleware()(e.adapter.RequireScopes("tools/admin", "tools/superuser")(okHandler())) @@ -406,8 +625,8 @@ func TestRequireScopesMultipleAllMissingNamesEveryScope(t *testing.T) { t.Errorf("WWW-Authenticate = %q, want scope=\"tools/admin tools/superuser\"", got) } body := rec.Body.String() - if !strings.Contains(body, `\"tools/admin\"`) || !strings.Contains(body, `\"tools/superuser\"`) { - t.Errorf("body = %s, want error_description to name every missing scope (not just the first)", body) + if strings.Contains(body, "tools/admin") || strings.Contains(body, "tools/superuser") || strings.Contains(body, "tools/add") { + t.Errorf("body = %s, want no scope names in the body served to an unauthenticated caller", body) } } @@ -490,3 +709,215 @@ func TestMiddlewareValidBearerTokenES256(t *testing.T) { t.Error("ClaimsFromContext returned nil for valid ES256 token") } } + +// resource_metadata: the emitter move into core, and the AS-hosted override. + +const asHostedPRMURL = "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + +// legacyChallenge reproduces how this adapter composed the challenge before the +// resource_metadata parameter moved into core: the header from +// resource.AuthErrorResponse, then the parameter appended with a space when no +// auth-param is present yet and ", " otherwise. +func legacyChallenge(err error, metadataURL string) string { + _, headers, _ := resource.AuthErrorResponse(err) + challenge := headers["WWW-Authenticate"] + if challenge == "" { + challenge = "Bearer" + } + sep := " " + if strings.Contains(challenge, "=") { + sep = ", " + } + return challenge + sep + `resource_metadata="` + metadataURL + `"` +} + +// scopeHintOf is the scope param a 401 now carries: the adapter advertises the +// resource's configured scopes on the challenge a client meets before it holds +// any token. A 403 is not affected — its scope param names the route's +// required scopes instead. +func scopeHintOf(scopes string) string { return `, scope="` + scopes + `"` } + +// TestWriteAuthErrorHeaderUnchangedByEmitterMove pins the move end to end: the +// header the middleware puts on the wire is byte-for-byte what the adapter +// composed itself before core gained AuthErrorResponseWithMetadata. The +// no-token case is included because it is the one with the bare-Bearer +// separator and the one MCP clients read first. +func TestWriteAuthErrorHeaderUnchangedByEmitterMove(t *testing.T) { + e := newTestEnv(t) + prmURL := e.adapter.Resource().PRMURL() + + t.Run("no token", func(t *testing.T) { + rec := httptest.NewRecorder() + e.adapter.Middleware()(okHandler()).ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil)) + if got, want := rec.Header().Get("WWW-Authenticate"), legacyChallenge(verifier.ErrTokenMissing, prmURL)+scopeHintOf("tools/add tools/multiply"); got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } + }) + + t.Run("invalid token", func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil) + req.Header.Set("Authorization", "Bearer not.a.valid.jwt") + rec := httptest.NewRecorder() + e.adapter.Middleware()(okHandler()).ServeHTTP(rec, req) + if got, want := rec.Header().Get("WWW-Authenticate"), legacyChallenge(verifier.ErrInvalidSignature, prmURL)+scopeHintOf("tools/add tools/multiply"); got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } + }) + + t.Run("insufficient scope", func(t *testing.T) { + handler := e.adapter.Middleware()(e.adapter.RequireScopes("tools/admin")(okHandler())) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/admin", nil) + req.Header.Set("Authorization", "Bearer "+e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + scopeErr := &resource.ScopeError{RequiredScopes: []string{"tools/admin"}, Err: verifier.ErrInsufficientScope} + if got, want := rec.Header().Get("WWW-Authenticate"), legacyChallenge(scopeErr, prmURL); got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } + }) +} + +// TestResourceMetadataOverrideOn401 covers the challenge path a client hits +// first: with the option set, resource_metadata names the AS-hosted +// document and never the derived one. +func TestResourceMetadataOverrideOn401(t *testing.T) { + e := newTestEnvWithOptions(t, resource.WithResourceMetadataURL(asHostedPRMURL)) + rec := httptest.NewRecorder() + e.adapter.Middleware()(okHandler()).ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + want := `Bearer resource_metadata="` + asHostedPRMURL + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } + if strings.Contains(got, e.adapter.Resource().PRMURL()) { + t.Errorf("WWW-Authenticate = %q; still advertises the derived PRM URL", got) + } +} + +// TestResourceMetadataOverrideOn403 covers the insufficient_scope challenge: +// the override must reach it too, alongside the scope parameter. +func TestResourceMetadataOverrideOn403(t *testing.T) { + e := newTestEnvWithOptions(t, resource.WithResourceMetadataURL(asHostedPRMURL)) + handler := e.adapter.Middleware()(e.adapter.RequireScopes("tools/admin")(okHandler())) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/admin", nil) + req.Header.Set("Authorization", "Bearer "+e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + got := rec.Header().Get("WWW-Authenticate") + want := `Bearer error="insufficient_scope", scope="tools/admin", resource_metadata="` + asHostedPRMURL + `"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestResourceMetadataOverrideLeavesPRMRouteAlone: the override moves the +// advertisement, not the route — the adapter still serves its own document at +// the derived well-known path, so an operator can migrate the advertisement +// without taking the local endpoint down. +func TestResourceMetadataOverrideLeavesPRMRouteAlone(t *testing.T) { + e := newTestEnvWithOptions(t, resource.WithResourceMetadataURL(asHostedPRMURL)) + prmPath := e.adapter.WellKnownPRMPath() + rec := httptest.NewRecorder() + e.adapter.Middleware()(e.adapter.PRMHandler()).ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, prmPath, nil)) + + if rec.Code != http.StatusOK { + t.Errorf("GET %s: status = %d, want 200", prmPath, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } +} + +// TestChallengeParamsAreSanitized drives the adapter's own copy of the +// sanitizer, which the middleware tests only ever reach with clean values. +// This copy also runs over resource_metadata, not just scope, so it has more +// surface than the mcp one — and the two rulesets have to stay identical or +// the same operator config reads differently depending on which adapter is +// mounted. +func TestChallengeParamsAreSanitized(t *testing.T) { + tests := []struct { + name string + scope string + want string + }{ + { + name: "a quote cannot close the parameter", + scope: `tools/add" x=y`, + want: `scope="tools/add x=y"`, + }, + { + name: "a run of offenses collapses to one space", + scope: `tools/add\"x`, + want: `scope="tools/add x"`, + }, + { + name: "CR and LF cannot split the header", + scope: "tools/add\r\nX-Injected: 1", + want: `scope="tools/add X-Injected: 1"`, + }, + { + name: "surrounding offenses are trimmed", + scope: `"tools/add"`, + want: `scope="tools/add"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newTestEnvWithScopes(t, tt.scope) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/resource", nil) + e.adapter.Middleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})). + ServeHTTP(rec, req) + + got := rec.Header().Get("WWW-Authenticate") + if !strings.Contains(got, tt.want) { + t.Errorf("WWW-Authenticate = %q, want it to contain %q", got, tt.want) + } + if strings.ContainsAny(got, "\r\n") { + t.Errorf("challenge carries a bare CR/LF: %q", got) + } + }) + } +} + +// TestWriteAuthError_EmitsTheSuppressedDiagnostic: the body no longer carries +// the verifier's message and writeAuthError owns the last reference to the +// error, so without this log a misconfigured aud or a kid rotation is +// undebuggable on the adapter path — the operator sees the fixed sentence and +// nothing anywhere else. +func TestWriteAuthError_EmitsTheSuppressedDiagnostic(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + e := newTestEnv(t) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/resource", nil) + e.adapter.Middleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + logged := buf.String() + if !strings.Contains(logged, "authplane: rejecting request") { + t.Errorf("the suppressed diagnostic did not reach slog.Default(); log was %q", logged) + } + // The message alone would still be emitted if the error value were dropped + // from the record, which is the whole diagnostic. Pin the attribute too. + if !strings.Contains(logged, verifier.ErrTokenMissing.Error()) { + t.Errorf("the record carries no err attribute naming the rejection; log was %q", logged) + } + if strings.Contains(rec.Body.String(), verifier.ErrTokenMissing.Error()) { + t.Errorf("response body = %q; the verifier message must stay out of it", rec.Body.String()) + } +} diff --git a/http/pkg/authplanehttp/helpers_test.go b/http/pkg/authplanehttp/helpers_test.go index 072deb2..df7f41b 100644 --- a/http/pkg/authplanehttp/helpers_test.go +++ b/http/pkg/authplanehttp/helpers_test.go @@ -34,6 +34,27 @@ type testEnv struct { } func newTestEnv(t *testing.T) *testEnv { + t.Helper() + return newTestEnvWithOptions(t) +} + +// newTestEnvWithScopes is newTestEnv with the resource's supported scopes +// chosen by the caller; pass none for a resource that advertises no scopes. +func newTestEnvWithScopes(t *testing.T, scopes ...string) *testEnv { + t.Helper() + return newTestEnvWith(t, scopes) +} + +// newTestEnvWithOptions is newTestEnv with extra resource options — used by the +// resource_metadata override tests, which need a Resource built with +// resource.WithResourceMetadataURL. +func newTestEnvWithOptions(t *testing.T, extra ...resource.Option) *testEnv { + t.Helper() + return newTestEnvWith(t, []string{"tools/add", "tools/multiply"}, extra...) +} + +// newTestEnvWith is the base the three constructors above delegate to. +func newTestEnvWith(t *testing.T, scopes []string, extra ...resource.Option) *testEnv { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -63,12 +84,13 @@ func newTestEnv(t *testing.T) *testEnv { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { client.Close() }) - res, err := client.Resource(testResource, - resource.WithScopes("tools/add", "tools/multiply"), + opts := append([]resource.Option{ + resource.WithScopes(scopes...), resource.WithVerifierOptions(verifier.WithInboundDPoP(verifier.InboundDPoPOptions{ ReplayStore: verifier.NewInMemoryDPoPReplayStore(), })), - ) + }, extra...) + res, err := client.Resource(testResource, opts...) if err != nil { t.Fatalf("client.Resource: %v", err) } diff --git a/llm-full.txt b/llm-full.txt index 4b9ead4..cdb6857 100644 --- a/llm-full.txt +++ b/llm-full.txt @@ -200,7 +200,7 @@ Top-level public types under `github.com/authplane/go-sdk/core/...`: | `TokenResponse`, `IntrospectionResponse`, `TokenExchangeInput`, `ConsentRequiredError`, OAuth error sentinels (`ErrInvalidGrant`, `ErrConsentRequired`, etc.) | `authplane` | Token-operation results and OAuth error types | | `Resource`, `Option`, `WithScopes`, `WithVerifierOptions` | `resource` | Protected-resource facade (verify + RFC 9728 PRM) | | `VerifyOption`, `WithDPoP` | `resource` | Per-call options for `Resource.VerifyToken` (e.g. attach a `DPoPContext`) | -| `HTTPStatus`, `AuthErrorResponse`, `ScopeError` | `resource` | Error → HTTP status / WWW-Authenticate helpers (RFC 6750) | +| `HTTPStatus`, `AuthErrorResponse`, `AuthErrorResponseVerbose`, `ScopeError` | `resource` | Error → HTTP status / WWW-Authenticate helpers (RFC 6750). The JSON body's `error_description` is a fixed sentence per error code; the `Verbose` variant restores the error's own message and is a development aid only | | `TokenVerifier`, `NewTokenVerifier`, `JWKSCache`, `NewJWKSCache` | `verifier` | Lower-level JWT verification + JWKS cache (used by `Resource`) | | `VerifiedClaims` (with `DPoPProof()` accessor) | `verifier` | Validated JWT claims; DPoP-bound tokens expose validated proof via `DPoPProof() *VerifiedDPoPProof` | | `VerifiedDPoPProof` | `verifier` | JTI, HTM, HTU, IAT, KeyThumbprint, Nonce of a validated DPoP proof | diff --git a/mark3labs/docs/user-guide.md b/mark3labs/docs/user-guide.md index 0017700..b57afc0 100644 --- a/mark3labs/docs/user-guide.md +++ b/mark3labs/docs/user-guide.md @@ -60,7 +60,7 @@ func main() { 1. `authplane.NewClient` performs RFC 8414 AS metadata discovery. 2. `client.Resource(uri, resource.WithScopes(...))` builds the resource (the JWKS cache is warmed and background refresh starts). -3. If `ClientOptions` includes `WithClientCredentials` or `WithClientAuthentication`, RFC 7662 introspection is auto-wired as the revocation checker, and `TokenExchange` becomes operational. +3. If `ClientOptions` includes `WithClientCredentials` or `WithClientAuthentication`, RFC 7662 introspection is auto-wired as the revocation checker, and `TokenExchange` becomes operational. The credentials must belong to a confidential client that is the issuing client or a runtime-client of the Resource — see §8. Internally `*Adapter` embeds [`*authplanehttp.Adapter`](../../http/docs/user-guide.md) — the generic Authplane net/http adapter — so the Bearer/DPoP middleware, scope-enforcing middleware, context helpers, and RFC 6750 / RFC 9728 `WWW-Authenticate` response (including `resource_metadata="..."` advertisement) all come from one shared implementation rather than being re-implemented per adapter. mark3labs-only additions are the context bridge and the URL-elicitation mapping. @@ -68,7 +68,7 @@ The adapter integrates with mark3labs/mcp-go through **two coordinated hooks**: | Hook | Purpose | |---|---| -| `adapter.AuthMiddleware(next)` | Standard `http.Handler` middleware (delegates to `authplanehttp.Middleware()`). Parses `Authorization: Bearer …` *or* `Authorization: DPoP …`, runs the verifier, and on success stores `*verifier.VerifiedClaims` plus the raw token in the **HTTP request** context. On failure it writes a 401 with an RFC 6750 §3.1 compliant `WWW-Authenticate` header that advertises the PRM URL via `resource_metadata=` (RFC 9728 §5.1). The PRM well-known path is auto-excluded from authentication. | +| `adapter.AuthMiddleware(next)` | Standard `http.Handler` middleware (delegates to `authplanehttp.Middleware()`). Parses `Authorization: Bearer …` *or* `Authorization: DPoP …`, runs the verifier, and on success stores `*verifier.VerifiedClaims` plus the raw token in the **HTTP request** context. On failure it writes a 401 with an RFC 6750 §3.1 compliant `WWW-Authenticate` header that advertises the PRM URL via `resource_metadata=` (RFC 9728 §5.1) and, when the resource is configured with scopes, the configured set via `scope=` (RFC 6750 §3). The PRM well-known path is auto-excluded from authentication. | | `server.WithHTTPContextFunc(adapter.HTTPContextFunc())` | Forwards claims/token from the HTTP request context into the **per-tool-call** MCP context. Without it, tool handlers receive a fresh context with no claims. | Scope enforcement is **per-tool**, not per-request. The middleware itself accepts any valid token; individual tool handlers call `ClaimsFromContext(ctx).RequireScope(...)`. This matches the MCP protocol: `initialize` and protocol-level messages must succeed with any authenticated client. @@ -149,9 +149,31 @@ Why `mcp.NewToolResultError(...)` instead of returning `err`? mark3labs/mcp-go c | `DevMode` | `bool` | no | Relaxes SSRF to allow HTTP, localhost, private networks. Also enabled if `AUTHPLANE_DEV_MODE=1`. Remove before production. | | `ClientOptions` | `[]authplane.Option` | no | SDK-level options: `WithClientCredentials`, `WithClientAuthentication`, `WithJWKSCacheTTL`, `WithCircuitBreaker`, `WithDPoP`, etc. | | `VerifierOptions` | `[]verifier.Option` | no | Verifier-level options: `WithAlgorithms`, `WithClockSkew`, `WithRevocationChecker`, `WithFailClosed`. | +| `ResourceMetadataURL` | `string` | no | Overrides the URL advertised in `resource_metadata`. Empty advertises the document this adapter serves. See §5.1. | `VerifierOptions` **replaces** the verifier option list set by `client.Resource`. When `ClientOptions` supplies credentials, the SDK auto-wires an introspection-backed revocation checker — if you also pass `VerifierOptions`, include `verifier.WithRevocationChecker(...)` (or `NullRevocationChecker`) explicitly if you want to keep, replace, or disable it. +### 5.1 Where the PRM document lives + +RFC 9728 admits two topologies, and the adapter serves both. + +**(a) Resource-hosted — the default.** The SDK derives `/.well-known/oauth-protected-resource[/path]` from `Options.Resource`, `ProtectedResourceMetadataHandler()` serves the document, and the 401 challenge advertises that URL. Nothing to configure. + +**(b) AS-hosted.** authserver 0.2.0 and later publishes a document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the RFC 9728 §3.1 path suffix of the Resource URI (or its slug). Point the challenge there when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that will not forward it: + +```go +adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: "https://auth.example.com", + Resource: "https://mcp.example.com/mcp", + Scopes: []string{"tools/query"}, + ResourceMetadataURL: "https://auth.example.com/.well-known/oauth-protected-resource/mcp", +}) +``` + +Only the advertisement moves. `WellKnownPRMPath()` and `ProtectedResourceMetadataHandler()` keep serving the derived route, so you can switch the pointer first and retire the local endpoint afterwards. The URL is validated at construction: absolute, `https` or `http`, no fragment, no userinfo. + +Whichever topology you use, RFC 9728 §3.3 pins the same constraint: the `resource` member inside the document must equal the URL clients call, byte for byte — a client must discard a document whose `resource` differs from the identifier it derived the request from. So the Resource URI registered at the authorization server, the identifier you configure here, and the public URL your server is reached on must be one and the same string, trailing slash and port included. + ## 6. Main API reference ### `NewAdapter(ctx context.Context, options Options) (*Adapter, error)` @@ -168,7 +190,7 @@ Constructs an adapter from an already-built client and resource. Use this when s Wraps an HTTP handler with Bearer (and DPoP) token authentication. Equivalent to `a.Middleware()(handler)`; the call shape is preserved for fluency in mark3labs code. -- Rejects unauthenticated requests with 401 and a `WWW-Authenticate: Bearer resource_metadata="…"` header (RFC 9728 §5.1). +- Rejects unauthenticated requests with 401 and a `WWW-Authenticate: Bearer resource_metadata="…", scope="…"` header — the PRM URL per RFC 9728 §5.1, and the resource's configured scopes (`Options.Scopes`) per RFC 6750 §3 and the MCP authorization spec's SHOULD. The `scope` param is omitted when `Options.Scopes` is empty. - Rejects invalid Bearer tokens with 401 + `error="invalid_token"`; DPoP-bound errors return the `DPoP` scheme as required by RFC 9449. - On success, injects `*verifier.VerifiedClaims` and the raw token into the request context. - The PRM well-known path is auto-excluded so the metadata endpoint stays publicly reachable even when this middleware wraps a broad route prefix. @@ -256,6 +278,22 @@ Returns the raw bearer token forwarded by `HTTPContextFunc`. Returns `""` outsid RFC 8693 token exchange frequently runs into an authorization-server response of `consent_required` when the user has not yet granted the requested downstream access. The MCP URL elicitation protocol (JSON-RPC error code `-32042`) lets the server ask the MCP client to open a URL out-of-band — typically a consent page — and retry the original operation once the user is done. +**Operator step for cross-client exchanges.** For each MCP server that exchanges for a downstream resource it does not act as, allowlist the exchanging client on the target Resource: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token issued to itself, fronted exchanges and Broker resources need nothing. + +Two exchange errors look like consent problems but are not: + +- `access_denied` (HTTP 403, `authplane.ErrAccessDenied`) on a cross-client exchange means the operator has not allowlisted the exchanging client on the target Resource (`policy.exchange.allowed_client_ids` / `policy.runtime.client_ids`). Re-prompting the user will not fix it — unlike `consent_required`, which the user resolves. +- `invalid_target` (HTTP 400, `authplane.ErrInvalidTarget`, RFC 8707 §2.2) means the `resource` string does not match a granted resource exactly — byte for byte, a trailing slash counts. + +Neither counts toward the circuit breaker: the AS answered, it just said no. + ### 7.1 Detecting consent errors ```go @@ -307,6 +345,14 @@ if err != nil { When credentials are supplied in `ClientOptions`, the SDK auto-wires RFC 7662 introspection as the revocation checker. Every successful JWT verification triggers an introspection round-trip; the token is rejected if the AS reports `active: false`. +The introspecting client must be **confidential** (client ID and secret) **and** either the client the token was issued to or a runtime-client of the Resource named in the token's `aud`. authserver ≥ 0.1.2 answers `{"active": false}` to anyone else — a public (secret-less) client cannot introspect at all, and a resource server introspecting with the wrong client rejects every token as revoked. Register the resource server as a runtime-client of its Resource: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +When introspection answers `active: false` for a token that already passed local JWT verification, the SDK logs one warning per resource pointing at this requirement. The warning is written to `slog.Default()`; install a handler with `slog.SetDefault` to route it into your own logging setup, or to silence it. + ```go adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ Issuer: "https://auth.example.com", @@ -411,6 +457,8 @@ When calling `adapter.Client()` operations directly (e.g. `Revoke`, `Introspect` | `ErrProtocolError` | Malformed response from AS. | | `ErrConsentRequired` | User consent required — prefer `*ConsentRequiredError` for the URL. | | `ErrInteractionRequired` | User interaction required. | +| `ErrAccessDenied` | Cross-client exchange refused (403): the exchanging client is not allowlisted on the target Resource. Operator fix, not a consent prompt. | +| `ErrInvalidTarget` | `resource` does not match a granted resource byte for byte (RFC 8707 §2.2). | | `ErrUseDPoPNonce` | AS returned a DPoP nonce; the client auto-retries with the nonce. | The full verifier error list (signature, claims, DPoP, etc.) lives in the [core user guide](../../core/docs/user-guide.md). diff --git a/mark3labs/pkg/authplanemark3labs/adapter.go b/mark3labs/pkg/authplanemark3labs/adapter.go index e090ee0..40bfc88 100644 --- a/mark3labs/pkg/authplanemark3labs/adapter.go +++ b/mark3labs/pkg/authplanemark3labs/adapter.go @@ -43,6 +43,14 @@ type Options struct { Resource string Scopes []string + // ResourceMetadataURL overrides the URL advertised in the RFC 9728 §5.1 + // resource_metadata parameter of the WWW-Authenticate challenge. Leave it + // empty to advertise the document this adapter serves itself; set it to the + // authorization server's copy ("/.well-known/oauth-protected-resource/{ref}") + // when the resource server cannot host well-known paths. Rejected at + // construction if it is not an absolute http(s) URL. + ResourceMetadataURL string + // DevMode relaxes SSRF protection to allow HTTP and localhost — required when // the issuer runs on a local development server. Remove before deploying to production. // The SDK also checks the AUTHPLANE_DEV_MODE=1 env var as a fallback. @@ -122,6 +130,9 @@ func NewAdapter(ctx context.Context, options Options) (*Adapter, error) { } resourceOpts := []resource.Option{resource.WithScopes(options.Scopes...)} + if options.ResourceMetadataURL != "" { + resourceOpts = append(resourceOpts, resource.WithResourceMetadataURL(options.ResourceMetadataURL)) + } if len(options.VerifierOptions) > 0 { // Only pass WithVerifierOptions when non-empty: WithVerifierOptions replaces // (not appends) the verifier option list, so passing an empty slice would diff --git a/mark3labs/pkg/authplanemark3labs/adapter_test.go b/mark3labs/pkg/authplanemark3labs/adapter_test.go index 0ed0351..b0e5f9a 100644 --- a/mark3labs/pkg/authplanemark3labs/adapter_test.go +++ b/mark3labs/pkg/authplanemark3labs/adapter_test.go @@ -86,6 +86,73 @@ func TestAuthMiddlewareInvalidTokenReturns401(t *testing.T) { } } +// Scope hint on 401 (RFC 6750 §3; MCP authorization spec: the server SHOULD +// name the scopes to request on the first challenge). The param is emitted +// by the embedded authplanehttp.Adapter; these tests pin that it reaches the +// mark3labs call shape unchanged. The resource in newTestEnv is configured +// with "tools/add" and "tools/multiply". + +// TestAuthMiddlewareNoTokenCarriesScopeHint pins the exact no-token challenge: +// resource_metadata first, then scope, both quoted and comma-separated. +func TestAuthMiddlewareNoTokenCarriesScopeHint(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestAuthMiddlewareInvalidTokenCarriesScopeHint covers the 401 that already +// has error="invalid_token": the scope param must still be present, after +// resource_metadata. +func TestAuthMiddlewareInvalidTokenCarriesScopeHint(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer not.a.valid.jwt") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer error="invalid_token", resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestAuthMiddlewareNoConfiguredScopesOmitsScopeHint: a resource with no +// scopes has nothing to hint, and RFC 6750 §3 forbids an empty scope value, +// so the challenge is byte-identical to the pre-hint shape. +func TestAuthMiddlewareNoConfiguredScopesOmitsScopeHint(t *testing.T) { + e := newTestEnvWithScopes(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + // TestAuthMiddlewareNoScopeEnforcement verifies that AuthMiddleware does NOT // reject tokens based on scope. A valid token with no scopes must be passed // through to the inner handler — scope enforcement is the tool handler's job. @@ -721,3 +788,43 @@ func TestURLElicitationErrorMarshalData(t *testing.T) { t.Error("elicitationId is empty in payload") } } + +// resource_metadata override. + +const asHostedPRMURL = "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + +// TestAuthMiddlewareResourceMetadataOverride pins that +// Options.ResourceMetadataURL reaches the challenge through the embedded http +// adapter, which is what emits it for this adapter. +func TestAuthMiddlewareResourceMetadataOverride(t *testing.T) { + e := newTestEnvWithMetadataURL(t, asHostedPRMURL) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + asHostedPRMURL + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestAuthMiddlewareResourceMetadataDefault: with no override the challenge +// carries the derived PRM URL, unchanged from before the option existed. +func TestAuthMiddlewareResourceMetadataDefault(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} diff --git a/mark3labs/pkg/authplanemark3labs/helpers_test.go b/mark3labs/pkg/authplanemark3labs/helpers_test.go index 12fa762..d72b338 100644 --- a/mark3labs/pkg/authplanemark3labs/helpers_test.go +++ b/mark3labs/pkg/authplanemark3labs/helpers_test.go @@ -40,6 +40,26 @@ type testEnv struct { // when the test completes. func newTestEnv(t *testing.T) *testEnv { t.Helper() + return newTestEnvWithScopes(t, "tools/add", "tools/multiply") +} + +// newTestEnvWithMetadataURL is newTestEnv with Options.ResourceMetadataURL set; +// an empty value leaves the adapter on the derived PRM URL. +func newTestEnvWithMetadataURL(t *testing.T, resourceMetadataURL string) *testEnv { + t.Helper() + return newTestEnvWith(t, resourceMetadataURL, "tools/add", "tools/multiply") +} + +// newTestEnvWithScopes is newTestEnv with the resource's supported scopes +// chosen by the caller; pass none for a resource that advertises no scopes. +func newTestEnvWithScopes(t *testing.T, scopes ...string) *testEnv { + t.Helper() + return newTestEnvWith(t, "", scopes...) +} + +// newTestEnvWith is the base the three constructors above delegate to. +func newTestEnvWith(t *testing.T, resourceMetadataURL string, scopes ...string) *testEnv { + t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -77,8 +97,10 @@ func newTestEnv(t *testing.T) *testEnv { adapter, err := authplanemark3labs.NewAdapter(context.Background(), authplanemark3labs.Options{ Issuer: srv.URL, Resource: testResource, - Scopes: []string{"tools/add", "tools/multiply"}, + Scopes: scopes, DevMode: true, // allow HTTP + localhost in tests + + ResourceMetadataURL: resourceMetadataURL, }) if err != nil { t.Fatalf("NewAdapter: %v", err) diff --git a/mcp/README.md b/mcp/README.md index 01bb56a..ce05624 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -12,41 +12,51 @@ go get github.com/authplane/go-sdk/mcp ## Quickstart +A server with one tool, RFC 9728 metadata served, and every request authenticated: + ```go package main import ( - "context" - "net/http" + "context" + "net/http" - "github.com/authplane/go-sdk/mcp/pkg/authplanemcp" - "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/authplane/go-sdk/mcp/pkg/authplanemcp" + "github.com/modelcontextprotocol/go-sdk/mcp" ) func main() { - ctx := context.Background() - - adapter, err := authplanemcp.NewAdapter(ctx, authplanemcp.Options{ - Issuer: "https://auth.example.com", - Resource: "https://mcp.example.com/mcp", - Scopes: []string{"tools/query", "tools/write"}, - }) - if err != nil { - panic(err) - } - defer adapter.Close() // stops background refresh goroutines, closes the client + adapter, err := authplanemcp.NewAdapter(context.Background(), authplanemcp.Options{ + Issuer: "https://auth.example.com", + Resource: "https://mcp.example.com/mcp", + Scopes: []string{"tools/ping"}, + }) + if err != nil { + panic(err) + } + defer adapter.Close() // stops background refresh goroutines, closes the client + server := mcp.NewServer(&mcp.Implementation{Name: "Ping", Version: "0.1.0"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "ping"}, func(_ context.Context, _ *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "pong"}}}, nil, nil + }) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil) + http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) + http.Handle("/mcp", adapter.AuthMiddleware(handler)) + http.ListenAndServe(":8080", nil) +} +``` - server := mcp.NewServer(&mcp.Implementation{Name: "My Server", Version: "1.0.0"}, nil) +```bash +go run . +``` - handler := mcp.NewStreamableHTTPHandler( - func(_ *http.Request) *mcp.Server { return server }, nil, - ) +Unauthenticated calls now get a 401 pointing at the metadata document; authenticated ones reach the tool. - http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) - http.Handle("/mcp", adapter.AuthMiddleware(handler)) +## Next steps - http.ListenAndServe(":8080", nil) -} -``` +- [Scope-gated tools](docs/user-guide.md#43-enforce-scope-inside-tool-handlers) — `Options.Scopes` is only advertised in the metadata document; the middleware accepts any valid token for the resource. Gate individual tools with `ClaimsFromContext(ctx).RequireScope(...)`. +- [Token exchange and consent](docs/user-guide.md#7-token-exchange-and-url-elicitation) — RFC 8693 exchange bridged to MCP URL elicitation. +- [Verified JWT claims](docs/user-guide.md#6-main-api-reference) — read `ClaimsFromContext` / `TokenFromContext` inside a tool. +- [Serving Protected Resource Metadata](docs/user-guide.md#42-mount-the-handlers) — the RFC 9728 document and its well-known path. -See the **[User Guide](docs/user-guide.md)** for the full API, per-tool scope enforcement, revocation checking, token exchange with URL elicitation, dev mode, and lifecycle details. +See the **[User Guide](docs/user-guide.md)** for the full API, revocation checking, DPoP, dev mode, and lifecycle details. diff --git a/mcp/docs/user-guide.md b/mcp/docs/user-guide.md index 07399bc..51c0abc 100644 --- a/mcp/docs/user-guide.md +++ b/mcp/docs/user-guide.md @@ -57,7 +57,7 @@ func main() { 1. `authplane.NewClient` performs RFC 8414 AS metadata discovery. 2. `client.Resource(uri, resource.WithScopes(...))` builds the resource (the JWKS cache is warmed and background refresh starts). -3. If `ClientOptions` includes `WithClientCredentials` or `WithClientAuthentication`, RFC 7662 introspection is auto-wired as the revocation checker, and `TokenExchange` becomes operational. +3. If `ClientOptions` includes `WithClientCredentials` or `WithClientAuthentication`, RFC 7662 introspection is auto-wired as the revocation checker, and `TokenExchange` becomes operational. The credentials must belong to a confidential client that is the issuing client or a runtime-client of the Resource — see §8. `AuthMiddleware` delegates token extraction to the MCP Go SDK's `auth.RequireBearerToken`, which places `auth.TokenInfo` into the request context (MCP's streamable transport reads it for session binding). The adapter then runs the core verifier, stores the resulting claims in a per-request box, and injects `*verifier.VerifiedClaims` into the context for tool handlers. @@ -122,9 +122,31 @@ mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "Add two numbers"}, | `DevMode` | `bool` | no | Relaxes SSRF to allow HTTP, localhost, private networks. Also enabled if `AUTHPLANE_DEV_MODE=1`. Remove before production. | | `ClientOptions` | `[]authplane.Option` | no | SDK-level options: `WithClientCredentials`, `WithClientAuthentication`, `WithJWKSCacheTTL`, `WithCircuitBreaker`, `WithDPoP`, etc. | | `VerifierOptions` | `[]verifier.Option` | no | Verifier-level options: `WithAlgorithms`, `WithClockSkew`, `WithRevocationChecker`, `WithFailClosed`. | +| `ResourceMetadataURL` | `string` | no | Overrides the URL advertised in `resource_metadata`. Empty advertises the document this adapter serves. See §5.1. | `VerifierOptions` **replaces** the verifier option list set by `client.Resource`. When `ClientOptions` supplies credentials, the SDK auto-wires an introspection-backed revocation checker — if you also pass `VerifierOptions`, include `verifier.WithRevocationChecker(...)` (or `NullRevocationChecker`) explicitly if you want to keep, replace, or disable it. +### 5.1 Where the PRM document lives + +RFC 9728 admits two topologies, and the adapter serves both. + +**(a) Resource-hosted — the default.** The SDK derives `/.well-known/oauth-protected-resource[/path]` from `Options.Resource`, `ProtectedResourceMetadataHandler()` serves the document, and the 401 challenge advertises that URL. Nothing to configure. + +**(b) AS-hosted.** authserver 0.2.0 and later publishes a document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the RFC 9728 §3.1 path suffix of the Resource URI (or its slug). Point the challenge there when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that will not forward it: + +```go +adapter, err := authplanemcp.NewAdapter(ctx, authplanemcp.Options{ + Issuer: "https://auth.example.com", + Resource: "https://mcp.example.com/mcp", + Scopes: []string{"tools/query"}, + ResourceMetadataURL: "https://auth.example.com/.well-known/oauth-protected-resource/mcp", +}) +``` + +Only the advertisement moves. `WellKnownPRMPath()` and `ProtectedResourceMetadataHandler()` keep serving the derived route, so you can switch the pointer first and retire the local endpoint afterwards. The URL is validated at construction: absolute, `https` or `http`, no fragment, no userinfo. + +Whichever topology you use, RFC 9728 §3.3 pins the same constraint: the `resource` member inside the document must equal the URL clients call, byte for byte — a client must discard a document whose `resource` differs from the identifier it derived the request from. So the Resource URI registered at the authorization server, the identifier you configure here, and the public URL your server is reached on must be one and the same string, trailing slash and port included. + ## 6. Main API reference ### `NewAdapter(ctx context.Context, options Options) (*Adapter, error)` @@ -141,7 +163,7 @@ Constructs an adapter from an already-built client and resource. Use this when s Wraps an HTTP handler with bearer-token authentication. -- Rejects unauthenticated requests with 401 and a `WWW-Authenticate` header pointing to the PRM URL. +- Rejects unauthenticated requests with 401 and a `WWW-Authenticate` header pointing to the PRM URL and naming the resource's configured scopes (`Options.Scopes`) in `scope="…"`, per RFC 6750 §3 and the MCP authorization spec's SHOULD — e.g. `Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="tools/query tools/write"`. The `scope` param is omitted when `Options.Scopes` is empty. - On success, injects `*verifier.VerifiedClaims` and the raw token into the request context. - Internally uses `auth.RequireBearerToken` from the MCP Go SDK so that `auth.TokenInfo` is placed in the context for the streamable transport's session-binding protection. @@ -191,6 +213,22 @@ RFC 8693 token exchange frequently runs into an authorization-server response of The adapter bridges these two: `adapter.TokenExchange` catches the consent-required error and rewrites it as `mcp.URLElicitationRequiredError` so the MCP client does the right thing automatically. +**Operator step for cross-client exchanges.** For each MCP server that exchanges for a downstream resource it does not act as, allowlist the exchanging client on the target Resource: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token issued to itself, fronted exchanges and Broker resources need nothing. + +Two exchange errors look like consent problems but are not: + +- `access_denied` (HTTP 403, `authplane.ErrAccessDenied`) on a cross-client exchange means the operator has not allowlisted the exchanging client on the target Resource (`policy.exchange.allowed_client_ids` / `policy.runtime.client_ids`). Re-prompting the user will not fix it — unlike `consent_required`, which the user resolves. +- `invalid_target` (HTTP 400, `authplane.ErrInvalidTarget`, RFC 8707 §2.2) means the `resource` string does not match a granted resource exactly — byte for byte, a trailing slash counts. + +Neither counts toward the circuit breaker: the AS answered, it just said no. + ### 7.1 Automatic mapping ```go @@ -224,7 +262,7 @@ The generated `URLElicitationRequiredError` carries: - The AS `error_description` as the prompt message (falling back to `"Consent is required to proceed"` when empty). - A newly minted elicitation ID so the MCP client can correlate the completion event. -If the AS does not provide a `ConsentURL`, the original `*authplane.ConsentRequiredError` is returned unchanged — the tool handler decides how to proceed (abort, fall back to a static message, etc.). +If the AS does not provide a `ConsentURL`, the original `*authplane.ConsentRequiredError` is returned unchanged — the tool handler decides how to proceed (abort, fall back to a static message, etc.). `access_denied` and `invalid_target` are never mapped to an elicitation: there is nothing for the user to do. ### 7.2 Custom consent handling @@ -248,6 +286,14 @@ URL elicitation is an MCP-protocol concept. The `http` adapter has **no equivale When credentials are supplied in `ClientOptions`, the SDK auto-wires RFC 7662 introspection as the revocation checker. Every successful JWT verification triggers an introspection round-trip; the token is rejected if the AS reports `active: false`. +The introspecting client must be **confidential** (client ID and secret) **and** either the client the token was issued to or a runtime-client of the Resource named in the token's `aud`. authserver ≥ 0.1.2 answers `{"active": false}` to anyone else — a public (secret-less) client cannot introspect at all, and a resource server introspecting with the wrong client rejects every token as revoked. Register the resource server as a runtime-client of its Resource: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +When introspection answers `active: false` for a token that already passed local JWT verification, the SDK logs one warning per resource pointing at this requirement. The warning is written to `slog.Default()`; install a handler with `slog.SetDefault` to route it into your own logging setup, or to silence it. + ```go adapter, err := authplanemcp.NewAdapter(ctx, authplanemcp.Options{ Issuer: "https://auth.example.com", @@ -352,6 +398,8 @@ When calling `adapter.Client()` operations directly (e.g. `Revoke`, `Introspect` | `ErrProtocolError` | Malformed response from AS. | | `ErrConsentRequired` | User consent required — prefer `*ConsentRequiredError` for the URL. | | `ErrInteractionRequired` | User interaction required. | +| `ErrAccessDenied` | Cross-client exchange refused (403): the exchanging client is not allowlisted on the target Resource. Operator fix, not a consent prompt. | +| `ErrInvalidTarget` | `resource` does not match a granted resource byte for byte (RFC 8707 §2.2). | | `ErrUseDPoPNonce` | AS returned a DPoP nonce; the client auto-retries with the nonce. | The full verifier error list (signature, claims, DPoP, etc.) lives in the [core user guide](../../core/docs/user-guide.md). diff --git a/mcp/internal/httputil/scope_hint.go b/mcp/internal/httputil/scope_hint.go new file mode 100644 index 0000000..afe166b --- /dev/null +++ b/mcp/internal/httputil/scope_hint.go @@ -0,0 +1,87 @@ +package httputil + +import ( + "net/http" + "regexp" + "strings" +) + +// WWWAuthenticateScopeHint wraps an http.ResponseWriter to append +// `scope="..."` to the WWW-Authenticate header of a 401 response (RFC 6750 +// §3). The MCP authorization spec says the server SHOULD name the scopes to +// request on the first challenge; the MCP go-sdk's auth.RequireBearerToken +// writes only `Bearer resource_metadata=...` and offers no hook for further +// params, so the header is amended here before the status line goes out. +// +// Scope is the space-separated list to advertise; when empty the writer is a +// pass-through. Only a 401 is amended — a 403 names route-specific scopes. +// +// The wrapper sits in front of the whole handler chain, so it also sees 401s +// written by the wrapped application. Those are left alone: an amended +// challenge is only correct for one this package knows the shape of, so the +// header must already be present and carry the Bearer scheme. +// +// http.Flusher is forwarded so that SSE streaming used by the MCP streamable +// transport continues to work correctly. +type WWWAuthenticateScopeHint struct { + http.ResponseWriter + Scope string +} + +// WriteHeader appends the scope param to a 401's existing Bearer +// WWW-Authenticate header before writing the status line. The separator is a +// space when the challenge carries no auth-param yet and `, ` otherwise +// (RFC 9110 §11.1). A missing header or a non-Bearer scheme passes through: +// synthesizing a Bearer challenge here would advertise one without +// resource_metadata, and appending to another scheme is meaningless. +func (w *WWWAuthenticateScopeHint) WriteHeader(code int) { + if code == http.StatusUnauthorized && w.Scope != "" { + h := w.Header() + if v := h.Get("WWW-Authenticate"); isBearerChallenge(v) { + sep := " " + if strings.Contains(v, "=") { + sep = ", " + } + h.Set("WWW-Authenticate", v+sep+`scope="`+sanitizeParamValue(w.Scope)+`"`) + } + } + w.ResponseWriter.WriteHeader(code) +} + +// isBearerChallenge reports whether v is a WWW-Authenticate value whose +// auth-scheme is Bearer. RFC 9110 §11.1 makes the scheme case-insensitive. +func isBearerChallenge(v string) bool { + if v == "" { + return false + } + scheme, _, _ := strings.Cut(v, " ") + return strings.EqualFold(scheme, "Bearer") +} + +// sanitizeParamValue makes a value safe to splice into a WWW-Authenticate +// quoted-string (RFC 9110 §5.6.4): CR, LF, `"` and `\` are each replaced by a +// space, and the result is trimmed. Substitution rather than deletion is what +// keeps `a"b` two scope tokens instead of silently fusing it into one — none +// of these octets is legal in a scope-token (RFC 6749 §3.3) or in the URI +// derived for resource_metadata, so no valid value is altered. +func sanitizeParamValue(v string) string { + return strings.TrimSpace(paramValueSanitizer.ReplaceAllString(v, " ")) +} + +// A run collapses to one space: `\"` is one offense, not two, and should not +// widen the value by an extra space. +var paramValueSanitizer = regexp.MustCompile(`[\r\n"\\]+`) + +// Flush forwards to the underlying ResponseWriter's Flusher if available. +// Required for SSE streaming used by the MCP streamable HTTP transport. +func (w *WWWAuthenticateScopeHint) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap returns the wrapped ResponseWriter so that http.ResponseController +// can reach the real writer for SetWriteDeadline, Hijack and friends. +func (w *WWWAuthenticateScopeHint) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/mcp/internal/httputil/scope_hint_test.go b/mcp/internal/httputil/scope_hint_test.go new file mode 100644 index 0000000..549538d --- /dev/null +++ b/mcp/internal/httputil/scope_hint_test.go @@ -0,0 +1,121 @@ +package httputil + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestWWWAuthenticateScopeHint pins the separator and gating of the scope +// hint: appended with `, ` after an existing param, with a space after a bare +// scheme, and left alone on any status other than 401, when no scope is +// configured, when upstream wrote no challenge at all, or when the challenge +// belongs to another auth-scheme. +func TestWWWAuthenticateScopeHint(t *testing.T) { + const prm = `Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource/mcp"` + tests := []struct { + name string + scope string + code int + existing string + want string + }{ + { + name: "appended after resource_metadata", + scope: "tools/add tools/multiply", + code: http.StatusUnauthorized, + existing: prm, + want: prm + `, scope="tools/add tools/multiply"`, + }, + { + name: "space after bare scheme", + scope: "tools/add", + code: http.StatusUnauthorized, + existing: "Bearer", + want: `Bearer scope="tools/add"`, + }, + { + name: "no challenge from upstream is a pass-through", + scope: "tools/add", + code: http.StatusUnauthorized, + want: "", + }, + { + name: "another auth-scheme is a pass-through", + scope: "tools/add", + code: http.StatusUnauthorized, + existing: `Basic realm="admin"`, + want: `Basic realm="admin"`, + }, + { + name: "scheme match is case-insensitive", + scope: "tools/add", + code: http.StatusUnauthorized, + existing: "bearer", + want: `bearer scope="tools/add"`, + }, + { + // Substituted, not deleted, and a run collapses to one space, so + // `a"b` stays two scope tokens rather than fusing into one. + name: "quoted-string octets become a space in the scope", + scope: `tools/add" x=\"y`, + code: http.StatusUnauthorized, + existing: prm, + want: prm + `, scope="tools/add x= y"`, + }, + { + name: "CR and LF cannot split the header", + scope: "tools/add\r\nX-Injected: 1", + code: http.StatusUnauthorized, + existing: prm, + want: prm + `, scope="tools/add X-Injected: 1"`, + }, + { + name: "surrounding offenses are trimmed, not left as spaces", + scope: `"tools/add"`, + code: http.StatusUnauthorized, + existing: prm, + want: prm + `, scope="tools/add"`, + }, + { + name: "403 is left alone", + scope: "tools/add", + code: http.StatusForbidden, + existing: prm, + want: prm, + }, + { + name: "empty scope is a pass-through", + code: http.StatusUnauthorized, + existing: prm, + want: prm, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + w := &WWWAuthenticateScopeHint{ResponseWriter: rec, Scope: tt.scope} + if tt.existing != "" { + w.Header().Set("WWW-Authenticate", tt.existing) + } + w.WriteHeader(tt.code) + if got := rec.Header().Get("WWW-Authenticate"); got != tt.want { + t.Errorf("WWW-Authenticate = %q, want %q", got, tt.want) + } + if rec.Code != tt.code { + t.Errorf("status = %d, want %d", rec.Code, tt.code) + } + }) + } +} + +// TestWWWAuthenticateScopeHintFlush verifies Flush is forwarded so SSE +// streaming keeps working through the wrapper. +func TestWWWAuthenticateScopeHintFlush(t *testing.T) { + rec := httptest.NewRecorder() + w := &WWWAuthenticateScopeHint{ResponseWriter: rec, Scope: "tools/add"} + w.Flush() + if !rec.Flushed { + t.Error("Flush was not forwarded to the underlying ResponseWriter") + } +} diff --git a/mcp/internal/httputil/www_authenticate.go b/mcp/internal/httputil/www_authenticate.go index 79bd879..11db78c 100644 --- a/mcp/internal/httputil/www_authenticate.go +++ b/mcp/internal/httputil/www_authenticate.go @@ -38,6 +38,12 @@ func (w *WWWAuthenticateQuoter) Flush() { } } +// Unwrap returns the wrapped ResponseWriter so that http.ResponseController +// can reach the real writer for SetWriteDeadline, Hijack and friends. +func (w *WWWAuthenticateQuoter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + // QuoteWWWAuthenticateParams ensures every key=value param in a WWW-Authenticate // header value is quoted if the value is not a valid HTTP token. // For example: Bearer resource_metadata=http://x → Bearer resource_metadata="http://x" diff --git a/mcp/pkg/authplanemcp/adapter.go b/mcp/pkg/authplanemcp/adapter.go index 8b7ea90..b02dcda 100644 --- a/mcp/pkg/authplanemcp/adapter.go +++ b/mcp/pkg/authplanemcp/adapter.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/authplane/go-sdk/core/authplane" @@ -42,6 +43,14 @@ type Options struct { Resource string Scopes []string + // ResourceMetadataURL overrides the URL advertised in the RFC 9728 §5.1 + // resource_metadata parameter of the WWW-Authenticate challenge. Leave it + // empty to advertise the document this adapter serves itself; set it to the + // authorization server's copy ("/.well-known/oauth-protected-resource/{ref}") + // when the resource server cannot host well-known paths. Rejected at + // construction if it is not an absolute http(s) URL. + ResourceMetadataURL string + // DevMode relaxes SSRF protection to allow HTTP and localhost — required when // the issuer runs on a local development server. Remove before deploying to production. // The SDK also checks the AUTHPLANE_DEV_MODE=1 env var as a fallback. @@ -67,10 +76,15 @@ type Options struct { // Always call Close() when the adapter is no longer needed to stop background // refresh goroutines and release HTTP connections. type Adapter struct { - client *authplane.Client - resource *resource.Resource - prmURL string // full URL for WWW-Authenticate ResourceMetadataURL - ownsClient bool // true when this Adapter constructed the client and must close it + client *authplane.Client + resource *resource.Resource + // resourceMetadataURL is the full URL handed to the MCP go-sdk's + // RequireBearerTokenOptions.ResourceMetadataURL — the derived PRM URL, or + // the override from resource.WithResourceMetadataURL when the document is + // hosted elsewhere (typically by the authorization server). + resourceMetadataURL string + scopeHint string // space-joined resource scopes advertised in the 401 scope param (RFC 6750 §3); empty when none configured + ownsClient bool // true when this Adapter constructed the client and must close it } // NewAdapter creates and initializes an Adapter. It calls authplane.NewClient, @@ -97,6 +111,9 @@ func NewAdapter(ctx context.Context, options Options) (*Adapter, error) { } resourceOpts := []resource.Option{resource.WithScopes(options.Scopes...)} + if options.ResourceMetadataURL != "" { + resourceOpts = append(resourceOpts, resource.WithResourceMetadataURL(options.ResourceMetadataURL)) + } if len(options.VerifierOptions) > 0 { // Only pass WithVerifierOptions when non-empty: WithVerifierOptions replaces // (not appends) the verifier option list, so passing an empty slice would @@ -111,10 +128,11 @@ func NewAdapter(ctx context.Context, options Options) (*Adapter, error) { } return &Adapter{ - client: client, - resource: res, - prmURL: res.PRMURL(), - ownsClient: true, + client: client, + resource: res, + resourceMetadataURL: res.ResourceMetadataURL(), + scopeHint: strings.Join(res.PRMConfig().ScopesSupported, " "), + ownsClient: true, }, nil } @@ -139,10 +157,11 @@ func NewAdapterFromClientAndResource(client *authplane.Client, res *resource.Res return nil, errors.New("authplane-mcp: res must not be nil") } return &Adapter{ - client: client, - resource: res, - prmURL: res.PRMURL(), - ownsClient: false, + client: client, + resource: res, + resourceMetadataURL: res.ResourceMetadataURL(), + scopeHint: strings.Join(res.PRMConfig().ScopesSupported, " "), + ownsClient: false, }, nil } @@ -157,6 +176,12 @@ func NewAdapterFromClientAndResource(client *authplane.Client, res *resource.Res // and causes MCP clients to fail discovery. httputil.WWWAuthenticateQuoter // intercepts the header and adds the required quotes before it reaches the client. // +// The MCP go-sdk also writes no `scope` param, and RequireBearerTokenOptions +// has no field for one — its Scopes field only enforces (403), it never +// advertises. httputil.WWWAuthenticateScopeHint appends `scope="..."` with the +// resource's configured scopes to every 401 (RFC 6750 §3; the MCP +// authorization spec says the server SHOULD name them on the first challenge). +// // On success the verified claims are also injected into the request context and are // accessible via ClaimsFromContext — allowing individual tool handlers to perform // fine-grained per-tool scope checks. A per-request claimsBox is used to pass @@ -179,15 +204,19 @@ func (a *Adapter) AuthMiddleware(handler http.Handler) http.Handler { // scope via ClaimsFromContext + RequireScope. The initialize handshake and // other protocol messages must succeed with any valid token. mux := auth.RequireBearerToken(a.verifyToken, &auth.RequireBearerTokenOptions{ - ResourceMetadataURL: a.prmURL, + ResourceMetadataURL: a.resourceMetadataURL, })(inner) // Wrap with httputil.WWWAuthenticateQuoter to ensure the WWW-Authenticate header - // emitted by the MCP go-sdk is RFC 6750 §3.1 compliant. + // emitted by the MCP go-sdk is RFC 6750 §3.1 compliant, then with + // WWWAuthenticateScopeHint to add the scope param. The quoter runs first + // (outermost), so it only ever sees the bare upstream value; the hint it + // hands down is already a quoted-string. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Inject an empty claimsBox into context for verifyToken to populate. ctx := context.WithValue(r.Context(), claimsBoxKey{}, &claimsBox{}) - mux.ServeHTTP(&httputil.WWWAuthenticateQuoter{ResponseWriter: w}, r.WithContext(ctx)) + hinted := &httputil.WWWAuthenticateScopeHint{ResponseWriter: w, Scope: a.scopeHint} + mux.ServeHTTP(&httputil.WWWAuthenticateQuoter{ResponseWriter: hinted}, r.WithContext(ctx)) }) } diff --git a/mcp/pkg/authplanemcp/adapter_test.go b/mcp/pkg/authplanemcp/adapter_test.go index 98ba013..bcec985 100644 --- a/mcp/pkg/authplanemcp/adapter_test.go +++ b/mcp/pkg/authplanemcp/adapter_test.go @@ -59,6 +59,72 @@ func TestAuthMiddlewareInvalidTokenReturns401(t *testing.T) { } } +// Scope hint on 401 (RFC 6750 §3; MCP authorization spec: the server SHOULD +// name the scopes to request on the first challenge). The MCP go-sdk's +// RequireBearerToken writes only resource_metadata, so the adapter appends +// the param itself. The resource in newTestEnv is configured with +// "tools/add" and "tools/multiply". + +// TestAuthMiddlewareNoTokenCarriesScopeHint pins the exact no-token challenge: +// quoted resource_metadata first, then scope, comma-separated. +func TestAuthMiddlewareNoTokenCarriesScopeHint(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestAuthMiddlewareInvalidTokenCarriesScopeHint covers the 401 produced by a +// failed verification: same challenge shape, scope still present. +func TestAuthMiddlewareInvalidTokenCarriesScopeHint(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer not.a.valid.jwt") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestAuthMiddlewareNoConfiguredScopesOmitsScopeHint: a resource with no +// scopes has nothing to hint, and RFC 6750 §3 forbids an empty scope value, +// so the challenge is byte-identical to the pre-hint shape. +func TestAuthMiddlewareNoConfiguredScopesOmitsScopeHint(t *testing.T) { + e := newTestEnvWithScopes(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + // TestAuthMiddlewareNoScopeEnforcement verifies that AuthMiddleware does NOT // reject tokens based on scope. A valid token with no scopes must be passed // through to the inner handler — scope enforcement is the tool handler's job. @@ -361,3 +427,67 @@ func unmarshalElicitations(t *testing.T, data json.RawMessage) []*mcp.ElicitPara } return payload.Elicitations } + +// resource_metadata override. + +const asHostedPRMURL = "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + +// TestAuthMiddlewareResourceMetadataOverride pins that Options.ResourceMetadataURL +// reaches the challenge. The MCP go-sdk composes this header itself, from +// RequireBearerTokenOptions.ResourceMetadataURL, so the override has to travel +// through that upstream field rather than being appended locally — which is why +// the assertion is on the emitted header and not on a stored value. +func TestAuthMiddlewareResourceMetadataOverride(t *testing.T) { + e := newTestEnvWithMetadataURL(t, asHostedPRMURL) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + asHostedPRMURL + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestAuthMiddlewareResourceMetadataDefault: with no override the challenge +// carries the derived PRM URL, unchanged from before the option existed. +func TestAuthMiddlewareResourceMetadataDefault(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + got := rec.Header().Get("Www-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.Resource().PRMURL() + `", scope="tools/add tools/multiply"` + if got != want { + t.Errorf("WWW-Authenticate = %q, want %q", got, want) + } +} + +// TestNewAdapterRejectsInvalidResourceMetadataURL: the core gate fires through +// the adapter constructor, so a bad value fails at startup rather than on the +// first 401. +func TestNewAdapterRejectsInvalidResourceMetadataURL(t *testing.T) { + // Reuse the mock AS from an existing env so discovery succeeds and the + // construction reaches the resource-level gate. + e := newTestEnv(t) + _, err := authplanemcp.NewAdapter(t.Context(), authplanemcp.Options{ + Issuer: e.issuer, + Resource: testResource, + Scopes: []string{"tools/add"}, + DevMode: true, + ResourceMetadataURL: "/.well-known/oauth-protected-resource/mcp", + }) + if err == nil { + t.Fatal("NewAdapter accepted a relative resource metadata URL") + } + if !strings.Contains(err.Error(), "resource metadata URL") { + t.Errorf("error = %q, want it to name the resource metadata URL", err) + } +} diff --git a/mcp/pkg/authplanemcp/helpers_test.go b/mcp/pkg/authplanemcp/helpers_test.go index 5159cfd..53aa0ec 100644 --- a/mcp/pkg/authplanemcp/helpers_test.go +++ b/mcp/pkg/authplanemcp/helpers_test.go @@ -39,6 +39,26 @@ type testEnv struct { // when the test completes. func newTestEnv(t *testing.T) *testEnv { t.Helper() + return newTestEnvWithScopes(t, "tools/add", "tools/multiply") +} + +// newTestEnvWithMetadataURL is newTestEnv with Options.ResourceMetadataURL set; +// an empty value leaves the adapter on the derived PRM URL. +func newTestEnvWithMetadataURL(t *testing.T, resourceMetadataURL string) *testEnv { + t.Helper() + return newTestEnvWith(t, resourceMetadataURL, "tools/add", "tools/multiply") +} + +// newTestEnvWithScopes is newTestEnv with the resource's supported scopes +// chosen by the caller; pass none for a resource that advertises no scopes. +func newTestEnvWithScopes(t *testing.T, scopes ...string) *testEnv { + t.Helper() + return newTestEnvWith(t, "", scopes...) +} + +// newTestEnvWith is the base the three constructors above delegate to. +func newTestEnvWith(t *testing.T, resourceMetadataURL string, scopes ...string) *testEnv { + t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -76,8 +96,10 @@ func newTestEnv(t *testing.T) *testEnv { adapter, err := authplanemcp.NewAdapter(context.Background(), authplanemcp.Options{ Issuer: srv.URL, Resource: testResource, - Scopes: []string{"tools/add", "tools/multiply"}, + Scopes: scopes, DevMode: true, // allow HTTP + localhost in tests + + ResourceMetadataURL: resourceMetadataURL, }) if err != nil { t.Fatalf("NewAdapter: %v", err) diff --git a/scripts/manual-e2e-setup.sh b/scripts/manual-e2e-setup.sh index 93f5a69..e646dfe 100755 --- a/scripts/manual-e2e-setup.sh +++ b/scripts/manual-e2e-setup.sh @@ -12,6 +12,8 @@ Usage: Environment (optional): AUTHSERVER_DIR Path to local authserver repo (default: ../authserver) + AUTHSERVER_REF Git ref of authserver to check out before building + (default: leave the checkout as is) EOF } @@ -25,13 +27,19 @@ if [ ! -d "${AUTHSERVER_DIR}" ]; then exit 1 fi -echo "==> Starting authserver demo server (client_credentials enabled)" +echo "==> Starting authserver demo server" ( cd "${AUTHSERVER_DIR}" + if [ -n "${AUTHSERVER_REF:-}" ]; then + echo "==> Checking out authserver ${AUTHSERVER_REF}" + git fetch --tags origin || echo "WARN: fetch failed, resolving ${AUTHSERVER_REF} from local refs" >&2 + git checkout "${AUTHSERVER_REF}" + rm -f bin/authserver + fi if [ ! -x "bin/authserver" ]; then go build -o bin/authserver ./cmd/authserver fi - AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true ./demo/mcp-demo-server-start.sh + ./demo/mcp-demo-server-start.sh ) echo ""