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..752b08a --- /dev/null +++ b/.github/scripts/conformance-case-body-drift.test.sh @@ -0,0 +1,737 @@ +#!/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 the .NET SDK — +# 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 the marker scan 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 the marker scan 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 +# --------------------------------------------------------------------------- +# +# Registration in this repo is a [Conformance] attribute, read by reflection over +# the compiled attributes and emitted to a scan file per test assembly. These +# fixtures are hand-written JSON rather than a real scan: the point is what the +# reader does with each shape, and producing one for real would make these +# controls depend on the .NET SDK, on the catalog clone, and on the suite +# building. +# +# The scan carries one entry per marker OCCURRENCE and no per-entry registration +# flag — every entry is a marker the scan found. What the reader has to get right +# is therefore different from a run report: deduplicate across tests and across +# assemblies, accept an assembly that legitimately declares nothing, and refuse +# anything that is not the emitter's documented shape. + +run_ids() { + rc=0 + out="$(CONFORMANCE_MARKER_SCAN_DIR="$1" "$IDSCRIPT" 2>&1)" || rc=$? +} + +# Writes a scan file for assembly $2 into directory $1. Remaining args are +# `case_id=declared_by` pairs; none means an assembly that declares no markers. +write_scan() { + local dir="$1" assembly="$2"; shift 2 + mkdir -p "$dir" + { + printf '{\n "assembly": "%s",\n "cases": [\n' "$assembly" + local first=1 pair + for pair in "$@"; do + [[ "$first" -eq 1 ]] || printf ',\n' + first=0 + printf ' {"case_id": "%s", "declared_by": "%s"}' "${pair%%=*}" "${pair#*=}" + done + [[ "$first" -eq 1 ]] || printf '\n' + printf ' ]\n}\n' + } > "$dir/$assembly.json" +} + +# --- every marker counts, deduplicated across tests and assemblies -------------- +# One entry per marker occurrence, so a case claimed by two tests arrives twice +# and a case claimed in two assemblies arrives from two files. Emitting it twice +# would put a duplicate into the id list, where the generic script would compare +# the same body twice and report a case count that does not match the catalog. +# +# Nothing keys on a test's outcome, and there is nothing in the scan to key on: +# the scan is of the compiled attributes, not of a run. That is the intended +# reading — a registered case whose test failed or was skipped is still +# registered, and still needs its body watched. +t_ids_dedupes_across_tests_and_assemblies() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + write_scan "$root" Authplane.Tests \ + "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache=Authplane.Tests.JwksTests.Rotates" \ + "rfc7009-revocation-server-errors-must-surface=Authplane.Tests.RevocationTests.Accepts" \ + "rfc7009-revocation-server-errors-must-surface=Authplane.Tests.RevocationTests.Rejects" + write_scan "$root" Authplane.Mcp.Tests \ + "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache=Authplane.Mcp.Tests.MiddlewareTests.Rotates" + + local out rc + run_ids "$root" + if [[ "$rc" -ne 0 ]]; then + fail "markers dedupe across tests and assemblies" "exit $rc, want 0: ${out##*$'\n'}" + elif [[ "$out" != "rfc7009-revocation-server-errors-must-surface +rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache" ]]; then + fail "markers dedupe across tests and assemblies" "printed: ${out//$'\n'/, }" + else + pass "a case claimed by several tests, in several assemblies, is printed once" + fi +} + +# --- an assembly that declares no markers is a legitimate state ------------------ +# The MCP adapter test assembly is in it today. It still writes a scan file, so +# the reader can tell a scan that ran and found nothing from a scan that never +# ran — and only one of those is allowed to pass. +t_ids_empty_assembly_is_allowed() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + write_scan "$root" Authplane.Tests \ + "rfc7009-revocation-server-errors-must-surface=Authplane.Tests.RevocationTests.Accepts" + write_scan "$root" Authplane.Mcp.Tests + + local out rc + run_ids "$root" + if [[ "$rc" -ne 0 ]]; then + fail "an assembly with no markers is allowed" "exit $rc, want 0: ${out##*$'\n'}" + elif [[ "$out" != "rfc7009-revocation-server-errors-must-surface" ]]; then + fail "an assembly with no markers is allowed" "printed: ${out//$'\n'/, }" + else + pass "an assembly that declares no markers contributes nothing and fails nothing" + fi +} + +# --- but no marker anywhere is not ----------------------------------------------- +# Every scan empty: the markers were dropped, or the emitter ran against the wrong +# assemblies. An empty list downstream is a vacuously green drift check, which is +# the failure the whole check exists to prevent. +t_ids_nothing_registered() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + write_scan "$root" Authplane.Tests + write_scan "$root" Authplane.Mcp.Tests + + local out rc + run_ids "$root" + if [[ "$rc" -ne 1 ]]; then + fail "a scan with no marker at all fails" "exit $rc, want 1" + elif ! grep -q "record no \[Conformance\] marker at all" <<<"$out"; then + fail "a scan with no marker at all fails" "unexpected message: ${out##*$'\n'}" + else + pass "scan files that between them hold no marker fail" + fi +} + +# --- a scan directory with no scan file in it ------------------------------------ +# The workflow deletes the directory before the run that writes it, so "the tests +# did not run" is a state that really occurs. It must not read as "nothing +# registered, carry on" — and it must not read as "nothing to do" either, which +# is what an empty glob would silently become. +t_ids_no_scan_file() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + + local out rc + run_ids "$root" + if [[ "$rc" -ne 1 ]]; then + fail "an empty scan directory fails" "exit $rc, want 1" + elif ! grep -q "holds no scan file" <<<"$out"; then + fail "an empty scan directory fails" "unexpected message: ${out##*$'\n'}" + else + pass "a scan directory holding no scan file fails" + fi + + run_ids "$root/absent" + if [[ "$rc" -ne 1 ]] || ! grep -q "is not a directory" <<<"$out"; then + fail "a missing scan directory fails" "exit $rc: ${out##*$'\n'}" + else + pass "a scan directory that does not exist fails, naming the path" + fi +} + +# --- an entry missing a field is the emitter's contract having changed ----------- +# Not a case that happens not to be registered. It would drop out of the read +# without a word, so the list would be short by one with nothing to show for it — +# and short is the direction that makes this check quietly stop guarding a case. +t_ids_malformed_entries() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + + local out rc + + mkdir -p "$root/a" + cat > "$root/a/Authplane.Tests.json" <<'JSON' +{ + "assembly": "Authplane.Tests", + "cases": [ + {"case_id": "rfc7009-revocation-server-errors-must-surface", "declared_by": "Authplane.Tests.RevocationTests.Accepts"}, + {"declared_by": "Authplane.Tests.RevocationTests.Rejects"} + ] +} +JSON + run_ids "$root/a" + if [[ "$rc" -ne 1 ]] || ! grep -q "missing or non-string case_id or declared_by" <<<"$out"; then + fail "an entry with no case_id fails" "exit $rc: ${out##*$'\n'}" + else + pass "a scan entry without a case_id fails instead of being dropped" + fi + + mkdir -p "$root/b" + cat > "$root/b/Authplane.Tests.json" <<'JSON' +{ + "assembly": "Authplane.Tests", + "cases": [ + {"case_id": "rfc7009-revocation-server-errors-must-surface", "declared_by": ""} + ] +} +JSON + run_ids "$root/b" + if [[ "$rc" -ne 1 ]] || ! grep -q "missing or non-string case_id or declared_by" <<<"$out"; then + fail "an entry with no declared_by fails" "exit $rc: ${out##*$'\n'}" + else + pass "a scan entry without a declaring method fails" + fi +} + +# --- a file that is not a marker scan --------------------------------------------- +# A stray JSON file in the directory, or the emitter's shape having moved. Reading +# case ids out of it anyway would be guessing, and the guess that finds nothing is +# indistinguishable from an assembly that declares nothing. +t_ids_unusable_scans() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + + local out rc + + mkdir -p "$root/a" + echo 'not json at all' > "$root/a/Authplane.Tests.json" + run_ids "$root/a" + if [[ "$rc" -ne 1 ]] || ! grep -q "not valid JSON" <<<"$out"; then + fail "an unparseable scan fails" "exit $rc: ${out##*$'\n'}" + else + pass "a scan file that is not JSON fails" + fi + + mkdir -p "$root/b" + echo '{"cases": [{"case_id": "rfc7009-x", "declared_by": "T.M"}]}' > "$root/b/stray.json" + run_ids "$root/b" + if [[ "$rc" -ne 1 ]] || ! grep -q "does not name the assembly it scanned" <<<"$out"; then + fail "a file that names no assembly fails" "exit $rc: ${out##*$'\n'}" + else + pass "a JSON file that does not name the assembly it scanned fails" + fi + + mkdir -p "$root/c" + echo '{"assembly": "Authplane.Tests"}' > "$root/c/Authplane.Tests.json" + run_ids "$root/c" + if [[ "$rc" -ne 1 ]] || ! grep -q "has no .cases array" <<<"$out"; then + fail "a scan with no cases array fails" "exit $rc: ${out##*$'\n'}" + else + pass "a scan file with no cases array fails" + fi + + mkdir -p "$root/d" + : > "$root/d/Authplane.Tests.json" + run_ids "$root/d" + if [[ "$rc" -ne 1 ]] || ! grep -q "not a readable, non-empty file" <<<"$out"; then + fail "an empty scan file fails" "exit $rc: ${out##*$'\n'}" + else + pass "an empty scan file fails rather than contributing nothing" + 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 — marker scan reader" +t_ids_dedupes_across_tests_and_assemblies +t_ids_empty_assembly_is_allowed +t_ids_nothing_registered +t_ids_no_scan_file +t_ids_malformed_entries +t_ids_unusable_scans + +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..3f7249b --- /dev/null +++ b/.github/scripts/conformance-registered-case-ids.sh @@ -0,0 +1,111 @@ +#!/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 records registrations, so it lives here and conformance-case-body-drift.sh stays generic. +# +# Registration here is a [Conformance] attribute on a test method. There is no run-time record to +# read: nothing calls ConformanceTracker or ConformanceCaseRunner, so ConformanceRegistry is empty +# for the whole run and ConformanceReportWriter — which has no callers either — would render every +# catalog case as not_run. So the ids come from the marker scan instead, emitted by +# ConformanceMarkerScanWriter from ConformanceCatalogAlignment.ScanConformanceMarkers. +# +# That scan is reflection over the compiled attributes, not a match over the test sources. It reads +# what the runtime reads, it raises on an assembly whose types will not load rather than returning +# a short list, and — the part that matters — it is the same scan the catalog-alignment assertion +# is written against, asserted in both directions on every PR: every catalog case must carry a +# marker, and every marker must name a catalog case. A marker this extractor missed would surface +# there as an uncovered catalog case and turn the run red. There is no path by which the id list +# silently shortens. +# +# The scan carries one entry per marker OCCURRENCE, so a case claimed by more than one test appears +# more than once and this script deduplicates. `declared_by` is not filtered on: it is checked for +# presence, because an entry without it is not a case that happens not to be registered — it is the +# emitter's contract having changed under this script. Nothing keys on the test's outcome either. A +# registered case whose test failed or was skipped is still registered and still needs its body +# watched. +# +# An assembly that declares no markers emits a scan file with an empty case list. That is a +# legitimate state — the MCP adapter test assembly is in it today — so emptiness is rejected on the +# union rather than per file. The union being empty is a hard failure: an id list with nothing in +# it makes the drift check vacuously green, which is the failure it exists to prevent. +# +# Requires the alignment tests to have run under CONFORMANCE_MARKER_SCAN_DIR, so the scan on disk +# belongs to this commit. +# +# Inputs (environment): +# CONFORMANCE_MARKER_SCAN_DIR directory holding one .json scan file per test +# assembly (default: $GITHUB_WORKSPACE/conformance-marker-scan) +# +# Exit status: +# 0 ids printed on stdout +# 1 the scan is missing, unreadable, malformed, or holds no registered case + +set -euo pipefail + +SCAN_DIR="${CONFORMANCE_MARKER_SCAN_DIR:-${GITHUB_WORKSPACE:-.}/conformance-marker-scan}" + +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 marker scan cannot be read." +fi + +if [[ ! -d "$SCAN_DIR" ]]; then + fail "registered case ids: '$SCAN_DIR' is not a directory. The alignment tests write the marker scan there when CONFORMANCE_MARKER_SCAN_DIR is set, so either they did not run or they failed before the scan was written." +fi + +shopt -s nullglob +scans=("$SCAN_DIR"/*.json) +shopt -u nullglob + +if [[ "${#scans[@]}" -eq 0 ]]; then + fail "registered case ids: '$SCAN_DIR' holds no scan file. A scan that never ran is not a scan that found nothing — each test assembly writes a file even when it declares no markers." +fi + +ids="" + +for scan in "${scans[@]}"; do + if [[ ! -r "$scan" || ! -s "$scan" ]]; then + fail "registered case ids: '$scan' is not a readable, non-empty file." + fi + + if ! jq -e . "$scan" > /dev/null 2>&1; then + fail "registered case ids: '$scan' is not valid JSON." + fi + + # The emitter names the assembly it scanned. Its absence means the file is not the artifact this + # script is written against, and reading case ids out of it anyway would be guessing. + if ! jq -e '(.assembly | type) == "string" and (.assembly | length) > 0' "$scan" > /dev/null 2>&1; then + fail "registered case ids: '$scan' does not name the assembly it scanned; this is not a marker scan file." + fi + + if ! jq -e '(.cases | type) == "array"' "$scan" > /dev/null 2>&1; then + fail "registered case ids: '$scan' has no .cases array." + fi + + # An entry with a missing or empty field would drop out of the read below without a word, taking + # a real registration with it. + if ! jq -e 'all(.cases[]; + (.case_id | type) == "string" and (.case_id | length) > 0 and + (.declared_by | type) == "string" and (.declared_by | length) > 0)' \ + "$scan" > /dev/null 2>&1; then + fail "registered case ids: '$scan' holds an entry with a missing or non-string case_id or declared_by." + fi + + ids+="$(jq -r '.cases[] | .case_id' "$scan")"$'\n' +done + +# One entry per marker occurrence upstream, so the same id can arrive from two tests, or from two +# assemblies. Sorting unique here is what the consumer expects. +ids="$(printf '%s' "$ids" | grep -v '^$' | sort -u || true)" + +if [[ -z "$ids" ]]; then + fail "registered case ids: the scan files in '$SCAN_DIR' record no [Conformance] marker at all. 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 new file mode 100755 index 0000000..21f51f6 --- /dev/null +++ b/.github/scripts/fetch-conformance-catalog.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# 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 +# 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 +# adopting new catalog cases, together with the SDK-side 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, release.yml and conformance-catalog-drift.yml). Keeping +# it inline in each meant the guard could be tightened in one and not the others; +# 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 reach dotnet's source discovery or a +# coverage glob, and `git add -A` in the release commit must never stage it. +# +# Requires: GITHUB_WORKSPACE, RUNNER_TEMP. +# +# Optional: CONFORMANCE_CATALOG_DEST overrides the clone directory. The drift +# workflow needs the pinned catalog and the catalog tip side by side in the +# same job to compare case bodies, so it cannot let both land on the default +# path. Every other caller leaves it unset and gets $RUNNER_TEMP/conformance. + +set -euo pipefail + +: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE must be set}" +: "${RUNNER_TEMP:?RUNNER_TEMP must be set}" + +REF_FILE="$GITHUB_WORKSPACE/.conformance-catalog-ref" +DEST="${CONFORMANCE_CATALOG_DEST:-$RUNNER_TEMP/conformance}" +CATALOG_REPO="https://github.com/AuthPlane/conformance.git" + +if [[ ! -f "$REF_FILE" ]]; then + echo "::error::$REF_FILE is missing; the conformance catalog revision is unpinned" + exit 1 +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. +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 +fi + +git init -q "$DEST" +if ! git -C "$DEST" fetch --depth=1 "$CATALOG_REPO" "$CONFORMANCE_CATALOG_REF"; then + echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable" + exit 1 +fi +git -C "$DEST" checkout -q FETCH_HEAD + +echo "Conformance catalog checked out at $CONFORMANCE_CATALOG_REF in $DEST" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 060a6c1..acc8c6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,22 +23,16 @@ jobs: - name: Check out shared conformance catalog (out of tree) # Pinned by SHA (was: clone of the latest default branch). The single # source of truth for the ref is the tracked `.conformance-catalog-ref` - # file at the repo root, also read by release.yml — bump it when - # adopting new catalog cases, together with the SDK-side conformance - # coverage, so a catalog change can never break CI on its own. The - # Checkout step above must precede this read. - shell: bash - run: | - CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")" - # Guard the pin before fetching: a non-SHA value would silently - # un-pin CI to whatever ref resolves at fetch time. - grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \ - || { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; } - git init -q "$RUNNER_TEMP/conformance" - git -C "$RUNNER_TEMP/conformance" \ - fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \ - || { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; } - git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD + # file at the repo root, also read by release.yml and the drift + # workflow — bump it when adopting new catalog cases, together with the + # SDK-side conformance coverage, so a catalog change can never break CI + # on its own. The Checkout step above must precede this read. + # + # The read/guard/fetch sequence lives in a script because three + # workflows need it. Inline in each, the 40-hex guard could be + # tightened in one and not the others: the pin was single-sourced but + # the logic reading it was not. + run: .github/scripts/fetch-conformance-catalog.sh - name: Setup .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 diff --git a/.github/workflows/conformance-catalog-drift.yml b/.github/workflows/conformance-catalog-drift.yml index b9d1756..5ef013e 100644 --- a/.github/workflows/conformance-catalog-drift.yml +++ b/.github/workflows/conformance-catalog-drift.yml @@ -4,16 +4,31 @@ name: Conformance catalog drift # # PR and release CI pin the catalog to the SHA in `.conformance-catalog-ref`, so # a new catalog case can never break CI on its own. The trade-off is that new -# cases go unnoticed until someone bumps the ref. This job closes that gap: on a -# weekly schedule it checks out the catalog's *default* branch (latest, -# unpinned), points the harness at it, and runs the same alignment assertion -# (Authplane.Tests.ConformanceCatalogAlignmentTests) that PR CI runs against the -# pinned catalog. The only difference is which catalog it reads. +# cases go unnoticed until someone bumps the ref. This job closes that gap on a +# weekly schedule. # # This workflow has no `pull_request` trigger, so a failing scheduled run cannot # block a PR. It deliberately FAILS on drift, so the run turns red and the # `::warning::` plus job-summary note are not buried in an otherwise-green run — # prompting a coordinated ref bump alongside the SDK-side coverage. +# +# The job runs TWO checks, because they see different things: +# +# 1. Case-ID alignment against the tip. It checks out the catalog's *default* +# branch (latest, unpinned), points the harness at it, and runs the same +# alignment assertion (ConformanceCatalogAlignmentTests) that PR CI runs +# against the pinned catalog. It reports cases ADDED to the catalog that +# this SDK does not yet cover, and markers naming a case the catalog no +# longer has. This is a set comparison over ids. +# +# 2. Case-BODY drift for the cases this SDK registers. It 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: @@ -110,11 +125,97 @@ jobs: cat "$log" exit "$status" - # Runs even when the alignment step fails the job, so the `::warning::` and + # 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 ci.yml and release.yml use — including its + # 40-hex-SHA guard and its unreachable-ref failure — rather than a third + # copy of that logic that could be tightened in one place and not the + # others. 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 .NET` 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 alignment.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. Registration in this repo is a [Conformance] + # attribute, and the ids come from reflection over the compiled + # attributes — the same scan the alignment assertion above is written + # against — rather than from a match over the test sources, so the two + # cannot disagree about what registered. + # + # Run against the PINNED catalog, not the tip. The scan itself does not + # read the catalog, but the alignment assertion in the same test class + # does, and pointing it at the pin 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() + shell: bash + env: + CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance-pinned/oauth-sdk-conformance-catalog.yaml + CONFORMANCE_MARKER_SCAN_DIR: ${{ runner.temp }}/conformance-marker-scan + run: | + # Discard anything an earlier step left behind. If the run below fails + # to produce a new scan, the id script must find nothing rather than + # silently read a stale one and describe the wrong commit. + rm -rf "$CONFORMANCE_MARKER_SCAN_DIR" + + status=0 + for proj in tests/Authplane.Tests/Authplane.Tests.csproj \ + tests/Authplane.Mcp.Tests/Authplane.Mcp.Tests.csproj; do + dotnet test "$proj" \ + --configuration Release \ + --filter "FullyQualifiedName~ConformanceCatalogAlignmentTests" \ + -- RunConfiguration.TreatNoTestsAsError=true \ + >>"$RUNNER_TEMP/pinned-suite.log" 2>&1 || status=$? + done + 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 scan, never an older one. + echo "::warning::The alignment tests 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/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 + COVERAGE_DIR: "tests/" + run: | + DRIFT_SUMMARY="$GITHUB_STEP_SUMMARY" \ + .github/scripts/conformance-case-body-drift.sh + + # Runs even when an earlier step fails the job, so the `::warning::` and # the job summary are always written on drift. A `failure` outcome alone - # does not mean drift — the step also fails on a compile error or a NuGet - # restore failure. Real drift is identified by the marker the assertion - # writes into its message (ConformanceCatalogAlignment.DriftMarker); + # does not mean drift — the alignment step also fails on a compile error or + # a NuGet restore failure. Real drift is identified by the marker the + # assertion writes into its message (ConformanceCatalogAlignment.DriftMarker); # anything else is reported as an infrastructure problem, as are # skipped/cancelled outcomes, where an earlier step failed and the # alignment never ran. @@ -122,9 +223,23 @@ jobs: if: always() shell: bash run: | - pinned="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")" - grep -Eq '^[0-9a-f]{40}$' <<<"$pinned" \ - || { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; } + # Quoted for the reader only. The 40-hex guard on this value lives in + # fetch-conformance-catalog.sh, which this workflow now runs, so a + # malformed ref turns that step red with its own message instead of + # failing the reporting step and taking the drift verdict with it. + pinned="$(tr -d '[:space:]' < "$GITHUB_WORKSPACE/.conformance-catalog-ref")" + + # 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 + case "${{ steps.align.outcome }}" in success) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 187e9e8..966b2f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,19 +81,10 @@ jobs: # Pinned by SHA (was: clone of the latest default branch), from the same # tracked `.conformance-catalog-ref` that ci.yml reads. A release must # verify against the catalog the PRs were verified against, or a case - # added between merge and tag could fail the release run. - shell: bash - run: | - CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")" - # Guard the pin before fetching: a non-SHA value would silently - # un-pin the release to whatever ref resolves at fetch time. - grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \ - || { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; } - git init -q "$RUNNER_TEMP/conformance" - git -C "$RUNNER_TEMP/conformance" \ - fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \ - || { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; } - git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD + # added between merge and tag could fail the release run. Shared with + # ci.yml and the drift workflow so the pin guard cannot be tightened in + # one and not the others. + run: .github/scripts/fetch-conformance-catalog.sh - name: Setup .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 diff --git a/.github/workflows/workflows-lint.yml b/.github/workflows/workflows-lint.yml index dfadf36..200bdaa 100644 --- a/.github/workflows/workflows-lint.yml +++ b/.github/workflows/workflows-lint.yml @@ -2,18 +2,30 @@ name: Lint workflows # Catches workflow YAML / shell-in-`run:` regressions at PR time so a # typo can't reach a release tag and surface only when a publish run -# fails. Scoped to changes under `.github/workflows/**` to keep CI -# overhead off unrelated PRs. +# fails. Scoped to changes under `.github/workflows/**` and +# `.github/scripts/*.sh` to keep CI overhead off unrelated PRs. +# +# `.github/scripts/*.sh` is in scope because 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 trigger stays narrow: the added path +# matches PRs touching those scripts, not every PR touching +# `.github/**`. +# +# Linting alone would not be enough for them, so they carry their own +# tests here as well. on: pull_request: paths: - ".github/workflows/**" + - ".github/scripts/*.sh" push: branches: - main paths: - ".github/workflows/**" + - ".github/scripts/*.sh" permissions: contents: read @@ -62,3 +74,20 @@ jobs: # job fails loudly instead of silently degrading. - name: Run actionlint run: actionlint -color -shellcheck=shellcheck + + # actionlint's `-shellcheck` only reaches shell inside `run:` blocks. + # The standalone scripts those blocks invoke need their own pass. + - name: Shellcheck the workflow support scripts + run: shellcheck .github/scripts/*.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 the .NET SDK + # nor the catalog clone, so they run here. + - name: Test conformance-case-body-drift.sh + run: .github/scripts/conformance-case-body-drift.test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f671370..59d9259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,252 +9,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Resource-server-side DPoP nonce enforcement (RFC 9449 §9). Until now the - SDK handled nonces only outbound — `IDPoPNonceStore` remembers what an AS - issued to us as a client — so a resource server built on it could not - adopt the server-provided-nonce mitigation at all. `InboundDPoPOptions` - gains a `nonceIssuer` parameter as the opt-in switch: `null` (the default) - leaves every existing deployment byte-identical, including proofs that - happen to carry an AS-issued nonce; non-null makes the nonce mandatory on - every inbound proof. The new `IDPoPNonceIssuer` mints and recognises the - nonces, with `HmacDPoPNonceIssuer` as the built-in implementation — - stateless HMAC-sealed timestamps rather than a lookup store, because §9 - nonces bound proof *lifetime* while single-use is already the `jti` replay - store's job, and a shared HMAC key makes any instance accept any - sibling's nonce without shared infrastructure (default lifetime 300s, - matching the max proof age). The HMAC key is a required constructor - argument: the key IS the deployment topology, and a defaulted per-process - key behind a load balancer would degenerate every request into a hard - 401 loop that only shows up under multi-replica load. The explicit - single-process door is `HmacDPoPNonceIssuer.CreateEphemeral()`. A - missing, unknown, or expired nonce raises the new - `DPoPNonceRequiredException` carrying a fresh nonce; both - `AuthplaneErrors.WwwAuthenticate` and the MCP middleware surface it as - HTTP 401 with a `DPoP`-scheme challenge carrying - `error="use_dpop_nonce"` plus the fresh nonce in a `DPoP-Nonce` response - header — deliberately distinct from `invalid_dpop_proof`, which tells the - client its proof is broken when only the nonce needs refreshing. The new - `AuthplaneErrors.ResponseHeaders` completes the framework-agnostic - adapter contract (status from `HttpStatus`, challenge from - `WwwAuthenticate`, extra headers from `ResponseHeaders`) by mapping - `DPoPNonceRequiredException` to its `DPoP-Nonce` header — a - `use_dpop_nonce` challenge without it is unsatisfiable — and the MCP - middleware consumes the same mapping for status, challenge and headers - alike. Issuer output is gated on the RFC 9449 §8.1 `NQCHAR` syntax at - `DPoPNonceRequiredException` and `VerifiedClaims`, so a misbehaving - custom issuer is rejected before its output can reach a response - header — and the rejection surfaces as `VerifierRuntimeException` - (HTTP 500): the server's plugin broke a contract, and reporting it as - `invalid_token` would send a conformant client into a re-authenticate - loop against a healthy AS. Nonce checks run only after every - other proof check has passed, so a genuinely invalid proof still gets - its proof error and never burns a nonce on a doomed retry. On the - success side, a nonce accepted in the second half of its lifetime is - surfaced as `VerifiedClaims.NextDPoPNonce` and advertised by the - middleware in the `DPoP-Nonce` header of the 200 — and of the - insufficient-scope 403, whose proof was accepted before the scope check - failed (RFC 9449 §8.2 — the RFC leaves *when* to supply a new nonce to - the server; rotating at half-life means a steadily active client never - takes the 401 round trip). The per-request - `DPoPRequestContext.RequiredNonce` exact-echo check is unchanged and - takes precedence over the resource-level policy, following the replay - store's per-request-override rule. +- `AccessDeniedException` (`access_denied`, 403) and `InvalidTargetException` (`invalid_target`, 400) typed by `MapOAuthError`; neither counts toward the circuit breaker. +- `AuthplaneMcpAuth.Options.ResourceMetadataUrl` points challenge `resource_metadata` at an AS-hosted PRM document instead of the derived resource-hosted URL; gated at construction under the resource identifier's shape rules (absolute `http(s)` URL with a host, and no fragment, userinfo, whitespace, backslash, malformed port, or out-of-grammar octet in the host, path or query), with no host policy and no `devMode` coupling, so `http://authserver:8080/...` boots. +- README **Compatibility** section: tested against authserver 0.2.0; introspection-based revocation requires authserver 0.1.2 or later. +- Resource-server-side DPoP nonce enforcement (RFC 9449 §9). The SDK handled nonces only outbound until now, so a resource server built on it could not adopt the mitigation at all. `InboundDPoPOptions` gains a `nonceIssuer` parameter as the opt-in switch; `null`, the default, leaves every existing deployment byte-identical, and non-null makes the nonce mandatory on every inbound proof — every client's first DPoP request then takes a 401 `use_dpop_nonce` round trip. +- Inbound DPoP nonces — new public API: `IDPoPNonceIssuer` and its built-in `HmacDPoPNonceIssuer`, `DPoPNonceRequiredException`, `AuthplaneErrors.ResponseHeaders` and `VerifiedClaims.NextDPoPNonce`. The HMAC key is a required constructor argument — the key is the deployment topology, and a per-process default behind a load balancer degenerates into a 401 loop; `HmacDPoPNonceIssuer.CreateEphemeral()` is the explicit single-process door. +- Inbound DPoP nonces — a missing, unknown or expired nonce answers 401 with a `DPoP`-scheme challenge carrying `error="use_dpop_nonce"` and a fresh nonce in a `DPoP-Nonce` response header. A framework-agnostic adapter must copy `AuthplaneErrors.ResponseHeaders` onto the response, or that challenge cannot be satisfied. +- Inbound DPoP nonces — a nonce accepted in the second half of its lifetime surfaces as `VerifiedClaims.NextDPoPNonce`, which the middleware advertises in the `DPoP-Nonce` header of the 200 and of the insufficient-scope 403. An adapter that copies only `ResponseHeaders` never sends the rotated nonce, so its clients take a 401 each time one expires — the round trip that rotating at half-life exists to avoid. +- Inbound DPoP nonces — nonce checks run only after every other proof check has passed, so an invalid proof still gets its own error, and the per-request `DPoPRequestContext.RequiredNonce` exact-echo check keeps precedence over the resource-level policy. Issuer output violating the RFC 9449 §8.1 `NQCHAR` syntax surfaces as `VerifierRuntimeException` (HTTP 500), not `invalid_token`. +- `AuthplaneErrors.ErrorResponseBody(...)`, `ErrorDescriptionFor(code)` and `ErrorCodeFor(error)`: the RFC 6750 §3 JSON error body and the fixed description, built from the code the challenge names. A null or empty code is the no-credentials case: both accept it without guarding, and the body then omits `error` entirely, as the challenge does (RFC 6750 §3.1 ties `invalid_request` to a 400, not to a 401 asking the caller to authenticate). +- `Authplane.Mcp` — the middleware logs the exception behind every failure under the `Authplane.Mcp` category before writing the response: `Error` for a 5xx, which is this server's own fault, and `Debug` for a rejection, since reaching one takes no credentials and a higher level would let an unauthenticated caller choose the host's log volume. Logging is optional — a host with no `ILoggerFactory` registered gets no lines and no error. ### Changed -- **Breaking for a deployment configured with a non-absolute resource - identifier.** A resource identifier must now be an absolute URL with a - scheme and a host, enforced at construction with an `ArgumentException`. - RFC 8707 §2 requires the resource parameter to be "an absolute URI, as - specified by Section 4.3 of [RFC3986]" (the scheme), and RFC 9728 §3 inserts - the well-known suffix after the host component (the host). Previously a - relative or opaque identifier was accepted and produced a malformed metadata - URL: `urn:example:api` derived - `/.well-known/oauth-protected-resourceexample:api`, and the relative `/mcp` - and scheme-relative `//api.example.com/mcp` slipped through via the - runtime's implicit `file` scheme — the latter is also how - `UseAuthplaneMcpAuth` could anchor the DPoP `htu` origin on `file://`. The - gate therefore runs at one site more than the fragment gate needed: the - `AuthplaneMcpAuth.Options` constructor — the single operator-facing entry - for the MCP adapter, so `CreateResourceAsync`, `SetupAsync`, and - `UseAuthplaneMcpAuth` (including the lazy-DI wiring the user guide shows) - all fail at startup — plus `AuthplaneResource.CreateAsync`, - `AuthplaneClient.CreateResourceAsync`, and - `OAuthProtectedResourceMetadata.GetDocumentUrl`. - *Migration:* configure the full URL of the protected resource — for example - `/mcp` becomes `https://api.example.com/mcp`. `http` hosts are still - accepted for local development; no scheme allowlist is imposed. -- **Breaking for a deployment configured with userinfo, whitespace, or a - backslash in the resource identifier.** Alongside the absolute-URL gate, - the identifier is now rejected at construction when it carries a userinfo - component (`https://svc:s3cr3t@api.example.com/mcp`, or `mailto:`-style - identifiers whose syntax fills the userinfo slot — RFC 9110 §4.2.4 forbids - generating userinfo in http(s) URIs), whitespace anywhere in the string, or - a backslash. Neither whitespace nor a backslash can appear unescaped in an - RFC 3986 URI, and `Uri` silently rewrites both instead of rejecting them — - surrounding whitespace is trimmed, an interior space is escaped to `%20`, - and a backslash becomes `/` — while the published PRM `resource` field - echoes the identifier verbatim, so the identifier and the derived document - URL diverged and a conformant client discards the document (RFC 9728 §3.3). - Userinfo previously passed construction and then failed on every request - inside `GetDocumentUrl`; a trailing space — typically from a `.env` value — - and a backslash were silently accepted. Those three now fail at startup with - an `ArgumentException` naming the actual defect. Whitespace and the backslash - are two of the three rewrite shapes this closes; C0 controls and DEL are the - third, rejected by the same gate with a message of their own, since telling an - operator to look for a space they cannot see is worse than saying nothing. - `Uri` canonicalizes the path in other ways that still construct — a non-ASCII - segment, a zero-width space (a format character above `0x20`, so neither - whitespace nor a control), a malformed percent-escape — which - `OAuthProtectedResourceMetadata` documents at its derivation as a known - limitation. This is not a claim that the divergence class is closed. - A port that is not RFC 3986 §3.2.3's `*DIGIT` in range — `:80O` with a letter - O, `:99999` — is now its own axis with its own message, rather than inheriting - the absoluteness one: all three are absolute URLs with a scheme and a host, - and what they have is a bad port. It runs ahead of the absoluteness gate, - because `Uri.TryCreate` fails on them and the parse failure would otherwise - report the wrong defect first. A leading zero is rejected as well: `:0080` is legal - RFC 3986 §3.2.3 syntax, but the derivation renders it `:80` while the emitted identifier - keeps it — the same emit-versus-derive divergence the axis exists to prevent, and not one - of the RFC 3986 §6.2 equivalences (host case, dot-segments, default-port removal) the - derivation is documented to apply. Only an all-digit port is echoed back; a port - carrying non-digits has the same shape as a userinfo whose `@` was forgotten - (`https://user:pass/x`), so it renders as `(malformed port)`. - - The gates also run in the `ProtectedResourceMetadata` constructor and `Build` - — the type that *emits* the identifier as the PRM `resource` field. Gating - only the derivation half would have left an operator able to construct and - serve a document naming an identifier the same SDK refuses to derive a URL - from. The query gate stays excluded there, and only there: a query is carried - into the derived URL, so emitting one raises no mismatch for that type to - prevent. - - *Migration:* remove credentials and surrounding whitespace from the - configured identifier, and percent-encode an intentional interior space - (`%20`) or backslash (`%5C`); none of these ever reached the served - metadata correctly. -- `OAuthProtectedResourceMetadata.GetDocumentUrl` now preserves the resource - identifier's query component in the derived Protected Resource Metadata - document URL. RFC 9728 §3 inserts the well-known string "between the host - component and the path and/or query components, if any"; a query is legal on - a resource identifier (RFC 8707 §2 states the SHOULD NOT and its exception - in the same sentence, carried forward by RFC 9728 §1.2). Previously the - derivation used only the authority and `Uri.AbsolutePath`, silently dropping - the query: `https://api.example.com/mcp?tenant=a` derived - `…/.well-known/oauth-protected-resource/mcp`; it now derives - `…/.well-known/oauth-protected-resource/mcp?tenant=a`. When no terminating - slash follows the host (`https://api.example.com?x=1`) the suffix lands - directly after the host and the query follows - (`…/.well-known/oauth-protected-resource?x=1`); a terminating slash before - the query is removed per RFC 9728 §3.1, deriving the same URL. The query is - carried over verbatim from the original identifier string, so its - percent-encoding is preserved byte-for-byte (`Uri.Query` is not used: `Uri` - canonicalizes on construction and unescapes percent-encodings of unreserved - characters, turning `%7E` into `~`). The *path* portion of the derived URL is - still taken from `Uri.AbsolutePath` and so is still canonicalized; that is - unchanged by this release. - A bare `?` is an empty query and derives a query-less URL: - `https://api.example.com/mcp?` derives - `…/.well-known/oauth-protected-resource/mcp`, with no dangling `?`. - *Migration:* if your resource identifier contains a non-empty query component, the PRM - document URL advertised in `WWW-Authenticate: … resource_metadata=` now - includes that query. Update any hard-coded expectation of the old query-less - URL. Your existing PRM route continues to serve the document — routing is - unchanged. Serving distinct documents per query value is not supported. - Identifiers without a query derive exactly the same URL as before. -- **Breaking for a deployment configured with a query outside the RFC 3986 - §3.4 `query` production.** Because the query now flows verbatim from the - configured identifier into the derived document URL, a query outside the - production produces an advertised `resource_metadata` value that is not a - URI and that no client can fetch. The identifier's query is therefore - validated at construction: characters outside the production (for example - `"` or a space) and malformed percent-escapes (`%zz`) are rejected with an - `ArgumentException`, so the misconfiguration surfaces at startup instead of - at request time. The gate applies to the same sites as the fragment gate, except - the `ProtectedResourceMetadata` constructor / `Build`: a query, unlike a fragment, - is carried into the derived URL, so emitting one raises no RFC 9728 §3.3 mismatch - for that type to prevent. - This is not a fix for a header-injection issue and there was none: the MCP - middleware has always escaped `"`, `\` and control characters in every - `WWW-Authenticate` parameter it emits, both before and after this change. - *Migration:* percent-encode the offending characters in the configured - identifier; every legal query character — unreserved, sub-delims, `:`, `@`, - `/`, `?`, and well-formed `%XX` escapes — is accepted unchanged. -- **Breaking for a deployment configured with a fragment.** A resource - identifier carrying a URI fragment is now rejected at construction with an - `ArgumentException`, instead of being silently accepted. RFC 8707 §2 states - "The URI MUST NOT include a fragment component", and RFC 9728 §1.2 defines - the resource identifier as a URL with no fragment component. Previously - `https://api.example.com/mcp#frag` was stored verbatim and echoed as the PRM - `resource` field, while `GetDocumentUrl` derived the well-known URL from the - authority plus `Uri.AbsolutePath` and so dropped the fragment. The served - document then named a resource that disagreed with the URL it was fetched - from, which RFC 9728 §3.3 requires a conformant client to discard — an - interop failure with no error raised anywhere on the server side. - The gate applies to `AuthplaneResource.CreateAsync`, - `AuthplaneClient.CreateResourceAsync`, `AuthplaneMcpAuth.CreateResourceAsync` - / `SetupAsync`, `OAuthProtectedResourceMetadata.GetDocumentUrl`, and the - `ProtectedResourceMetadata` constructor / `ProtectedResourceMetadata.Build` — - the last of these being the type that *emits* the identifier as the PRM - `resource` field, so gating only the derivation half would have left the - mismatch constructible through public API. - The exception message names the offending identifier, with the fragment and - any userinfo elided. - *Migration:* drop the fragment from the configured resource identifier — for - example `https://api.example.com/mcp#frag` becomes - `https://api.example.com/mcp`. Because the fragment never reached the served - metadata document or the well-known URL, removing it changes no - externally-visible value; deployments without a fragment are unaffected. The - check looks for the literal `#` fragment delimiter (RFC 3986 §3.5), so a - percent-encoded `%23` remains ordinary path data and is still accepted. - Whether a resource identifier must additionally be an absolute URL is a - separate axis, addressed by the absolute-URL entry above. - -- CI and release runs now check out the shared conformance catalog at the SHA - pinned in the tracked `.conformance-catalog-ref` instead of the catalog's - default branch, so a catalog change can no longer break a build on its own. - The catalog-alignment guard is asserted in both directions — every catalog - case carries a `[Conformance]` marker, and every marked id exists in the - catalog — and a weekly `conformance-catalog-drift` workflow runs the same - assertion against the catalog's unpinned tip as an early warning. +- `TokenRevokedException` from an `active=false` introspection now names the other cause: the AS not recognising this resource server as the token's owner (authserver ≥ 0.1.2 runtime-client rule). +- User guides document `access_denied` vs `consent_required` on token exchange, the `allowed_client_ids` operator step, and the confidential + runtime-client requirement for introspection. +- `manual-e2e-setup.sh` no longer sets `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED` (on by default since authserver 0.2.0) and accepts `AUTHSERVER_REF` to check out an authserver ref before building. +- `manual-e2e-smoke.sh` no longer calls `POST /admin/scopes` (the route does not exist in authserver 0.2.0; the demo provisioner creates the scopes). +- `OAuthProtectedResourceMetadata.GetDocumentUrl` now derives the whole document URL — authority and path, not only the query — by slicing the original identifier string, so the result is the configured identifier with the well-known string inserted between the authority and the path. **Migration**: none for an identifier already written in the form clients are configured with; one carrying an uppercase scheme or host, a default port, or dot-segments now advertises a different, non-normalized `resource_metadata` URL, so update any hard-coded expectation of the old value. +- Derived PRM URL — reading the path off `Uri.AbsolutePath` re-rendered what the identifier did carry — a percent-escaped unreserved character unescaped, a dot-segment removed, an uppercase scheme or host lowercased, a default port dropped — while the PRM `resource` member emitted the configured bytes verbatim, which is the mismatch RFC 9728 §3.3 has a conformant client discard the document over. +- Derived PRM URL — the MCP middleware's PRM routing follows that derivation: it now compares the request's encoded target against the path sliced off the derived URL, keeping the decoded-path comparison as a fallback for hosts that do not expose a raw request target. +- **Breaking** A resource identifier must now be an absolute URL with a scheme and a host, enforced at construction with an `ArgumentException` — RFC 8707 §2 for the scheme, RFC 9728 §3 for the host. **Migration**: configure the full URL clients address. +- **Breaking** The identifier is also rejected at construction when it carries userinfo (RFC 9110 §4.2.4), whitespace, a backslash, a C0 control or DEL. Userinfo would publish a credential to unauthenticated callers; the other characters are silently rewritten by `Uri`, so the served document's `resource` member no longer matches the advertised URL and a conformant client discards it (RFC 9728 §3.3). **Migration**: remove credentials and surrounding whitespace from the configured identifier, and percent-encode an intentional interior space (`%20`) or backslash (`%5C`). +- Identifier gates — the same gates run in the `ProtectedResourceMetadata` constructor and `Build` — the type that emits the identifier as the PRM `resource` field — so a document cannot name an identifier this SDK refuses to derive a URL from. The query gate stays excluded there, since a query is carried into the derived URL and raises no mismatch. +- **Breaking** A port that is not RFC 3986 §3.2.3's `*DIGIT` in range — `:80O` with a letter O, `:99999` — is now rejected at construction on its own axis with its own message, ahead of the absoluteness gate that would otherwise report the wrong defect. A leading zero is rejected too: `:0080` is legal syntax, but stripping it is not an RFC 3986 §6.2 equivalence and a normalizing URL stack renders it `:80`, so a client re-derives a document URL that disagrees with the served document's verbatim `resource` member. **Migration**: write the port as in-range digits with no leading zero. +- `OAuthProtectedResourceMetadata.GetDocumentUrl` now preserves the resource identifier's query in the derived document URL — RFC 9728 §3 inserts the well-known string ahead of the path and query. **Migration**: update any hard-coded expectation of the old query-less URL. A bare `?` derives a URL with no query, an identifier without a query is unaffected, and serving a different document per query value is not supported. +- **Breaking** The identifier's query is now validated at construction against the RFC 3986 §3.4 production, because it flows verbatim into the derived document URL, where an out-of-grammar octet yields an advertised `resource_metadata` no client can fetch. **Migration**: percent-encode the offending octets. Rejected: a literal `"`, a space, and a malformed `%zz`. Unreserved characters, sub-delims, `:`, `@`, `/`, `?` and well-formed `%XX` are accepted unchanged. +- **Breaking** The identifier's path is validated at construction against the RFC 3986 §3.3 production, for the same reason as the query: the byte-exact derivation carries it verbatim into the advertised URL. **Migration**: percent-encode the offending octets. Rejected: a non-ASCII segment such as `/café` (percent-encode it as UTF-8), a zero-width space (U+200B), the delimiter set `"<>[]^{|}` and the backtick, a malformed `%zz` and a truncated `%2`. For a rejected identifier the previously derived URL was already the percent-encoded form, so re-encoding it advertises the same URL as before — but the identifier string now spells it explicitly, and the PRM `resource` member the document serves changes with it. +- **Breaking** A resource identifier carrying a URI fragment is now rejected at construction with an `ArgumentException` instead of being silently accepted (RFC 8707 §2; RFC 9728 §1.2). It was previously stored verbatim and echoed into the PRM document. **Migration**: drop the fragment. A percent-encoded `%23` is still accepted as path data. + +- CI and release runs now check out the shared conformance catalog at the SHA pinned in `.conformance-catalog-ref` instead of the catalog's default branch, so a catalog change can no longer break a build on its own. The alignment guard is asserted in both directions, and a weekly drift workflow reports divergence from the catalog tip. + +- Conformance catalog pin bumped to `583a6d9`, with markers for its three new resource-identifier cases. +- **BREAKING** `AuthplaneErrors.WwwAuthenticate(...)` now emits a fixed `error_description` chosen by the `error=` code instead of the exception message. **Migration**: log `error.Message` server-side, or pass `verboseDescription: true`. +- **BREAKING** `Authplane.Mcp` — the middleware now answers every failure with an RFC 6750 §3 JSON body (`application/json; charset=utf-8`) instead of prose such as `Missing Authorization header.` or `invalid_token: dpop_proof_missing`. **Migration**: parse `error` and `error_description` from the object; the status is unchanged, but the challenge is not — see the next entry. +- **BREAKING** `Authplane.Mcp` — the challenge and the body no longer carry the exception message, and the middleware's seven hardcoded descriptions give way to a fixed description per `error` code. `use_dpop_nonce` has no fixed description and takes the contentless fallback. +- **BREAKING** `Authplane.Mcp` — a 503 (`JwksFetchException`, `MetadataFetchException`) now answers `temporarily_unavailable` (RFC 6749 §5.2) instead of `server_error`, which read as a defect in this resource server rather than the authorization server being unreachable and retryable; a 500 still answers `server_error`, `CircuitOpenException` included. **Migration**: match `temporarily_unavailable` wherever a client tells a transient outage from a fault. + +### Deprecated + +- `VerifiedClaims.MayAct` marked `[Obsolete]`: authserver 0.2.0 no longer issues `may_act`; removed in the next minor. ### Fixed -- The MCP middleware's generic error arm hardcoded 401 for every - `AuthplaneException`, contradicting the `AuthplaneErrors.HttpStatus` - mapping it now shares with framework-agnostic adapters: a JWKS or - metadata outage surfaced to the client as 401 `invalid_token` — - prompting a pointless re-authentication against a healthy AS — instead - of 503, and a verifier-side runtime fault as anything but 500. The arm - now takes its status from `HttpStatus` and emits a `WWW-Authenticate` - challenge only on 401: a 5xx is the server's fault, and a challenge - would direct the client to fix credentials that are not the problem. -- The conformance-catalog parser in `Authplane.Conformance.Shared` used - to drop cases silently in shapes it did not understand: a case with an - `id` but no `title` was dropped in every non-final position (the final - case already fell back to its id), and a case whose title contains an - apostrophe was dropped in any position (the title regex could not match - past the `'`). A dropped case never reaches - `ConformanceCatalogAlignment`, which treats an absent case as - nothing-to-check — so the alignment guard stayed green while - under-checking. The parser now keeps a title-less case with its id as - the title, parses quoted titles properly (apostrophes, escaped quotes, - and long scalars wrapped across lines the way the catalog emitter - writes them), and throws on any case list item or quoted scalar it - cannot parse instead of skipping it. The same fail-loudly rule now - covers the block boundary and the scalar grammar: a full-line comment - no longer ends the `cases:` block (only a top-level key or the - document-end marker does, anything else at column 0 throws), a quoted - scalar whose continuation leaves the case item throws instead of - swallowing the cases in between, the double-quoted escape set is - decoded properly (`\n`, `\t`, `\r`, `\0`, `\/`, `\"`, `\\`, `\ `, - `\uXXXX`) with unknown escapes throwing instead of being mangled, - block scalar indicators throw instead of being returned as the value, - ids parse through the same scalar grammar as titles, and the case - field indentation is derived from the file instead of hardcoded. The - catalog drift guard is a contract shared with the other AuthPlane - SDKs; failing loudly on unparseable catalog shapes is now this SDK's - side of it. +- The MCP middleware's decoded-path fallback held `%5C` back from decoding while Kestrel decodes it, so a `%5C`-bearing resource identifier answered 401 at its own advertised metadata URL on hosts without a raw request target. +- `AuthplaneResource.CreateAsync` no longer abandons the `AuthplaneClient` it builds when the resource constructor rejects its arguments; `DisposeAsync` is now idempotent. +- The conformance drift marker is no longer duplicated between `ConformanceCatalogAlignment.DriftMarker` and the drift workflow with nothing tying them together; a test now fails if either copy changes without the other. +- The authority now has an RFC 3986 §3.2.2 character-production gate, so an internationalized host is rejected at construction instead of reaching a `WWW-Authenticate` challenge as a non-URI. +- The host production gate no longer throws `IndexOutOfRangeException` on an authority that is userinfo and nothing else (`https://user@`); the missing host is reported by the absoluteness gate as an `ArgumentException`, as it was before the gate was added. +- `AuthplaneClient.CreateAsync` no longer abandons the client it built when the priming metadata fetch fails or the caller's token is cancelled. +- An opaque resource identifier such as `mailto:ops@example.com` is no longer reported as carrying userinfo; it is still refused, now because an opaque URI has no host to derive a metadata document URL from. +- The MCP middleware's generic error arm hardcoded 401 for every `AuthplaneException`, so a JWKS or metadata outage surfaced as 401 `invalid_token` — prompting a pointless re-authentication against a healthy AS — instead of 503. The arm now takes its status from `AuthplaneErrors.HttpStatus` and emits `WWW-Authenticate` only on a 401, so a 5xx no longer carries a challenge, and a verifier runtime fault maps to 500. The 403 `insufficient_scope` challenge is unchanged. +- The conformance-catalog parser in `Authplane.Conformance.Shared` silently dropped cases it could not parse: one with an `id` but no `title` in any non-final position, and one whose title contains an apostrophe in any position. A dropped case never reaches `ConformanceCatalogAlignment`, so coverage it should have demanded went unasserted. The parser now keeps a title-less case with its id as the title, parses quoted titles properly, and no longer lets a full-line comment end the `cases:` block. Anything it still cannot parse — a case, a scalar, a block scalar indicator, an unknown escape — now throws, so a shape it does not understand breaks the build instead of vanishing. ## [0.1.0] - 2026-08-07 diff --git a/README.md b/README.md index 11125ef..0bbcbd6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ OAuth 2.1 JWT validation and token operations for .NET resource servers, with a Requires .NET 8.0 or later. +## Compatibility + +Tested against authserver 0.2.0. Introspection-based revocation (`IntrospectionRevocation`) requires authserver 0.1.2 or later. + ## Capabilities ### Standards and RFCs diff --git a/RELEASE_SETUP.md b/RELEASE_SETUP.md index c07a2f1..9fff315 100644 --- a/RELEASE_SETUP.md +++ b/RELEASE_SETUP.md @@ -58,9 +58,7 @@ End-to-end happy path: - atomic-pushes branch + tag using the Release Bot token, - creates the GitHub Release with notes extracted from CHANGELOG, - deletes the source branch. -4. **`publish-nuget.yml` triggers automatically** on the tag push. It builds, packs, and pushes `Authplane.Sdk` then `Authplane.Mcp` to NuGet. The `nuget` environment gates the publish: a `maintainers` reviewer has to approve the deployment before anything reaches nuget.org. -5. **Record the release in the default branch's changelog.** `release.yml` deletes the source branch, so the `## [X.Y.Z]` heading only ever exists on the tag — the default branch still says `## [Unreleased]`. Open a follow-up PR that renames it and opens a fresh empty `## [Unreleased]` above. The `## [X.Y.Z]` section on the default branch must be a byte-for-byte copy of the tagged one; if the notes need correcting after the fact, edit the GitHub Release body, never the published section. -6. **Merge the next-dev bump PR** that step 1 opened. It carries the `-pre.N` bump for the default branch, and it does not auto-merge (`allow_auto_merge` is off on this repository), so it needs a manual merge like any other PR. +4. **`publish-nuget.yml` triggers automatically** on the tag push. It builds, packs, and pushes `Authplane.Sdk` then `Authplane.Mcp` to NuGet. The `nuget` environment gates the publish. ## 6. Hotfix flow diff --git a/docs/user-guide.md b/docs/user-guide.md index 7c63523..c4dc98b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -65,6 +65,22 @@ Typical mapping for HTTP APIs: | `DPoPProofMissingException`, `InvalidDPoPProofException`, `DPoPBindingMismatchException`, `DPoPReplayDetectedException` | 401 | DPoP-bound token issues. | | `JwksFetchException` | 502/503 | JWKS or discovery fetch failed. | +### What the caller is told + +Both halves of a failure response — the `WWW-Authenticate` challenge and the JSON body — are built from one error code and the fixed sentence it selects, never from the exception's message: + +| `error` | `error_description` | +|---|---| +| `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` | +| `invalid_request` (no credentials presented) | `The request did not carry an access token` | +| anything else, `use_dpop_nonce` included | `The request could not be authenticated` | + +The response reaches a caller who by definition has not authenticated, and the SDK's messages name the failing detail — the unknown `kid`, the claim that did not validate, and on an audience mismatch the exact `aud` the resource expects, which is the value they would 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 what a conforming client acts on. The message stays on the exception, so log it server-side. + +`AuthplaneErrors.WwwAuthenticate(error, realm, verboseDescription: true)` and `AuthplaneErrors.ErrorResponseBody(code, error, verboseDescription: true)` restore the message for local debugging. They disclose SDK internals to unauthenticated callers; do not enable them in production. + OAuth **client** flows (`AuthplaneAuthClient`) use `AuthplaneTokenRequestException`, `ConsentRequiredException`, `CircuitOpenException`, etc.; circuit breaker records failures only for transport/server-class errors (see `CircuitPolicy`). ## Security notes diff --git a/scripts/manual-e2e-setup.sh b/scripts/manual-e2e-setup.sh index eb38b38..4cd4c17 100755 --- a/scripts/manual-e2e-setup.sh +++ b/scripts/manual-e2e-setup.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" AUTHSERVER_DIR="${AUTHSERVER_DIR:-$REPO_ROOT/../authserver}" +AUTHSERVER_REF="${AUTHSERVER_REF:-}" usage() { cat <<'EOF' @@ -12,11 +13,14 @@ 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) What this script does: - 1) Builds authserver binary if needed - 2) Starts authserver demo server with client_credentials enabled - 3) Leaves demo client credentials in: + 1) Optionally checks out AUTHSERVER_REF in the authserver repo + 2) Builds authserver binary if needed + 3) Starts authserver demo server + 4) Leaves demo client credentials in: - /tmp/authserver-demo.client-id - /tmp/authserver-demo.key EOF @@ -32,13 +36,20 @@ 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 checkout "${AUTHSERVER_REF}" + # The binary below is only rebuilt when missing; a ref change must not + # run a stale build. + 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 "" diff --git a/scripts/manual-e2e-smoke.sh b/scripts/manual-e2e-smoke.sh index e72a1f1..ebb3c1e 100755 --- a/scripts/manual-e2e-smoke.sh +++ b/scripts/manual-e2e-smoke.sh @@ -7,8 +7,6 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" RUN_SETUP=1 RESOURCE_URL="${RESOURCE_URL:-http://localhost:8080/mcp}" ISSUER_URL="${ISSUER_URL:-http://localhost:9000}" -ADMIN_URL="${ADMIN_URL:-http://localhost:9001}" -ADMIN_KEY="${ADMIN_KEY:-b480b9760e730abe43b98d0ba01418961df392de0fc6358c36a9a62a8764a7c1}" SERVER_LOG="/tmp/csharp-adapters-manual-e2e-smoke.log" usage() { @@ -43,29 +41,13 @@ cleanup() { } trap cleanup EXIT -register_scope() { - local scope_name="$1" - local status - status="$( - curl -sS -o /dev/null -w "%{http_code}" \ - -X POST "${ADMIN_URL}/admin/scopes" \ - -H "Authorization: Bearer ${ADMIN_KEY}" \ - -H "Content-Type: application/json" \ - -d "{\"resource\":\"${RESOURCE_URL}\",\"name\":\"${scope_name}\",\"description\":\"Manual E2E smoke scope ${scope_name}\"}" \ - || true - )" - if [ "${status}" != "201" ] && [ "${status}" != "409" ]; then - echo "WARN: could not ensure scope ${scope_name} for ${RESOURCE_URL} (status=${status}); continuing" >&2 - fi -} - if [ "${RUN_SETUP}" -eq 1 ]; then bash "${SCRIPT_DIR}/manual-e2e-setup.sh" fi -echo "==> Ensuring authserver scopes for resource: ${RESOURCE_URL}" -register_scope "tools/add" -register_scope "tools/multiply" +# The demo scopes (tools/add, tools/multiply) are created by the authserver +# demo provisioner started in setup; there is no /admin/scopes route to +# register them against. echo "==> Starting C# demo server" ( diff --git a/src/Authplane.Mcp/AuthplaneMcpAuth.cs b/src/Authplane.Mcp/AuthplaneMcpAuth.cs index c0e5686..1b3c85d 100644 --- a/src/Authplane.Mcp/AuthplaneMcpAuth.cs +++ b/src/Authplane.Mcp/AuthplaneMcpAuth.cs @@ -35,13 +35,30 @@ public sealed class Options /// public InboundDPoPOptions? InboundDPoP { get; } + /// + /// Optional absolute URL advertised as the resource_metadata + /// parameter of every WWW-Authenticate challenge (RFC 9728 §5.1) in + /// place of the URL derived from . Use it when the + /// Protected Resource Metadata document is hosted by the authorization + /// server rather than by this resource — authserver serves one per + /// registered Resource at + /// {issuer}/.well-known/oauth-protected-resource/{ref}. Null + /// (the default) keeps the derived resource-hosted URL, and the + /// middleware keeps serving that document regardless of this value. + /// The resource field of the document at this URL must equal + /// byte for byte (RFC 9728 §3.3), or clients + /// discard it. + /// + public string? ResourceMetadataUrl { get; } + public Options( string issuer, string resource, IReadOnlyList scopes, bool devMode = false, string? realm = null, - InboundDPoPOptions? inboundDpop = null) + InboundDPoPOptions? inboundDpop = null, + string? resourceMetadataUrl = null) { Issuer = issuer ?? throw new ArgumentNullException(nameof(issuer)); Resource = resource ?? throw new ArgumentNullException(nameof(resource)); @@ -58,13 +75,70 @@ public Options( ResourceIdentifiers.ThrowIfFragment(resource, nameof(resource)); ResourceIdentifiers.ThrowIfWhitespaceOrBackslash(resource, nameof(resource)); ResourceIdentifiers.ThrowIfMalformedPort(resource, nameof(resource)); + ResourceIdentifiers.ThrowIfInvalidHost(resource, nameof(resource)); ResourceIdentifiers.ThrowIfNotAbsoluteUrl(resource, nameof(resource)); ResourceIdentifiers.ThrowIfUserInfo(resource, nameof(resource)); + ResourceIdentifiers.ThrowIfInvalidPath(resource, nameof(resource)); ResourceIdentifiers.ThrowIfInvalidQuery(resource, nameof(resource)); Scopes = scopes ?? throw new ArgumentNullException(nameof(scopes)); DevMode = devMode; Realm = realm; InboundDPoP = inboundDpop; + if (resourceMetadataUrl is not null) + { + ThrowIfInvalidResourceMetadataUrl(resourceMetadataUrl); + } + + ResourceMetadataUrl = resourceMetadataUrl; + } + + /// + /// The override reaches the same resource_metadata quoted-string + /// the resource identifier does, so it gets the identifier's whole gate + /// set rather than a subset: an IDN host, a raw non-ASCII path segment, + /// a malformed percent-escape or a bad port all parse into a + /// with a scheme and a host, ride into the challenge + /// and out to unauthenticated clients, and fail discovery with nothing + /// on the server side to say why. Same order as the identifier above, + /// and the same at construction. + /// + /// Shape only: no scheme narrowing beyond http(s) and no host policy. + /// The value is advertised, never fetched, so it carries no SSRF + /// surface of its own, and http is accepted on any host because + /// a loopback-only carve-out would refuse the in-cluster and + /// docker-compose topologies dev mode exists to serve, so a single + /// deployment configuration is accepted wherever this option is set. + /// + private static void ThrowIfInvalidResourceMetadataUrl(string resourceMetadataUrl) + { + const string subject = "resourceMetadataUrl"; + + // Raw-string scans first, exactly as the resource gate orders them: + // Uri.TryCreate trims surrounding whitespace before parsing, so a + // trailing space or a backslash clears every check below and is + // then stored and advertised verbatim. The challenge escaper + // strips control characters but not U+0020, so the client fetches + // a percent-encoded space and gets a 404 — a silent discovery + // failure, which is the thing this gate exists to prevent. + ResourceIdentifiers.ThrowIfFragment(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + ResourceIdentifiers.ThrowIfWhitespaceOrBackslash(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + ResourceIdentifiers.ThrowIfMalformedPort(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + ResourceIdentifiers.ThrowIfInvalidHost(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + ResourceIdentifiers.ThrowIfNotAbsoluteUrl(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + ResourceIdentifiers.ThrowIfUserInfo(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + ResourceIdentifiers.ThrowIfInvalidPath(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + ResourceIdentifiers.ThrowIfInvalidQuery(resourceMetadataUrl, nameof(resourceMetadataUrl), subject); + + // http(s) only: those are the schemes an OAuth client will + // dereference, so anything else names a document nobody retrieves. + if (!Uri.TryCreate(resourceMetadataUrl, UriKind.Absolute, out var uri) || + (!uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + !uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException( + "resourceMetadataUrl scheme must be https or http (RFC 9728 §3).", + nameof(resourceMetadataUrl)); + } } } diff --git a/src/Authplane.Mcp/AuthplaneMcpAuthExtensions.cs b/src/Authplane.Mcp/AuthplaneMcpAuthExtensions.cs index 7a2fc86..c1e0520 100644 --- a/src/Authplane.Mcp/AuthplaneMcpAuthExtensions.cs +++ b/src/Authplane.Mcp/AuthplaneMcpAuthExtensions.cs @@ -3,15 +3,28 @@ using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; namespace Authplane.Mcp; public static class AuthplaneMcpAuthExtensions { - private static string ProtectedResourceMetadataUrl(AuthplaneResource resource) => - resource.GetProtectedResourceMetadataDocumentUrl(); + private const string WellKnownPrmPath = "/.well-known/oauth-protected-resource"; + + /// + /// The URL a challenge advertises as resource_metadata: the + /// configured + /// when set, otherwise the URL derived from the resource identifier. + /// Challenges only — the PRM GET route below keys off the derived URL, + /// which is the one document this middleware serves. + /// + private static string ProtectedResourceMetadataUrl( + AuthplaneMcpAuth.Options options, + AuthplaneResource resource) => + options.ResourceMetadataUrl ?? resource.GetProtectedResourceMetadataDocumentUrl(); /// /// Extracts token + optional DPoP proof from the request and enforces the required scope @@ -60,17 +73,71 @@ public static IApplicationBuilder UseAuthplaneMcpAuth( if (HttpMethods.IsGet(context.Request.Method)) { var authplaneResource = context.RequestServices.GetRequiredService(); - var documentUrl = ProtectedResourceMetadataUrl(authplaneResource); - // Routing is path-keyed: `AbsolutePath` excludes any query the - // advertised document URL carries (a resource identifier with a - // query keeps it in the derived URL per RFC 9728 §3), so the - // one configured document is served regardless of the request's - // query string. Serving distinct documents per query value is - // not supported. - var expectedPath = new Uri(documentUrl, UriKind.Absolute).AbsolutePath; + // Always the derived URL, never Options.ResourceMetadataUrl: + // that override points challenges at a document hosted + // elsewhere (the authorization server), whose path says + // nothing about where this middleware answers. The + // resource-hosted document stays served either way. + var documentUrl = authplaneResource.GetProtectedResourceMetadataDocumentUrl(); + // Routing is path-keyed, and compares like against like. The + // derived document URL is byte-exact with respect to the + // configured identifier, so the expected path is sliced off it + // rather than re-parsed: `Uri.AbsolutePath` re-renders what it + // returns (`%7E` becomes `~`) — the canonicalization the + // derivation itself stopped applying — and comparing its + // output against the *decoded* `Request.Path` left an + // advertised `…/m%7Ecp` answered 401 at its own URL. The + // slice boundaries are the derivation's own: the authority + // cannot contain '/', so the first occurrence of the + // well-known string is the inserted one, and the path cannot + // contain a raw '?', so the first '?' after it starts the + // query. The query is excluded on both sides (a resource + // identifier with a query keeps it in the derived URL per + // RFC 9728 §3), so the one configured document is served + // regardless of the request's query string. Serving distinct + // documents per query value is not supported. + // The derivation unconditionally inserts the well-known + // string (OAuthProtectedResourceMetadata.GetDocumentUrl), so + // this IndexOf cannot miss. That invariant lives in another + // class, and this is the only site that depends on it — the + // guard pins it here, where without it a regression would + // surface as an ArgumentOutOfRangeException from the '?' + // IndexOf on every unauthenticated GET. + var wellKnownStart = documentUrl.IndexOf(WellKnownPrmPath, StringComparison.Ordinal); + string? expectedPath = null; + if (wellKnownStart >= 0) + { + var expectedQueryStart = documentUrl.IndexOf('?', wellKnownStart); + expectedPath = expectedQueryStart >= 0 + ? documentUrl[wellKnownStart..expectedQueryStart] + : documentUrl[wellKnownStart..]; + } + + // The primary comparison is over the encoded request target — + // `IHttpRequestFeature.RawTarget`, the bytes of the request + // line — because that is the representation the advertised URL + // is expressed in: a client that reads `resource_metadata` and + // fetches it sends those bytes. The decoded comparison stays + // as a fallback for hosts that do not populate `RawTarget` and + // for a client requesting an RFC 3986 §6.2.2.2-equivalent form + // of the advertised path. Its expected side is decoded the way + // Kestrel decodes into `Request.Path` — everything except + // `%2F` — so the two sides stay like for like on the server + // that decoding was measured against; see DecodePathLikeKestrel + // for why `Uri.UnescapeDataString` is the wrong decoder here. var requestPath = context.Request.Path.Value ?? string.Empty; - if (PathsMatch(requestPath, expectedPath) || - PathsMatch(requestPath, "/.well-known/oauth-protected-resource")) + var rawTarget = context.Features.Get()?.RawTarget; + string? rawRequestPath = null; + if (!string.IsNullOrEmpty(rawTarget)) + { + var rawQueryStart = rawTarget.IndexOf('?', StringComparison.Ordinal); + rawRequestPath = rawQueryStart >= 0 ? rawTarget[..rawQueryStart] : rawTarget; + } + + if ((expectedPath is not null && + ((rawRequestPath is not null && PathsMatch(rawRequestPath, expectedPath)) || + PathsMatch(requestPath, DecodePathLikeKestrel(expectedPath)))) || + PathsMatch(requestPath, WellKnownPrmPath)) { context.Response.ContentType = "application/json; charset=utf-8"; context.Response.Headers[HeaderNames.CacheControl] = "public, max-age=3600"; @@ -82,7 +149,7 @@ await context.Response } var verifier = context.RequestServices.GetRequiredService(); - var resourceMetadataUrl = ProtectedResourceMetadataUrl(verifier); + var resourceMetadataUrl = ProtectedResourceMetadataUrl(options, verifier); // RFC 9449 §7.1: the DPoP challenge `algs` parameter SHOULD reflect // what the resource actually accepts. When InboundDPoPOptions narrows // the set (e.g. ES256-only) we must mirror that — otherwise the @@ -117,7 +184,7 @@ await context.Response description: null, realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("Missing Authorization header.").ConfigureAwait(false); + await WriteErrorBodyAsync(context, AuthplaneErrors.ErrorResponseBody()).ConfigureAwait(false); return; } @@ -143,7 +210,7 @@ await context.Response description: null, realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("Invalid Authorization header format.").ConfigureAwait(false); + await WriteErrorBodyAsync(context, AuthplaneErrors.ErrorResponseBody()).ConfigureAwait(false); return; } @@ -157,7 +224,7 @@ await context.Response description: null, realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("Missing access token.").ConfigureAwait(false); + await WriteErrorBodyAsync(context, AuthplaneErrors.ErrorResponseBody()).ConfigureAwait(false); return; } @@ -192,8 +259,9 @@ await context.Response proofs: dpopHeaderValues, replayStore: replayStore); } - catch (DPoPMultipleProofsException) + catch (DPoPMultipleProofsException ex) { + LogFailure(context, ex, StatusCodes.Status401Unauthorized); // RFC 9449 §4.3 #1 → §7.1: the one DPoP failure that // carries error="invalid_dpop_proof"; every other DPoP // rejection stays on invalid_token. The challenge is @@ -208,10 +276,12 @@ await context.Response ChallengeScheme.DPoPOnly, resourceMetadataUrl, error: OAuthConstants.ErrorCodes.InvalidDPoPProof, - description: "multiple_dpop_proofs", + description: AuthplaneErrors.ErrorDescriptionFor(OAuthConstants.ErrorCodes.InvalidDPoPProof), realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("invalid_dpop_proof: multiple_dpop_proofs").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.InvalidDPoPProof)) + .ConfigureAwait(false); return; } } @@ -247,8 +317,9 @@ await context.Response } } } - catch (InsufficientScopeException) + catch (InsufficientScopeException ex) { + LogFailure(context, ex, StatusCodes.Status403Forbidden); context.Response.StatusCode = StatusCodes.Status403Forbidden; // RFC 9449 §8.2 lets the server supply a nonce on any // response: the proof was accepted before the scope check @@ -264,68 +335,83 @@ await context.Response usedDpopScheme ? ChallengeScheme.DPoPOnly : ChallengeScheme.BearerOnly, resourceMetadataUrl, error: "insufficient_scope", - description: "Insufficient scope", + description: AuthplaneErrors.ErrorDescriptionFor(OAuthConstants.ErrorCodes.InsufficientScope), realm: options.Realm, scope: requiredScopes is { Length: > 0 } ? string.Join(' ', requiredScopes) : null, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("insufficient_scope").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.InsufficientScope)) + .ConfigureAwait(false); return; } - catch (DPoPProofMissingException) + catch (DPoPProofMissingException ex) { + LogFailure(context, ex, StatusCodes.Status401Unauthorized); // RFC 9449 §7.1 — DPoP errors use the DPoP challenge scheme. context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers.WWWAuthenticate = BuildChallenge( ChallengeScheme.DPoPOnly, resourceMetadataUrl, error: "invalid_token", - description: "dpop_proof_missing", + description: AuthplaneErrors.ErrorDescriptionFor(OAuthConstants.ErrorCodes.InvalidToken), realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("invalid_token: dpop_proof_missing").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.InvalidToken)) + .ConfigureAwait(false); return; } - catch (InvalidDPoPProofException) + catch (InvalidDPoPProofException ex) { + LogFailure(context, ex, StatusCodes.Status401Unauthorized); context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers.WWWAuthenticate = BuildChallenge( ChallengeScheme.DPoPOnly, resourceMetadataUrl, error: "invalid_token", - description: "invalid_dpop_proof", + description: AuthplaneErrors.ErrorDescriptionFor(OAuthConstants.ErrorCodes.InvalidToken), realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("invalid_token: invalid_dpop_proof").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.InvalidToken)) + .ConfigureAwait(false); return; } - catch (DPoPBindingMismatchException) + catch (DPoPBindingMismatchException ex) { + LogFailure(context, ex, StatusCodes.Status401Unauthorized); context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers.WWWAuthenticate = BuildChallenge( ChallengeScheme.DPoPOnly, resourceMetadataUrl, error: "invalid_token", - description: "dpop_binding_mismatch", + description: AuthplaneErrors.ErrorDescriptionFor(OAuthConstants.ErrorCodes.InvalidToken), realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("invalid_token: dpop_binding_mismatch").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.InvalidToken)) + .ConfigureAwait(false); return; } - catch (DPoPReplayDetectedException) + catch (DPoPReplayDetectedException ex) { + LogFailure(context, ex, StatusCodes.Status401Unauthorized); context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers.WWWAuthenticate = BuildChallenge( ChallengeScheme.DPoPOnly, resourceMetadataUrl, error: "invalid_token", - description: "dpop_replay_detected", + description: AuthplaneErrors.ErrorDescriptionFor(OAuthConstants.ErrorCodes.InvalidToken), realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("invalid_token: dpop_replay_detected").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.InvalidToken)) + .ConfigureAwait(false); return; } catch (DPoPNonceRequiredException ex) { + LogFailure(context, ex, StatusCodes.Status401Unauthorized); // RFC 9449 §9 choreography: 401 with a DPoP-scheme challenge // carrying error="use_dpop_nonce" AND the fresh nonce in the // DPoP-Nonce response header. The client re-signs its proof @@ -344,10 +430,12 @@ await context.Response ChallengeScheme.DPoPOnly, resourceMetadataUrl, error: OAuthConstants.ErrorCodes.UseDpopNonce, - description: "dpop_nonce_required", + description: AuthplaneErrors.ErrorDescriptionFor(OAuthConstants.ErrorCodes.UseDpopNonce), realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync("use_dpop_nonce: dpop_nonce_required").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.UseDpopNonce)) + .ConfigureAwait(false); return; } catch (AuthplaneException ex) @@ -361,17 +449,20 @@ await context.Response // into this branch — advertising Bearer alone is what stops // the negotiate-DPoP-then-reject loop. var status = AuthplaneErrors.HttpStatus(ex); + LogFailure(context, ex, status); context.Response.StatusCode = status; if (status == StatusCodes.Status401Unauthorized) { context.Response.Headers.WWWAuthenticate = BuildChallenge( defaultScheme, resourceMetadataUrl, - error: "invalid_token", - description: ex.Message, + error: AuthplaneErrors.ErrorCodeFor(ex), + description: AuthplaneErrors.ErrorDescriptionFor(AuthplaneErrors.ErrorCodeFor(ex)), realm: options.Realm, dpopAlgs: dpopAlgs); - await context.Response.WriteAsync($"invalid_token: {ex.Message}").ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(AuthplaneErrors.ErrorCodeFor(ex))) + .ConfigureAwait(false); } else { @@ -379,7 +470,9 @@ await context.Response // outage, misconfigured verifier extension). No // WWW-Authenticate: a challenge would direct the client to // fix credentials that are not the problem. - await context.Response.WriteAsync(ex.Message).ConfigureAwait(false); + await WriteErrorBodyAsync(context, + AuthplaneErrors.ErrorResponseBody(AuthplaneErrors.ServerErrorCodeFor(status))) + .ConfigureAwait(false); } return; } @@ -450,6 +543,82 @@ private static Microsoft.Extensions.Primitives.StringValues BuildChallenge( }; } + /// + /// Write the RFC 6750 §3 JSON error body, with the media type that says so. + /// + /// The bodies used to be prose ("Missing Authorization header.") or a + /// colon-joined pair ("invalid_token: dpop_proof_missing"), neither of + /// which a client can parse; every sibling SDK in this family answers with + /// the RFC's {"error": ..., "error_description": ...} object, so + /// this one does too. + /// + private static Task WriteErrorBodyAsync(HttpContext context, string json) + { + context.Response.ContentType = "application/json; charset=utf-8"; + return context.Response.WriteAsync(json); + } + + /// + /// The category the middleware logs under. Named for the assembly so an + /// operator can raise this one path to Debug without raising the host's. + /// + private const string LogCategory = "Authplane.Mcp"; + + /// + /// Pre-compiled by rather than called through + /// the ILogger.Log* extensions: CA1848 is an error in this + /// repository, and this sits on the request path. + /// + private static readonly Action LogServerFailure = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, "AuthplaneVerificationFailed"), + "Authplane could not verify {Method} {Path} and answered {Status}."); + + private static readonly Action LogRejection = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(2, "AuthplaneRequestRejected"), + "Authplane rejected {Method} {Path} with {Status}."); + + /// + /// Record why the request failed before the response discards it. The body + /// and challenge carry a fixed description chosen by the error code, never + /// the exception's own message — the caller has by definition not + /// authenticated, and the SDK's messages name the unknown kid, the + /// claim that did not validate, or the aud the resource expects. The + /// operator still needs that detail, and this middleware is the last place + /// that holds it: every arm below catches, answers, and returns. + /// + /// Logging is optional, not required. A host with no logging registered + /// gets null from and this is a no-op, + /// so adding the call cannot turn a working host into a failing one. + /// + /// A rejection is logged at Debug, not Warning: reaching it takes no + /// credentials, so an unauthenticated caller would otherwise choose this + /// server's log volume. An operator diagnosing a rejection turns the + /// category up for as long as it takes. A 5xx is the server's own fault, + /// cannot be provoked by a caller, and is what an operator needs to see + /// without having been told to look, so it goes to Error. + /// + private static void LogFailure(HttpContext context, Exception ex, int status) + { + var logger = context.RequestServices.GetService()?.CreateLogger(LogCategory); + if (logger is null) + { + return; + } + + if (status >= StatusCodes.Status500InternalServerError) + { + LogServerFailure(logger, context.Request.Method, context.Request.Path.Value ?? string.Empty, status, ex); + } + else if (logger.IsEnabled(LogLevel.Debug)) + { + LogRejection(logger, context.Request.Method, context.Request.Path.Value ?? string.Empty, status, ex); + } + } + private static string BuildSingleChallenge( string schemeToken, string resourceMetadataUrl, @@ -522,6 +691,65 @@ private static string EscapeChallengeString(string value) return sb.ToString(); } + /// + /// Decodes a percent-encoded path the way Kestrel decodes the request + /// target into Request.Path (measured; the other ASP.NET Core + /// servers are unmeasured): every escape except %2F, which stays + /// encoded because decoding it would add a segment boundary the client + /// never sent. It is not + /// (nor PathString.FromUriComponent) — those decode every escape, + /// and the difference bites on exactly %2F: unescaping the + /// expected path turned an identifier's mcp%2F into mcp/, + /// which after the trailing-slash trim both failed to match the + /// identifier's own advertised document URL and falsely matched the URL + /// a different, %2F-less identifier advertises. + /// + /// + /// %2F is the only exception. This decoder used to hold back + /// %5C as well, on the reasoning that a backslash is a segment + /// separator too — it is not, to Kestrel, which decodes %5C like + /// any other escape. A %5C-bearing resource identifier therefore + /// answered 401 at its own advertised metadata URL wherever the raw + /// request target is unavailable and this fallback decides. The claim is + /// now measured against a live Kestrel rather than asserted; the test + /// project's Kestrel_DecodesEveryEscapeExceptPercent2F_Measured + /// is the measurement. + /// + private static string DecodePathLikeKestrel(string encodedPath) + { + StringBuilder? sb = null; + var start = 0; + for (var i = 0; i + 2 < encodedPath.Length; i++) + { + if (encodedPath[i] != '%' || + !Uri.IsHexDigit(encodedPath[i + 1]) || + !Uri.IsHexDigit(encodedPath[i + 2])) + { + continue; + } + + var octet = (Uri.FromHex(encodedPath[i + 1]) << 4) | Uri.FromHex(encodedPath[i + 2]); + if (octet != '/') + { + continue; + } + + sb ??= new StringBuilder(encodedPath.Length); + sb.Append(Uri.UnescapeDataString(encodedPath[start..i])); + sb.Append(encodedPath, i, 3); + i += 2; + start = i + 1; + } + + if (sb is null) + { + return Uri.UnescapeDataString(encodedPath); + } + + sb.Append(Uri.UnescapeDataString(encodedPath[start..])); + return sb.ToString(); + } + private static bool PathsMatch(string requestPath, string expectedPath) { var a = requestPath.TrimEnd('/'); @@ -536,6 +764,17 @@ private static bool PathsMatch(string requestPath, string expectedPath) b = "/"; } + // OrdinalIgnoreCase stays deliberately now that the expected side is + // the operator's exact bytes. The compared strings are paths only — + // the scheme and host, the components RFC 3986 §6.2.2.1 makes + // case-insensitive, never reach them — but the one case-insensitive + // part of an encoded path is the hex digits of a percent-escape + // (§6.2.2.1 again: `%2f` and `%2F` name the same octet), and the raw + // request target carries the client's casing of them. The folding is + // broader than that (it also matches a case-variant of the path + // letters themselves), which is pre-existing laxity, not a routing + // hazard: there is one document, so a lax match can only serve it, + // never a different identifier's. return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); } diff --git a/src/Authplane.Mcp/docs/user-guide.md b/src/Authplane.Mcp/docs/user-guide.md index b1ee960..cc40153 100644 --- a/src/Authplane.Mcp/docs/user-guide.md +++ b/src/Authplane.Mcp/docs/user-guide.md @@ -79,6 +79,26 @@ var resource = serviceProvider.GetRequiredService(); var prmJson = resource.GetProtectedResourceMetadata().ToRfc9728Json(); ``` +### Where the PRM document lives + +A `WWW-Authenticate` challenge names the PRM document through its `resource_metadata` parameter (RFC 9728 §5.1). Two topologies serve that document: + +- **Resource-hosted — the default.** The middleware serves the document itself at `/.well-known/oauth-protected-resource{path}`, derived from `resource`, and advertises that URL. Nothing to configure. +- **AS-hosted.** authserver 0.2.0 and later serves a document for every registered Resource at `{issuer}/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the RFC 9728 §3.1 path suffix of the Resource URI (or its slug). Set `resourceMetadataUrl` to point challenges there — useful when the resource server cannot host well-known paths, for instance behind a gateway that owns `/.well-known`. + +```csharp +var options = new AuthplaneMcpAuth.Options( + issuer: "https://auth.company.com", + resource: "https://mcp.company.com/mcp", + scopes: new[] { "tools/query" }, + devMode: false, + resourceMetadataUrl: "https://auth.company.com/.well-known/oauth-protected-resource/mcp"); +``` + +The override is validated at construction under the same shape rules as the resource identifier — absolute http(s) URL with a host, and no fragment, userinfo, whitespace, backslash, malformed port, or out-of-grammar octet in the host, path or query — and changes the advertised URL only: the middleware keeps serving the resource-hosted document at its derived path either way. There is no host policy and no `devMode` dependency on this value: it is advertised to clients, never fetched by the SDK, so `http://authserver:8080/...` and a private-network address are both accepted, which is what the in-cluster and docker-compose topologies need. + +Whichever topology you choose, RFC 9728 §3.3 requires the `resource` value **inside** the document to equal the URL clients call, byte for byte. The Resource URI registered at the authorization server, the `resource` configured here, and the public URL of the server must match exactly — a trailing slash or a different case in the host is enough for a conformant client to discard the document. + ### Translate consent errors Wrap a tool that calls `AuthplaneAuthClient.TokenExchangeAsync` so consent failures surface as MCP URL-elicitation errors: @@ -95,6 +115,20 @@ var result = await UrlElicitationSupport.TryWithUrlElicitationAsync(async () => If `AuthplaneAuthClient` throws `ConsentRequiredException`, `UrlElicitationSupport` returns a structured `-32042` envelope the MCP client can render. +Two rejections look similar but are not consent problems, and neither counts toward the circuit breaker: + +- `AccessDeniedException` (`access_denied`, HTTP 403) — the operator has not allow-listed this MCP server's client on the target Resource. Re-prompting the user will not fix it. +- `InvalidTargetException` (`invalid_target`, HTTP 400) — the `resource` value does not match a granted resource exactly, byte for byte (a trailing slash counts). + +**Operator step.** For each MCP server that exchanges for a downstream resource it does not itself act as, allow-list its client id on that 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. + ## 5. Main API reference ### `AuthplaneMcpAuth.Options` @@ -126,7 +160,7 @@ public static Task TryWithUrlElicitationAsync(Func> body); ## 6. Configuration -`AuthplaneMcpAuth.Options` covers the issuer, resource URI, scopes, and the `devMode` toggle. For finer-grained control of outbound HTTP (timeouts, SSRF policy), construct an `AuthplaneClient` yourself with explicit `FetchSettings` and pass the resulting `AuthplaneResource` to the DI container — the middleware uses whichever resource is registered. +`AuthplaneMcpAuth.Options` covers the issuer, resource URI, scopes, the `devMode` toggle, the challenge `realm`, inbound DPoP, and `resourceMetadataUrl` (see [Where the PRM document lives](#where-the-prm-document-lives)). For finer-grained control of outbound HTTP (timeouts, SSRF policy), construct an `AuthplaneClient` yourself with explicit `FetchSettings` and pass the resulting `AuthplaneResource` to the DI container — the middleware uses whichever resource is registered. ## 7. Intermediate features @@ -143,8 +177,59 @@ The middleware translates the SDK exception hierarchy into HTTP responses, inclu | `TokenMissingException`, `TokenExpiredException`, `InvalidSignatureException`, `InvalidClaimsException` | 401 | | `InsufficientScopeException` | 403 | | `DPoPProofMissingException`, `InvalidDPoPProofException`, `DPoPBindingMismatchException`, `DPoPReplayDetectedException` | 401 | -| `JwksFetchException`, `MetadataFetchException` | 502 | -| `CircuitOpenException` | 503 | +| `JwksFetchException`, `MetadataFetchException` | 503 | +| `CircuitOpenException`, `ProtocolException`, `VerifierRuntimeException` | 500 | + +Every 401 and 403 also carries an RFC 6750 §3 JSON body +(`application/json; charset=utf-8`) whose `error` and `error_description` are +the same pair the `WWW-Authenticate` challenge names, so a client reading +either half sees the same answer: + +```json +{"error":"invalid_token","error_description":"The access token is missing or not valid for this resource"} +``` + +A 5xx carries a body of the same shape but no challenge: telling a client to +fix credentials that are not the problem sends it round a loop it cannot exit. +Its `error` is `temporarily_unavailable` on a 503, where the authorization +server is unreachable from here and the condition is worth retrying, and +`server_error` on a 500, where this resource server is at fault: + +```json +{"error":"temporarily_unavailable","error_description":"The authorization server cannot be reached from this resource server"} +``` + +A request that carried no credentials at all — no `Authorization` header, an +unrecognized scheme, or an empty token — is the one case with no `error` on +either side. RFC 6750 §3 has the challenge omit it, because the error codes +describe a request that did authenticate and failed, and the body omits it for +the same reason: + +```json +{"error_description":"The request did not carry a usable access token"} +``` + +A client should read that absence the way the challenge already reads: begin +discovery and authenticate. + +The description is a fixed sentence chosen by the `error` code, never the +exception's own message — the SDK's messages name the unknown `kid`, the claim +that did not validate, or the `aud` the resource expects, and the caller here +has by definition not authenticated. + +The detail is not lost. Before writing any of these responses the middleware +logs the exception under the `Authplane.Mcp` category: at `Error` for a 5xx, +which is this server's own fault and which you want to see without having been +told to look, and at `Debug` for a rejection, since reaching a rejection takes +no credentials and logging every one higher would let an unauthenticated caller +choose your log volume. Raise the category while diagnosing: + +```json +{ "Logging": { "LogLevel": { "Authplane.Mcp": "Debug" } } } +``` + +Logging is optional: a host with no `ILoggerFactory` registered gets no log +lines and no error. ## 8. Advanced features diff --git a/src/Authplane/AuthplaneClient.cs b/src/Authplane/AuthplaneClient.cs index 29676bc..051626f 100644 --- a/src/Authplane/AuthplaneClient.cs +++ b/src/Authplane/AuthplaneClient.cs @@ -12,6 +12,38 @@ public sealed class AuthplaneClient : IAsyncDisposable private readonly HttpClient _httpClient; private readonly JwksCache _jwksCache; private readonly MetadataCache _metadataCache; + private int _disposed; + + /// + /// Counts clients constructed and disposed while attached. Lifetime assertions need + /// this because the resources a client owns — the and the + /// JWKS refresh state — are reachable only through the instance, so a client this + /// assembly builds on the caller's behalf and then abandons cannot be observed any + /// other way. + /// + internal sealed class LifetimeProbe + { + private int _constructed; + private int _disposed; + + internal int Constructed => Volatile.Read(ref _constructed); + + internal int Disposed => Volatile.Read(ref _disposed); + + /// Constructed clients not yet released. Must be zero once a call returns. + internal int Live => Constructed - Disposed; + + internal void NoteConstructed() => Interlocked.Increment(ref _constructed); + + internal void NoteDisposed() => Interlocked.Increment(ref _disposed); + } + + /// + /// Attachment point for a . Scoped to the current async + /// control flow rather than the process, so an assertion sees only the clients its own + /// call built and is unaffected by clients other tests construct in parallel. + /// + internal static readonly AsyncLocal Probe = new(); private AuthplaneClient(string issuer, FetchSettings fetchSettings) { @@ -84,6 +116,10 @@ private AuthplaneClient(string issuer, FetchSettings fetchSettings) return new JwksFetchResult(jwks, serverTtl); }, refreshInterval: TimeSpan.FromMinutes(5)); + + // Last statement in the constructor: every field that DisposeAsync releases is + // now assigned, so a counted instance is always a disposable one. + Probe.Value?.NoteConstructed(); } public string Issuer { get; } @@ -104,9 +140,26 @@ public static async Task CreateAsync( var settings = fetchSettings ?? FetchSettings.FromDevMode(devMode: false); var client = new AuthplaneClient(issuer, settings); - // Force initial metadata fetch so a bad issuer fails at CreateAsync rather than - // at the first token-verify call. - await client._metadataCache.GetAsync(cancellationToken).ConfigureAwait(false); + + // The client is owned here until it is returned: the priming fetch throws on an + // unreachable AS, on metadata that does not validate, and when the caller's token + // is cancelled, and every one of those exits would otherwise abandon a client + // nothing can reach — its HttpClient and handler, the metadata cache's semaphore + // and background refresh, and the JWKS cache's gate are released only by + // DisposeAsync. A caller retrying a flapping AS accumulates one set per attempt. + // Dispose it here and let the original failure propagate. + try + { + // Force initial metadata fetch so a bad issuer fails at CreateAsync rather than + // at the first token-verify call. + await client._metadataCache.GetAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await client.DisposeAsync().ConfigureAwait(false); + throw; + } + return client; } @@ -118,9 +171,11 @@ public static async Task CreateAsync( /// /// Resource identifier this RS publishes (RFC 9728). /// Must be an absolute URL with a scheme and a host (RFC 8707 §2, - /// RFC 9728 §3) and must not contain a fragment component (RFC 8707 §2, - /// RFC 9728 §1.2); violations are rejected here rather than silently - /// producing a malformed metadata URL. + /// RFC 9728 §3) whose host is within the RFC 3986 §3.2.2 production — an + /// internationalized host belongs in a URI as its A-label — and must not + /// contain a fragment component (RFC 8707 §2, RFC 9728 §1.2); violations are + /// rejected here rather than silently producing a malformed metadata + /// URL. /// Scopes this RS requires; surfaced in PRM and on 401 /// challenges. /// Optional revocation hook (RFC 7009). @@ -364,8 +419,17 @@ private static async Task FetchMetadataAsync( public async ValueTask DisposeAsync() { + // Idempotent: a caller that disposes a client this assembly already disposed on a + // failed construction path must not double-release, and the live count has to + // move exactly once per instance to mean anything. + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + await _jwksCache.DisposeAsync().ConfigureAwait(false); await _metadataCache.DisposeAsync().ConfigureAwait(false); _httpClient.Dispose(); + Probe.Value?.NoteDisposed(); } } diff --git a/src/Authplane/Errors.cs b/src/Authplane/Errors.cs index 644f8cb..240a243 100644 --- a/src/Authplane/Errors.cs +++ b/src/Authplane/Errors.cs @@ -167,6 +167,17 @@ public sealed class DPoPNotSupportedException : DPoPException public DPoPNotSupportedException(string message) : base(message) { } } +/// +/// Raised when the revocation checker reports the token as revoked, or when +/// the check itself failed under failClosed. With +/// , active=false for a token +/// that already passed local JWT verification usually means the AS did not +/// recognise this resource server as the token's owner rather than a real +/// revocation: authserver ≥ 0.1.2 answers active=false unless the +/// introspecting client is the issuing client or a runtime-client of the +/// Resource named in aud +/// (authserver admin resource runtime-client add --client-id <rs-client-id> --slug <resource-slug>). +/// public sealed class TokenRevokedException : AuthplaneException { public TokenRevokedException(string message) : base(message) { } @@ -310,6 +321,33 @@ public ConsentRequiredException( } } +/// +/// Thrown when the AS refuses a token exchange with access_denied +/// (HTTP 403): the exchanging client is not on the target Resource's exchange +/// allow-list. Unlike , no user +/// interaction can satisfy it — the operator has to allow-list the client on +/// the Resource. Not an AS outage; ignores it. +/// +public sealed class AccessDeniedException : AuthplaneTokenRequestException +{ + public AccessDeniedException(string message, int? httpStatus, + string? errorDescription = null, string? errorUri = null) + : base(message, OAuthConstants.ErrorCodes.AccessDenied, httpStatus, errorDescription, errorUri) { } +} + +/// +/// Thrown when the AS rejects the resource parameter with +/// invalid_target (RFC 8707 §2.2): the value does not match a granted +/// resource byte for byte (a trailing slash counts). Not an AS outage; +/// ignores it. +/// +public sealed class InvalidTargetException : AuthplaneTokenRequestException +{ + public InvalidTargetException(string message, int? httpStatus, + string? errorDescription = null, string? errorUri = null) + : base(message, OAuthConstants.ErrorCodes.InvalidTarget, httpStatus, errorDescription, errorUri) { } +} + /// /// Wraps a malformed or unexpected token endpoint response body. /// Inherits from so callers can @@ -362,21 +400,19 @@ public static class AuthplaneErrors /// DPoP-Nonce header is unsatisfiable (RFC 9449 §9). /// public static string WwwAuthenticate(AuthplaneException error, string realm = "") + => WwwAuthenticate(error, realm, verboseDescription: false); + + /// + /// with + /// restoring + /// error.Message in error_description, which is what this + /// helper emitted before the description became a fixed per-code sentence. + /// A development aid: the challenge reaches a caller who has not + /// authenticated, so do not enable it in production. + /// + public static string WwwAuthenticate(AuthplaneException error, string realm, bool verboseDescription) { - var errorCode = error switch - { - InsufficientScopeException => OAuthConstants.ErrorCodes.InsufficientScope, - // RFC 9449 §7.1 prescribes `invalid_dpop_proof` for §4.3 - // cardinality rejections, and §9 prescribes `use_dpop_nonce` - // for nonce-policy rejections. This helper only builds the - // challenge value — the DPoP-Nonce response header the §9 - // choreography also requires comes from ResponseHeaders, which - // the adapter (having the response in hand) must apply. The - // other DPoP failures keep `invalid_token`. - DPoPMultipleProofsException => OAuthConstants.ErrorCodes.InvalidDPoPProof, - DPoPNonceRequiredException => OAuthConstants.ErrorCodes.UseDpopNonce, - _ => OAuthConstants.ErrorCodes.InvalidToken, - }; + var errorCode = ErrorCodeFor(error); // DPoPNotSupportedException is thrown by a resource that does NOT // accept DPoP — answering it with a `DPoP …` challenge would tell // the client to negotiate DPoP and have the next request rejected @@ -392,10 +428,182 @@ public static string WwwAuthenticate(AuthplaneException error, string realm = "" parts.Add($"realm=\"{EscapeQuotedString(realm)}\""); } parts.Add($"error=\"{errorCode}\""); - parts.Add($"error_description=\"{EscapeQuotedString(error.Message)}\""); + parts.Add($"error_description=\"{EscapeQuotedString(ErrorDescription(errorCode, error, verboseDescription))}\""); return $"{scheme} " + string.Join(", ", parts); } + /// + /// The RFC 6750 §3.1 / RFC 9449 §7.1 error code this exception is answered + /// with. Shared by + /// and so the challenge and the body that + /// travel in the same response can never name different codes. + /// + public static string ErrorCodeFor(AuthplaneException error) => error switch + { + InsufficientScopeException => OAuthConstants.ErrorCodes.InsufficientScope, + // RFC 9449 §7.1 prescribes `invalid_dpop_proof` for §4.3 + // cardinality rejections, and §9 prescribes `use_dpop_nonce` + // for nonce-policy rejections. The challenge builder only produces + // the header value — the DPoP-Nonce response header the §9 + // choreography also requires comes from ResponseHeaders, which + // the adapter (having the response in hand) must apply. The + // other DPoP failures keep `invalid_token`. + DPoPMultipleProofsException => OAuthConstants.ErrorCodes.InvalidDPoPProof, + DPoPNonceRequiredException => OAuthConstants.ErrorCodes.UseDpopNonce, + _ => OAuthConstants.ErrorCodes.InvalidToken, + }; + + /// + /// The error_description emitted for an error code. + /// + /// Both the challenge and the JSON body are served to a caller who by + /// definition has not authenticated, so the description is built from the + /// error code and never from the exception'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 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. + /// + private static readonly System.Collections.Generic.Dictionary SafeErrorDescriptions = new() + { + [OAuthConstants.ErrorCodes.InvalidToken] = "The access token is missing or not valid for this resource", + [OAuthConstants.ErrorCodes.InsufficientScope] = "The access token does not carry the scope this operation requires", + [OAuthConstants.ErrorCodes.InvalidDPoPProof] = "The DPoP proof is missing or not valid for this request", + // The 5xx case, where the server side is at fault. Same text + // @authplane/mcp emits on its own 500 fallback, so a caller reading + // two SDKs of this family reads one sentence. + [ServerErrorCode] = "Internal Server Error", + // A 503 is the authorization server being unreachable from here — not + // this resource server failing — so the caller reads a transient + // condition rather than a defect it might report. + [TemporarilyUnavailableCode] = "The authorization server cannot be reached from this resource server", + }; + + /// + /// Covers an error code with no entry in SafeErrorDescriptions — any + /// code added without a matching row, and today use_dpop_nonce, which + /// no sibling SDK carries a row for. Kept deliberately contentless for the + /// same reason the table exists. + /// + public const string FallbackErrorDescription = "The request could not be authenticated"; + + /// + /// The error_description for a request that presented no + /// credentials at all. This case carries no error: RFC 6750 §3.1 + /// reserves the error codes for a request that did authenticate and + /// failed, and ties invalid_request to a malformed request answered + /// with 400 — a plain "please authenticate" 401 is neither. §3 accordingly + /// has the challenge omit error, and + /// 's no-argument form omits it from the + /// body for the same reason, so the two halves still agree. + /// + /// Use that no-argument form only when composing a response without an + /// exception in hand: + /// always emits an error parameter, and a + /// TokenMissingException passed to it reaches + /// 's invalid_token fallthrough. An adapter + /// holding the exception should key both halves off + /// ErrorCodeFor(ex) so they cannot name different codes. + /// + public const string MissingCredentialsDescription = + "The request did not carry a usable access token"; + + /// + /// RFC 6749 §5.2 server_error — the body's error when the + /// failure is the server's, not the caller's credentials'. No challenge + /// accompanies it: telling a client to fix credentials that are not the + /// problem would send it round a loop it cannot exit. + /// + public const string ServerErrorCode = "server_error"; + + /// + /// RFC 6749 §5.2 temporarily_unavailable — the body's error + /// when the failure is a 503. server_error reads as a fault in this + /// resource server; a 503 here is the authorization server being + /// unreachable, which is transient and worth retrying, and the two are + /// worth telling apart in a client's logs. Only + /// and reach it: a + /// CircuitOpenException is 500 by the default arm of + /// , deliberately. + /// + public const string TemporarilyUnavailableCode = "temporarily_unavailable"; + + /// + /// The body's error for a server-side failure, chosen by the status + /// produced. Keyed off the status rather than the + /// exception type so the code and the status cannot drift apart. + /// + public static string ServerErrorCodeFor(int httpStatus) => + httpStatus == 503 ? TemporarilyUnavailableCode : ServerErrorCode; + + /// + /// The fixed, caller-safe error_description for an error code, + /// for adapters that compose a challenge from a code they already know + /// rather than from an exception. + /// + public static string ErrorDescriptionFor(string? errorCode) + => string.IsNullOrEmpty(errorCode) + ? MissingCredentialsDescription + : ErrorDescription(errorCode, null, false); + + private static string ErrorDescription(string? errorCode, AuthplaneException? error, bool verbose) + { + if (verbose && error is not null) + { + return error.Message; + } + + // Dictionary.TryGetValue throws on a null key; the fallback's contract is + // "any code with no matching row", which a caller holding none is. + return errorCode is not null && SafeErrorDescriptions.TryGetValue(errorCode, out var description) + ? description + : FallbackErrorDescription; + } + + /// + /// Build the RFC 6750 §3 JSON error body served alongside the challenge. + /// + /// Pass null or empty for a request + /// that presented no credentials: the body then carries + /// and no error + /// member, matching the challenge that omits error for the same + /// case. A client branching on error should read its absence as + /// "authenticate", which is what the challenge already says. + /// + /// is used only by + /// , the development-only escape + /// hatch that puts the exception's message back on the wire. + /// + public static string ErrorResponseBody( + string? errorCode = null, + AuthplaneException? error = null, + bool verboseDescription = false) + { + // Serialized rather than string-interpolated: the verbose form puts + // error.Message back on the wire, and the verifier interpolates + // token-controlled header values into those messages, so the body has + // to be escaped by something that knows the JSON rules (RFC 8259 §7). + var body = new System.Collections.Generic.Dictionary(); + if (!string.IsNullOrEmpty(errorCode)) + { + body["error"] = errorCode; + } + + body["error_description"] = string.IsNullOrEmpty(errorCode) + ? (verboseDescription && error is not null ? error.Message : MissingCredentialsDescription) + : ErrorDescription(errorCode, error, verboseDescription); + + return System.Text.Json.JsonSerializer.Serialize(body); + } + /// /// RFC 7235 §2.2.1 quoted-string escape: backslash-prefix any embedded /// " or \. SDK-generated messages don't contain these today, @@ -440,7 +648,7 @@ private static string EscapeQuotedString(string value) /// /// Map an to an HTTP status code. - /// Pair with and + /// Pair with and /// when building an error response. /// public static int HttpStatus(AuthplaneException error) => error switch @@ -464,7 +672,7 @@ private static string EscapeQuotedString(string value) /// /// Extra response headers a correct error response must carry alongside - /// the code and + /// the code and /// challenge. Today the only entry is DPoP-Nonce for /// : RFC 9449 §9 requires the /// fresh nonce on the use_dpop_nonce 401, and without it a @@ -561,6 +769,8 @@ public static AuthplaneAuthClientException MapOAuthError( OAuthConstants.ErrorCodes.InvalidScope => new InvalidScopeException(defaultMessage, httpStatus, errorDescription, errorUri), OAuthConstants.ErrorCodes.InvalidRequest => new InvalidRequestException(defaultMessage, httpStatus, errorDescription, errorUri), OAuthConstants.ErrorCodes.UnsupportedGrantType => new UnsupportedGrantTypeException(defaultMessage, httpStatus, errorDescription, errorUri), + OAuthConstants.ErrorCodes.AccessDenied => new AccessDeniedException(defaultMessage, httpStatus, errorDescription, errorUri), + OAuthConstants.ErrorCodes.InvalidTarget => new InvalidTargetException(defaultMessage, httpStatus, errorDescription, errorUri), _ => new AuthplaneTokenRequestException(defaultMessage, oauthError, httpStatus, errorDescription, errorUri), }; } diff --git a/src/Authplane/Internal/ResourceIdentifiers.cs b/src/Authplane/Internal/ResourceIdentifiers.cs index 75144e8..885b217 100644 --- a/src/Authplane/Internal/ResourceIdentifiers.cs +++ b/src/Authplane/Internal/ResourceIdentifiers.cs @@ -6,6 +6,16 @@ namespace Authplane; /// internal static class ResourceIdentifiers { + /// + /// What the gates call the value they rejected. These same gates guard more + /// than one operator-supplied setting that reaches the + /// resource_metadata quoted-string, so the sentence has to name the + /// one the operator actually typed — an operator who put a trailing space + /// in resourceMetadataUrl and is told "Resource identifier must not + /// contain whitespace" goes looking at the wrong setting. + /// + internal const string DefaultSubject = "Resource identifier"; + /// /// Reject a resource identifier carrying a URI fragment. /// @@ -13,16 +23,18 @@ internal static class ResourceIdentifiers /// §1.2 restates it in the definition of the resource identifier — "a URL /// that uses the https scheme and has no fragment component." /// - /// Without this gate the fragment is silently dropped rather than rejected: - /// derives the - /// well-known URL from Uri.AbsolutePath and the authority, neither of - /// which carries the fragment, while AuthplaneResource.Resource keeps - /// the identifier verbatim and emits it as the PRM resource field. - /// The served document then names a resource that differs from the URL it - /// was fetched from, and RFC 9728 §3.3 requires a conformant client to - /// discard it — an interop failure with no error anywhere on the server - /// side. Failing at construction turns that into a startup error the - /// operator can act on. + /// Without this gate the fragment flows into the derived well-known URL: + /// slices the + /// path and query off the original identifier string, relying on this gate + /// for the guarantee that nothing follows them. A trailing #frag + /// would ride the slice into the advertised resource_metadata + /// value; a fetch of that URL strips the fragment on the wire (RFC 3986 + /// §3.5), so the served document — whose resource field emits the + /// identifier verbatim, fragment included — names a resource that differs + /// from what the client can ever fetch, and RFC 9728 §3.3 requires a + /// conformant client to discard it — an interop failure with no error + /// anywhere on the server side. Failing at construction turns that into a + /// startup error the operator can act on. /// /// The check is a literal '#' scan rather than a parse: '#' is the only /// fragment delimiter (RFC 3986 §3.5), a percent-encoded %23 is data @@ -31,14 +43,15 @@ internal static class ResourceIdentifiers /// /// The operator-configured resource identifier. /// Name of the caller's parameter, for the exception. + /// What to call the value in the message; defaults to the resource identifier. /// The identifier contains a fragment. - internal static void ThrowIfFragment(string resource, string paramName) + internal static void ThrowIfFragment(string resource, string paramName, string subject = DefaultSubject) { var fragmentStart = resource.IndexOf('#', StringComparison.Ordinal); if (fragmentStart >= 0) { throw new ArgumentException( - "Resource identifier must not contain a fragment component " + + $"{subject} must not contain a fragment component " + $"(RFC 8707 §2, RFC 9728 §1.2), got '{Redact(resource, fragmentStart)}'.", paramName); } @@ -68,8 +81,9 @@ internal static void ThrowIfFragment(string resource, string paramName) /// /// The operator-configured resource identifier. /// Name of the caller's parameter, for the exception. + /// What to call the value in the message; defaults to the resource identifier. /// The query is not a valid RFC 3986 §3.4 query. - internal static void ThrowIfInvalidQuery(string resource, string paramName) + internal static void ThrowIfInvalidQuery(string resource, string paramName, string subject = DefaultSubject) { var queryStart = resource.IndexOf('?', StringComparison.Ordinal); if (queryStart < 0) @@ -88,7 +102,7 @@ internal static void ThrowIfInvalidQuery(string resource, string paramName) { var escape = resource[i..Math.Min(i + 3, resource.Length)]; throw new ArgumentException( - $"Resource identifier query contains a malformed percent-encoding ('{escape}' at offset {i}); " + $"{subject} query contains a malformed percent-encoding ('{escape}' at offset {i}); " + $"every '%' must be followed by two hex digits (RFC 3986 §2.1), got '{Redact(resource, resource.Length)}'.", paramName); } @@ -100,7 +114,7 @@ internal static void ThrowIfInvalidQuery(string resource, string paramName) if (!IsQueryChar(c)) { throw new ArgumentException( - $"Resource identifier query contains a character ('{c}') at offset {i} outside the RFC 3986 §3.4 " + $"{subject} query contains a character ('{c}') at offset {i} outside the RFC 3986 §3.4 " + $"query production; percent-encode it in the configured identifier, got '{Redact(resource, resource.Length)}'.", paramName); } @@ -119,6 +133,107 @@ private static bool IsQueryChar(char c) => or '!' or '$' or '&' or '\'' or '(' or ')' or '*' or '+' or ',' or ';' or '=' or ':' or '@' or '/' or '?'; + /// + /// Reject a resource identifier whose path is not a valid RFC 3986 §3.3 + /// path production: *( "/" segment ), where a segment is + /// *pchar and pchar is unreserved / pct-encoded / sub-delims + /// / ":" / "@". + /// + /// The derived well-known URL carries the path sliced verbatim off the + /// original identifier string, so whatever the operator configured is what + /// gets advertised. A path outside the production makes that URL not a + /// URI: RFC 3986 §2 limits a URI to a fixed ASCII repertoire, and RFC 8707 + /// §2 requires the resource identifier to be an absolute URI — a raw + /// non-ASCII segment (/café, or a zero-width space) is an IRI shape + /// at best, and a malformed percent-escape (%zz) is in no + /// production at all. Failing at construction turns the misconfiguration + /// into a startup error the operator can act on, rather than an + /// unfetchable advertised URL discovered at request time — or a + /// WWW-Authenticate field value carrying bytes RFC 9110 §5.5 gives + /// no interpretation for, which an ASCII-encoding server stack then maps + /// to ?, handing the client a different, valid-looking URL. + /// + /// The same argument the query gate records applies here verbatim: + /// is public + /// API whose return value a caller may place in a header, or a redirect, + /// of their own with no escaper in the path. What the gate does not touch + /// is any well-formed percent-encoding: %7E, %2F and + /// %23 are inside the production and ride the slice out byte-exact + /// — rejecting the shapes that are not a URI is what makes preserving the + /// ones that are sound. + /// + /// The operator-configured resource identifier. + /// Name of the caller's parameter, for the exception. + /// What to call the value in the message; defaults to the resource identifier. + /// The path is not a valid RFC 3986 §3.3 path. + internal static void ThrowIfInvalidPath(string resource, string paramName, string subject = DefaultSubject) + { + var authorityStart = resource.IndexOf("//", StringComparison.Ordinal); + if (authorityStart < 0) + { + return; + } + + // The path runs from the first '/' or '?' after the authority to the + // query delimiter, the same boundaries the derivation slices at; a + // '?' first means there is no path component at all. + var pathStart = resource.IndexOfAny(['/', '?'], authorityStart + 2); + if (pathStart < 0 || resource[pathStart] == '?') + { + return; + } + + var queryStart = resource.IndexOf('?', pathStart); + var pathEnd = queryStart >= 0 ? queryStart : resource.Length; + + for (var i = pathStart; i < pathEnd; i++) + { + var c = resource[i]; + if (c == '%') + { + if (i + 2 >= pathEnd + || !char.IsAsciiHexDigit(resource[i + 1]) + || !char.IsAsciiHexDigit(resource[i + 2])) + { + var escape = resource[i..Math.Min(i + 3, pathEnd)]; + throw new ArgumentException( + $"{subject} path contains a malformed percent-encoding ('{escape}' at offset {i}); " + + $"every '%' must be followed by two hex digits (RFC 3986 §2.1), got '{Redact(resource, resource.Length)}'.", + paramName); + } + + i += 2; + continue; + } + + if (!IsPathChar(c)) + { + // A non-ASCII character is named by code point, matching the + // control-character message above: 'é' would print, but a + // zero-width space is invisible and half a surrogate pair is + // not a character. + var shown = c <= 0x7E ? $"'{c}'" : $"U+{(int)c:X4}"; + throw new ArgumentException( + $"{subject} path contains a character ({shown}) at offset {i} outside the RFC 3986 §3.3 " + + $"path production; percent-encode it in the configured identifier, got '{Redact(resource, resource.Length)}'.", + paramName); + } + } + } + + /// + /// RFC 3986 §3.3 path characters other than pct-encoded: pchar — + /// unreserved (ALPHA / DIGIT / "-" / "." / "_" / "~"), sub-delims + /// ("!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="), + /// ":" / "@" — plus the "/" segment delimiter. The §3.4 query production + /// is this set plus '?'. + /// + private static bool IsPathChar(char c) => + char.IsAsciiLetterOrDigit(c) + || c is '-' or '.' or '_' or '~' + or '!' or '$' or '&' or '\'' or '(' or ')' or '*' or '+' or ',' or ';' or '=' + or ':' or '@' or '/'; + /// /// Stands in for an identifier the formatter will not echo, because it could /// not be shown to be free of credentials. @@ -154,8 +269,8 @@ private static bool IsQueryChar(char c) => /// with does not work here — on .NET /// it keeps the userinfo it appears to drop. /// - /// Anything that does not yield a host is refused rather than echoed, - /// matching the python sibling's except ValueError. The one exception + /// Anything that does not yield a host is refused rather than echoed: + /// a value that failed to parse cannot be redacted safely. The one exception /// is an identifier with no authority at all and no @ in it — an /// opaque urn:example:api has nowhere to hide a credential, and /// naming it is what makes the error actionable. @@ -196,17 +311,15 @@ private static string Redact(string resource, int fragmentStart) /// anywhere in the string. /// /// Neither character can appear unescaped in an RFC 3986 URI — no grammar - /// production admits them — and does not reject them but - /// silently rewrites: surrounding whitespace is trimmed before parsing, an - /// interior space is escaped to %20 in the derived parts, and a - /// backslash is converted to '/'. AuthplaneResource.Resource keeps - /// the identifier verbatim and emits it as the PRM resource field, - /// so each rewrite makes the served document name a resource that differs - /// from the URL it was derived from. None of these rewrites is an RFC 3986 - /// equivalence (unlike case, default ports and dot-segments, which are), - /// so RFC 9728 §3.3 requires a conformant client to discard the document — - /// an interop failure with no error anywhere on the server side. Failing - /// at construction turns that into a startup error the operator can act on. + /// production admits them — and the derivation slices the derived document + /// URL off the original identifier string, so either would ride along + /// verbatim: the advertised resource_metadata value would not be a + /// URI, and no client could fetch it. Surrounding whitespace fails more + /// quietly still: + /// trims it before parsing, so the absoluteness gate alone would accept + /// the identifier while the emitted PRM resource field and the + /// derived URL both keep the stray bytes. Failing at construction turns + /// each shape into a startup error the operator can act on. /// /// Runs ahead of at every site, both /// so the error names the actual defect instead of misreporting @@ -217,38 +330,42 @@ private static string Redact(string resource, int fragmentStart) /// Name of the caller's parameter, for the exception. /// The identifier contains whitespace /// or a backslash. - internal static void ThrowIfWhitespaceOrBackslash(string resource, string paramName) + /// What to call the value in the message; defaults to the resource identifier. + internal static void ThrowIfWhitespaceOrBackslash(string resource, string paramName, string subject = DefaultSubject) { foreach (var c in resource) { if (char.IsWhiteSpace(c)) { throw new ArgumentException( - "Resource identifier must not contain whitespace; remove surrounding whitespace, or percent-encode an intentional space as %20 (RFC 3986 §2.1).", + $"{subject} must not contain whitespace; remove surrounding whitespace, or percent-encode an intentional space as %20 (RFC 3986 §2.1).", paramName); } - // C0 controls and DEL, which `Uri` percent-encodes into the derived - // URL while the identifier is emitted verbatim. `char.IsWhiteSpace` - // does not cover them: U+0001 and U+007F are not separators. Closed - // here rather than in a path validator, matching python's whitespace - // gate (`ch.isspace() or ord(ch) <= 0x20`). Its own message, since - // telling an operator to look for a space they cannot see is worse - // than saying nothing. U+200B is deliberately not covered — it is a - // format character above 0x20, and stays with the path-canonicalization - // divergence class recorded at the derivation in - // `OAuthProtectedResourceMetadata`. + // C0 controls and DEL, which no RFC 3986 production admits and the + // byte-exact derivation would carry verbatim into the advertised + // URL. `char.IsWhiteSpace` does not cover them: U+0001 and U+007F + // are not separators. Closed here rather than in a path validator: + // the gate covers `ch <= 0x20` as well as `char.IsWhiteSpace`, + // because U+0001 and U+007F are controls RFC 3986 admits nowhere + // but `IsWhiteSpace` does not report. Its own message, since + // telling an operator to look for a + // space they cannot see is worse than saying nothing. U+200B is + // not covered here — it is a format character above 0x20, neither + // whitespace nor a control; in the path or the query it falls to + // the §3.3/§3.4 production gates, which reject every raw + // non-ASCII character. if (c <= 0x20 || c == 0x7F) { throw new ArgumentException( - $"Resource identifier must not contain a control character (U+{(int)c:X4} at offset {resource.IndexOf(c, StringComparison.Ordinal)}); percent-encode it (RFC 3986 §2.1).", + $"{subject} must not contain a control character (U+{(int)c:X4} at offset {resource.IndexOf(c, StringComparison.Ordinal)}); percent-encode it (RFC 3986 §2.1).", paramName); } if (c == '\\') { throw new ArgumentException( - "Resource identifier must not contain a backslash; percent-encode it as %5C (RFC 3986 §2.1).", + $"{subject} must not contain a backslash; percent-encode it as %5C (RFC 3986 §2.1).", paramName); } } @@ -283,20 +400,33 @@ internal static void ThrowIfWhitespaceOrBackslash(string resource, string paramN /// Name of the caller's parameter, for the exception. /// The identifier is not an absolute /// URL with a scheme and a host. - internal static void ThrowIfNotAbsoluteUrl(string resource, string paramName) + /// What to call the value in the message; defaults to the resource identifier. + internal static void ThrowIfNotAbsoluteUrl(string resource, string paramName, string subject = DefaultSubject) { // Uri.TryCreate trims surrounding whitespace before parsing, so this // gate on its own would accept a non-trimmed identifier; // ThrowIfWhitespaceOrBackslash runs ahead of it at every site and // rejects that shape first. + // The authority delimiter is required rather than inferred from + // `Uri.Host`. A host is a subcomponent of the authority (RFC 3986 §3.2) + // and an authority is introduced by "//", so an identifier without the + // delimiter has no host however the platform parser reports one — and it + // does report one for an opaque identifier whose scheme puts a + // host-shaped string after its ':' (`mailto:ops@example.com` parses to + // Host "example.com"). Such an identifier used to clear this gate on that + // phantom host and get refused one gate later as carrying userinfo, which + // named the wrong defect; and had it cleared both, the derivation would + // have sliced it into `mailto:ops@example.com/.well-known/…`, a URL that + // resolves to nothing and would have been advertised to clients. if (!Uri.TryCreate(resource, UriKind.Absolute, out var uri) || !resource.StartsWith(uri.Scheme + ":", StringComparison.OrdinalIgnoreCase) || + !resource.AsSpan(uri.Scheme.Length + 1).StartsWith("//") || string.IsNullOrEmpty(uri.Host)) { // The identifier is not echoed: it can carry userinfo, matching // ThrowIfFragment and the userinfo guard in GetDocumentUrl. throw new ArgumentException( - "Resource identifier must be an absolute URL with a scheme and a host (RFC 8707 §2, RFC 9728 §3).", + $"{subject} must be an absolute URL with a scheme and a host (RFC 8707 §2, RFC 9728 §3).", paramName); } } @@ -313,9 +443,9 @@ internal static void ThrowIfNotAbsoluteUrl(string resource, string paramName) /// make. fails on it, /// so this runs ahead of the absoluteness gate — behind it the /// parse failure gets there first and the message is the wrong one again. - /// Python orders it last because its absoluteness check does not fail on - /// these shapes; the axis is the same, the position is forced by the - /// platform. + /// The axis is independent of the ordering — the position is forced by + /// the platform's parser, and an implementation whose absoluteness check + /// does not fail on these shapes is free to order it last. /// /// The port is read off the original string, since a value Uri /// would not parse cannot be read back from it. Only the digits are echoed: @@ -323,13 +453,15 @@ internal static void ThrowIfNotAbsoluteUrl(string resource, string paramName) /// was forgotten (https://user:pass/x), so quoting it back would /// defeat the redaction the other gates apply. /// - /// Matches the python sibling, which landed this as its own axis with - /// its own message. + /// Its own axis with its own message, rather than folded into a + /// neighbouring gate — the operator needs to be told which rule the + /// identifier broke. /// /// The operator-configured resource identifier. /// Name of the caller's parameter, for the exception. + /// What to call the value in the message; defaults to the resource identifier. /// The port is malformed or out of range. - internal static void ThrowIfMalformedPort(string resource, string paramName) + internal static void ThrowIfMalformedPort(string resource, string paramName, string subject = DefaultSubject) { var authorityStart = resource.IndexOf("//", StringComparison.Ordinal); if (authorityStart < 0) @@ -373,15 +505,16 @@ internal static void ThrowIfMalformedPort(string resource, string paramName) var allDigits = port.All(char.IsAsciiDigit); // A leading zero is legal `*DIGIT` per RFC 3986 §3.2.3 and is rejected - // anyway, because this SDK derives through Uri.GetLeftPart, which - // re-renders the port: `:0080` emits verbatim and derives `:80`. That is - // the emit-versus-derive divergence this axis exists to make - // unconstructible, and it is not an RFC 3986 §6.2 equivalence — unlike - // host case (§6.2.2.1), dot-segments (§6.2.2.3) and default-port removal - // (§6.2.3), which are, and which the derivation is documented to apply. - // A conformant client discards the mismatch (RFC 9728 §3.3). python and - // go rebuild from `netloc` / `u.Host` and never re-render, so this is a - // cs-only exposure rather than a family gap. + // anyway. The derivation now slices the authority off the original + // string, so `:0080` would emit and derive consistently on this side — + // but stripping the zero is not an RFC 3986 §6.2 equivalence, so a + // client is not entitled to it either, and real URL stacks (this + // runtime's own `Uri` among them) normalize `:0080` to `:80` anyway. A + // client whose stack has normalized its configured identifier + // re-derives a document URL naming `:80` against a served document + // naming `:0080` — the RFC 9728 §3.3 mismatch, moved client-side. + // Rejecting the shape keeps the identifier one that renders the same + // through any stack. var hasLeadingZero = port.Length > 1 && port[0] == '0'; if (allDigits && !hasLeadingZero @@ -393,10 +526,10 @@ internal static void ThrowIfMalformedPort(string resource, string paramName) var shown = allDigits ? $"'{port}'" : "(malformed port)"; var reason = hasLeadingZero - ? "must not carry a leading zero, which the derived metadata URL would strip" + ? "must not carry a leading zero, which normalizing URL parsers strip" : "must be digits in the range 0-65535"; throw new ArgumentException( - $"Resource identifier port {reason} (RFC 3986 §3.2.3), got {shown}.", + $"{subject} port {reason} (RFC 3986 §3.2.3), got {shown}.", paramName); } @@ -411,19 +544,32 @@ internal static void ThrowIfMalformedPort(string resource, string paramName) /// unhandled per-request exception instead of the startup error the /// constructor gates exist to produce. This covers both explicit /// credentials (https://svc:s3cr3t@api.example.com/mcp) and schemes - /// whose syntax puts data in the userinfo slot - /// (mailto:ops@example.com parses with UserInfo "ops" and a - /// non-empty host, so it clears the absoluteness gate). + /// whose syntax puts data in the userinfo slot. + /// + /// Scoped to the authority, because that is where the subcomponent lives. + /// RFC 3986 §3.2 defines authority = [ userinfo "@" ] host [ ":" port ] + /// and an authority is introduced by "//", so an identifier without one has + /// no userinfo subcomponent for an '@' to delimit and the '@' is data. This + /// gate must not claim it: mailto:ops@example.com parses to + /// "ops" and a non-empty , + /// but that is the platform parser modelling an opaque URI through the + /// authority-shaped properties it has, not a userinfo component in the URI. + /// Reporting such an identifier as carrying credentials named the wrong + /// defect and sent the operator looking for a secret that was never there; + /// it is refused for the reason it is actually refused for — an opaque URI + /// has no host, so no metadata URL derives from it — by + /// . /// /// Runs after , which rejects an - /// identifier that does not parse; for such input the repeated parse here - /// fails and this guard is a no-op — safe only because the absoluteness - /// gate has already reported the defect, not because this guard would. + /// identifier that does not parse; for such input this guard is a no-op — + /// safe only because the absoluteness gate has already reported the defect, + /// not because this guard would. /// /// The operator-configured resource identifier. /// Name of the caller's parameter, for the exception. + /// What to call the value in the message; defaults to the resource identifier. /// The identifier contains userinfo. - internal static void ThrowIfUserInfo(string resource, string paramName) + internal static void ThrowIfUserInfo(string resource, string paramName, string subject = DefaultSubject) { // Read off the original string, not `Uri.UserInfo`. That property is the // empty string both when there is no '@' and when the subcomponent is @@ -434,22 +580,177 @@ internal static void ThrowIfUserInfo(string resource, string paramName) // the `resource_metadata` value unauthenticated clients are handed. // `GetLeftPart(UriPartial.Authority)` keeps it too, the same .NET quirk // `Redact`'s doc records for `UriPartial.Path`. - // Two checks, not one. The authority slice catches the empty form, which - // `Uri.UserInfo` cannot see; `Uri.UserInfo` catches a scheme that puts - // data in the userinfo slot without a "//" (`mailto:ops@example.com` - // parses with UserInfo "ops" and a non-empty host), which the slice - // cannot see. Neither subsumes the other. - if (HasAuthorityDelimiter(resource, '@') - || (Uri.TryCreate(resource, UriKind.Absolute, out var uri) - && !string.IsNullOrEmpty(uri.UserInfo))) + // The slice is also what keeps the gate to its own axis. `Uri.UserInfo` + // is non-empty for an opaque identifier whose scheme puts data before an + // '@' with no authority at all, where RFC 3986 §3.2 has no userinfo + // subcomponent to populate; reading it here made this gate reject such an + // identifier as carrying credentials. The absoluteness gate refuses it for + // the reason it is actually refused for. + if (HasAuthorityDelimiter(resource, '@')) { // The identifier is not echoed: it can carry credentials. throw new ArgumentException( - "Resource identifier must not contain a userinfo component (RFC 9110 §4.2.4).", + $"{subject} must not contain a userinfo component (RFC 9110 §4.2.4).", paramName); } } + /// + /// Reject a resource identifier whose host is not an RFC 3986 §3.2.2 + /// host production: IP-literal / IPv4address / reg-name, + /// where reg-name is *( unreserved / pct-encoded / sub-delims ). + /// + /// The other gates over the authority are not character productions: the + /// host's only constraints were whitespace and control characters, the + /// malformed-port gate, and the userinfo gate. An internationalized host + /// therefore cleared all of them — é is above 0x20 and is not + /// whitespace, there is no ':' for the port gate to read and no '@' for the + /// userinfo gate, and + /// parses an IDN host to a non-empty , so the + /// absoluteness gate saw a scheme and a host and passed it. + /// + /// slices the + /// authority verbatim off the original string, so such a host rode into the + /// derived URL and out to unauthenticated clients in the + /// resource_metadata parameter of a WWW-Authenticate + /// challenge — a value RFC 9110 §5.5 gives no interpretation for outside + /// ASCII. RFC 3986 §3.2.2 reg-name admits only the ASCII repertoire, + /// and an internationalized name belongs in a URI as its A-label + /// (xn--caf-dma.example.com), so the derived URL was not a URI and + /// RFC 8707 §2 requires the identifier to be one. + /// + /// Runs ahead of , for the reason + /// the malformed-port gate records: + /// fails on a malformed percent-escape in the host, so behind the absoluteness + /// gate that parse failure gets there first and the operator is told the + /// identifier is not an absolute URL when the actual defect is one character in + /// the host. The two authority gates group together for the same reason. It + /// therefore also runs on a string not yet known to be an absolute URL, which + /// costs nothing: the host slice is keyed off the "//" delimiter, so an + /// identifier without an authority is a no-op here and the absoluteness gate + /// behind it still reports it. + /// + /// Converting to an A-label here is deliberately not attempted. The + /// identifier's identity is exact-string, and the served document's + /// resource member emits the configured bytes verbatim; encoding the + /// host on the way into the derived URL alone would make the advertised URL + /// disagree with the one a client re-derives from its own copy of the + /// identifier, which is the mismatch RFC 9728 §3.3 has it discard the + /// document over. Producing the A-label is the operator's job, and the + /// message says so. + /// + /// The operator-configured resource identifier. + /// Name of the caller's parameter, for the exception. + /// What to call the value in the message; defaults to the resource identifier. + /// The host is not a valid RFC 3986 §3.2.2 host. + internal static void ThrowIfInvalidHost(string resource, string paramName, string subject = DefaultSubject) + { + var authorityStart = resource.IndexOf("//", StringComparison.Ordinal); + if (authorityStart < 0) + { + return; + } + + authorityStart += 2; + var authorityEnd = resource.IndexOfAny(['/', '?', '#'], authorityStart); + if (authorityEnd < 0) + { + authorityEnd = resource.Length; + } + + if (authorityEnd <= authorityStart) + { + return; + } + + // Host boundaries, matching the malformed-port gate: after any userinfo, + // and up to the port delimiter, which is the first ':' outside an IPv6 + // literal's brackets (RFC 3986 §3.2.2). + var hostStart = resource.LastIndexOf('@', authorityEnd - 1, authorityEnd - authorityStart) + 1; + if (hostStart <= 0) + { + hostStart = authorityStart; + } + + if (hostStart >= authorityEnd) + { + // The authority is userinfo and nothing else ("https://user@"), so + // there is no host to read a character out of. The absoluteness + // gate behind this one reports the missing host with a message; + // indexing here would fail with no message at all. + return; + } + + var hostEnd = authorityEnd; + var bracket = resource.LastIndexOf(']', authorityEnd - 1, authorityEnd - hostStart); + var searchFrom = bracket >= 0 ? bracket + 1 : hostStart; + if (searchFrom < authorityEnd) + { + var colon = resource.IndexOf(':', searchFrom, authorityEnd - searchFrom); + if (colon >= 0) + { + hostEnd = colon; + } + } + + // An IP-literal is bracketed, and its body admits ':' for IPv6 plus the + // RFC 6874 zone identifier, whose "%25" prefix and name are pct-encoded + // and unreserved characters. Everything else is a reg-name. + var isIpLiteral = resource[hostStart] == '['; + var scanStart = isIpLiteral ? hostStart + 1 : hostStart; + var scanEnd = isIpLiteral && hostEnd > scanStart && resource[hostEnd - 1] == ']' + ? hostEnd - 1 + : hostEnd; + + for (var i = scanStart; i < scanEnd; i++) + { + var c = resource[i]; + if (c == '%') + { + if (i + 2 >= scanEnd + || !char.IsAsciiHexDigit(resource[i + 1]) + || !char.IsAsciiHexDigit(resource[i + 2])) + { + var escape = resource[i..Math.Min(i + 3, scanEnd)]; + throw new ArgumentException( + $"{subject} host contains a malformed percent-encoding ('{escape}' at offset {i}); " + + $"every '%' must be followed by two hex digits (RFC 3986 §2.1), got '{Redact(resource, resource.Length)}'.", + paramName); + } + + i += 2; + continue; + } + + if (IsRegNameChar(c) || (isIpLiteral && c == ':')) + { + continue; + } + + // Named by code point past ASCII, matching the path and query gates: + // 'é' would print, but a zero-width space is invisible and half a + // surrogate pair is not a character. + var shown = c <= 0x7E ? $"'{c}'" : $"U+{(int)c:X4}"; + throw new ArgumentException( + $"{subject} host contains a character ({shown}) at offset {i} outside the RFC 3986 §3.2.2 " + + "host production, which admits only ASCII; configure an internationalized host as its A-label " + + $"(for example 'xn--caf-dma.example.com' rather than 'café.example.com'), got '{Redact(resource, resource.Length)}'.", + paramName); + } + } + + /// + /// RFC 3986 §3.2.2 reg-name characters other than pct-encoded: + /// unreserved (ALPHA / DIGIT / "-" / "." / "_" / "~") and sub-delims + /// ("!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="). + /// Neither ':' nor '@' is included: they delimit the port and the userinfo, + /// and both sit outside the host slice this is applied to. + /// + private static bool IsRegNameChar(char c) => + char.IsAsciiLetterOrDigit(c) + || c is '-' or '.' or '_' or '~' + or '!' or '$' or '&' or '\'' or '(' or ')' or '*' or '+' or ',' or ';' or '='; + /// /// Whether the authority component of contains /// . diff --git a/src/Authplane/Metadata/OAuthProtectedResourceMetadata.cs b/src/Authplane/Metadata/OAuthProtectedResourceMetadata.cs index bb45511..23db08c 100644 --- a/src/Authplane/Metadata/OAuthProtectedResourceMetadata.cs +++ b/src/Authplane/Metadata/OAuthProtectedResourceMetadata.cs @@ -8,19 +8,27 @@ public static class OAuthProtectedResourceMetadata /// /// RFC 9728 §3.1 — absolute URL of the Protected Resource Metadata document for . /// Path template: /.well-known/oauth-protected-resource{resource-path}{resource-query}. - /// Trailing slashes on the resource path are dropped, so identifiers differing only by - /// a trailing slash resolve to the same metadata document. A query component on the - /// identifier is preserved: RFC 9728 §3 inserts the well-known string "between the host - /// component and the path and/or query components, if any". Only the well-known path - /// derivation normalizes — the resource identifier itself stays exact-string everywhere else. + /// The derived URL is byte-exact with respect to the identifier: every component is + /// sliced off the original string, so the result is the identifier with the well-known + /// string inserted between the authority and the path. Exactly two transformations + /// apply: trailing slashes on the resource path are dropped per RFC 9728 §3.1, so + /// identifiers differing only by a trailing slash resolve to the same metadata + /// document, and a bare trailing ? — an empty query — derives the query-less + /// URL. A non-empty query component is preserved: RFC 9728 §3 inserts the well-known + /// string "between the host component and the path and/or query components, if any". + /// The resource identifier itself stays exact-string everywhere else. /// /// The resource identifier. Must be an absolute URL carrying no /// fragment component (RFC 8707 §2, RFC 9728 §1.2) and no userinfo. A percent-encoded /// %23 is path data, not a fragment, and stays accepted. /// The absolute URL of the metadata document for . /// is null, empty or - /// whitespace, or carries a fragment component or userinfo. Carrying a fragment previously - /// returned a URL with the fragment silently dropped. + /// whitespace; carries a fragment component (previously the URL was returned with the + /// fragment silently dropped) or a userinfo component; contains whitespace, a C0 control + /// or DEL, or a backslash; carries a malformed or leading-zero port; is not an absolute + /// URL with a scheme and a host; or carries a host, path or query outside its RFC 3986 + /// production (§3.2.2 / §3.3 / §3.4) — a raw non-ASCII character or a malformed + /// percent-escape anywhere in any of the three components. public static string GetDocumentUrl(string resourceUrl) { ArgumentException.ThrowIfNullOrWhiteSpace(resourceUrl); @@ -39,46 +47,91 @@ public static string GetDocumentUrl(string resourceUrl) ResourceIdentifiers.ThrowIfFragment(resourceUrl, nameof(resourceUrl)); ResourceIdentifiers.ThrowIfWhitespaceOrBackslash(resourceUrl, nameof(resourceUrl)); ResourceIdentifiers.ThrowIfMalformedPort(resourceUrl, nameof(resourceUrl)); + ResourceIdentifiers.ThrowIfInvalidHost(resourceUrl, nameof(resourceUrl)); ResourceIdentifiers.ThrowIfNotAbsoluteUrl(resourceUrl, nameof(resourceUrl)); ResourceIdentifiers.ThrowIfUserInfo(resourceUrl, nameof(resourceUrl)); + ResourceIdentifiers.ThrowIfInvalidPath(resourceUrl, nameof(resourceUrl)); ResourceIdentifiers.ThrowIfInvalidQuery(resourceUrl, nameof(resourceUrl)); - var uri = new Uri(resourceUrl, UriKind.Absolute); + // Every component is sliced off the original identifier string; the + // parsed `Uri` is never consulted. `Uri` canonicalizes on construction + // and re-renders what it hands back: `Uri.AbsolutePath` unescapes + // percent-encodings of unreserved characters (`%7E` becomes `~`) and + // applies RFC 3986 §5.2.4 dot-segment removal; + // `Uri.GetLeftPart(UriPartial.Authority)` lowercases the scheme and + // host, removes a default port, and drops an IPv6 zone identifier + // (`[fe80::1%25eth0]` becomes `[fe80::1]`). The served document's + // `resource` member carries the configured bytes verbatim, so any + // re-rendering here makes the advertised document URL disagree with + // the URL a client re-derives from its own copy of the identifier — + // the mismatch RFC 9728 §3.3 has it discard the document over. Those + // rewrites are RFC 3986 §6.2 equivalences a recipient MAY apply, but + // the identifier's identity is exact-string, so the derivation + // preserves the configured bytes and leaves normalization to parties + // entitled to it. `Uri` also rewrote shapes that are not URIs at all — + // it re-encoded a raw non-ASCII segment (`/café` became `/caf%C3%A9`), + // percent-encoded a zero-width space, and repaired a malformed + // percent-escape (`/m%zzcp` became `/m%25zzcp`); on the path and + // query those shapes never reach the slice — the §3.3 and §3.4 + // production gates above reject them at construction. The host is + // gated the same way: `ThrowIfInvalidHost` is the §3.2.2 production + // over it, so an IDN host (`https://café.example.com/mcp`) is refused + // at construction rather than sliced into the derived URL — a non-URI, + // since `reg-name` admits only ASCII and an IDN belongs in a URI as + // its A-label. Converting it here instead is deliberately not done: + // the served document emits the identifier verbatim, so encoding the + // host only on the way into the derived URL would create the §3.3 + // mismatch. Producing the A-label is the operator's job. + // + // The slice boundaries lean on the gates above. The absoluteness gate + // guarantees the string leads with its scheme, and RFC 3986 §3.1 + // admits no ':' inside one, so the first ':' ends it. The authority + // then runs to the first '/' or '?': the userinfo gate leaves no + // userinfo to hide either character in, an IPv6 literal admits + // neither (RFC 3986 §3.2.2), and the fragment gate guarantees no '#' + // anywhere in the string. The "//" check is defensive rather than + // load-bearing: the absoluteness gate requires the delimiter outright, + // so every identifier reaching here carries it, and a shape without it + // would still slice at the first '/' or '?' after the scheme. + var authorityStart = resourceUrl.IndexOf(':', StringComparison.Ordinal) + 1; + if (resourceUrl.AsSpan(authorityStart).StartsWith("//")) + { + authorityStart += 2; + } + + var pathStart = resourceUrl.IndexOfAny(['/', '?'], authorityStart); + var schemeAndAuthority = pathStart >= 0 ? resourceUrl[..pathStart] : resourceUrl; + + // The fragment gate above guarantees nothing follows the query. + var queryStart = resourceUrl.IndexOf('?', StringComparison.Ordinal); + var pathEnd = queryStart >= 0 ? queryStart : resourceUrl.Length; // Root "/" trims to empty, yielding the bare well-known URL. RFC 9728 // §3.1 removes the terminating slash following the host when a path or // query component is present, so `https://api.example.com/?x=1` and // `https://api.example.com?x=1` both derive - // `…/.well-known/oauth-protected-resource?x=1`. - // - // The path is NOT byte-exact, unlike the query below: `Uri.AbsolutePath` - // returns the canonicalized form, which unescapes percent-encodings of - // unreserved characters (`%7E` becomes `~`) and applies RFC 3986 §5.2.4 - // dot-segment removal. That diverges from python's `urlsplit(...).path` - // and java's raw derivation. A known limitation; the fix is to slice the - // path off the original string the way the query already is. - var resourcePath = uri.AbsolutePath.TrimEnd('/'); + // `…/.well-known/oauth-protected-resource?x=1`. When the identifier + // has no path (`pathStart` is -1, or lands on the '?'), the slice is + // empty and the suffix sits directly after the authority. + var resourcePath = pathStart >= 0 + ? resourceUrl[pathStart..pathEnd].TrimEnd('/') + : string.Empty; // The query component is carried over verbatim: RFC 9728 §3 inserts the // well-known string "between the host component and the path and/or // query components, if any". A query is legal on a resource identifier // — RFC 8707 §2 states the SHOULD NOT and its exception in the same - // sentence, and RFC 9728 §1.2 carries that forward. The query is sliced - // off the original string, not off the parsed Uri: `Uri` canonicalizes - // on construction and unescapes percent-encodings of unreserved - // characters (`%7E` becomes `~`), so `Uri.Query` is not byte-for-byte. - // The fragment gate above guarantees nothing follows the query. - var queryStart = resourceUrl.IndexOf('?', StringComparison.Ordinal); + // sentence, and RFC 9728 §1.2 carries that forward. var query = queryStart >= 0 ? resourceUrl[queryStart..] : string.Empty; - // A bare "?" is an empty query. Empty-versus-absent was settled - // family-wide as absent: the derived document URL is query-less - // rather than carrying a dangling "?". + // A bare "?" is an empty query. Empty-versus-absent resolves as + // absent: the derived document URL is query-less rather than + // carrying a dangling "?". if (query == "?") { query = string.Empty; } - return $"{uri.GetLeftPart(UriPartial.Authority)}/.well-known/oauth-protected-resource{resourcePath}{query}"; + return $"{schemeAndAuthority}/.well-known/oauth-protected-resource{resourcePath}{query}"; } } diff --git a/src/Authplane/Metadata/ProtectedResourceMetadata.cs b/src/Authplane/Metadata/ProtectedResourceMetadata.cs index fcc26fc..6e68268 100644 --- a/src/Authplane/Metadata/ProtectedResourceMetadata.cs +++ b/src/Authplane/Metadata/ProtectedResourceMetadata.cs @@ -43,9 +43,12 @@ public ProtectedResourceMetadata( // // The same argument carries every axis whose defect makes the derived // URL disagree with the emitted identifier, so all four run here. Only - // the query gate is excluded, and for a reason specific to it: a query - // is carried into the derived URL, so emitting one raises no mismatch - // for this type to prevent. + // the character-production gates are excluded — path, query and host — + // and for a reason specific to them: all three components are carried + // into the derived URL verbatim, so emitting them raises no mismatch + // for this type to prevent. What those gates protect is the derived URL + // being a URI a client can fetch, a property of the derivation this + // type does not perform. ResourceIdentifiers.ThrowIfFragment(resource, nameof(resource)); ResourceIdentifiers.ThrowIfWhitespaceOrBackslash(resource, nameof(resource)); ResourceIdentifiers.ThrowIfMalformedPort(resource, nameof(resource)); diff --git a/src/Authplane/OAuth/OAuthConstants.cs b/src/Authplane/OAuth/OAuthConstants.cs index 62a9cde..8e0e258 100644 --- a/src/Authplane/OAuth/OAuthConstants.cs +++ b/src/Authplane/OAuth/OAuthConstants.cs @@ -62,6 +62,8 @@ public static class ErrorCodes public const string ServerError = "server_error"; public const string UnsupportedGrantType = "unsupported_grant_type"; public const string UnsupportedTokenType = "unsupported_token_type"; + public const string AccessDenied = "access_denied"; + public const string InvalidTarget = "invalid_target"; public const string DPoPReplayDetected = "dpop_replay_detected"; public const string DPoPBindingMismatch = "dpop_binding_mismatch"; public const string DPoPProofMissing = "dpop_proof_missing"; diff --git a/src/Authplane/Resilience/CircuitPolicy.cs b/src/Authplane/Resilience/CircuitPolicy.cs index 3d1ab1d..e8c3f45 100644 --- a/src/Authplane/Resilience/CircuitPolicy.cs +++ b/src/Authplane/Resilience/CircuitPolicy.cs @@ -21,6 +21,11 @@ public static class CircuitPolicy OAuthConstants.ErrorCodes.InvalidDPoPProof, OAuthConstants.ErrorCodes.InvalidRequest, OAuthConstants.ErrorCodes.UnsupportedGrantType, + // Exchange policy decisions, not AS health: the client is not on the + // Resource's exchange allow-list (403) or `resource` does not match a + // granted resource (400). + OAuthConstants.ErrorCodes.AccessDenied, + OAuthConstants.ErrorCodes.InvalidTarget, }; /// diff --git a/src/Authplane/Verifier/AuthplaneResource.cs b/src/Authplane/Verifier/AuthplaneResource.cs index c237b3b..2d0904b 100644 --- a/src/Authplane/Verifier/AuthplaneResource.cs +++ b/src/Authplane/Verifier/AuthplaneResource.cs @@ -72,16 +72,18 @@ internal AuthplaneResource( // Authoritative identifier gates: every construction path — CreateAsync, // AuthplaneClient.CreateResourceAsync, and the MCP adapter's factory — // funnels through this constructor, so no configured resource can carry - // a fragment, whitespace, a backslash, userinfo, or a malformed query - // into the PRM document or the derived well-known URL, and no relative, + // a fragment, whitespace, a backslash, userinfo, or a malformed path + // or query into the PRM document or the derived well-known URL, and no relative, // scheme-relative, or host-less identifier can derive a malformed one. // The fragment check runs first so an identifier broken both ways // reports the fragment. ResourceIdentifiers.ThrowIfFragment(Resource, nameof(resource)); ResourceIdentifiers.ThrowIfWhitespaceOrBackslash(Resource, nameof(resource)); ResourceIdentifiers.ThrowIfMalformedPort(Resource, nameof(resource)); + ResourceIdentifiers.ThrowIfInvalidHost(Resource, nameof(resource)); ResourceIdentifiers.ThrowIfNotAbsoluteUrl(Resource, nameof(resource)); ResourceIdentifiers.ThrowIfUserInfo(Resource, nameof(resource)); + ResourceIdentifiers.ThrowIfInvalidPath(Resource, nameof(resource)); ResourceIdentifiers.ThrowIfInvalidQuery(Resource, nameof(resource)); Scopes = scopes ?? throw new ArgumentNullException(nameof(scopes)); _ownsClient = ownsClient; @@ -138,9 +140,11 @@ internal AuthplaneResource( /// the iss in tokens this resource will verify. /// Resource identifier this RS publishes (RFC 9728). /// Must be an absolute URL with a scheme and a host (RFC 8707 §2, - /// RFC 9728 §3) and must not contain a fragment component (RFC 8707 §2, - /// RFC 9728 §1.2); violations are rejected here rather than silently - /// producing a malformed metadata URL. + /// RFC 9728 §3) whose host is within the RFC 3986 §3.2.2 production — an + /// internationalized host belongs in a URI as its A-label — and must not + /// contain a fragment component (RFC 8707 §2, RFC 9728 §1.2); violations are + /// rejected here rather than silently producing a malformed metadata + /// URL. /// Scopes this RS requires; surfaced in PRM and in /// WWW-Authenticate on 401 challenges. /// HTTP/timeout/dev-mode policy; defaults to @@ -172,17 +176,16 @@ public static async Task CreateAsync( ArgumentException.ThrowIfNullOrWhiteSpace(resource); // Repeated ahead of the constructor so a bad identifier fails before // the issuer metadata fetch below rather than after a network round - // trip. It also keeps these two cases clear of a known leak: the - // constructor runs after AuthplaneClient.CreateAsync, and a throw from - // it leaks that client (its HttpClient and the JwksCache refresh task - // are released only by DisposeAsync). Do not read this guard as a fix - // for that — the other throw paths in the constructor still leak, and - // the constructor's copies are what actually guarantee the invariant. + // trip. It is not what keeps the client from leaking on a rejected + // identifier — the try/catch around the constructor below is; these + // copies only save the round trip. ResourceIdentifiers.ThrowIfFragment(resource, nameof(resource)); ResourceIdentifiers.ThrowIfWhitespaceOrBackslash(resource, nameof(resource)); ResourceIdentifiers.ThrowIfMalformedPort(resource, nameof(resource)); + ResourceIdentifiers.ThrowIfInvalidHost(resource, nameof(resource)); ResourceIdentifiers.ThrowIfNotAbsoluteUrl(resource, nameof(resource)); ResourceIdentifiers.ThrowIfUserInfo(resource, nameof(resource)); + ResourceIdentifiers.ThrowIfInvalidPath(resource, nameof(resource)); ResourceIdentifiers.ThrowIfInvalidQuery(resource, nameof(resource)); ArgumentNullException.ThrowIfNull(scopes); @@ -193,11 +196,27 @@ public static async Task CreateAsync( var settings = fetchSettings ?? FetchSettings.FromDevMode(devMode: false); var client = await AuthplaneClient.CreateAsync(issuer, settings, cancellationToken).ConfigureAwait(false); - return new AuthplaneResource(client, resource, scopeList, ownsClient: true, - revocationChecker: revocationChecker, failClosed: failClosed, - clockSkewSeconds: clockSkewSeconds, - inboundDpop: inboundDpop, - allowedAlgorithms: allowedAlgorithms); + + // This overload owns the client it just built, and ownership only transfers + // once the constructor returns. Every validating throw in the constructor — + // the identifier gates, a negative clockSkewSeconds, an empty or unsupported + // allowedAlgorithms — would otherwise abandon a client nothing can reach, + // stranding its HttpClient and the JwksCache background refresh, which are + // released only by DisposeAsync. Dispose it here and let the original + // failure propagate. + try + { + return new AuthplaneResource(client, resource, scopeList, ownsClient: true, + revocationChecker: revocationChecker, failClosed: failClosed, + clockSkewSeconds: clockSkewSeconds, + inboundDpop: inboundDpop, + allowedAlgorithms: allowedAlgorithms); + } + catch + { + await client.DisposeAsync().ConfigureAwait(false); + throw; + } } public Task VerifyAsync( @@ -431,7 +450,22 @@ public async Task VerifyAsync( .ConfigureAwait(false); if (isRevoked) { - throw new TokenRevokedException($"Token '{jti}' has been revoked."); + // The JWT already passed local verification, so an active=false from + // introspection is either a real revocation or the AS refusing to answer a + // client it does not consider the token's owner (authserver >= 0.1.2 + // runtime-client rule). That distinction is operator-only: the middleware + // copies Message into the WWW-Authenticate error_description and the + // response body, so it goes on the inner exception, which no caller sees. + // Only the built-in checker can produce the introspection cause; a custom + // IRevocationChecker must not be blamed for a call it never made. + throw _revocationChecker is IntrospectionRevocation + ? new TokenRevokedException( + $"Token '{jti}' has been revoked.", + new VerifierRuntimeException( + "introspection returned active=false for a token that passed local verification; " + + "if this is not a revocation, the AS does not recognise this resource server as the " + + "token's owner (authserver >= 0.1.2: issuing client or runtime-client of the Resource in aud).")) + : new TokenRevokedException($"Token '{jti}' has been revoked."); } } catch (TokenRevokedException) @@ -747,8 +781,8 @@ string GetClaim(string name) string? nextNonce = null; if (!string.IsNullOrWhiteSpace(dpopRequest.RequiredNonce)) { - // Legacy exact-echo check (sibling-SDK `expected_nonce` parity): - // failures keep InvalidDPoPProofException, as released. + // Legacy exact-echo check, kept for the released behaviour: + // failures stay InvalidDPoPProofException. var proofNonce = GetClaim("nonce"); if (string.IsNullOrWhiteSpace(proofNonce)) { diff --git a/src/Authplane/Verifier/IntrospectionRevocation.cs b/src/Authplane/Verifier/IntrospectionRevocation.cs index 14053c5..cddb4cd 100644 --- a/src/Authplane/Verifier/IntrospectionRevocation.cs +++ b/src/Authplane/Verifier/IntrospectionRevocation.cs @@ -9,6 +9,15 @@ namespace Authplane; /// Reports a token as revoked when the AS responds with active=false. /// Caches "active" results for a configurable TTL to avoid per-request AS round-trips. /// +/// +/// The must authenticate as a confidential +/// client that is either the token's issuing client or a runtime-client of the +/// Resource named in aud. authserver ≥ 0.1.2 answers +/// active=false to anyone else — including every public client — so a +/// resource server introspecting with the wrong credentials rejects every +/// token as revoked. Register the resource server with +/// authserver admin resource runtime-client add --client-id <rs-client-id> --slug <resource-slug>. +/// public sealed class IntrospectionRevocation : IRevocationChecker { private readonly AuthplaneAuthClient _client; diff --git a/src/Authplane/Verifier/VerifiedClaims.cs b/src/Authplane/Verifier/VerifiedClaims.cs index 3403a33..62ddc53 100644 --- a/src/Authplane/Verifier/VerifiedClaims.cs +++ b/src/Authplane/Verifier/VerifiedClaims.cs @@ -101,7 +101,8 @@ public void RequireScope(string scope) public string Act => Raw.TryGetValue("act", out var v) && v is IDictionary d && d.TryGetValue("sub", out var sub) ? sub?.ToString() ?? string.Empty : string.Empty; - /// RFC 8693 §4.2 authorized actor claim (may_act.sub), or empty if absent. + /// RFC 8693 §4.4 authorized actor claim (may_act.sub), or empty if absent. + [Obsolete("authserver 0.2.0 no longer issues may_act; removed in the next minor")] public string MayAct => Raw.TryGetValue("may_act", out var v) && v is IDictionary d && d.TryGetValue("sub", out var sub) ? sub?.ToString() ?? string.Empty : string.Empty; diff --git a/src/Authplane/docs/user-guide.md b/src/Authplane/docs/user-guide.md index 9d0739b..da35ddc 100644 --- a/src/Authplane/docs/user-guide.md +++ b/src/Authplane/docs/user-guide.md @@ -178,7 +178,7 @@ var documentUrl = OAuthProtectedResourceMetadata.GetDocumentUrl(resourceUri); var json = resource.GetProtectedResourceMetadata().ToRfc9728Json(); ``` -The `Authplane.Mcp` middleware serves this document publicly on `GET` before auth runs. +The `Authplane.Mcp` middleware serves this document publicly on `GET` before auth runs, and advertises its URL in every challenge. A deployment whose document is hosted by the authorization server instead — authserver 0.2.0 serves one per registered Resource — points challenges at it with the adapter's `resourceMetadataUrl` option; either way RFC 9728 §3.3 requires the document's `resource` field to equal the URL clients call byte for byte. ### Token exchange (RFC 8693) @@ -190,6 +190,20 @@ var exchanged = await auth.TokenExchangeAsync(new TokenExchangeOptions( When the AS surfaces `consent_required` / `interaction_required`, the call throws `ConsentRequiredException`. Adapters (e.g. `Authplane.Mcp`) translate that into framework-specific responses; see `UrlElicitationSupport` in the MCP adapter for the MCP `-32042` mapping. +Two other rejections are policy decisions, not outages, and neither counts toward the circuit breaker: + +- `AccessDeniedException` (`access_denied`, HTTP 403) — a cross-client exchange where the operator has not allow-listed the exchanging client on the target Resource. Re-prompting the user will not fix it; this is different from `consent_required`. +- `InvalidTargetException` (`invalid_target`, HTTP 400, RFC 8707 §2.2) — the `resource` value does not match a granted resource exactly, byte for byte (a trailing slash counts). + +**Operator step.** For each MCP server that exchanges for a downstream resource it does not itself act as, allow-list its client id on that 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. + ### Token revocation (RFC 7009) ```csharp @@ -210,6 +224,12 @@ var resource = await client.CreateResourceAsync( `failClosed: true` rejects tokens whenever the revocation check itself errors; default `false` allows the verification to succeed when the AS is unreachable. +The `AuthplaneAuthClient` behind `IntrospectionRevocation` must be a confidential client **and** either the token's issuing client or a runtime-client of the Resource named in `aud`. Since authserver 0.1.2 anyone else gets `{"active": false}` — a public client cannot introspect at all — so a resource server introspecting with the wrong credentials silently rejects every token as revoked (`TokenRevokedException` names this cause). Register the resource server's client on the Resource: + +```sh +authserver admin resource runtime-client add --client-id --slug +``` + ### JWKS resilience `AuthplaneClient` keeps the JWKS hot via: @@ -234,6 +254,8 @@ Typical mapping for HTTP APIs: | `JwksFetchException`, `MetadataFetchException` | 502/503 | JWKS or discovery fetch failed. | | `AuthplaneTokenRequestException` | varies | Generic OAuth client-flow failure with `OAuthError` and `HttpStatus`. | | `ConsentRequiredException` | 403 | AS requires consent / URL elicitation. Translate via `UrlElicitationSupport` (MCP adapter). | +| `AccessDeniedException` | 403 | Exchanging client not allow-listed on the target Resource; operator fix, not a consent prompt. | +| `InvalidTargetException` | 400 | `resource` does not match a granted resource exactly (RFC 8707 §2.2). | | `CircuitOpenException` | 503 | Auth client circuit breaker is open. | ## 10. Lifecycle and disposal diff --git a/tests/Authplane.Conformance.Shared/ConformanceCatalogAlignment.cs b/tests/Authplane.Conformance.Shared/ConformanceCatalogAlignment.cs index 9cea4b1..a004bd2 100644 --- a/tests/Authplane.Conformance.Shared/ConformanceCatalogAlignment.cs +++ b/tests/Authplane.Conformance.Shared/ConformanceCatalogAlignment.cs @@ -18,6 +18,15 @@ public static class ConformanceCatalogAlignment /// log for it to tell real catalog drift apart from a build or harness failure, which /// fails the same step with a different cause. /// + /// + /// .github/workflows/conformance-catalog-drift.yml spells this value out again in + /// its "Report drift" step — YAML cannot read a C# const — so the two are duplicated with + /// nothing in the language tying them together. Changing it here alone leaves that grep + /// matching nothing, and every real drift is then reclassified as "a build or harness + /// problem": wrong, and green-looking, which is the failure shape this whole area exists + /// to remove. ConformanceDriftMarkerContractTests reads the workflow and fails if + /// the two disagree, so the duplication cannot drift silently — change both together. + /// public const string DriftMarker = "Conformance-catalog drift:"; /// @@ -33,12 +42,19 @@ public static class ConformanceCatalogAlignment /// /// /// This checks the marker-to-catalog mapping and nothing else. No conformance report is - /// produced today — has no callers — so a mismatch - /// currently affects no artifact. It is asserted because the mapping is the input a report - /// would be built from: were the writer wired, an uncovered case would render as - /// not_run and a marker naming an absent id would be dropped entirely, and neither - /// would fail the run. Keeping the mapping honest now is what leaves wiring the writer a - /// change to reporting alone. + /// produced today — has no callers, and nothing feeds + /// , so a report would render every case as not_run. + /// It is asserted because the mapping is the input such a report would be built from: were the + /// writer wired, an uncovered case would render as not_run and a marker naming an absent + /// id would be dropped entirely, and neither would fail the run. Keeping the mapping honest now + /// is what leaves wiring the writer a change to reporting alone. + /// + /// + /// It is also what makes the marker scan usable as an id source. The scheduled case-body drift + /// check scopes itself to the case ids this SDK registers, and takes them from + /// . A scan that quietly missed a marker would quietly + /// shorten that scope; asserting the scan against the catalog in both directions, on every PR, + /// is what makes a miss impossible to have without a red run. /// /// /// Cases explicitly deferred via Level = "none" still count as covered: the marker is @@ -130,15 +146,76 @@ public static void AssertNoUnknownCaseIds(Assembly testAssembly) } } + /// + /// Every marker in , one + /// entry per marker occurrence, carrying the method that declares it. + /// + /// + /// + /// This is the same scan keys on, exposed so the + /// repo-specific half of the case-body drift check reads the registered case ids from the + /// scan the alignment assertion is written against rather than from a second extractor of + /// its own. Two extractors would be free to disagree, and the one that under-reports is the + /// one nothing would notice. + /// + /// + /// Occurrences, not ids: a case claimed by more than one test appears once per test, and the + /// declaring method is what points a reader at the coverage when the check names a case. The + /// consumer deduplicates. + /// + /// + public static IReadOnlyList ScanConformanceMarkers(Assembly testAssembly) + { + ArgumentNullException.ThrowIfNull(testAssembly); + + var markers = new List(); + foreach (var type in LoadTypes(testAssembly)) + { + foreach (var method in type.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static)) + { + foreach (var attr in method.GetCustomAttributes()) + { + markers.Add(new ConformanceMarker( + attr.CaseId, + $"{method.DeclaringType?.FullName}.{method.Name}")); + } + } + } + + markers.Sort((left, right) => + { + var byId = string.CompareOrdinal(left.CaseId, right.CaseId); + return byId != 0 ? byId : string.CompareOrdinal(left.DeclaredBy, right.DeclaredBy); + }); + + return markers; + } + /// /// Case ids declared by markers in the assembly. /// + private static SortedSet ScanMarkers(Assembly testAssembly) + { + var markedIds = new SortedSet(StringComparer.Ordinal); + foreach (var marker in ScanConformanceMarkers(testAssembly)) + { + markedIds.Add(marker.CaseId); + } + + return markedIds; + } + + /// + /// The assembly's types. + /// /// /// A type that fails to load is raised rather than skipped: skipping it would silently lose /// every marker it declares, which then surfaces as a list of uncovered catalog cases and /// sends the reader hunting for coverage that already exists. /// - private static SortedSet ScanMarkers(Assembly testAssembly) + private static Type[] LoadTypes(Assembly testAssembly) { Type[] types; try @@ -159,20 +236,12 @@ private static SortedSet ScanMarkers(Assembly testAssembly) ex); } - var markedIds = new SortedSet(StringComparer.Ordinal); - foreach (var type in types) - { - foreach (var method in type.GetMethods( - BindingFlags.Public | BindingFlags.NonPublic | - BindingFlags.Instance | BindingFlags.Static)) - { - foreach (var attr in method.GetCustomAttributes()) - { - markedIds.Add(attr.CaseId); - } - } - } - - return markedIds; + return types; } } + +/// +/// One occurrence: the case id it claims and the fully +/// qualified test method that claims it. +/// +public sealed record ConformanceMarker(string CaseId, string DeclaredBy); diff --git a/tests/Authplane.Conformance.Shared/ConformanceMarkerScanWriter.cs b/tests/Authplane.Conformance.Shared/ConformanceMarkerScanWriter.cs new file mode 100644 index 0000000..57c9fb9 --- /dev/null +++ b/tests/Authplane.Conformance.Shared/ConformanceMarkerScanWriter.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using System.Text.Json; + +namespace Authplane.Conformance; + +/// +/// Writes the scan of a test assembly to disk, for the +/// scheduled case-body drift check to read. +/// +/// +/// +/// The drift check compares the body of every case this SDK registers between the pinned catalog +/// and the catalog tip, so it needs the registered case ids. Those come from +/// — reflection over the compiled +/// attributes, the same scan +/// is written against. Nothing here re-derives the ids from the test sources: a second extractor +/// reports what it matched and stays silent about what it missed, and silence is the failure the +/// drift check exists to remove. +/// +/// +/// Writing happens only when names a directory, so an +/// ordinary dotnet test run produces nothing. CI sets it, runs the alignment tests, and +/// reads the files back with .github/scripts/conformance-registered-case-ids.sh. +/// +/// +/// One file per test assembly, named after the assembly. An assembly that declares no markers +/// writes a file with an empty case list rather than no file at all: the reader must be able to +/// tell a scan that ran and found nothing from a scan that never ran, and only the first of those +/// is a legitimate state. +/// +/// +public static class ConformanceMarkerScanWriter +{ + /// + /// Environment variable naming the directory to write scan files into. Unset means do nothing. + /// + /// + /// .github/workflows/conformance-catalog-drift.yml and + /// .github/scripts/conformance-registered-case-ids.sh spell this name out again — YAML + /// and shell cannot read a C# const. Change all three together, or the drift job silently + /// collects nothing and its own empty-list guard is what reports it. + /// + public const string DestinationDirectoryVariable = "CONFORMANCE_MARKER_SCAN_DIR"; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + /// + /// Write the marker scan of if + /// is set, and return the path written; return + /// null when the variable is unset. + /// + public static string? WriteIfRequested(Assembly testAssembly) + { + ArgumentNullException.ThrowIfNull(testAssembly); + + var destination = Environment.GetEnvironmentVariable(DestinationDirectoryVariable); + if (string.IsNullOrWhiteSpace(destination)) + { + return null; + } + + destination = destination.Trim(); + + // A relative path would resolve against the test host's working directory, which is the + // assembly's output directory and not anything the workflow named. Writing there would + // succeed and leave the reader looking at an empty directory — the scan-never-ran state, + // reported as if the emitter were broken. + if (!Path.IsPathRooted(destination)) + { + throw new InvalidOperationException( + $"{DestinationDirectoryVariable} must be an absolute path, got '{destination}'. A " + + "relative path resolves against the test host's working directory, so the scan " + + "would be written somewhere the reader never looks."); + } + + var assemblyName = testAssembly.GetName().Name; + if (string.IsNullOrWhiteSpace(assemblyName)) + { + throw new InvalidOperationException( + "The test assembly has no simple name, so its marker scan has nowhere to go " + + "without colliding with another assembly's."); + } + + var markers = ConformanceCatalogAlignment.ScanConformanceMarkers(testAssembly); + + var payload = new + { + assembly = assemblyName, + cases = markers + .Select(m => new { case_id = m.CaseId, declared_by = m.DeclaredBy }) + .ToList(), + }; + + Directory.CreateDirectory(destination); + var path = Path.Combine(destination, assemblyName + ".json"); + File.WriteAllText(path, JsonSerializer.Serialize(payload, JsonOptions) + "\n"); + return path; + } +} diff --git a/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthExtensionsGuardTests.cs b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthExtensionsGuardTests.cs index 2a674b5..789705e 100644 --- a/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthExtensionsGuardTests.cs +++ b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthExtensionsGuardTests.cs @@ -46,6 +46,8 @@ public void UseAuthplaneMcpAuth_NullOptions_Throws() [InlineData("https://mcp.example.com/m\\cp", "backslash")] [InlineData("https://mcp.example.com/mcp?a=\"b\"", "query")] [InlineData("https://mcp.example.com/mcp?a=%zz", "query")] + [InlineData("https://mcp.example.com/café", "path")] + [InlineData("https://mcp.example.com/m%zzcp", "path")] [InlineData("https://mcp.example.com:80O/mcp", "port")] public void Options_InvalidResource_ThrowsAtConstruction(string resource, string expectedInMessage) { @@ -94,6 +96,93 @@ public void Options_RelativeResource_FailsBeforeLazyDiWiringCanBeDeclared() Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); } + /// + /// resourceMetadataUrl is advertised to clients and never fetched by + /// us, so nothing on the server side would ever notice a value that cannot + /// be dereferenced: the failure surfaces at the client, on the first + /// unauthenticated request, as a challenge pointing at nothing. It is held + /// to the rules the issuer is held to, at construction, for the same reason + /// the resource identifier is. + /// + [Theory] + [InlineData("/.well-known/oauth-protected-resource/mcp", "absolute URL")] + [InlineData("//auth.example.com/.well-known/oauth-protected-resource/mcp", "absolute URL")] + [InlineData("mailto:ops@auth.example.com", "absolute URL")] + [InlineData("ftp://auth.example.com/prm", "must be https or http")] + [InlineData("https://svc:s3cr3t@auth.example.com/prm", "userinfo")] + // The identifier's whole gate set, not a subset: each of these parses into + // a Uri with a scheme and a host, so an absoluteness check alone lets it + // ride into the challenge and out to unauthenticated clients. + [InlineData("https://café.example.com/prm", "host")] + [InlineData("https://auth.example.com/pr%zzm", "path")] + [InlineData("https://auth.example.com/prm?x=café", "query")] + [InlineData("https://auth.example.com:80O/prm", "port")] + [InlineData("https://auth.example.com/prm#frag", "fragment")] + // Uri.TryCreate trims surrounding whitespace before parsing, so without a + // raw-string gate these clear every parsed-URL check and are advertised + // verbatim: the client fetches `.../prm%20` and gets a 404, with nothing + // on the server side to say why. + [InlineData("https://auth.example.com/prm ", "whitespace")] + [InlineData(" https://auth.example.com/prm", "whitespace")] + [InlineData("https://auth.example.com/prm\tx", "whitespace")] + [InlineData("https://auth.example.com\\prm", "backslash")] + public void Options_InvalidResourceMetadataUrl_ThrowsAtConstruction( + string resourceMetadataUrl, string expectedInMessage) + { + var ex = Assert.Throws(() => + new AuthplaneMcpAuth.Options( + issuer: "https://auth.example.com", + resource: "https://mcp.example.com/mcp", + scopes: new[] { "tools/add" }, + resourceMetadataUrl: resourceMetadataUrl)); + + Assert.Equal("resourceMetadataUrl", ex.ParamName); + Assert.Contains(expectedInMessage, ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Shape only, with no host policy and no devMode dependency: the + /// value is advertised and never fetched, so it carries no SSRF surface of + /// its own, and a loopback-only carve-out would refuse the in-cluster and + /// docker-compose topologies dev mode exists to serve, so a single + /// deployment configuration is accepted wherever this option is set. + /// + [Theory] + [InlineData("http://localhost:9000/.well-known/oauth-protected-resource/mcp")] + [InlineData("http://authserver:8080/.well-known/oauth-protected-resource/mcp")] + [InlineData("https://10.1.2.3/.well-known/oauth-protected-resource/mcp")] + public void Options_HttpAndPrivateResourceMetadataUrl_AcceptedWithoutDevMode(string url) + { + var options = new AuthplaneMcpAuth.Options( + issuer: "https://auth.example.com", + resource: "https://mcp.example.com/mcp", + scopes: new[] { "tools/add" }, + devMode: false, + resourceMetadataUrl: url); + + Assert.Equal(url, options.ResourceMetadataUrl); + } + + /// + /// The message has to name the setting the operator typed. These gates are + /// shared with the resource identifier, so a hard-coded subject would send + /// someone who mistyped resourceMetadataUrl off to look at + /// resource. + /// + [Fact] + public void Options_InvalidResourceMetadataUrl_MessageNamesTheSetting() + { + var ex = Assert.Throws(() => + new AuthplaneMcpAuth.Options( + issuer: "https://auth.example.com", + resource: "https://mcp.example.com/mcp", + scopes: new[] { "tools/add" }, + resourceMetadataUrl: "https://auth.example.com/prm ")); + + Assert.StartsWith("resourceMetadataUrl", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("Resource identifier", ex.Message, StringComparison.Ordinal); + } + /// /// The deeper gates stay in place as defence in depth: the /// construction path re-runs the same diff --git a/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthHostProductionTests.cs b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthHostProductionTests.cs new file mode 100644 index 0000000..131bf2c --- /dev/null +++ b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthHostProductionTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace Authplane.Mcp.Tests; + +/// +/// The adapter's options are the single operator-facing entry for the resource +/// identifier, so they carry their own copy of the identifier gate set: the user +/// guide's lazy DI wiring defers the AuthplaneResource constructor to the first +/// request, and without this copy a misconfigured identifier boots clean and then +/// takes an unhandled exception out of the middleware — including out of the +/// public PRM GET. +/// +/// The RFC 3986 §3.2.2 host production has to be in that copy for the same reason +/// as every other axis in it. +/// +public sealed class AuthplaneMcpAuthHostProductionTests +{ + [Theory] + [InlineData("https://café.example.com/mcp")] + [InlineData("https://例え.example.com/mcp")] + [InlineData("https://api​.example.com/mcp")] + public void Options_NonAsciiHost_IsRejectedAtConstruction(string resource) + { + var ex = Assert.Throws(() => + new AuthplaneMcpAuth.Options( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains("host", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("§3.2.2", ex.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("https://api.example.com/mcp")] + [InlineData("https://xn--caf-dma.example.com/mcp")] + [InlineData("https://[fe80::1%25eth0]/mcp")] + [InlineData("http://localhost:8080/mcp")] + public void Options_HostInsideTheProduction_StaysAccepted(string resource) + { + var ex = Record.Exception(() => + new AuthplaneMcpAuth.Options( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Null(ex); + } +} diff --git a/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthMiddlewareTests.cs b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthMiddlewareTests.cs index 810b6a9..e387380 100644 --- a/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthMiddlewareTests.cs +++ b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthMiddlewareTests.cs @@ -5,14 +5,18 @@ using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using Xunit; namespace Authplane.Mcp.Tests; -public sealed class AuthplaneMcpAuthMiddlewareTests : IDisposable +public sealed class AuthplaneMcpAuthMiddlewareTests + : IClassFixture, IDisposable { + private readonly KestrelPathGroundTruth _kestrel; private readonly HttpListener _listener; private readonly int _port; private readonly string _issuer; @@ -20,8 +24,9 @@ public sealed class AuthplaneMcpAuthMiddlewareTests : IDisposable private readonly string _kid; private readonly ECDsa _ecdsa; - public AuthplaneMcpAuthMiddlewareTests() + public AuthplaneMcpAuthMiddlewareTests(KestrelPathGroundTruth kestrel) { + _kestrel = kestrel; _ecdsa = Ecdsa.GenerateP256(); (_issuer, _listener) = LoopbackHttpListener.Start(); _port = new Uri(_issuer).Port; @@ -140,6 +145,108 @@ public async Task BearerDPoPBound_MissingDPoPHeader_Returns401() Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); } + [Fact] + public async Task Rejection_LogsTheCause_WhileTheBodyKeepsTheFixedDescription() + { + // The body and challenge deliberately withhold the exception's own + // message. The operator still needs it, and this middleware is the + // last place that holds it, so it goes to the log instead of the wire + // — the two halves of the same decision. + // + // Debug, not Warning: reaching a rejection takes no credentials, so + // logging every one higher would let an unauthenticated caller choose + // this server's log volume. + var verifier = await CreateResourceAsync(tokenScopes: new[] { "tools/add" }); + + var accessToken = MintAccessToken( + issuer: _issuer, + audience: _resource, + ecdsa: _ecdsa, + kid: _kid, + cnfJkt: "test-jkt", + scope: "tools/add"); + + var loggerFactory = new CapturingLoggerFactory(LogLevel.Debug); + var services = new ServiceCollection(); + services.AddSingleton(verifier); + services.AddSingleton(loggerFactory); + var provider = services.BuildServiceProvider(); + + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add", "tools/multiply" }, + devMode: true); + + var requestDelegate = BuildPipeline(provider, options); + + var ctx = await InvokeAsync( + requestDelegate, + provider, + token: accessToken, + authScheme: "Bearer", + dpopHeader: null, + mcpToolCallName: "add"); + + Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); + + var record = Assert.Single(loggerFactory.Records); + Assert.Equal(LogLevel.Debug, record.Level); + Assert.NotNull(record.Exception); + Assert.IsAssignableFrom(record.Exception); + Assert.Equal("Authplane.Mcp", loggerFactory.Category); + + // The message the log now carries is the one the response must not. + ctx.Response.Body.Position = 0; + using var reader = new System.IO.StreamReader(ctx.Response.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + Assert.DoesNotContain(record.Exception!.Message, body, StringComparison.Ordinal); + Assert.DoesNotContain( + record.Exception!.Message, + ctx.Response.Headers.WWWAuthenticate.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task Rejection_WithNoLoggerFactoryRegistered_StillAnswers() + { + // Logging is optional. A host that registers none gets null from + // GetService, and the call has to be a no-op rather + // than a NullReferenceException on the rejection path — where it + // would turn every 401 into a 500. + var verifier = await CreateResourceAsync(tokenScopes: new[] { "tools/add" }); + + var accessToken = MintAccessToken( + issuer: _issuer, + audience: _resource, + ecdsa: _ecdsa, + kid: _kid, + cnfJkt: "test-jkt", + scope: "tools/add"); + + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add", "tools/multiply" }, + devMode: true); + + var requestDelegate = BuildPipeline(provider, options); + + var ctx = await InvokeAsync( + requestDelegate, + provider, + token: accessToken, + authScheme: "Bearer", + dpopHeader: null, + mcpToolCallName: "add"); + + Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); + } + [Fact] public async Task MultipleDPoPHeaders_Returns401_WithInvalidDPoPProofCode() { @@ -426,6 +533,229 @@ public async Task GetProtectedResourceMetadata_AtRootWellKnownPath_AlsoReturns20 Assert.Equal(_resource, doc.RootElement.GetProperty("resource").GetString()); } + [Fact] + public async Task PrmDocument_IsServedAtTheAdvertisedUrl() + { + // The one request a client that just read `resource_metadata` will + // perform: a GET of the derived document URL, verbatim. Driven + // through the middleware end to end, with the request shaped the way + // a real server delivers it — encoded bytes in + // IHttpRequestFeature.RawTarget, decoded path in Request.Path. + var verifier = await CreateResourceAsync(tokenScopes: new[] { "tools/add" }); + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add" }, + devMode: true); + var requestDelegate = BuildPipeline(provider, options); + + var documentUrl = verifier.GetProtectedResourceMetadataDocumentUrl(); + Assert.Equal("http://localhost:8080/.well-known/oauth-protected-resource/mcp", documentUrl); + + var ctx = await InvokeAdvertisedDocumentUrlGetAsync(requestDelegate, provider, documentUrl); + + Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode); + ctx.Response.Body.Position = 0; + using var reader = new System.IO.StreamReader(ctx.Response.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + using var doc = JsonDocument.Parse(body); + Assert.Equal(_resource, doc.RootElement.GetProperty("resource").GetString()); + } + + [Fact] + public async Task PrmDocument_IsServedAtTheAdvertisedUrl_WhenPathCarriesAPreservedPercentEncoding() + { + // The %7E row: the derivation preserves the percent-encoding + // byte-exact, so the advertised URL carries `%7E` while the decoded + // request path carries `~`. Routing compares the encoded request + // target against the path sliced off the derived URL, so the URL the + // challenge advertises is the URL that answers — previously the + // expected path was re-parsed through Uri.AbsolutePath (which + // unescapes `%7E`) and compared against the decoded request path, + // which happened to serve this row only by double-decoding. + var resource = "http://localhost:8080/m%7Ecp"; + var verifier = await AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: resource, + scopes: new[] { "tools/add" }, + fetchSettings: FetchSettings.FromDevMode(devMode: true)); + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: resource, + scopes: new[] { "tools/add" }, + devMode: true); + var requestDelegate = BuildPipeline(provider, options); + + var documentUrl = verifier.GetProtectedResourceMetadataDocumentUrl(); + Assert.Equal("http://localhost:8080/.well-known/oauth-protected-resource/m%7Ecp", documentUrl); + + var ctx = await InvokeAdvertisedDocumentUrlGetAsync(requestDelegate, provider, documentUrl); + + Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode); + ctx.Response.Body.Position = 0; + using var reader = new System.IO.StreamReader(ctx.Response.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + using var doc = JsonDocument.Parse(body); + Assert.Equal(resource, doc.RootElement.GetProperty("resource").GetString()); + } + + [Fact] + public async Task PrmDocument_IsServedAtTheAdvertisedUrl_WhenPathCarriesAPercentEncodedSlash() + { + // The %2F row — the one escape where Kestrel's path decoder and + // Uri.UnescapeDataString disagree. Kestrel leaves %2F encoded in + // Request.Path (decoding it would change segment structure), so a + // fallback that unescaped the expected path with UnescapeDataString + // compared `…/mcp/` (trimmed to `…/mcp`) against a request path still + // carrying `…/mcp%2F`: the identifier's own advertised URL answered + // 401 on hosts without RawTarget, and the URL a different, %2F-less + // identifier advertises falsely matched — serving a document whose + // `resource` member says `/mcp%2F` to a client that derived `/mcp`, + // the exact RFC 9728 §3.3 mismatch this routing exists to avoid. + var resource = "http://localhost:8080/mcp%2F"; + var verifier = await AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: resource, + scopes: new[] { "tools/add" }, + fetchSettings: FetchSettings.FromDevMode(devMode: true)); + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: resource, + scopes: new[] { "tools/add" }, + devMode: true); + var requestDelegate = BuildPipeline(provider, options); + + var documentUrl = verifier.GetProtectedResourceMetadataDocumentUrl(); + Assert.Equal("http://localhost:8080/.well-known/oauth-protected-resource/mcp%2F", documentUrl); + + // Served at its own advertised URL — both on a host exposing the raw + // request target (primary comparison) and on one that does not + // (decoded fallback). + foreach (var populateRawTarget in new[] { true, false }) + { + var ctx = await InvokeAdvertisedDocumentUrlGetAsync( + requestDelegate, provider, documentUrl, populateRawTarget); + + Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode); + ctx.Response.Body.Position = 0; + using var reader = new System.IO.StreamReader(ctx.Response.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + using var doc = JsonDocument.Parse(body); + Assert.Equal(resource, doc.RootElement.GetProperty("resource").GetString()); + } + + // And NOT at the URL a different identifier (`…/mcp`) advertises: + // that request must fall through to auth, not receive a document + // whose `resource` member disagrees with the URL it was fetched from. + var other = await InvokeAdvertisedDocumentUrlGetAsync( + requestDelegate, + provider, + "http://localhost:8080/.well-known/oauth-protected-resource/mcp"); + Assert.Equal(StatusCodes.Status401Unauthorized, other.Response.StatusCode); + } + + [Fact] + public async Task Kestrel_DecodesEveryEscapeExceptPercent2F_Measured() + { + // The observed behaviour every decoded-path assertion in this file + // rests on, and the only place it is stated as a measurement rather + // than assumed: a real Kestrel bound to a loopback port, handed the + // literal bytes of a request line over a socket, echoing back the + // Request.Path it produced. Pinned here so a runtime that changes + // this fails one obvious test instead of a scattering of routing + // ones — and so the SDK's model of the decoder + // (DecodePathLikeKestrel) has something to be wrong against. + + // %2F stays encoded: decoding it would add a segment boundary the + // client did not send. Byte-exact, so the client's hex casing + // survives — which is why the path comparison folds case. + Assert.Equal("/mcp%2F", await _kestrel.DecodePathAsync("/mcp%2F")); + Assert.Equal("/mcp%2f", await _kestrel.DecodePathAsync("/mcp%2f")); + + // %5C does NOT stay encoded — Kestrel decodes it to a backslash like + // any other escape. The SDK's decoder used to hold it back, and its + // doc comment asserted this behaviour rather than measuring it; the + // last row shows the two escapes really are treated differently + // side by side, so holding %5C back was never a spelling variant of + // the %2F rule. + Assert.Equal("/m\\cp", await _kestrel.DecodePathAsync("/m%5Ccp")); + Assert.Equal("/m\\cp", await _kestrel.DecodePathAsync("/m%5ccp")); + Assert.Equal("/a\\b%2Fc~d", await _kestrel.DecodePathAsync("/a%5Cb%2Fc%7Ed")); + + // Everything else decodes, including the escapes that would otherwise + // look structural: %25 is not re-scanned as the start of an escape. + Assert.Equal("/m~cp", await _kestrel.DecodePathAsync("/m%7Ecp")); + Assert.Equal("/m cp", await _kestrel.DecodePathAsync("/m%20cp")); + Assert.Equal("/m%cp", await _kestrel.DecodePathAsync("/m%25cp")); + } + + [Fact] + public async Task PrmDocument_IsServedAtTheAdvertisedUrl_WhenPathCarriesAPercentEncodedBackslash() + { + // The %5C row. `ThrowIfWhitespaceOrBackslash` rejects a raw backslash + // in a resource identifier, but `%5C` is a well-formed escape that + // the path validator's `%` branch steps over — so this identifier + // constructs, and derives a document URL that keeps the escape. + // + // Kestrel decodes `%5C` (see the ground-truth test above), so on + // the fallback branch `Request.Path` carries `m\cp`. A decoder that + // preserved `%5C` on the expected side would leave the two spellings + // unequal and answer 401 at the identifier's own advertised URL — + // the %2F defect above with the sign flipped. The row exists so that + // branch executes. + var resource = "http://localhost:8080/m%5Ccp"; + var verifier = await AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: resource, + scopes: new[] { "tools/add" }, + fetchSettings: FetchSettings.FromDevMode(devMode: true)); + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: resource, + scopes: new[] { "tools/add" }, + devMode: true); + var requestDelegate = BuildPipeline(provider, options); + + var documentUrl = verifier.GetProtectedResourceMetadataDocumentUrl(); + Assert.Equal("http://localhost:8080/.well-known/oauth-protected-resource/m%5Ccp", documentUrl); + + // Served at its own advertised URL both on a host exposing the raw + // request target (primary comparison) and on one that does not + // (decoded fallback) — the fallback is the branch under test. + foreach (var populateRawTarget in new[] { true, false }) + { + var ctx = await InvokeAdvertisedDocumentUrlGetAsync( + requestDelegate, provider, documentUrl, populateRawTarget); + + Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode); + ctx.Response.Body.Position = 0; + using var reader = new System.IO.StreamReader(ctx.Response.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + using var doc = JsonDocument.Parse(body); + Assert.Equal(resource, doc.RootElement.GetProperty("resource").GetString()); + } + + // And not at the URL a different, escape-less identifier advertises: + // decoding `%5C` must not collapse two identifiers onto one document. + var other = await InvokeAdvertisedDocumentUrlGetAsync( + requestDelegate, + provider, + "http://localhost:8080/.well-known/oauth-protected-resource/mcp"); + Assert.Equal(StatusCodes.Status401Unauthorized, other.Response.StatusCode); + } + [Fact] public async Task ResourceWithQuery_ChallengeAdvertisesQuery_AndPrmRouteStaysPathKeyed() { @@ -521,6 +851,122 @@ public async Task MissingAuthorizationHeader_Returns401WithWwwAuthenticate() StringComparison.Ordinal); } + /// + /// RFC 9728 §5.1 leaves the PRM document's location to the deployment: it + /// is the URL the challenge names, not a path the resource must serve + /// itself. authserver 0.2.0 publishes one document per registered Resource + /// at {issuer}/.well-known/oauth-protected-resource/{ref}, which a + /// resource server that cannot host well-known paths of its own points at + /// instead. The test above pins the default — the derived resource-hosted + /// URL — so the two together show the option changes that and nothing else. + /// + [Fact] + public async Task ResourceMetadataUrlOverride_IsAdvertisedOn401() + { + var verifier = await CreateResourceAsync(tokenScopes: new[] { "tools/add" }); + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + var asHostedUrl = $"{_issuer}/.well-known/oauth-protected-resource/mcp"; + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add" }, + devMode: true, + resourceMetadataUrl: asHostedUrl); + var requestDelegate = BuildPipeline(provider, options); + + var ctx = await InvokeRawAsync( + requestDelegate, + provider, + authorizationHeader: null, + bodyJson: "{\"method\":\"tools/call\",\"params\":{\"name\":\"add\"}}"); + + Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); + var www = ctx.Response.Headers.WWWAuthenticate.ToString(); + Assert.Contains($"resource_metadata=\"{asHostedUrl}\"", www, StringComparison.Ordinal); + Assert.DoesNotContain( + "http://localhost:8080/.well-known/oauth-protected-resource/mcp", + www, + StringComparison.Ordinal); + } + + [Fact] + public async Task ResourceMetadataUrlOverride_IsAdvertisedOnInsufficientScope403() + { + // The 403 matters as much as the 401: a client that only ever presents + // an under-scoped token reaches the AS through this challenge alone. + var verifier = await CreateResourceAsync(tokenScopes: new[] { "tools/add" }); + + var accessToken = MintAccessToken( + issuer: _issuer, + audience: _resource, + ecdsa: _ecdsa, + kid: _kid, + cnfJkt: null, + scope: "tools/add"); + + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + + var asHostedUrl = $"{_issuer}/.well-known/oauth-protected-resource/mcp"; + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add", "tools/multiply" }, + devMode: true, + resourceMetadataUrl: asHostedUrl); + + var requestDelegate = BuildPipeline(provider, options); + + var ctx = await InvokeAsync( + requestDelegate, + provider, + token: accessToken, + authScheme: "Bearer", + dpopHeader: null, + mcpToolCallName: "multiply"); + + Assert.Equal(StatusCodes.Status403Forbidden, ctx.Response.StatusCode); + var www = ctx.Response.Headers.WWWAuthenticate.ToString(); + Assert.Contains("error=\"insufficient_scope\"", www, StringComparison.Ordinal); + Assert.Contains($"resource_metadata=\"{asHostedUrl}\"", www, StringComparison.Ordinal); + } + + [Fact] + public async Task ResourceMetadataUrlOverride_LeavesThePrmDocumentRouteOnTheDerivedUrl() + { + // The override redirects discovery, not hosting: the well-known path + // this middleware answers is derived from the resource identifier and + // has nothing to do with where the advertised document lives. Keying + // the route off the override would take the resource-hosted document + // offline the moment an operator pointed clients at the AS-hosted one, + // which is a migration step nobody asked for. + var verifier = await CreateResourceAsync(tokenScopes: new[] { "tools/add" }); + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add" }, + devMode: true, + resourceMetadataUrl: $"{_issuer}/.well-known/oauth-protected-resource/mcp"); + var requestDelegate = BuildPipeline(provider, options); + + var ctx = await InvokePrmDocumentGetAsync(requestDelegate, provider); + + Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode); + ctx.Response.Body.Position = 0; + using var reader = new System.IO.StreamReader(ctx.Response.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + using var doc = JsonDocument.Parse(body); + // RFC 9728 §3.3 — whichever document a client ends up reading, its + // `resource` must equal the identifier byte for byte. + Assert.Equal(_resource, doc.RootElement.GetProperty("resource").GetString()); + } + [Fact] public async Task MissingAuthorizationHeader_WithRealm_IncludesRealmInWwwAuthenticate() { @@ -1226,6 +1672,163 @@ private async Task InvokePrmDocumentGetAsync( return ctx; } + /// + /// GET the advertised PRM document URL through the middleware, shaping + /// the request the way a real server delivers it: the encoded bytes of + /// the request line in + /// (unless is false, modelling a + /// host that does not expose the raw target), and in Request.Path + /// the decoded path a real Kestrel actually produces for those bytes, + /// measured by . + /// + /// + /// The decoded side is measured rather than modelled on purpose. The + /// middleware's fallback branch compares Request.Path against the + /// expected path put through the SDK's own model of Kestrel's decoder; a + /// second copy of that model on the request-shaping side would reduce the + /// assertion to PathsMatch(f(p), f(p)) — true whatever f + /// does, so a model that disagrees with Kestrel would still pass green. + /// It did: the model claimed Kestrel preserves %5C, and Kestrel + /// decodes it. See + /// . + /// + private async Task InvokeAdvertisedDocumentUrlGetAsync( + RequestDelegate requestDelegate, + ServiceProvider provider, + string documentUrl, + bool populateRawTarget = true) + { + var target = documentUrl[documentUrl.IndexOf("/.well-known/", StringComparison.Ordinal)..]; + var queryStart = target.IndexOf('?', StringComparison.Ordinal); + var rawPath = queryStart >= 0 ? target[..queryStart] : target; + + var ctx = new DefaultHttpContext(); + ctx.RequestServices = provider; + ctx.Request.Scheme = "http"; + ctx.Request.Host = new HostString("localhost", 8080); + ctx.Request.PathBase = PathString.Empty; + ctx.Request.Path = new PathString(await _kestrel.DecodePathAsync(rawPath).ConfigureAwait(false)); + ctx.Request.QueryString = queryStart >= 0 ? new QueryString(target[queryStart..]) : QueryString.Empty; + if (populateRawTarget) + { + ctx.Features.Get()!.RawTarget = target; + } + + ctx.Request.Method = HttpMethods.Get; + ctx.Response.Body = new System.IO.MemoryStream(); + + await requestDelegate(ctx).ConfigureAwait(false); + return ctx; + } + + // ----------------------------------------------------------------------- + // Error bodies: RFC 6750 §3 JSON, fixed description, no message + // ----------------------------------------------------------------------- + + [Fact] + public async Task NoCredentials_Returns401_WithNoErrorCodeInEitherHalf() + { + // A request that presented nothing gets no `error` in the challenge + // (RFC 6750 §3.1 defines the codes for a request that did present + // credentials, and ties `invalid_request` to a malformed request + // answered with 400), and the body makes the same omission rather than + // inventing a code the header does not carry. + var (ctx, _) = await InvokeUnauthenticatedAsync(authorizationHeader: null); + + Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", ctx.Response.ContentType); + + var (error, description) = ReadErrorBody(ctx); + Assert.Null(error); + Assert.Equal("The request did not carry a usable access token", description); + + var www = ctx.Response.Headers.WWWAuthenticate.ToString(); + Assert.DoesNotContain("error=", www, StringComparison.Ordinal); + } + + [Fact] + public async Task UnknownScheme_Returns401_WithNoErrorCodeInEitherHalf() + { + var (ctx, _) = await InvokeUnauthenticatedAsync(authorizationHeader: "Basic abc"); + + Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); + var (error, description) = ReadErrorBody(ctx); + Assert.Null(error); + Assert.Equal("The request did not carry a usable access token", description); + + var www = ctx.Response.Headers.WWWAuthenticate.ToString(); + Assert.DoesNotContain("error=", www, StringComparison.Ordinal); + } + + [Fact] + public async Task RejectedToken_Returns401_WithFixedDescriptionAndNoInternalMessage() + { + // The body used to be `invalid_token: {ex.Message}` — the verifier's + // own sentence, naming the unknown kid or the claim that failed, to a + // caller who by definition has not authenticated. Both halves of the + // response now carry the per-code sentence instead. + var (ctx, _) = await InvokeUnauthenticatedAsync( + authorizationHeader: "Bearer not-a-real-token"); + + Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", ctx.Response.ContentType); + + var (error, description) = ReadErrorBody(ctx); + Assert.Equal("invalid_token", error); + Assert.Equal( + "The access token is missing or not valid for this resource", + description); + + // Neither half leaks: no JWT/claim vocabulary in either. + var www = ctx.Response.Headers.WWWAuthenticate.ToString(); + foreach (var leak in new[] { "kid", "claim", "signature", "token-", "JWT" }) + { + Assert.DoesNotContain(leak, description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(leak, www, StringComparison.OrdinalIgnoreCase); + } + } + + private async Task<(HttpContext Context, ServiceProvider Provider)> InvokeUnauthenticatedAsync( + string? authorizationHeader) + { + var verifier = await AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add" }, + fetchSettings: FetchSettings.FromDevMode(devMode: true)); + + var services = new ServiceCollection(); + services.AddSingleton(verifier); + var provider = services.BuildServiceProvider(); + + var options = new AuthplaneMcpAuth.Options( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add" }, + devMode: true); + + var ctx = await InvokeRawAsync( + BuildPipeline(provider, options), + provider, + authorizationHeader, + bodyJson: "{\"method\":\"tools/call\",\"params\":{\"name\":\"add\"}}"); + + return (ctx, provider); + } + + // Error is null when the body omits the member, which is the + // no-credentials case: RFC 6750 §3 leaves `error` out of the challenge + // there, and the body follows it. + private static (string? Error, string Description) ReadErrorBody(HttpContext ctx) + { + ctx.Response.Body.Seek(0, System.IO.SeekOrigin.Begin); + using var reader = new System.IO.StreamReader(ctx.Response.Body); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return ( + doc.RootElement.TryGetProperty("error", out var error) ? error.GetString() : null, + doc.RootElement.GetProperty("error_description").GetString()!); + } + private async Task InvokeRawAsync( RequestDelegate requestDelegate, ServiceProvider provider, diff --git a/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthNonceTests.cs b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthNonceTests.cs index bca5eab..d6dc165 100644 --- a/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthNonceTests.cs +++ b/tests/Authplane.Mcp.Tests/AuthplaneMcpAuthNonceTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using Xunit; @@ -213,6 +214,33 @@ public async Task NoncePolicyOn_MisbehavingIssuer_Returns500_WithoutWwwAuthentic Assert.False(ctx.Response.Headers.ContainsKey("DPoP-Nonce")); } + [Fact] + public async Task NoncePolicyOn_MisbehavingIssuer_LogsTheCauseAtError() + { + // The 500 body says "Internal Server Error" and nothing else, by + // design. An operator cannot act on that, and a 5xx is this server's + // own fault rather than something a caller provoked, so the cause goes + // to the log at Error — visible without anyone having been told to + // raise a category first. + var loggerFactory = new CapturingLoggerFactory(); + var (pipeline, provider, accessToken, dpopProvider) = + await BuildDpopPipelineAsync(new MisbehavingNonceIssuer(), loggerFactory); + + var proof = await dpopProvider.GenerateProofAsync( + "POST", _resource, + new DPoPProofOptions(accessToken: accessToken), + CancellationToken.None); + + var ctx = await InvokeWithDpopAsync(pipeline, provider, accessToken, proof); + + Assert.Equal(StatusCodes.Status500InternalServerError, ctx.Response.StatusCode); + + var record = Assert.Single(loggerFactory.Records); + Assert.Equal(LogLevel.Error, record.Level); + Assert.NotNull(record.Exception); + Assert.Equal("Authplane.Mcp", loggerFactory.Category); + } + private sealed class MisbehavingNonceIssuer : IDPoPNonceIssuer { public string Issue() => "bad nonce"; @@ -294,7 +322,7 @@ private sealed class FixedTimeProvider : TimeProvider } private async Task<(RequestDelegate Pipeline, ServiceProvider Provider, string AccessToken, DPoPProvider DpopProvider)> - BuildDpopPipelineAsync(IDPoPNonceIssuer? nonceIssuer) + BuildDpopPipelineAsync(IDPoPNonceIssuer? nonceIssuer, ILoggerFactory? loggerFactory = null) { var keyMaterial = DPoPKeyMaterial.CreateES256(); var dpopProvider = new DPoPProvider(keyMaterial); @@ -312,6 +340,11 @@ private sealed class FixedTimeProvider : TimeProvider var services = new ServiceCollection(); services.AddSingleton(verifier); services.AddSingleton(); + if (loggerFactory is not null) + { + services.AddSingleton(loggerFactory); + } + var provider = services.BuildServiceProvider(); var options = new AuthplaneMcpAuth.Options( diff --git a/tests/Authplane.Mcp.Tests/CapturingLoggerFactory.cs b/tests/Authplane.Mcp.Tests/CapturingLoggerFactory.cs new file mode 100644 index 0000000..2d56544 --- /dev/null +++ b/tests/Authplane.Mcp.Tests/CapturingLoggerFactory.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Logging; + +namespace Authplane.Mcp.Tests; + +/// +/// Records what the middleware logs, so a test can assert that the cause the +/// response deliberately withholds reached the operator instead of being +/// dropped. drives : +/// the middleware guards its Debug call with it, and a test that left it at +/// the default would be asserting on a record production never writes. +/// +internal sealed class CapturingLoggerFactory(LogLevel minimumLevel = LogLevel.Trace) : ILoggerFactory +{ + private readonly CapturingLogger _logger = new(minimumLevel); + + public IReadOnlyList Records => _logger.Records; + + public string? Category { get; private set; } + + public ILogger CreateLogger(string categoryName) + { + Category = categoryName; + return _logger; + } + + public void AddProvider(ILoggerProvider provider) + { + } + + public void Dispose() + { + } +} + +internal sealed class CapturingLogger(LogLevel minimumLevel) : ILogger +{ + private readonly List _records = []; + + internal sealed record Record(LogLevel Level, EventId EventId, Exception? Exception, string Message); + + public IReadOnlyList Records => _records; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= minimumLevel; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + _records.Add(new Record(logLevel, eventId, exception, formatter(state, exception))); + } +} diff --git a/tests/Authplane.Mcp.Tests/ConformanceCatalogAlignmentTests.cs b/tests/Authplane.Mcp.Tests/ConformanceCatalogAlignmentTests.cs index 3dc466d..39deb37 100644 --- a/tests/Authplane.Mcp.Tests/ConformanceCatalogAlignmentTests.cs +++ b/tests/Authplane.Mcp.Tests/ConformanceCatalogAlignmentTests.cs @@ -33,4 +33,34 @@ public void ConformanceMarkers_NameCasesThatExistInTheCatalog() ConformanceCatalogAlignment.AssertNoUnknownCaseIds( typeof(ConformanceCatalogAlignmentTests).Assembly); } + + /// + /// Emits the [Conformance] marker scan of this assembly for the scheduled case-body drift + /// check, when the workflow asks for it. + /// + /// + /// The scan is empty today, for the same reason the assertion above has nothing to check: this + /// assembly declares no markers. It is emitted anyway so the reader sees a scan that ran and + /// found nothing rather than a scan that never ran, and so markers added here later are + /// watched by the drift check without anyone having to remember to widen it. + /// + /// Emptiness is therefore not asserted here. The reader requires the union across assemblies + /// to be non-empty, which is the guard that matters: an empty id list makes the drift check + /// vacuously green. + /// + [Fact] + public void ConformanceMarkerScan_IsWellFormedAndEmittedWhenRequested() + { + var markers = ConformanceCatalogAlignment.ScanConformanceMarkers( + typeof(ConformanceCatalogAlignmentTests).Assembly); + + Assert.All(markers, marker => + { + Assert.False(string.IsNullOrWhiteSpace(marker.CaseId)); + Assert.False(string.IsNullOrWhiteSpace(marker.DeclaredBy)); + }); + + ConformanceMarkerScanWriter.WriteIfRequested( + typeof(ConformanceCatalogAlignmentTests).Assembly); + } } diff --git a/tests/Authplane.Mcp.Tests/KestrelPathGroundTruth.cs b/tests/Authplane.Mcp.Tests/KestrelPathGroundTruth.cs new file mode 100644 index 0000000..fae2783 --- /dev/null +++ b/tests/Authplane.Mcp.Tests/KestrelPathGroundTruth.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Authplane.Mcp.Tests; + +/// +/// Observed ground truth for how Kestrel decodes a request target into +/// HttpContext.Request.Path: a real Kestrel server on a loopback port, +/// driven by the literal bytes of a request line written to a socket, echoing +/// back the Request.Path it produced. +/// +/// +/// +/// The middleware's decoded-path fallback compares Request.Path against +/// an expected path put through the SDK's own model of that decoder. Shaping +/// the request with a second, hand-written copy of the same model makes the +/// assertion PathsMatch(f(p), f(p)) — true for any f, so a model +/// that is wrong about Kestrel still passes green. Measuring the decoded path +/// instead of modelling it is what lets the test fail when the model is wrong. +/// +/// +/// The request goes out over a raw socket rather than HttpClient +/// because the input under measurement is the request target exactly as a +/// client puts it on the wire, and normalises some escapes +/// on the way out — which would silently measure the decoder against an input +/// other than the one asked for. Connection: close plus an explicit +/// Content-Length on the echo keeps the read a plain read-to-EOF with +/// no chunked framing to unpick. +/// +/// +public sealed class KestrelPathGroundTruth : IDisposable +{ + private static readonly TimeSpan ExchangeTimeout = TimeSpan.FromSeconds(30); + + private readonly IHost _host; + private readonly int _port; + + public KestrelPathGroundTruth() + { + // Port 0 lets Kestrel bind a free port itself, so there is no + // allocate-then-bind TOCTOU to retry around (the reason + // LoopbackHttpListener exists for HttpListener, which cannot). + _host = new HostBuilder() + .ConfigureLogging(logging => logging.ClearProviders()) + .ConfigureWebHost(webHost => webHost + .UseKestrel(options => options.Listen(IPAddress.Loopback, 0)) + .Configure(app => app.Run(async context => + { + var payload = Encoding.UTF8.GetBytes(context.Request.Path.Value ?? string.Empty); + context.Response.ContentType = "text/plain; charset=utf-8"; + context.Response.ContentLength = payload.Length; + await context.Response.Body.WriteAsync(payload).ConfigureAwait(false); + }))) + .Build(); + + _host.Start(); + + var address = _host.Services.GetRequiredService() + .Features.Get()!.Addresses.First(); + _port = new Uri(address, UriKind.Absolute).Port; + } + + /// + /// Sends as the request target of a real + /// HTTP/1.1 request line and returns the Request.Path Kestrel + /// produced from it. + /// + /// + /// Kestrel did not answer 200 — it rejected the target outright, which is + /// itself ground truth worth surfacing rather than swallowing. + /// + public async Task DecodePathAsync(string requestTarget) + { + using var timeout = new CancellationTokenSource(ExchangeTimeout); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, _port, timeout.Token).ConfigureAwait(false); + + // Refuse what cannot be transmitted verbatim. `Encoding.ASCII` maps any + // non-ASCII char to '?' without error, so `DecodePathAsync("/café")` would + // put `/caf?` on the wire and hand back a query-stripped `Request.Path` + // as if it were the answer — the exact failure this class's remark + // gives as its reason for bypassing `HttpClient`/`Uri`: silently + // measuring the decoder against an input other than the one asked for. + if (!Ascii.IsValid(requestTarget)) + { + throw new ArgumentException( + $"Request target '{requestTarget}' is not ASCII, so it cannot be put on the " + + "wire byte-for-byte. Percent-encode the non-ASCII octets first — this " + + "fixture measures what it transmits, and transmitting a substitute would " + + "measure the wrong input.", + nameof(requestTarget)); + } + + var stream = client.GetStream(); + var requestLine = Encoding.ASCII.GetBytes( + $"GET {requestTarget} HTTP/1.1\r\nHost: localhost:{_port}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(requestLine, timeout.Token).ConfigureAwait(false); + await stream.FlushAsync(timeout.Token).ConfigureAwait(false); + + using var received = new MemoryStream(); + await stream.CopyToAsync(received, timeout.Token).ConfigureAwait(false); + var response = Encoding.UTF8.GetString(received.ToArray()); + + var headerEnd = response.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (headerEnd < 0) + { + throw new InvalidOperationException( + $"Kestrel returned no complete response for request target '{requestTarget}'."); + } + + var statusLine = response[..response.IndexOf("\r\n", StringComparison.Ordinal)]; + if (!statusLine.StartsWith("HTTP/1.1 200 ", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Kestrel did not serve request target '{requestTarget}': {statusLine}"); + } + + return response[(headerEnd + 4)..]; + } + + public void Dispose() + { + _host.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + _host.Dispose(); + } +} diff --git a/tests/Authplane.Tests/AuthplaneClientCreateOwnershipTests.cs b/tests/Authplane.Tests/AuthplaneClientCreateOwnershipTests.cs new file mode 100644 index 0000000..e67c3ee --- /dev/null +++ b/tests/Authplane.Tests/AuthplaneClientCreateOwnershipTests.cs @@ -0,0 +1,248 @@ +using System.Net; +using Xunit; + +namespace Authplane.Tests; + +/// +/// constructs the client before it primes the +/// metadata cache, so it owns that client until it returns one. Every way the priming +/// fetch can fail is a path that used to abandon it: the and its +/// handler and connection pool, the metadata cache's semaphore and background refresh, and +/// the JWKS cache's gate are released only by DisposeAsync, and nothing can reach +/// them once the call has thrown. +/// +/// This is the likelier trigger than the constructor-argument paths its sibling class +/// pins: an authorization server that is down at process start is a transient condition +/// callers retry, and each attempt used to strand one full set. +/// +public sealed class AuthplaneClientCreateOwnershipTests +{ + /// + /// A listener that answers every request with the given status, or holds the request + /// open until teardown when is set. + /// + private static (string Issuer, IDisposable Server) StartListener( + HttpStatusCode status = HttpStatusCode.InternalServerError, + bool hangForever = false) + { + var (issuer, listener) = LoopbackHttpListener.Start(); + var shutdown = new CancellationTokenSource(); + + var loop = Task.Run(async () => + { + while (listener.IsListening) + { + HttpListenerContext ctx; + try + { + ctx = await listener.GetContextAsync().ConfigureAwait(false); + } + catch + { + return; + } + + try + { + if (hangForever) + { + // Never answered while the test runs: the only thing that ends + // the request is the caller's cancellation token, which is what + // the test asserts on. Teardown releases it so the loop does not + // outlive the test. + await Task.Delay(Timeout.Infinite, shutdown.Token).ConfigureAwait(false); + } + + ctx.Response.StatusCode = (int)status; + } + catch + { + // Client went away or the listener is shutting down; neither affects + // the assertion under test. + } + finally + { + try + { + ctx.Response.Close(); + } + catch + { + // Already torn down. + } + } + } + }); + + return (issuer, new Stopper(listener, loop, shutdown)); + } + + private sealed class Stopper(HttpListener listener, Task loop, CancellationTokenSource shutdown) + : IDisposable + { + public void Dispose() + { + shutdown.Cancel(); + listener.Stop(); + listener.Close(); + try + { + loop.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // The loop exits through its own exception path when the listener closes. + } + + shutdown.Dispose(); + } + } + + /// + /// The AS answers, but neither discovery endpoint yields a metadata document — the + /// startup shape of a misconfigured or half-deployed authorization server. + /// + [Fact] + public async Task MetadataFetchFails_ReleasesTheClientItBuilt() + { + var (issuer, server) = StartListener(); + using (server) + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + try + { + await Assert.ThrowsAnyAsync(() => + AuthplaneClient.CreateAsync(issuer, FetchSettings.FromDevMode(true))); + } + finally + { + AuthplaneClient.Probe.Value = null; + } + + // Without the first assertion the second passes vacuously: the probe would + // read 0/0 if the call had failed before ever constructing a client. + Assert.Equal(1, probe.Constructed); + Assert.Equal(0, probe.Live); + } + } + + /// + /// The retry loop is what turns one stranded client into a leak that matters, and a + /// flapping AS fails identically on every attempt. + /// + [Fact] + public async Task RepeatedMetadataFailure_AccumulatesNoClients() + { + var (issuer, server) = StartListener(); + using (server) + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + try + { + for (var i = 0; i < 5; i++) + { + await Assert.ThrowsAnyAsync(() => + AuthplaneClient.CreateAsync(issuer, FetchSettings.FromDevMode(true))); + } + } + finally + { + AuthplaneClient.Probe.Value = null; + } + + Assert.Equal(5, probe.Constructed); + Assert.Equal(0, probe.Live); + } + } + + /// + /// The cancellation arm: the caller's token, not a server response, ends the fetch. + /// It leaves by a different exception type and so needs its own row. + /// + [Fact] + public async Task CancelledDuringMetadataFetch_ReleasesTheClientItBuilt() + { + var (issuer, server) = StartListener(hangForever: true); + using (server) + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + try + { + await Assert.ThrowsAnyAsync(() => + AuthplaneClient.CreateAsync(issuer, FetchSettings.FromDevMode(true), cts.Token)); + } + finally + { + AuthplaneClient.Probe.Value = null; + } + + Assert.Equal(1, probe.Constructed); + Assert.Equal(0, probe.Live); + } + } + + /// + /// The same two arms one frame up, through , + /// which builds its client by calling and so + /// never reaches the try/catch that guards its own constructor. + /// + [Fact] + public async Task ResourceCreateAsync_MetadataFetchFails_ReleasesTheClient() + { + var (issuer, server) = StartListener(); + using (server) + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + try + { + await Assert.ThrowsAnyAsync(() => + AuthplaneResource.CreateAsync( + issuer: issuer, + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + fetchSettings: FetchSettings.FromDevMode(true))); + } + finally + { + AuthplaneClient.Probe.Value = null; + } + + Assert.Equal(1, probe.Constructed); + Assert.Equal(0, probe.Live); + } + } + + [Fact] + public async Task ResourceCreateAsync_Cancelled_ReleasesTheClient() + { + var (issuer, server) = StartListener(hangForever: true); + using (server) + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + try + { + await Assert.ThrowsAnyAsync(() => + AuthplaneResource.CreateAsync( + issuer: issuer, + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + fetchSettings: FetchSettings.FromDevMode(true), + cancellationToken: cts.Token)); + } + finally + { + AuthplaneClient.Probe.Value = null; + } + + Assert.Equal(1, probe.Constructed); + Assert.Equal(0, probe.Live); + } + } +} diff --git a/tests/Authplane.Tests/AuthplaneErrorsTests.cs b/tests/Authplane.Tests/AuthplaneErrorsTests.cs index 491fd78..a489438 100644 --- a/tests/Authplane.Tests/AuthplaneErrorsTests.cs +++ b/tests/Authplane.Tests/AuthplaneErrorsTests.cs @@ -20,7 +20,13 @@ public void WwwAuthenticate_BearerScheme_ForNonDPoPException() var header = AuthplaneErrors.WwwAuthenticate(new TokenExpiredException("expired")); Assert.StartsWith("Bearer ", header, StringComparison.Ordinal); Assert.Contains("error=\"invalid_token\"", header, StringComparison.Ordinal); - Assert.Contains("error_description=\"expired\"", header, StringComparison.Ordinal); + // The message ("expired") no longer reaches the wire: the description is + // the fixed sentence the error code selects. + Assert.Contains( + "error_description=\"The access token is missing or not valid for this resource\"", + header, + StringComparison.Ordinal); + Assert.DoesNotContain("expired", header, StringComparison.Ordinal); } [Fact] @@ -91,9 +97,16 @@ public void WwwAuthenticate_EscapesQuotesAndBackslashesInErrorDescription() new TokenExpiredException("bad \"token\" with \\ slash"), realm: "api \"prod\" \\"); + // The fixed description carries no quote or backslash to escape, so the + // message path is exercised through the verbose overload instead — the + // one way an operator can still put a caller-influenced string here. + var verbose = AuthplaneErrors.WwwAuthenticate( + new TokenExpiredException("bad \"token\" with \\ slash"), + realm: "", + verboseDescription: true); Assert.Contains( "error_description=\"bad \\\"token\\\" with \\\\ slash\"", - header, + verbose, StringComparison.Ordinal); Assert.Contains( "realm=\"api \\\"prod\\\" \\\\\"", @@ -122,7 +135,17 @@ public void WwwAuthenticate_StripsCRLFAndControlChars_FromErrorDescriptionAndRea Assert.DoesNotContain("\t", header, StringComparison.Ordinal); Assert.DoesNotContain("\x7f", header, StringComparison.Ordinal); - Assert.Contains("error_description=\"expiredX-Injected: 1tab\"", header, StringComparison.Ordinal); + // As above: the fixed description has no CTL to strip, so the stripping + // is pinned on the message through the verbose overload. + var verbose = AuthplaneErrors.WwwAuthenticate( + new TokenExpiredException("expired\r\nX-Injected: 1\ttab\x7f"), + realm: "", + verboseDescription: true); + Assert.DoesNotContain("\r", verbose, StringComparison.Ordinal); + Assert.DoesNotContain("\n", verbose, StringComparison.Ordinal); + Assert.DoesNotContain("\t", verbose, StringComparison.Ordinal); + Assert.DoesNotContain("\x7f", verbose, StringComparison.Ordinal); + Assert.Contains("error_description=\"expiredX-Injected: 1tab\"", verbose, StringComparison.Ordinal); Assert.Contains("realm=\"apiX-Realm-Injection: yes\"", header, StringComparison.Ordinal); } @@ -158,6 +181,52 @@ public void HttpStatus_DefaultsTo500_ForUnknownException() Assert.Equal(500, AuthplaneErrors.HttpStatus(new CircuitOpenException())); } + [Fact] + public void ServerErrorCodeFor_Separates503FromEveryOther5xx() + { + // server_error reads as a fault in this resource server. A 503 here is + // the authorization server being unreachable — transient, and worth a + // retry — which is what RFC 6749 §5.2's temporarily_unavailable says. + Assert.Equal("temporarily_unavailable", AuthplaneErrors.ServerErrorCodeFor(503)); + Assert.Equal("server_error", AuthplaneErrors.ServerErrorCodeFor(500)); + + // Keyed off the status, so the two exceptions that produce a 503 reach + // the transient code and CircuitOpenException — 500 by the default arm + // above — does not. + Assert.Equal( + "temporarily_unavailable", + AuthplaneErrors.ServerErrorCodeFor(AuthplaneErrors.HttpStatus(new JwksFetchException("unreachable")))); + Assert.Equal( + "temporarily_unavailable", + AuthplaneErrors.ServerErrorCodeFor(AuthplaneErrors.HttpStatus(new MetadataFetchException("unreachable")))); + Assert.Equal( + "server_error", + AuthplaneErrors.ServerErrorCodeFor(AuthplaneErrors.HttpStatus(new CircuitOpenException()))); + } + + [Fact] + public void TemporarilyUnavailable_CarriesASafeDescription() + { + var description = AuthplaneErrors.ErrorDescriptionFor(AuthplaneErrors.TemporarilyUnavailableCode); + + Assert.NotEqual(AuthplaneErrors.FallbackErrorDescription, description); + // The same rule the rest of the table follows: no comma, because a + // comma separates challenge parameters in a WWW-Authenticate value. + Assert.DoesNotContain(",", description, StringComparison.Ordinal); + } + + [Fact] + public void ErrorResponseBody_For503_NamesTheTransientCode() + { + var body = AuthplaneErrors.ErrorResponseBody(AuthplaneErrors.ServerErrorCodeFor(503)); + + using var doc = System.Text.Json.JsonDocument.Parse(body); + Assert.Equal("temporarily_unavailable", doc.RootElement.GetProperty("error").GetString()); + Assert.Equal( + AuthplaneErrors.ErrorDescriptionFor(AuthplaneErrors.TemporarilyUnavailableCode), + doc.RootElement.GetProperty("error_description").GetString()); + } + // ----------------------------------------------------------------------- // MapOAuthError // ----------------------------------------------------------------------- @@ -169,6 +238,7 @@ public void HttpStatus_DefaultsTo500_ForUnknownException() [InlineData("invalid_scope", typeof(InvalidScopeException))] [InlineData("invalid_request", typeof(InvalidRequestException))] [InlineData("unsupported_grant_type", typeof(UnsupportedGrantTypeException))] + [InlineData("invalid_target", typeof(InvalidTargetException))] public void MapOAuthError_DispatchesTypedSubclass(string oauthError, Type expectedType) { var ex = (AuthplaneTokenRequestException)AuthplaneErrors.MapOAuthError( @@ -184,6 +254,21 @@ public void MapOAuthError_DispatchesTypedSubclass(string oauthError, Type expect Assert.Equal("https://errors.example.com/x", ex.ErrorUri); } + [Fact] + public void MapOAuthError_AccessDenied403_ReturnsAccessDeniedException() + { + // authserver 0.2.0 answers a cross-client exchange the Resource has not + // allow-listed with access_denied + 403. Must not collapse into the + // bare-401/403 InvalidClient handling or the generic base type. + var ex = Assert.IsType(AuthplaneErrors.MapOAuthError( + oauthError: "access_denied", + httpStatus: 403, + errorDescription: "client not allowed to exchange for this resource")); + Assert.Equal("access_denied", ex.OAuthError); + Assert.Equal(403, ex.HttpStatus); + Assert.Equal("client not allowed to exchange for this resource", ex.ErrorDescription); + } + [Fact] public void MapOAuthError_UnknownCode_ReturnsBaseTokenRequestException() { @@ -285,4 +370,116 @@ public void MapOAuthError_ConsentRequired_BlankConsentUrl_BecomesNull() var consent = Assert.IsType(ex); Assert.Null(consent.ConsentUrl); } + + // ----------------------------------------------------------------------- + // ErrorResponseBody + // ----------------------------------------------------------------------- + + [Fact] + public void ErrorResponseBody_NeverCarriesTheExceptionMessage() + { + // The body reaches a caller who has not authenticated, and the SDK's + // messages name the failing detail — here the exact audience the + // resource expects, which is the value a caller needs in order to go + // request a token for it. + var json = AuthplaneErrors.ErrorResponseBody( + AuthplaneErrors.ErrorCodeFor( + new InvalidClaimsException("aud mismatch: expected https://api.example.com/mcp"))); + + Assert.Contains( + "\"error_description\":\"The access token is missing or not valid for this resource\"", + json, + StringComparison.Ordinal); + Assert.DoesNotContain("api.example.com", json, StringComparison.Ordinal); + } + + [Fact] + public void ErrorResponseBody_OmitsTheErrorCodeWhenNoCredentialsWerePresented() + { + // The three pre-token 401 paths build a challenge with no `error` + // parameter, as RFC 6750 §3 requires for a request that carried no + // authentication information. The body has to make the same omission: + // §3.1 ties `invalid_request` to a malformed request answered with + // 400, so naming it here would both misreport the failure and put the + // body at odds with the header it travels with. + var json = AuthplaneErrors.ErrorResponseBody(); + + Assert.DoesNotContain("\"error\":", json, StringComparison.Ordinal); + Assert.Equal( + "{\"error_description\":\"The request did not carry a usable access token\"}", + json); + } + + [Fact] + public void ErrorDescriptionFor_AcceptsNullTheWayErrorResponseBodyDoes() + { + // The XML doc invites an adapter to compose a challenge "from a code it + // already knows", and ErrorResponseBody documents null as the + // no-credentials case. An adapter holding one nullable code feeds both, + // so the pair has to answer, not throw: Dictionary.TryGetValue raises + // ArgumentNullException on a null key. + Assert.Equal( + "The request did not carry a usable access token", + AuthplaneErrors.ErrorDescriptionFor(null)); + Assert.Equal( + "The request did not carry a usable access token", + AuthplaneErrors.ErrorDescriptionFor(string.Empty)); + + // and the two halves agree on it + Assert.Contains( + "\"error_description\":\"" + AuthplaneErrors.ErrorDescriptionFor(null) + "\"", + AuthplaneErrors.ErrorResponseBody(), + StringComparison.Ordinal); + } + + [Fact] + public void ErrorResponseBody_FallsBackForACodeWithNoRow() + { + // use_dpop_nonce is the live case: no sibling SDK carries a row for it, + // so it takes the contentless fallback rather than a sentence this SDK + // invented on its own. + var json = AuthplaneErrors.ErrorResponseBody(OAuthConstants.ErrorCodes.UseDpopNonce); + + Assert.Contains("\"error\":\"use_dpop_nonce\"", json, StringComparison.Ordinal); + Assert.Contains( + $"\"error_description\":\"{AuthplaneErrors.FallbackErrorDescription}\"", + json, + StringComparison.Ordinal); + } + + [Fact] + public void ErrorResponseBody_AgreesWithTheChallengeItTravelsWith() + { + // One table, two surfaces: a client reads whichever half it finds, so + // they must not drift. + var error = new InsufficientScopeException("missing tools/delete"); + var header = AuthplaneErrors.WwwAuthenticate(error); + var json = AuthplaneErrors.ErrorResponseBody(AuthplaneErrors.ErrorCodeFor(error)); + + Assert.Contains( + $"error_description=\"{AuthplaneErrors.ErrorDescriptionFor(AuthplaneErrors.ErrorCodeFor(error))}\"", + header, + StringComparison.Ordinal); + Assert.Contains( + "\"error_description\":\"The access token does not carry the scope this operation requires\"", + json, + StringComparison.Ordinal); + } + + [Fact] + public void ErrorResponseBody_RestoresTheMessageUnderVerboseDescription() + { + // The development escape hatch, and the reason the body is serialized + // rather than interpolated: JSON escaping has to hold for a message + // carrying quotes and CRLF. + var json = AuthplaneErrors.ErrorResponseBody( + OAuthConstants.ErrorCodes.InvalidToken, + new TokenExpiredException("bad \"token\"\r\nX-Injected: 1"), + verboseDescription: true); + + using var doc = System.Text.Json.JsonDocument.Parse(json); + Assert.Equal( + "bad \"token\"\r\nX-Injected: 1", + doc.RootElement.GetProperty("error_description").GetString()); + } } diff --git a/tests/Authplane.Tests/AuthplaneResourceClientOwnershipTests.cs b/tests/Authplane.Tests/AuthplaneResourceClientOwnershipTests.cs new file mode 100644 index 0000000..dd400d1 --- /dev/null +++ b/tests/Authplane.Tests/AuthplaneResourceClientOwnershipTests.cs @@ -0,0 +1,233 @@ +using System.Net; +using System.Text; +using Xunit; + +namespace Authplane.Tests; + +/// +/// builds the it +/// hands to the resource, so it owns that client until the constructor returns and +/// ownership transfers. These tests pin that every rejecting exit from the constructor +/// still releases it: an abandoned client keeps an and the JWKS +/// refresh state alive with nothing able to reach them, and a caller that retries a +/// misconfiguration in a loop accumulates one set per attempt. +/// +public sealed class AuthplaneResourceClientOwnershipTests : IDisposable +{ + private readonly HttpListener _listener; + private readonly string _issuer; + private readonly Task _serverLoop; + + public AuthplaneResourceClientOwnershipTests() + { + (_issuer, _listener) = LoopbackHttpListener.Start(); + + _serverLoop = Task.Run(async () => + { + while (_listener.IsListening) + { + HttpListenerContext? ctx; + try + { + ctx = await _listener.GetContextAsync().ConfigureAwait(false); + } + catch + { + return; + } + + try + { + var path = ctx.Request.Url?.AbsolutePath ?? ""; + var body = path.StartsWith("/.well-known/oauth-authorization-server", StringComparison.Ordinal) + || path.StartsWith("/.well-known/openid-configuration", StringComparison.Ordinal) + ? $"{{\"issuer\":\"{_issuer}\",\"jwks_uri\":\"{_issuer}/.well-known/jwks.json\"}}" + : "{\"keys\":[]}"; + + var bytes = Encoding.UTF8.GetBytes(body); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes); + } + catch + { + // Client went away mid-response; the assertion under test does not care. + } + finally + { + ctx.Response.Close(); + } + } + }); + } + + /// + /// The metadata fetch has to succeed for the client to exist at all, which is what makes + /// these the leaking paths: the constructor runs only after a client has been built. + /// + private async Task AssertClientReleasedAsync( + Func createAsync) + where TException : Exception + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + try + { + await Assert.ThrowsAsync(createAsync); + } + finally + { + AuthplaneClient.Probe.Value = null; + } + + // The probe is only meaningful if the run actually got past the metadata fetch and + // built a client. Without this the whole assertion passes vacuously. + Assert.Equal(1, probe.Constructed); + Assert.Equal(0, probe.Live); + return probe; + } + + [Fact] + public async Task NegativeClockSkew_ReleasesTheClientItBuilt() + { + await AssertClientReleasedAsync(() => + AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + fetchSettings: FetchSettings.FromDevMode(true), + clockSkewSeconds: -1)); + } + + [Fact] + public async Task EmptyAllowedAlgorithms_ReleasesTheClientItBuilt() + { + await AssertClientReleasedAsync(() => + AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + fetchSettings: FetchSettings.FromDevMode(true), + allowedAlgorithms: Array.Empty())); + } + + [Fact] + public async Task UnsupportedAllowedAlgorithm_ReleasesTheClientItBuilt() + { + await AssertClientReleasedAsync(() => + AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + fetchSettings: FetchSettings.FromDevMode(true), + allowedAlgorithms: new[] { "HS256" })); + } + + /// + /// A caller retrying a misconfiguration must not accumulate clients — the failure mode + /// that makes the single-instance leak matter in a hosted process. + /// + [Fact] + public async Task RepeatedRejection_AccumulatesNoClients() + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + try + { + for (var i = 0; i < 5; i++) + { + await Assert.ThrowsAsync(() => + AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + fetchSettings: FetchSettings.FromDevMode(true), + clockSkewSeconds: -1)); + } + } + finally + { + AuthplaneClient.Probe.Value = null; + } + + Assert.Equal(5, probe.Constructed); + Assert.Equal(0, probe.Live); + } + + /// + /// The happy path must not release the client: the resource is constructed with + /// ownsClient: true and releases it from its own DisposeAsync. + /// + [Fact] + public async Task SuccessfulCreate_TransfersOwnershipAndDisposesOnce() + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + try + { + var resource = await AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + fetchSettings: FetchSettings.FromDevMode(true)); + + Assert.Equal(1, probe.Constructed); + Assert.Equal(1, probe.Live); + + await resource.DisposeAsync(); + Assert.Equal(0, probe.Live); + + // Disposing the resource twice must not double-release the client underneath. + await resource.DisposeAsync(); + Assert.Equal(1, probe.Disposed); + } + finally + { + AuthplaneClient.Probe.Value = null; + } + } + + /// + /// A client the caller owns is never released by the resource, whether the identifier is + /// accepted or rejected — only the overload that builds its own client disposes it. + /// + [Fact] + public async Task CallerOwnedClient_IsNotReleasedByARejectedResource() + { + var probe = new AuthplaneClient.LifetimeProbe(); + AuthplaneClient.Probe.Value = probe; + try + { + await using var client = await AuthplaneClient.CreateAsync( + _issuer, FetchSettings.FromDevMode(true)); + + await Assert.ThrowsAsync(() => + client.CreateResourceAsync( + resource: "https://api.example.com/mcp", + scopes: new[] { "read" }, + clockSkewSeconds: -1)); + + Assert.Equal(1, probe.Constructed); + Assert.Equal(1, probe.Live); + } + finally + { + AuthplaneClient.Probe.Value = null; + } + } + + public void Dispose() + { + _listener.Stop(); + _listener.Close(); + try + { + _serverLoop.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // Server loop exits via the exception path when the listener closes. + } + } +} diff --git a/tests/Authplane.Tests/AuthplaneResource_ClaimEdgeCasesTests.cs b/tests/Authplane.Tests/AuthplaneResource_ClaimEdgeCasesTests.cs index 408aec4..92faa61 100644 --- a/tests/Authplane.Tests/AuthplaneResource_ClaimEdgeCasesTests.cs +++ b/tests/Authplane.Tests/AuthplaneResource_ClaimEdgeCasesTests.cs @@ -55,6 +55,15 @@ public AuthplaneVerifierBranchCoverageTests() ctx.Response.ContentLength64 = bytes.Length; await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); } + else if (path == "/oauth/introspect") + { + // Always inactive: the tests that reach it are the revocation ones. + var bytes = System.Text.Encoding.UTF8.GetBytes("{\"active\":false}"); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); + } else { ctx.Response.StatusCode = 404; @@ -136,6 +145,76 @@ public async Task VerifyAsync_InvalidSignature_ThrowsInvalidSignatureException() await Assert.ThrowsAsync(() => verifier.VerifyAsync(token)); } + /// + /// The caller-visible message must not disclose the AS-to-resource-server trust state: + /// TokenRevokedException maps to 401 and the MCP middleware copies Message verbatim into + /// the WWW-Authenticate error_description and the response body. + /// + [Fact] + public async Task VerifyAsync_Revoked_ByIntrospection_KeepsOwnershipHintOffTheWire() + { + await using var authClient = new AuthplaneAuthClient( + issuerUrl: _issuer, + clientId: "rs_client", clientSecret: "rs_secret", + fetchSettings: FetchSettings.FromDevMode(true)); + var verifier = await AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add" }, + fetchSettings: FetchSettings.FromDevMode(true), + revocationChecker: new IntrospectionRevocation(authClient)); + + var token = MintToken( + signingKey: _signingKey, + kid: _kid, + issuer: _issuer, + audience: _resource, + expires: DateTimeOffset.UtcNow.AddMinutes(5).UtcDateTime); + + var ex = await Assert.ThrowsAsync(() => verifier.VerifyAsync(token)); + + Assert.DoesNotContain("introspection", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("recognise", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith("has been revoked.", ex.Message, StringComparison.Ordinal); + // The operator half rides on the inner exception, which no response path reads. + Assert.NotNull(ex.InnerException); + Assert.Contains("introspection returned active=false", ex.InnerException!.Message, StringComparison.Ordinal); + Assert.Contains("runtime-client", ex.InnerException!.Message, StringComparison.Ordinal); + } + + /// + /// A custom checker never calls introspection, so nothing in the failure may blame it. + /// + [Fact] + public async Task VerifyAsync_Revoked_ByCustomChecker_CarriesNoIntrospectionHint() + { + var verifier = await AuthplaneResource.CreateAsync( + issuer: _issuer, + resource: _resource, + scopes: new[] { "tools/add" }, + fetchSettings: FetchSettings.FromDevMode(true), + revocationChecker: new AlwaysRevokedChecker()); + + var token = MintToken( + signingKey: _signingKey, + kid: _kid, + issuer: _issuer, + audience: _resource, + expires: DateTimeOffset.UtcNow.AddMinutes(5).UtcDateTime); + + var ex = await Assert.ThrowsAsync(() => verifier.VerifyAsync(token)); + + Assert.EndsWith("has been revoked.", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("introspection", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Null(ex.InnerException); + } + + private sealed class AlwaysRevokedChecker : IRevocationChecker + { + public Task IsRevokedAsync(string token, CancellationToken cancellationToken = default) + => Task.FromResult(true); + } + private static string MintToken( ECDsa signingKey, string kid, diff --git a/tests/Authplane.Tests/AuthplaneVerifierTests.cs b/tests/Authplane.Tests/AuthplaneVerifierTests.cs index 1b6739c..3e0c2f8 100644 --- a/tests/Authplane.Tests/AuthplaneVerifierTests.cs +++ b/tests/Authplane.Tests/AuthplaneVerifierTests.cs @@ -69,6 +69,24 @@ public async Task CreateResourceAsync_InvalidQuery_Throws() Assert.Contains("query", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task CreateResourceAsync_InvalidPath_Throws() + { + // Same path as the fragment and query cases above, for the path gate: + // this is the one construction path that reaches the constructor + // without passing the early copy in CreateAsync, so it is where the + // constructor's gate is the only thing standing. + using var server = new OneShotJwksServer("{\"keys\":[]}"); + await using var client = await AuthplaneClient.CreateAsync( + server.IssuerUrl, FetchSettings.FromDevMode(true)); + + var ex = await Assert.ThrowsAsync(async () => + await client.CreateResourceAsync("https://api.example.com/café", new[] { "read:data" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains("path", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task CreateResourceAsync_NonUrlResource_Rejected() { diff --git a/tests/Authplane.Tests/CircuitPolicyTests.cs b/tests/Authplane.Tests/CircuitPolicyTests.cs index f3ac1bc..14f761b 100644 --- a/tests/Authplane.Tests/CircuitPolicyTests.cs +++ b/tests/Authplane.Tests/CircuitPolicyTests.cs @@ -46,6 +46,22 @@ public void TokenRequest_ConsentRequired_DoesNotRecord() Assert.False(CircuitPolicy.ShouldRecordFailure(ex)); } + [Fact] + public void TokenRequest_AccessDenied403_DoesNotRecord() + { + // Cross-client exchange refused by the Resource's allow-list. The 403 + // would otherwise fall into the "401/403 means client auth" branch. + var ex = new AccessDeniedException("HTTP 403", httpStatus: 403); + Assert.False(CircuitPolicy.ShouldRecordFailure(ex)); + } + + [Fact] + public void TokenRequest_InvalidTarget_DoesNotRecord() + { + var ex = new InvalidTargetException("HTTP 400", httpStatus: 400); + Assert.False(CircuitPolicy.ShouldRecordFailure(ex)); + } + [Fact] public void TokenRequest_InvalidClient_Records() { diff --git a/tests/Authplane.Tests/ConformanceCatalogAlignmentTests.cs b/tests/Authplane.Tests/ConformanceCatalogAlignmentTests.cs index 7beda2a..ade7d8d 100644 --- a/tests/Authplane.Tests/ConformanceCatalogAlignmentTests.cs +++ b/tests/Authplane.Tests/ConformanceCatalogAlignmentTests.cs @@ -14,7 +14,12 @@ namespace Authplane.Tests; /// /// This runs on every PR against the catalog SHA pinned in .conformance-catalog-ref, so /// bumping that pin without the matching coverage fails here rather than merging green. +/// +/// The emitter [Fact] below calls the scan writer, which reads a process-global environment +/// variable that ConformanceMarkerScanContractTests points at a temp directory. Both classes join +/// the same collection so the two never run at once. /// +[Collection(ConformanceMarkerScanContractTests.ScanDirectoryCollection)] public sealed class ConformanceCatalogAlignmentTests { [Fact] @@ -23,4 +28,36 @@ public void CatalogCasesAndConformanceMarkers_Agree() ConformanceCatalogAlignment.AssertCatalogAndMarkersAgree( typeof(ConformanceCatalogAlignmentTests).Assembly); } + + /// + /// Emits the [Conformance] marker scan of this assembly for the scheduled case-body drift + /// check, when the workflow asks for it. + /// + /// + /// It lives beside the assertion above on purpose. The drift check is only as good as the id + /// list it is scoped to, and what makes this list trustworthy is that the same scan is + /// asserted against the catalog in both directions one test over, in the same run and against + /// the same catalog — a marker the scan missed shows up there as an uncovered catalog case + /// and turns the run red, rather than quietly shortening the drift check's scope. + /// + /// Emission is conditional on CONFORMANCE_MARKER_SCAN_DIR, so an ordinary run writes nothing. + /// The assertions below hold either way: they pin the extractor's own contract, which the + /// reader script re-checks on the file it finds. + /// + [Fact] + public void ConformanceMarkerScan_IsWellFormedAndEmittedWhenRequested() + { + var markers = ConformanceCatalogAlignment.ScanConformanceMarkers( + typeof(ConformanceCatalogAlignmentTests).Assembly); + + Assert.NotEmpty(markers); + Assert.All(markers, marker => + { + Assert.False(string.IsNullOrWhiteSpace(marker.CaseId)); + Assert.False(string.IsNullOrWhiteSpace(marker.DeclaredBy)); + }); + + ConformanceMarkerScanWriter.WriteIfRequested( + typeof(ConformanceCatalogAlignmentTests).Assembly); + } } diff --git a/tests/Authplane.Tests/ConformanceDriftMarkerContractTests.cs b/tests/Authplane.Tests/ConformanceDriftMarkerContractTests.cs new file mode 100644 index 0000000..840213b --- /dev/null +++ b/tests/Authplane.Tests/ConformanceDriftMarkerContractTests.cs @@ -0,0 +1,128 @@ +using System.Text.RegularExpressions; +using Authplane.Conformance; +using Xunit; + +namespace Authplane.Tests; + +/// +/// Ties to the copy of it embedded in +/// the scheduled drift workflow. +/// +/// +/// The workflow classifies a failed alignment step by grepping the test log for the marker: +/// present means real catalog drift, absent means a build or harness problem. YAML cannot +/// read a C# const, so the value is spelled out in both places. Nothing in the language +/// keeps them equal, and the failure is silent in the worst direction — rename the const +/// alone and the grep matches nothing, so every genuine drift is reported as infrastructure +/// noise and the scheduled job stays green-looking while the catalog moves away underneath. +/// These tests are what makes the duplication safe. +/// +public sealed class ConformanceDriftMarkerContractTests +{ + private const string WorkflowRelativePath = + ".github/workflows/conformance-catalog-drift.yml"; + + /// + /// Matches the workflow's classification grep, capturing the literal it searches for. + /// + private static readonly Regex GrepLiteralRe = + new(@"grep\s+-qF\s+'(?[^']*)'", RegexOptions.Compiled); + + /// + /// Matches anything shaped like a drift marker anywhere in the workflow, so a second + /// hand-written copy is caught wherever it sits — the classification grep, a + /// ::warning:: line, or job-summary prose. Deliberately matched by shape rather + /// than by the current spelling: a test that looked for today's exact string could not + /// see a stale copy of yesterday's. + /// + private static readonly Regex MarkerShapedRe = + new(@"[A-Za-z][A-Za-z -]*drift:", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + [Fact] + public void Workflow_GrepsExactlyTheMarkerTheAssertionWrites() + { + var workflow = ReadWorkflow(); + + var literals = GrepLiteralRe.Matches(workflow) + .Select(m => m.Groups["literal"].Value) + .ToList(); + + // Without this the assertion below passes on an empty set — the workflow could have + // dropped the classification grep entirely and this test would not notice. + Assert.NotEmpty(literals); + + foreach (var literal in literals) + { + Assert.Equal(ConformanceCatalogAlignment.DriftMarker, literal); + } + } + + [Fact] + public void Workflow_CarriesNoOtherSpellingOfTheMarker() + { + var workflow = ReadWorkflow(); + + var spellings = MarkerShapedRe.Matches(workflow) + .Select(m => m.Value) + .Distinct(StringComparer.Ordinal) + .ToList(); + + Assert.NotEmpty(spellings); + + // Every copy, functional or prose, has to read the same as the const. A summary that + // names a marker the assertion no longer writes sends a reader looking for the wrong + // string in the log. + Assert.Equal( + new[] { ConformanceCatalogAlignment.DriftMarker }, + spellings); + } + + /// + /// The other half of the contract: the assertion has to actually put the marker in its + /// message, or the workflow's grep classifies real drift as a harness problem however + /// well the two literals agree. + /// + [Fact] + public void DriftFailureMessage_StartsWithTheMarker() + { + // An assembly that carries no [Conformance] markers leaves every catalog case + // uncovered, which is the drift direction the scheduled job exists to catch. + var ex = Assert.Throws(() => + ConformanceCatalogAlignment.AssertCatalogAndMarkersAgree(typeof(object).Assembly)); + + Assert.StartsWith(ConformanceCatalogAlignment.DriftMarker, ex.Message, StringComparison.Ordinal); + } + + private static string ReadWorkflow() => File.ReadAllText(ResolveWorkflowPath()); + + /// + /// Walks up from the test binary's working directory, matching how + /// ConformanceCatalog resolves the catalog itself. + /// + private static string ResolveWorkflowPath() + { + var dir = Directory.GetCurrentDirectory(); + for (var i = 0; i < 10; i++) + { + var candidate = Path.Combine(dir, WorkflowRelativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + var parent = Directory.GetParent(dir); + if (parent is null) + { + break; + } + + dir = parent.FullName; + } + + throw new FileNotFoundException( + $"Could not find `{WorkflowRelativePath}` in any ancestor of " + + $"`{Directory.GetCurrentDirectory()}`. The drift marker is duplicated between the " + + "workflow and ConformanceCatalogAlignment.DriftMarker; this test is what keeps the " + + "two equal, so a silently skipped lookup is itself the failure."); + } +} diff --git a/tests/Authplane.Tests/ConformanceMarkerScanContractTests.cs b/tests/Authplane.Tests/ConformanceMarkerScanContractTests.cs new file mode 100644 index 0000000..f72f76c --- /dev/null +++ b/tests/Authplane.Tests/ConformanceMarkerScanContractTests.cs @@ -0,0 +1,169 @@ +using System.Reflection; +using System.Text.Json; +using Authplane.Conformance; +using Xunit; + +namespace Authplane.Tests; + +/// +/// Ties the JSON emits to the shape +/// .github/scripts/conformance-registered-case-ids.sh reads back. +/// +/// +/// The emitter is C# and the reader is jq; nothing in either language keeps the field names equal. +/// The failure is silent in the worst direction and on the worst schedule: the reader only runs in +/// the weekly drift job, so renaming a field here leaves every test, the formatter and the build +/// green on the PR, and the break surfaces up to a week later as a step that "could not run" — +/// indistinguishable, in the report the job writes, from a case having been re-tightened. +/// +/// The assertions below are deliberately written the way the reader's jq guards are written, field +/// name for field name, so the two move together or this test says so. +/// +[Collection(ScanDirectoryCollection)] +public sealed class ConformanceMarkerScanContractTests +{ + /// + /// xUnit collection shared by every test class that moves + /// or calls the writer + /// that reads it. + /// + /// + /// The variable is process-global and xUnit runs test classes in parallel, so a class that + /// points it at its own temp directory also redirects any WriteIfRequested call racing + /// it — two writers on one path, which surfaces as a sharing violation rather than as a clean + /// failure. Members of one collection never run concurrently, so joining this one removes the + /// race. + /// + public const string ScanDirectoryCollection = "conformance marker scan"; + + private static Assembly TestAssembly => typeof(ConformanceMarkerScanContractTests).Assembly; + + [Fact] + public void WriteIfRequested_EmitsTheShapeTheReaderScriptRequires() + { + var previous = Environment.GetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable); + var destination = Path.Combine( + Path.GetTempPath(), "authplane-marker-scan-" + Guid.NewGuid().ToString("n")); + + try + { + Environment.SetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable, destination); + + var path = ConformanceMarkerScanWriter.WriteIfRequested(TestAssembly); + + // The reader globs `$SCAN_DIR/*.json` and treats one file per assembly as the unit, so + // the name is part of the contract and not an implementation detail. + Assert.Equal( + Path.Combine(destination, TestAssembly.GetName().Name + ".json"), path); + Assert.True(File.Exists(path)); + + using var document = JsonDocument.Parse(File.ReadAllText(path!)); + var root = document.RootElement; + Assert.Equal(JsonValueKind.Object, root.ValueKind); + + // `(.assembly | type) == "string" and (.assembly | length) > 0`, plus the identity the + // reader cannot check for itself: the file names the assembly it actually scanned. + Assert.True(root.TryGetProperty("assembly", out var assembly)); + Assert.Equal(JsonValueKind.String, assembly.ValueKind); + Assert.Equal(TestAssembly.GetName().Name, assembly.GetString()); + + // `(.cases | type) == "array"`. + Assert.True(root.TryGetProperty("cases", out var cases)); + Assert.Equal(JsonValueKind.Array, cases.ValueKind); + + // `all(.cases[]; .case_id and .declared_by are non-empty strings)`. Every entry is + // checked rather than sampled: the reader rejects the whole file on one bad entry. + var emitted = new List(); + foreach (var entry in cases.EnumerateArray()) + { + Assert.True(entry.TryGetProperty("case_id", out var caseId)); + Assert.Equal(JsonValueKind.String, caseId.ValueKind); + Assert.NotEmpty(caseId.GetString()!); + + Assert.True(entry.TryGetProperty("declared_by", out var declaredBy)); + Assert.Equal(JsonValueKind.String, declaredBy.ValueKind); + Assert.NotEmpty(declaredBy.GetString()!); + + emitted.Add(caseId.GetString()!); + } + + // What the reader ends up with after its `sort -u` must be the scan this assembly + // vouches for one test class over, in ConformanceCatalogAlignmentTests. Asserting the + // sets are equal is what makes the emitted list neither short nor invented; asserting + // it is non-empty is what stops this test from passing on a writer that emits nothing. + var scanned = ConformanceCatalogAlignment.ScanConformanceMarkers(TestAssembly) + .Select(marker => marker.CaseId) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + + Assert.NotEmpty(scanned); + Assert.Equal( + scanned, + emitted.Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList()); + } + finally + { + Environment.SetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable, previous); + + if (Directory.Exists(destination)) + { + Directory.Delete(destination, recursive: true); + } + } + } + + /// + /// An ordinary dotnet test run leaves no scan behind — the emitter is opt-in, so a + /// developer machine and PR CI never write one. + /// + [Fact] + public void WriteIfRequested_WritesNothingWhenTheVariableIsUnset() + { + var previous = Environment.GetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable); + + try + { + Environment.SetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable, null); + + Assert.Null(ConformanceMarkerScanWriter.WriteIfRequested(TestAssembly)); + } + finally + { + Environment.SetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable, previous); + } + } + + /// + /// A relative destination resolves against the test host's working directory, so the write + /// would succeed somewhere the reader never looks and be reported as a scan that never ran. + /// + [Fact] + public void WriteIfRequested_RejectsARelativeDestination() + { + var previous = Environment.GetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable); + + try + { + Environment.SetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable, "conformance-marker-scan"); + + Assert.Throws( + () => ConformanceMarkerScanWriter.WriteIfRequested(TestAssembly)); + } + finally + { + Environment.SetEnvironmentVariable( + ConformanceMarkerScanWriter.DestinationDirectoryVariable, previous); + } + } +} diff --git a/tests/Authplane.Tests/MetadataValidationTests.cs b/tests/Authplane.Tests/MetadataValidationTests.cs index a093c38..5a402d7 100644 --- a/tests/Authplane.Tests/MetadataValidationTests.cs +++ b/tests/Authplane.Tests/MetadataValidationTests.cs @@ -333,12 +333,20 @@ public void RevocationEndpoint_HttpsEnforcedInProdMode() } [Fact] - [Conformance("rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache")] + [Conformance("rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache", + Level = "partial", + Gaps = "stimulus.operation,setup.new_metadata,setup.rotation_sequence,setup.metadata_refresh_interval,expected.outcome,expected.bound,expected.side_effect", + Note = "Exercises the stimulus constraint only: the kid-miss path is driven through public VerifyAsync with no force-refresh argument, test hook or reflection. The re-fetch itself is not observed — the fixture serves one static empty JWKS and counts nothing, and the only assertion is a rejection. jwks_uri is never rotated and metadata is never re-read, so the rebind to new_metadata.jwks_uri is not shown either; a single VerifyAsync also does not span the metadata refresh interval the case's stimulus calls for. The rebind exists in AuthplaneClient (the JWKS fetcher reads jwks_uri from the metadata cache on every refresh) but no test drives it through the public API")] public async Task JwksCache_RefreshesOnKidMiss() { - // AuthplaneClient.GetSigningKeyAsync fetches fresh JWKS when a kid is not - // in the cache. This is a partial rotation mechanism (JWKS content refreshes - // but the URI itself is not re-discovered). + // A kid miss drives AuthplaneClient.GetSigningKeyAsync through ordinary + // VerifyAsync traffic, with nothing forced from the outside. The test + // observes only the rejection: the fixture serves one static empty JWKS + // and counts no requests, so the re-fetch is exercised but not asserted. + // `jwks_uri` never changes either, so the rotation half of the catalog + // case — metadata re-read after the refresh interval, JWKS fetched from + // the new `jwks_uri` — is not demonstrated here. The [Conformance] + // attribute above records both gaps. _metadataBody = $"{{\"issuer\":\"{_issuer}\",\"jwks_uri\":\"{_issuer}/.well-known/jwks.json\"}}"; var resource = await AuthplaneResource.CreateAsync( @@ -358,7 +366,15 @@ public async Task JwksCache_RefreshesOnKidMiss() }, payload: new System.Collections.Generic.Dictionary()); - await Assert.ThrowsAsync(() => resource.VerifyAsync(token)); + // Type alone proves nothing here: AuthplaneResource funnels every + // unhandled exception into InvalidSignatureException, so a JWKS fetch + // failure or a transport error would satisfy a type-only assertion just + // as well. The message is what pins the kid-miss path the declaration + // above claims this test drives — without it, a reordering makes that + // claim silently false while the test stays green. + var ex = await Assert.ThrowsAsync( + () => resource.VerifyAsync(token)); + Assert.Contains("not found in JWKS", ex.Message, StringComparison.Ordinal); await resource.DisposeAsync(); } diff --git a/tests/Authplane.Tests/OAuthProtectedResourceMetadataTests.cs b/tests/Authplane.Tests/OAuthProtectedResourceMetadataTests.cs index 9cd5c7c..44f0a02 100644 --- a/tests/Authplane.Tests/OAuthProtectedResourceMetadataTests.cs +++ b/tests/Authplane.Tests/OAuthProtectedResourceMetadataTests.cs @@ -24,9 +24,9 @@ public void GetDocumentUrl_PercentEncodedHashIsData() // RFC 3986 §3.5: '#' is the only fragment delimiter, so a percent-encoded // %23 is ordinary path data and must keep deriving a document URL. The // fragment guard scans for the literal character precisely so it cannot - // swallow this case. Sibling of the %2F assertion below: Uri.AbsolutePath - // hands back the escaped form for both, because both encode *reserved* - // characters, which canonicalization leaves alone. + // swallow this case. Sibling of the %2F assertion below: the path is + // sliced off the original string, so the escaped form survives + // byte-for-byte in both. Assert.Equal( "https://api.example.com/.well-known/oauth-protected-resource/mcp%23x", OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp%23x")); @@ -57,8 +57,8 @@ public void GetDocumentUrl_DropsTrailingSlashOnResourcePath() // RFC 3986 §3.3: a percent-encoded %2F is data inside the final // segment, not the "/" delimiter, so it must survive the trim. - // Pins that Uri.AbsolutePath hands back the escaped form rather - // than decoding it into a trimmable slash. + // Pins that the trim runs over the raw slice, where the escaped + // form never decodes into a trimmable slash. Assert.Equal( "https://api.example.com/.well-known/oauth-protected-resource/mcp%2F", OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp%2F")); @@ -75,10 +75,13 @@ public void GetDocumentUrl_DropsTrailingSlashOnResourcePath() OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com//mcp")); } - // Not tagged [Conformance]: the path-derivation catalog case is query-less - // and does not cover these assertions. The marker for the query case lands - // together with the catalog case itself. + // The query case, distinct from the path-derivation case above: that one + // is query-less and stays so deliberately, since SDKs report against case + // ids and widening it would silently change what a passing report means. + // The first three assertions are the case's result_shape, one row each, in + // catalog order; the rest are this SDK's own additions. [Fact] + [Conformance("rfc9728-well-known-url-must-preserve-the-resource-query-component")] public void GetDocumentUrl_PreservesQueryComponent() { // RFC 9728 §3 inserts the well-known string "between the host component @@ -91,12 +94,32 @@ public void GetDocumentUrl_PreservesQueryComponent() "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp?tenant=a")); + // The row the case exists for: two identifiers differing only by their + // query MUST NOT collapse onto one document URL. Without this the + // requirement_summary is unasserted, and the failure it describes is a + // multi-tenant misroute — a client asking for tenant a's metadata is + // served tenant b's, HTTP 200, no signal. + Assert.Equal( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=b", + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp?tenant=b")); + // No terminating slash exists to remove — the suffix lands directly // after the host and the query follows. Assert.Equal( "https://api.example.com/.well-known/oauth-protected-resource?x=1", OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com?x=1")); + // Distinctness over the whole derived set, so a future reorder or an + // accidental collapse cannot pass by dropping one row: three + // identifiers in, three different document URLs out. + string[] derived = + [ + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp?tenant=a"), + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp?tenant=b"), + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com?x=1"), + ]; + Assert.Equal(derived.Length, derived.Distinct().Count()); + // RFC 9728 §3.1 removes the terminating slash following the host when // a path or query component is present, so this derives the same URL // as the slashless form above. @@ -123,8 +146,8 @@ public void GetDocumentUrl_BareQuestionMark_DerivesQuerylessUrl() { // A bare "?" is an empty query, which is legal per RFC 3986 // (`*( pchar / "/" / "?" )` admits zero characters). Empty-versus- - // absent was settled family-wide as absent, so the derived document - // URL is query-less rather than carrying a dangling "?". + // absent resolves as absent, so the derived document URL is query-less + // rather than carrying a dangling "?". Assert.Equal( "https://api.example.com/.well-known/oauth-protected-resource/mcp", OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp?")); @@ -145,8 +168,8 @@ public void GetDocumentUrl_QueryDistinctIdentifiers_DeriveDistinctUrls() // With one sanctioned exception, asserted below rather than left for a // reader to discover: a bare trailing '?' is an empty query and derives // the query-less URL, so `…/mcp?` and `…/mcp` do collapse. That is - // deliberate and family-wide — python's urlsplit yields "" for it and - // urlunsplit omits it, go dropped its ForceQuery carry — and it is the + // deliberate — an empty query and an absent one resolve the same way + // wherever this identifier is parsed — and it is the // one direction with an RFC 9728 §3.3 consequence: a client holding // `…/mcp` derives the shared URL and is served a document naming // `…/mcp?`, which §3.3 tells it to discard. Settled as harmless in @@ -161,4 +184,114 @@ public void GetDocumentUrl_QueryDistinctIdentifiers_DeriveDistinctUrls() Assert.Equal(none, OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp?")); } + + // The byte-exact axes below are not tagged [Conformance]: the + // path-derivation catalog case carries none of these shapes. Each fact + // pins one member of the class the derivation used to re-render: the PRM + // `resource` member emits the configured bytes verbatim, so any rewrite in + // the derived URL is the RFC 9728 §3.3 mismatch a conformant client + // discards the document over. The members of that class that are not URIs + // in the first place — a raw non-ASCII segment (`/café`), a zero-width + // space, a malformed percent-escape (`/m%zzcp`) — are rejected at + // construction by the §3.3 path gate rather than preserved; those live in + // ResourcePathValidationTests. What byte-exactness pins here is every + // shape that is a URI and that `Uri` would still have rewritten. + + [Fact] + public void GetDocumentUrl_PercentEncodedUnreservedInPath_IsPreservedByteForByte() + { + // %7E encodes an unreserved character ('~'), which Uri.AbsolutePath + // unescapes during canonicalization — the path sibling of the %7E + // query case above, and the case that pins the path slice to the + // original string rather than the parsed Uri. + Assert.Equal( + "https://api.example.com/.well-known/oauth-protected-resource/m%7Ecp", + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/m%7Ecp")); + } + + [Fact] + public void GetDocumentUrl_Ipv6ZoneIdentifier_IsPreservedByteForByte() + { + // The authority-side member of the same class: + // Uri.GetLeftPart(UriPartial.Authority) re-rendered the authority and + // dropped an RFC 6874 zone identifier — `[fe80::1%25eth0]` derived + // `[fe80::1]`. The authority is now sliced off the original string. + Assert.Equal( + "https://[fe80::1%25eth0]/.well-known/oauth-protected-resource/mcp", + OAuthProtectedResourceMetadata.GetDocumentUrl("https://[fe80::1%25eth0]/mcp")); + } + + [Fact] + public void GetDocumentUrl_DoesNotApplyRfc3986Equivalences() + { + // The byte-exact slice deliberately stops applying the RFC 3986 §6.2 + // equivalences the Uri-based derivation performed as a side effect: + // case (§6.2.2.1), dot-segments (§6.2.2.3), default-port removal + // (§6.2.3). They are equivalences — a recipient MAY normalize them — + // but the identifier's identity is exact-string, and a client + // re-deriving from its own copy of the identifier starts from the + // same bytes; emitting one form while deriving another was the + // divergence, not the cure for it. + Assert.Equal( + "HTTPS://API.EXAMPLE.COM/.well-known/oauth-protected-resource/mcp", + OAuthProtectedResourceMetadata.GetDocumentUrl("HTTPS://API.EXAMPLE.COM/mcp")); + + Assert.Equal( + "https://api.example.com:443/.well-known/oauth-protected-resource/mcp", + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com:443/mcp")); + + Assert.Equal( + "https://api.example.com/.well-known/oauth-protected-resource/a/../b", + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/a/../b")); + } + + [Fact] + public void GetDocumentUrl_DerivedUrlIsTheIdentifierWithTheWellKnownStringInserted() + { + // The identity property stated end to end: removing the well-known + // insertion from the derived URL yields the configured identifier, + // byte for byte, for every divergence shape above at once. The sample + // deliberately excludes the two shapes where the identity does not + // hold, both asserted individually above: a trailing slash (removed + // per RFC 9728 §3.1) and a bare '?' (an empty query, settled as + // deriving the query-less URL). + string[] identifiers = + [ + "https://api.example.com/m%7Ecp", + "https://api.example.com/mcp%2F", + "https://api.example.com/mcp%23x", + "https://[fe80::1%25eth0]/mcp", + "HTTPS://API.EXAMPLE.COM/mcp", + "https://api.example.com:443/mcp", + "https://api.example.com/a/../b", + "https://api.example.com/mcp?tenant=a%7Eb", + "https://api.example.com?x=1", + "https://api.example.com", + ]; + + const string wellKnown = "/.well-known/oauth-protected-resource"; + foreach (var identifier in identifiers) + { + var derived = OAuthProtectedResourceMetadata.GetDocumentUrl(identifier); + // Removed at the known insertion offset — the end of the + // authority, where the path or query starts — rather than by + // Replace: the property is "remove the one inserted segment", and + // an identifier whose own path contained the well-known string + // would make a Replace pass for the wrong reason. The offset is + // the derivation's own path-start boundary — the first '/' or '?' + // after the authority — so a path-less identifier (with or + // without a query, where a '/'-only search returns -1 or finds a + // '/' inside the query) computes the same offset the derivation + // inserted at; for the bare authority-only shape that is the end + // of the string. + var authorityStart = identifier.IndexOf("//", StringComparison.Ordinal) + 2; + var insertionOffset = identifier.IndexOfAny(['/', '?'], authorityStart); + if (insertionOffset < 0) + { + insertionOffset = identifier.Length; + } + + Assert.Equal(identifier, derived.Remove(insertionOffset, wellKnown.Length)); + } + } } diff --git a/tests/Authplane.Tests/ResourceFragmentRejectionTests.cs b/tests/Authplane.Tests/ResourceFragmentRejectionTests.cs index 37def0b..c6ab66c 100644 --- a/tests/Authplane.Tests/ResourceFragmentRejectionTests.cs +++ b/tests/Authplane.Tests/ResourceFragmentRejectionTests.cs @@ -1,3 +1,4 @@ +using Authplane.Conformance; using Xunit; namespace Authplane.Tests; @@ -7,7 +8,7 @@ namespace Authplane.Tests; /// matching RFC 9728 §1.2 definition of the resource identifier. /// /// Before this gate the fragment was silently dropped: the derived well-known -/// URL is built from the authority plus Uri.AbsolutePath, which never +/// URL was built from the authority plus Uri.AbsolutePath, which never /// carries a fragment, while the PRM resource field echoes the /// identifier verbatim. The served document therefore named a resource that /// differed from its own URL, and RFC 9728 §3.3 requires a conformant client to @@ -16,11 +17,17 @@ namespace Authplane.Tests; /// public sealed class ResourceFragmentRejectionTests { + // The catalog case is the construction gate specifically: it is satisfied + // only by a rejection observable from the call that builds the resource, + // not by the fragment being dropped later while the well-known URL is + // derived. CreateAsync is that call, and the first row is the case's own + // setup value. [Theory] [InlineData("https://api.example.com/mcp#section")] [InlineData("https://api.example.com/mcp#")] [InlineData("https://api.example.com/#frag")] [InlineData("https://api.example.com#frag")] + [Conformance("rfc8707-resource-indicator-must-not-contain-a-fragment")] public async Task CreateAsync_FragmentInResource_Throws(string resource) { // No test server: the guard runs ahead of the issuer metadata fetch, so @@ -69,9 +76,9 @@ public void ProtectedResourceMetadataCtor_FragmentInResource_Throws() [Fact] public void FragmentRejection_MessageNamesTheOffendingIdentifier() { - // A process can host several resources against one AS, so paramName - // alone does not say which identifier failed. Parity with the sibling - // SDKs, all of which name something. + // The message names the offending identifier so the failure is + // attributable when one process hosts several resources against one AS + // — paramName alone does not say which one failed. var ex = Assert.Throws(() => OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp#anchor-value")); diff --git a/tests/Authplane.Tests/ResourceIdentifierAbsolutenessTests.cs b/tests/Authplane.Tests/ResourceIdentifierAbsolutenessTests.cs index 222658c..9cd8d7a 100644 --- a/tests/Authplane.Tests/ResourceIdentifierAbsolutenessTests.cs +++ b/tests/Authplane.Tests/ResourceIdentifierAbsolutenessTests.cs @@ -1,3 +1,4 @@ +using Authplane.Conformance; using Xunit; namespace Authplane.Tests; @@ -21,10 +22,20 @@ namespace Authplane.Tests; /// public sealed class ResourceIdentifierAbsolutenessTests { + // The catalog case carries these two rows and requires each to reject on + // its own — a guard that only asks whether the identifier is + // authority-less would accept the scheme-relative form, so rejecting + // "/mcp" alone does not satisfy it. + // + // The opaque row lives in its own unmarked theory below. The case's note 4 + // excludes it in as many words — "This case takes no position on it: ... + // its 'reject' outcome must not be read as covering the opaque case" — so + // carrying it under this marker is exactly the read the catalog forbids, + // even though this SDK does reject it. [Theory] [InlineData("/mcp")] // relative reference [InlineData("//api.example.com/mcp")] // scheme-relative (network-path) reference - [InlineData("urn:example:api")] // scheme but no host + [Conformance("rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host")] public async Task CreateAsync_NonAbsoluteUrlResource_Throws(string resource) { // No test server: the guard runs ahead of the issuer metadata fetch, so @@ -39,6 +50,92 @@ public async Task CreateAsync_NonAbsoluteUrlResource_Throws(string resource) Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); } + // The authority delimiter is required as written rather than inferred from + // `Uri.Host`, and this is the shape an operator produces by dropping a + // slash off an ordinary configuration. Unmarked: the catalog case's rows + // are the relative and scheme-relative references, and this is neither. + // + // Which branch of the gate turns it away is worth pinning rather than + // assuming. Measured on this runtime, `Uri.TryCreate` returns false for a + // scheme-only `https:` with no "//" — it does not fabricate an authority — + // so the parse clause rejects it and the explicit "//" requirement never + // gets a look at it. That requirement earns its place on the opaque + // identifiers that *do* parse with a host-shaped part after the ':' + // (`mailto:ops@example.com`), covered in ResourceUserInfoRejectionTests. + // If a future runtime starts fixing this shape up into an authority, the + // "//" clause catches it and this row keeps passing either way — which is + // the reason to have it. + [Theory] + [InlineData("https:api.example.com/mcp")] // no "//" at all + [InlineData("https:/api.example.com/mcp")] // one slash: a path, not an authority + [InlineData("http:api.example.com/mcp")] + public async Task CreateAsync_SpecialSchemeWithoutTheAuthorityDelimiter_Throws(string resource) + { + var ex = await Assert.ThrowsAsync(() => + AuthplaneResource.CreateAsync( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("https:api.example.com/mcp")] + [InlineData("https:/api.example.com/mcp")] + public void GetDocumentUrl_SpecialSchemeWithoutTheAuthorityDelimiter_Throws(string resource) + { + // The derivation is the half that would slice a phantom authority out + // of this, so it gets the row too. + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl(resource)); + + Assert.Equal("resourceUrl", ex.ParamName); + Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // Outside the catalog case, by that case's own note: an opaque identifier + // has a scheme but no host, and the SDKs have not settled whether to + // accept one for RFC 8707 audience binding. This SDK rejects it, and this + // is that behaviour of ours rather than a claim about the case. + [Theory] + [InlineData("urn:example:api")] + public async Task CreateAsync_OpaqueResource_Throws(string resource) + { + var ex = await Assert.ThrowsAsync(() => + AuthplaneResource.CreateAsync( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // The row the case *mandates*: note 3 makes accepting `http://localhost` + // a MUST — "an SDK is free to add a separate https policy ... but that is + // a different requirement" — so it belongs under the marker, and against + // the case's own `resource.create` stimulus rather than the path accessor. + [Fact] + [Conformance("rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host")] + public async Task CreateAsync_HttpLocalhost_IsAccepted() + { + // Reaches the metadata fetch, which is as far as this can go without a + // test server — the point is that the identifier gate does not reject + // it, so any failure past this must not be an ArgumentException naming + // `resource`. + var ex = await Record.ExceptionAsync(() => + AuthplaneResource.CreateAsync( + issuer: "https://auth.example.com", + resource: "http://localhost:8080/mcp", + scopes: new[] { "tools/add" })); + + Assert.False( + ex is ArgumentException { ParamName: "resource" }, + $"the identifier gate rejected a row the case requires accepting: {ex?.Message}"); + } + [Theory] [InlineData("/mcp")] [InlineData("//api.example.com/mcp")] @@ -113,10 +210,11 @@ public async Task CreateAsync_BackslashInResource_ThrowsNamingBackslash() public void GetDocumentUrl_WhitespaceOrBackslashInResource_Throws(string resource, string expectedInMessage) { // Pins the two rewrite shapes this axis closes — whitespace and the - // backslash — rejected before any derivation. Not the divergence class - // as a whole: `Uri` still canonicalizes a non-ASCII segment, a C0 - // control and a malformed percent-escape, which the derivation's own - // comment records as a known limitation. + // backslash — rejected before any derivation. Its siblings resolve + // differently: a C0 control is rejected by the same gate with its own + // message, while a non-ASCII segment and a malformed percent-escape + // construct and derive byte-exact, pinned per-axis in + // OAuthProtectedResourceMetadataTests. var ex = Assert.Throws(() => OAuthProtectedResourceMetadata.GetDocumentUrl(resource)); @@ -259,10 +357,12 @@ public void GetDocumentUrl_MalformedPort_Throws(string resource) } [Theory] - // A leading zero is legal RFC 3986 §3.2.3 syntax, and the derivation strips - // it — `:0080` emits verbatim and derives `:80`, which is the emit-vs-derive - // divergence this axis exists to make unconstructible. Not an RFC 3986 §6.2 - // equivalence, unlike the normalizations the derivation is documented to apply. + // A leading zero is legal RFC 3986 §3.2.3 syntax and is rejected anyway: + // the derivation slices the authority verbatim, but stripping the zero is + // not an RFC 3986 §6.2 equivalence, and a client whose URL stack + // normalizes `:0080` to `:80` re-derives a document URL disagreeing with + // the served document's verbatim `resource` member — the mismatch this + // axis exists to make unconstructible, moved client-side. [InlineData("https://api.example.com:0080/mcp")] [InlineData("https://api.example.com:00/mcp")] [InlineData("https://[::1]:0080/mcp")] @@ -275,8 +375,9 @@ public void GetDocumentUrl_LeadingZeroPort_Throws(string resource) } [Theory] - // C0 controls and DEL: percent-encoded into the derived URL while the - // identifier is emitted verbatim, and not covered by char.IsWhiteSpace. + // C0 controls and DEL: no RFC 3986 production admits them, the byte-exact + // derivation would carry them verbatim into the advertised URL, and + // char.IsWhiteSpace does not cover them. [InlineData("https://api.example.com/a\u0001b")] [InlineData("https://api.example.com/a\u007Fb")] public void GetDocumentUrl_ControlCharacter_Throws(string resource) diff --git a/tests/Authplane.Tests/ResourceIdentifierHostProductionTests.cs b/tests/Authplane.Tests/ResourceIdentifierHostProductionTests.cs new file mode 100644 index 0000000..91daf22 --- /dev/null +++ b/tests/Authplane.Tests/ResourceIdentifierHostProductionTests.cs @@ -0,0 +1,227 @@ +using Xunit; + +namespace Authplane.Tests; + +/// +/// RFC 3986 §3.2.2 — the host production is IP-literal / IPv4address / +/// reg-name, and reg-name admits only unreserved / pct-encoded / +/// sub-delims, all ASCII. An internationalized host belongs in a URI as its +/// A-label (xn--caf-dma.example.com). +/// +/// No gate over the authority was a character production before this. The host's +/// only constraints were whitespace and control characters, the malformed-port +/// gate, and the userinfo gate, so https://café.example.com/mcp cleared +/// all of them: é is above 0x20 and not whitespace, there is no ':' to +/// read as a port and no '@' as userinfo, and Uri.TryCreate parses an IDN +/// host to a non-empty Host, so the absoluteness gate saw a scheme and a +/// host. The derivation slices the authority verbatim off the original string, so +/// the host rode into the derived metadata URL and out to unauthenticated clients +/// in the resource_metadata parameter of a WWW-Authenticate +/// challenge — bytes RFC 9110 §5.5 gives no interpretation for. +/// +/// +/// Deliberately unmarked. The pinned catalog carries no case over the host +/// component — rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host +/// is the presence-of-a-host axis, not a character production over it — so +/// claiming a case here would name coverage that maps to nothing, which the +/// alignment guard rejects and rightly. +/// +public sealed class ResourceIdentifierHostProductionTests +{ + [Theory] + [InlineData("https://café.example.com/mcp")] // Latin-1 supplement, prints + [InlineData("https://例え.example.com/mcp")] // CJK + [InlineData("https://api​.example.com/mcp")] // zero-width space, invisible + [InlineData("https://api.exämple.com/mcp")] // non-ASCII in a later label + [InlineData("https://münchen.example.com:8443/mcp")] // with a valid port after it + [InlineData("https://café.example.com")] // no path at all + public async Task CreateAsync_NonAsciiHost_ThrowsNamingTheHostProduction(string resource) + { + // No test server: the gate runs ahead of the issuer metadata fetch, so a + // misconfigured identifier fails without a network round trip. + var ex = await Assert.ThrowsAsync(() => + AuthplaneResource.CreateAsync( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains("host", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("§3.2.2", ex.Message, StringComparison.Ordinal); + } + + /// + /// The message has to tell the operator what to do about it — the gate + /// deliberately does not convert to an A-label on their behalf, because + /// encoding the host on the way into the derived URL alone would make it + /// disagree with the identifier the served document emits verbatim. + /// + [Fact] + public void GetDocumentUrl_NonAsciiHost_MessageNamesTheALabelRemedy() + { + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl("https://café.example.com/mcp")); + + Assert.Equal("resourceUrl", ex.ParamName); + Assert.Contains("A-label", ex.Message, StringComparison.Ordinal); + Assert.Contains("xn--", ex.Message, StringComparison.Ordinal); + } + + /// + /// A non-ASCII host is named by code point, not printed. 'é' would render, but + /// a zero-width space is invisible and half a surrogate pair is not a + /// character — the same rule the path and query gates apply. + /// + [Fact] + public void GetDocumentUrl_InvisibleNonAsciiHostCharacter_IsNamedByCodePoint() + { + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api​.example.com/mcp")); + + Assert.Contains("U+200B", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void GetDocumentUrl_MalformedPercentEscapeInHost_ThrowsNamingTheEscape() + { + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api%zz.example.com/mcp")); + + Assert.Equal("resourceUrl", ex.ParamName); + Assert.Contains("host", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("percent-encoding", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// The A-label form is what the gate is steering operators towards, so it has + /// to derive byte-exact. + /// + [Fact] + public void GetDocumentUrl_ALabelHost_IsAcceptedAndDerivedByteExact() + { + Assert.Equal( + "https://xn--caf-dma.example.com/.well-known/oauth-protected-resource/mcp", + OAuthProtectedResourceMetadata.GetDocumentUrl("https://xn--caf-dma.example.com/mcp")); + } + + /// + /// Everything inside the production stays accepted and byte-exact. A gate that + /// rejected any of these would be worse than no gate: these are ordinary + /// configurations, and the IPv6 and zone-identifier rows are exactly the shapes + /// a naive hex-only reading of the IP-literal production turns away. + /// + [Theory] + [InlineData("https://api.example.com/mcp")] + [InlineData("https://api.example.com:8443/mcp")] + [InlineData("http://localhost:8080/mcp")] + [InlineData("http://127.0.0.1:8080/mcp")] + [InlineData("https://[::1]:8443/mcp")] + [InlineData("https://[fe80::1]/mcp")] + [InlineData("https://[fe80::1%25eth0]/mcp")] // RFC 6874 zone id: 'eth0' is not all hex + [InlineData("https://api-1.sub_domain.example.com/mcp")] + [InlineData("https://api.example.com/mcp?tenant=acme")] + [InlineData("https://api.example.com/mcp/a@b")] // '@' in the path is data, not userinfo + public void GetDocumentUrl_HostInsideTheProduction_StaysAccepted(string resource) + { + var ex = Record.Exception(() => OAuthProtectedResourceMetadata.GetDocumentUrl(resource)); + + Assert.Null(ex); + } + + /// + /// The zone identifier survives byte-exact rather than being dropped — the + /// property the derivation's string slice exists to preserve, re-asserted here + /// because the new gate scans that same slice and must not disturb it. + /// + [Fact] + public void GetDocumentUrl_Ipv6ZoneIdentifier_IsPreservedByteExact() + { + Assert.Equal( + "https://[fe80::1%25eth0]/.well-known/oauth-protected-resource/mcp", + OAuthProtectedResourceMetadata.GetDocumentUrl("https://[fe80::1%25eth0]/mcp")); + } + + /// + /// A non-ASCII character outside the host must not be blamed on the host. The + /// path gate owns its own component, and reporting the wrong one sends the + /// operator to the wrong part of their configuration. + /// + [Fact] + public void GetDocumentUrl_NonAsciiInPath_IsStillBlamedOnThePath() + { + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/café")); + + Assert.Contains("path", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("§3.2.2", ex.Message, StringComparison.Ordinal); + } + + /// + /// An authority that is userinfo and nothing else has no host for this gate to + /// read a character out of. The gate has to hand the identifier on rather than + /// index past the end of it: it runs ahead of the absoluteness and userinfo + /// gates at every site, so it is first to see the input, and a throw with no + /// message replaces the diagnosis the operator used to get. The rows below have + /// no path, query or fragment either — with any of the three the authority ends + /// before the end of the string and the index is in bounds, which is why this + /// shape and only this shape needs the boundary check. + /// + [Theory] + [InlineData("https://user@")] + [InlineData("https://@")] + [InlineData("https://svc:s3cr3t@")] + [InlineData("https://user@:8443")] // port delimiter, still no host + public async Task CreateAsync_AuthorityIsUserInfoOnly_ReportsTheMissingHost(string resource) + { + var ex = await Assert.ThrowsAsync(() => + AuthplaneResource.CreateAsync( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("https://user@")] + [InlineData("https://@")] + [InlineData("https://svc:s3cr3t@")] + [InlineData("https://user@:8443")] + public void GetDocumentUrl_AuthorityIsUserInfoOnly_ReportsTheMissingHost(string resource) + { + // Public API, and its documented contract is ArgumentException: asserting + // the exact type is the point of the row, not merely that it does not + // return a URL. + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl(resource)); + + Assert.Equal("resourceUrl", ex.ParamName); + Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetDocumentUrl_AuthorityIsUserInfoOnly_DoesNotEchoTheCredential() + { + // The shape carries credentials, so the gate that ends up reporting it must + // hold to the same non-echo rule as the userinfo gate. + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl("https://svc:s3cr3t@")); + + Assert.DoesNotContain("s3cr3t", ex.Message, StringComparison.Ordinal); + } + + /// + /// A userinfo carrying a non-ASCII character is reported as userinfo, not as a + /// bad host: the userinfo gate runs first, and its rejection does not echo the + /// identifier because it can carry credentials. + /// + [Fact] + public void GetDocumentUrl_NonAsciiInUserInfo_IsStillBlamedOnUserInfo() + { + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl("https://ü:pw@api.example.com/mcp")); + + Assert.Contains("userinfo", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/Authplane.Tests/ResourcePathValidationTests.cs b/tests/Authplane.Tests/ResourcePathValidationTests.cs new file mode 100644 index 0000000..8a6deec --- /dev/null +++ b/tests/Authplane.Tests/ResourcePathValidationTests.cs @@ -0,0 +1,91 @@ +using Xunit; + +namespace Authplane.Tests; + +/// +/// RFC 3986 §3.3 — the resource identifier's path must be a valid +/// path production. The derived well-known URL carries the path +/// verbatim from the original identifier string, so a path outside the +/// production — a raw non-ASCII segment, an unencoded delimiter, a malformed +/// percent-escape — yields an advertised URL that is not a URI (RFC 3986 §2, +/// RFC 8707 §2) and that no client can fetch. It is rejected at construction, +/// where the operator can act on it, rather than at request time — before it +/// can reach a WWW-Authenticate field value, whose RFC 9110 §5.5 +/// grammar has no interpretation for non-ASCII bytes. +/// +/// The other half of the contract is what the gate does not touch: every +/// well-formed percent-encoding stays byte-exact through the derivation, +/// asserted in . +/// +public sealed class ResourcePathValidationTests +{ + [Theory] + [InlineData("https://api.example.com/café", "path")] + [InlineData("https://api.example.com/m\u200Bcp", "path")] + [InlineData("https://api.example.com/m%zzcp", "path")] + [InlineData("https://api.example.com/m%2", "path")] + // The escape is malformed because the path is terminal at '?': the two + // characters after '%' must be hex digits *inside the path*. + [InlineData("https://api.example.com/m%2?x=1", "path")] + [InlineData("https://api.example.com/m(() => + AuthplaneResource.CreateAsync( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains(expectedInMessage, ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("https://api.example.com/café")] + [InlineData("https://api.example.com/m\u200Bcp")] + [InlineData("https://api.example.com/m%zzcp")] + [InlineData("https://api.example.com/m(() => + OAuthProtectedResourceMetadata.GetDocumentUrl(resourceUrl)); + + Assert.Equal("resourceUrl", ex.ParamName); + Assert.Contains("path", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetDocumentUrl_LegalPathCharacters_Accepted() + { + // Every non-pct-encoded character the production allows: unreserved, + // sub-delims, ":" / "@", and the "/" delimiter — plus a well-formed + // escape, preserved byte-exact. + const string path = "/-._~!$&'()*+,;=:@/x%7E"; + + Assert.Equal( + "https://api.example.com/.well-known/oauth-protected-resource" + path, + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com" + path)); + } + + [Fact] + public void GetDocumentUrl_NonAsciiInQuery_IsAlsoRejected() + { + // The query production is ASCII-only too — char.IsAsciiLetterOrDigit + // and the sub-delims list admit no raw non-ASCII — so U+200B has no + // hiding place one component over. + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl("https://api.example.com/mcp?a=\u200B")); + + Assert.Equal("resourceUrl", ex.ParamName); + Assert.Contains("query", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/Authplane.Tests/ResourceUserInfoRejectionTests.cs b/tests/Authplane.Tests/ResourceUserInfoRejectionTests.cs index a0c748c..f46866c 100644 --- a/tests/Authplane.Tests/ResourceUserInfoRejectionTests.cs +++ b/tests/Authplane.Tests/ResourceUserInfoRejectionTests.cs @@ -15,10 +15,10 @@ public sealed class ResourceUserInfoRejectionTests [Theory] // Explicit credentials in an https identifier. [InlineData("https://svc:s3cr3t@api.example.com/mcp")] - // A scheme whose syntax fills the userinfo slot: mailto parses with - // UserInfo "ops" and Host "example.com", so it clears the absoluteness - // gate and must be stopped here. - [InlineData("mailto:ops@example.com")] + // The empty form, which Uri.UserInfo cannot distinguish from "no userinfo": + // RFC 9110 §4.2.4 forbids generating the subcomponent, not merely non-empty + // credentials. + [InlineData("https://@api.example.com/mcp")] public async Task CreateAsync_UserInfoInResource_Throws(string resource) { // No test server: the guard runs ahead of the issuer metadata fetch, so @@ -48,6 +48,58 @@ public void GetDocumentUrl_UserInfoBackstop_StillThrows() Assert.Contains("RFC 9110", ex.Message, StringComparison.Ordinal); } + /// + /// The gate is scoped to the authority, because that is the only place RFC 3986 + /// §3.2 puts a userinfo subcomponent. An opaque identifier has no authority, so + /// its '@' is data and this gate must not claim it — it is refused, but for the + /// reason it is actually refused for: no host, so no metadata URL derives from + /// it. The distinction is the whole value of the error message, which is what + /// tells the operator what to change. + /// + [Theory] + [InlineData("mailto:ops@example.com")] + [InlineData("urn:example:api")] + public async Task CreateAsync_OpaqueIdentifier_IsNotReportedAsUserInfo(string resource) + { + var ex = await Assert.ThrowsAsync(() => + AuthplaneResource.CreateAsync( + issuer: "https://auth.example.com", + resource: resource, + scopes: new[] { "tools/add" })); + + Assert.Equal("resource", ex.ParamName); + Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("userinfo", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("mailto:ops@example.com")] + [InlineData("urn:example:api")] + public void GetDocumentUrl_OpaqueIdentifier_IsNotReportedAsUserInfo(string resource) + { + var ex = Assert.Throws(() => + OAuthProtectedResourceMetadata.GetDocumentUrl(resource)); + + Assert.Equal("resourceUrl", ex.ParamName); + Assert.Contains("absolute URL", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("userinfo", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// An '@' outside the authority is data (RFC 3986 §§3.3, 3.4) and stays + /// accepted — the other half of scoping the gate. A guard that scanned the + /// whole string would turn these away. + /// + [Theory] + [InlineData("https://api.example.com/mcp/a@b")] + [InlineData("https://api.example.com/mcp?to=a@b")] + public void GetDocumentUrl_AtSignOutsideTheAuthority_StaysAccepted(string resource) + { + var ex = Record.Exception(() => OAuthProtectedResourceMetadata.GetDocumentUrl(resource)); + + Assert.Null(ex); + } + [Fact] public void GetDocumentUrl_UserInfoReportedBeforeQuery() { diff --git a/tests/Authplane.Tests/VerifiedClaimsTests.cs b/tests/Authplane.Tests/VerifiedClaimsTests.cs index ad9c509..7414a2c 100644 --- a/tests/Authplane.Tests/VerifiedClaimsTests.cs +++ b/tests/Authplane.Tests/VerifiedClaimsTests.cs @@ -76,6 +76,7 @@ public void Act_ReturnsEmpty_WhenAbsentOrWrongShape() Assert.Equal(string.Empty, Build(raw: rawWithString).Act); } +#pragma warning disable CS0618 // Obsolete: MayAct kept until the next minor; behaviour still pinned [Fact] public void MayAct_ReturnsMayActSubFromRaw_WhenStructured() { @@ -89,6 +90,7 @@ public void MayAct_ReturnsEmpty_WhenAbsent() { Assert.Equal(string.Empty, Build().MayAct); } +#pragma warning restore CS0618 [Fact] public void HasClaim_KeyOnly_ReturnsTrueIfKeyPresent()