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..9815085 --- /dev/null +++ b/.github/scripts/conformance-case-body-drift.test.sh @@ -0,0 +1,660 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests for conformance-case-body-drift.sh and conformance-registered-case-ids.py. +# +# 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 SDK's +# dependencies installed — 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.py" + +# conformance-registered-case-ids.py is Python, so without an interpreter half +# this suite would silently drop the cases it is here to cover. Refuse to run +# instead of passing for that reason. +if ! command -v python3 > /dev/null 2>&1; then + echo "error: these tests need python3; conformance-registered-case-ids.py reads its report with it" >&2 + exit 1 +fi + +failures=0 + +# One root that an EXIT trap removes, so a fixture still gets cleaned up when +# `set -e` kills the shell from inside a helper — the moment a leak is least +# welcome. The per-case RETURN traps below do not fire then. +TESTROOT="$(mktemp -d)" +trap 'rm -rf "$TESTROOT"' EXIT + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n %s\n' "$1" "$2"; failures=$((failures + 1)); } + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +# Writes a catalog to $1. $2 picks the wording of the jwks_uri rotation case: +# "pinned" for the one this repo'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 repo registers ------------------------------------ +# The whole reason the check takes an id list: diffing the entire catalog fires +# on cases this repo 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.py +# --------------------------------------------------------------------------- +# +# The report lists one entry per CATALOG case, so presence in it is not +# registration — `nodeid` is. These fixtures are hand-written JSON rather than a +# suite run: the point is what the filter does with each shape, and running the +# suite to produce one would make these controls depend on the SDK's +# dependencies, on the catalog clone, and on the suite passing. + +# Invoked through its shebang, the way the workflow invokes it, so a lost +# executable bit fails here rather than on the weekly schedule. +run_ids() { + rc=0 + out="$(CONFORMANCE_REPORT="$1" "$IDSCRIPT" 2>&1)" || rc=$? +} + +# --- only cases with a nodeid are registered ------------------------------------ +# The placeholder entries pytest_sessionfinish synthesizes for uncovered catalog +# cases carry a case_id and a status, and no nodeid key at all. Letting one +# through would put a case this repo never registered into the comparison, where +# its absence from the pin then fails the whole check for no reason. +# +# The failed and skipped entries are the other half of the contract: both are +# registered, and `status` would have dropped them. A skipped one is not +# hypothetical — the marker docs route a case with nothing behind it to +# pytest.xfail, which the report records as skipped. +t_ids_filters_unregistered() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + cat > "$root/report.json" <<'JSON' +{ + "cases": [ + {"case_id": "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache", "status": "passed", "nodeid": "conformance-tests/test_rfc8414_conformance.py::test_jwks_uri_rotation"}, + {"case_id": "rfc7009-revocation-server-errors-must-surface", "status": "failed", "nodeid": "conformance-tests/test_oauth_protocol_conformance.py::test_revocation_server_errors"}, + {"case_id": "rfc9068-at-jwt-typ-must-be-enforced", "status": "skipped", "nodeid": "conformance-tests/test_jwt_and_dpop_conformance.py::test_at_jwt_typ"}, + {"case_id": "rfc9999-not-covered-here", "status": "not_run"} + ] +} +JSON + + local out rc + run_ids "$root/report.json" + if [[ "$rc" -ne 0 ]]; then + fail "only cases with a nodeid are registered" "exit $rc, want 0: ${out##*$'\n'}" + elif [[ "$out" != "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache +rfc7009-revocation-server-errors-must-surface +rfc9068-at-jwt-typ-must-be-enforced" ]]; then + fail "only cases with a nodeid are registered" "printed: ${out//$'\n'/, }" + else + pass "a placeholder entry is filtered out and a failed or skipped test still counts as registered" + fi +} + +# --- a report where nothing registered ----------------------------------------- +# Every entry a placeholder: the suite was deselected down to nothing, or it died +# before any marked test ran. An empty list downstream is a vacuously green drift +# check. +t_ids_nothing_registered() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + cat > "$root/report.json" <<'JSON' +{"cases": [{"case_id": "rfc9999-not-covered-here", "status": "not_run"}]} +JSON + + local out rc + run_ids "$root/report.json" + if [[ "$rc" -ne 1 ]]; then + fail "a report with no registration fails" "exit $rc, want 1" + elif ! grep -q "records no case with a nodeid" <<<"$out"; then + fail "a report with no registration fails" "unexpected message: ${out##*$'\n'}" + else + pass "a report whose entries are all placeholders fails" + fi +} + +# --- an entry with no case_id --------------------------------------------------- +# It would drop out of the filter silently and take a real registration with it, +# so the count would be short by one with nothing to show for it. +t_ids_missing_case_id() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + cat > "$root/report.json" <<'JSON' +{ + "cases": [ + {"case_id": "rfc7009-revocation-server-errors-must-surface", "status": "passed", "nodeid": "conformance-tests/test_oauth_protocol_conformance.py::test_revocation"}, + {"status": "passed", "nodeid": "conformance-tests/test_oauth_protocol_conformance.py::test_something_else"} + ] +} +JSON + + local out rc + run_ids "$root/report.json" + if [[ "$rc" -ne 1 ]]; then + fail "an entry with no case_id fails" "exit $rc, want 1" + elif ! grep -q "missing or non-string case_id" <<<"$out"; then + fail "an entry with no case_id fails" "unexpected message: ${out##*$'\n'}" + else + pass "a case entry without a case_id fails instead of being dropped" + fi +} + +# --- a nodeid that is not a string ---------------------------------------------- +# Not a shape conftest.py writes. Guessing at it would be a guess about which +# cases stop being guarded, so it fails instead. +t_ids_non_string_nodeid() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + cat > "$root/report.json" <<'JSON' +{"cases": [{"case_id": "rfc7009-revocation-server-errors-must-surface", "status": "passed", "nodeid": 42}]} +JSON + + local out rc + run_ids "$root/report.json" + if [[ "$rc" -ne 1 ]]; then + fail "a non-string nodeid fails" "exit $rc, want 1" + elif ! grep -q "non-string nodeid" <<<"$out"; then + fail "a non-string nodeid fails" "unexpected message: ${out##*$'\n'}" + else + pass "a nodeid that is not a string fails, naming the case" + fi +} + +# --- a report that is not there, not JSON, or has no cases ---------------------- +# The workflow deletes the previous report before the run that writes the one +# this reads, so "absent" is a state that really occurs and must not read as +# "nothing registered, carry on". +t_ids_unusable_reports() { + local root; root="$(mktemp -d "$TESTROOT/XXXXXX")"; trap 'rm -rf "$root"' RETURN + + local out rc + run_ids "$root/absent.json" + if [[ "$rc" -ne 1 ]] || ! grep -q "does not exist" <<<"$out"; then + fail "a missing report fails" "exit $rc: ${out##*$'\n'}" + else + pass "a missing report fails, naming the path" + fi + + echo 'not json at all' > "$root/bad.json" + run_ids "$root/bad.json" + if [[ "$rc" -ne 1 ]] || ! grep -q "could not be read as JSON" <<<"$out"; then + fail "an unparseable report fails" "exit $rc: ${out##*$'\n'}" + else + pass "a report that is not JSON fails" + fi + + echo '{"cases": []}' > "$root/empty.json" + run_ids "$root/empty.json" + if [[ "$rc" -ne 1 ]] || ! grep -q "no non-empty .cases. array" <<<"$out"; then + fail "a report with no cases fails" "exit $rc: ${out##*$'\n'}" + else + pass "a report with an empty cases array fails" + fi +} + +echo "conformance-case-body-drift.sh — case body comparison" +t_identical_catalogs_are_clean +t_retightened_case_is_drift +t_unregistered_drift_is_ignored +t_standards_in_scope_is_not_a_case +t_comment_shaped_scalar_line_is_body +t_structural_comment_is_not_body +t_registered_id_absent_from_pin +t_case_absent_from_tip_warns +t_blank_id_list +t_malformed_id_list +t_nothing_compared +t_missing_input +t_empty_input +t_malformed_catalog_shapes + +echo "conformance-registered-case-ids.py — registration filter" +t_ids_filters_unregistered +t_ids_nothing_registered +t_ids_missing_case_id +t_ids_non_string_nodeid +t_ids_unusable_reports + +if [[ "$failures" -gt 0 ]]; then + echo "$failures failing" + exit 1 +fi +echo "all passing" diff --git a/.github/scripts/conformance-registered-case-ids.py b/.github/scripts/conformance-registered-case-ids.py new file mode 100755 index 0000000..e8c3f87 --- /dev/null +++ b/.github/scripts/conformance-registered-case-ids.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Print the conformance case ids this SDK registers, one per line. + +This is the repo-specific half of the case-body drift check: the id source +depends on how this repo's harness records registrations, so it lives here and +``conformance-case-body-drift.sh`` stays generic. + +The ids come out of ``conformance-report.json``, which ``conformance-tests/ +conftest.py`` writes from ``pytest_sessionfinish``. That is the harness's own +record of what registered, so it cannot disagree with what the suite actually +did -- the reason for reading the report rather than grepping +``@pytest.mark.conformance(...)`` out of the test sources, which would be a +second, weaker id extractor that reports what it matched and stays silent about +what it missed. + +The report lists one entry per CATALOG case, so presence in it is not +registration. ``nodeid`` is: ``pytest_runtest_logreport`` sets it when a marked +test runs, and the placeholder entries ``pytest_sessionfinish`` synthesizes for +uncovered catalog cases are ``{"case_id": ..., "status": "not_run"}`` with no +``nodeid`` key at all. Reading ``nodeid`` rather than ``status`` matters -- a +registered case whose test failed or skipped is still registered, and still +needs its body watched, but its status is not ``passed``. + +Requires the suite to have run, so the report on disk belongs to this commit. + +Reading the report with Python rather than jq keeps the consumer in the same +language as the producer: one parse, and every shape guard below phrased against +the structure ``conftest.py`` actually writes. + +Inputs (environment): + CONFORMANCE_REPORT path to conformance-report.json + (default: $GITHUB_WORKSPACE/conformance-report.json) + +Exit status: + 0 ids printed on stdout + 1 the report is missing, unreadable, or holds no registered case +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any, NoReturn, cast + + +def fail(message: str) -> NoReturn: + print(f"::error::{message}", file=sys.stderr) + raise SystemExit(1) + + +def as_object(value: Any, what: str) -> dict[str, Any]: + """Return a decoded JSON value as an object, or fail naming what was read. + + The cast is sound after the isinstance: JSON object keys are always strings. + """ + if not isinstance(value, dict): + fail(f"registered case ids: {what} is not a JSON object.") + return cast("dict[str, Any]", value) + + +def main() -> None: + workspace = os.environ.get("GITHUB_WORKSPACE", ".") + report_path = Path(os.environ.get("CONFORMANCE_REPORT", f"{workspace}/conformance-report.json")) + + if not report_path.is_file(): + fail( + f"registered case ids: '{report_path}' does not exist. conformance-tests/conftest.py " + "writes it from pytest_sessionfinish, so either the suite did not run or it failed " + "before the report was written." + ) + + try: + decoded: Any = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + fail(f"registered case ids: '{report_path}' could not be read as JSON: {exc}") + + payload = as_object(decoded, f"the top level of '{report_path}'") + + cases: Any = payload.get("cases") + if not isinstance(cases, list) or not cases: + fail(f"registered case ids: '{report_path}' has no non-empty 'cases' array.") + + ids: list[str] = [] + for raw_entry in cast("list[Any]", cases): + entry = as_object(raw_entry, f"a case entry in '{report_path}'") + + # An entry with a case_id that is not a string, or empty, would silently + # drop out of the filter below and take a real registration with it. + case_id: Any = entry.get("case_id") + if not isinstance(case_id, str) or not case_id: + fail( + f"registered case ids: '{report_path}' holds a case entry with a missing or " + "non-string case_id." + ) + + # Absent or empty is the placeholder shape: a catalog case with no + # marker behind it. Present but not a string is a report this script + # will not guess at, because the guess would be about which cases stop + # being guarded. + nodeid: Any = entry.get("nodeid") + if nodeid is None or nodeid == "": + continue + if not isinstance(nodeid, str): + fail( + f"registered case ids: '{report_path}' holds a non-string nodeid on case " + f"'{case_id}'." + ) + ids.append(case_id) + + if not ids: + fail( + f"registered case ids: '{report_path}' records no case with a nodeid, so no " + "@pytest.mark.conformance marker registered. Any check restricted to this list " + "would be vacuously green." + ) + + print("\n".join(ids)) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/fetch-conformance-catalog.sh b/.github/scripts/fetch-conformance-catalog.sh new file mode 100755 index 0000000..0d39ab6 --- /dev/null +++ b/.github/scripts/fetch-conformance-catalog.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Fetch the conformance catalog at the revision this repo pins. +# +# Generic: nothing here is specific to one workflow or one caller's layout. +# Every workflow in this repo that needs the pinned catalog runs this one +# script, so a guard tightened here is tightened for all of them. +# +# The catalog lives in github.com/AuthPlane/conformance — a public repo, updated +# independently of this one — so cloning its default branch would let a catalog +# change turn an unrelated PR red here. The ref is pinned instead, single-sourced +# from the tracked .conformance-catalog-ref at the repo root: bump it when +# adopting new catalog cases, together with the coverage for them, so a catalog +# change can never break CI on its own. +# +# This script exists because the read/guard/fetch sequence is needed by more than +# one workflow (ci.yml, release.yml and conformance-catalog-drift.yml). Kept +# inline in each, the guard could be tightened in one and not the others; the pin +# would be single-sourced but the logic reading it would not. +# +# Clones into $RUNNER_TEMP — outside $GITHUB_WORKSPACE — so the catalog stays out +# of the working tree: it must never be picked up by this repo's own build, test +# or coverage tooling, and `git add -A` in the release commit must never stage it +# as an embedded gitlink. +# +# Plain git over HTTPS is enough: the repo is public and read-only here, so there +# is no token to plumb and no third-party action surface to SHA-pin. +# +# Requires: GITHUB_WORKSPACE, RUNNER_TEMP. +# +# Optional: CONFORMANCE_CATALOG_DEST overrides the clone directory. A caller that +# needs the pinned catalog and the catalog tip side by side in the same job +# cannot let both land on the default path. Every other caller leaves it unset +# and gets $RUNNER_TEMP/conformance. + +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" +CATALOG_FILE="oauth-sdk-conformance-catalog.yaml" + +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 BEFORE the fetch: the ref must be a full commit SHA, +# not a branch or tag name, either of which would silently track a moving target. +if ! grep -Eq '^[0-9a-f]{40}$' <<< "$CONFORMANCE_CATALOG_REF"; then + echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA, got '$CONFORMANCE_CATALOG_REF'" + exit 1 +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 + +# The alignment assertion hard-fails when CONFORMANCE_CATALOG_PATH points at a +# missing file, but it reports that as a harness problem rather than drift. Fail +# here instead, where the cause is unambiguous: the fetch succeeded and the +# catalog still is not where every caller expects it. +if [[ ! -f "$DEST/$CATALOG_FILE" ]]; then + echo "::error::$CATALOG_FILE is not in the catalog at $CONFORMANCE_CATALOG_REF; the fetch succeeded but produced no catalog in $DEST" + exit 1 +fi + +echo "Conformance catalog checked out at $CONFORMANCE_CATALOG_REF in $DEST" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747fe7f..36acabf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,28 +26,18 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # AuthPlane/conformance is the public sibling repo carrying the shared - # oauth-sdk-conformance-catalog.yaml. Cloned to $RUNNER_TEMP — outside - # $GITHUB_WORKSPACE — so the catalog stays out of the working tree, - # matching release.yml's invariant. Plain git clone is enough: - # actions/checkout disallows paths outside the workspace, and we don't - # need its auth/persist-credentials features for a public read-only repo. + # oauth-sdk-conformance-catalog.yaml, pinned by SHA in the tracked + # .conformance-catalog-ref (read from the checked-out workspace, so the + # Checkout step above must precede this one). + # + # The read/guard/fetch sequence lives in the script rather than inline + # here: release.yml and the drift workflow need the same three lines, and + # inline in each the 40-hex-SHA guard could be tightened in one copy and + # not the others. The script clones to $RUNNER_TEMP — outside + # $GITHUB_WORKSPACE — so the catalog stays out of the working tree. - name: Clone shared conformance catalog (out of tree) if: matrix.package == 'root' - run: | - # Conformance catalog pinned by SHA, single-sourced from the tracked - # .conformance-catalog-ref at the repo root (read from the checked-out - # workspace, so the Checkout step above must precede this one). Bump - # that file when adopting new catalog cases, together with the SDK-side - # conformance coverage, so a catalog change can never break CI on its - # own. Source: github.com/AuthPlane/conformance. - CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")" - 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 + run: .github/scripts/fetch-conformance-catalog.sh - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/conformance-catalog-drift.yml b/.github/workflows/conformance-catalog-drift.yml index 09a48f5..aecb571 100644 --- a/.github/workflows/conformance-catalog-drift.yml +++ b/.github/workflows/conformance-catalog-drift.yml @@ -1,11 +1,26 @@ name: Conformance catalog drift # Weekly (plus on-demand) check that the SDK's @pytest.mark.conformance markers -# still cover the LATEST conformance catalog default branch, independent of the -# pinned SHA that gates PR CI (.conformance-catalog-ref). A newly added, -# uncovered catalog case FAILS this scheduled job so the drift is visible on the -# Actions dashboard; it never breaks PR CI, which has no pull_request trigger and -# runs against the pinned .conformance-catalog-ref. +# still agree with the LATEST conformance catalog default branch, independent of +# the pinned SHA that gates PR CI (.conformance-catalog-ref). Drift FAILS this +# scheduled job so it is visible on the Actions dashboard; it never breaks PR CI, +# which has no pull_request trigger and runs against the pinned +# .conformance-catalog-ref. +# +# The job runs TWO checks, because they see different things: +# +# 1. Case-ID alignment against the tip. Reports cases ADDED to the catalog that +# no marker covers, and markers naming a case the catalog no longer carries. +# This is a set comparison over ids. +# +# 2. Case-BODY drift for the cases the markers register. 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 +# covered 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: @@ -45,20 +60,146 @@ jobs: env: AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml run: | - pytest conformance-tests/test_catalog_alignment.py -v + # Tee'd so the Report step can tell drift from a harness fault: only + # the two disagreement assertions carry the drift prefix. + set -o pipefail + pytest conformance-tests/test_catalog_alignment.py -v \ + | tee "$RUNNER_TEMP/align.log" + + # The catalog at the ref this repo PINS, alongside the tip cloned above. + # The body check needs both to compare anything; with only the tip it + # would have nothing to compare against and could report nothing but + # green. Reuses the same fetch the pinned workflows use — including its + # 40-hex-SHA guard and its unreachable-ref failure — rather than a second + # copy of that logic that could be tightened in one place and not the + # other. CONFORMANCE_CATALOG_DEST keeps it off the default path, which + # the tip clone above already occupies. + # + # Placed AFTER the alignment check, with if: always(), so the two checks + # fail independently. Only check 2 reads the pinned catalog. Run before + # `align`, a failed fetch — a transient error on this second clone, or a + # pinned SHA that has become unreachable — would skip the alignment step + # (it carries no `if:`, so it defaults to `success()`) and silently drop + # the id-level check that ran on its own before check 2 existed. `Report + # drift` would then find no align.log and report the skip as a build or + # harness problem, which is the wrong diagnosis for a check that never + # started. + - name: Fetch pinned conformance catalog (out of tree) + if: always() + env: + CONFORMANCE_CATALOG_DEST: ${{ runner.temp }}/conformance-pinned + run: .github/scripts/fetch-conformance-catalog.sh + + # The ids for check 2. They come from the harness's own report rather than + # from a grep over the test sources, so they cannot disagree with what + # actually registered. + # + # Run against the PINNED catalog, not the tip. The question the body check + # asks is "which cases does this SDK declare conformance to under its + # current pin", and anchoring the list to the pin keeps it stable while + # the tip moves. It also keeps this step independent of the step above, + # which is EXPECTED to go red whenever the tip has drifted. + # + # if: always() because of that: the id-level check failing is the normal + # way this job reports, and the body check must still run after it. + # + # A consequence worth naming, so the silence is not misread as a check + # that ran: conftest.py builds the report by iterating the ids of the + # catalog it was pointed at, which here is the pinned one, so this list is + # a SUBSET of the pinned catalog by construction. The drift script's + # "registers a case the pinned catalog does not hold" branch is therefore + # unreachable from this workflow. A marker naming a case the pin does not + # carry is reported by test_catalog_alignment.py, which ci.yml runs + # against the pin on every PR. + - name: Collect the case ids this SDK registers + if: always() + env: + AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance-pinned/oauth-sdk-conformance-catalog.yaml + run: | + # Delete the report the step above wrote against the TIP catalog. If + # the run below fails to produce a new one, the id script must find + # nothing rather than silently read the previous step's report and + # describe the wrong catalog. + rm -f "$GITHUB_WORKSPACE/conformance-report.json" + + # The whole directory, not just the alignment module: conftest records + # a case id only for a marked test that actually ran, so a narrower + # selection would shorten the id list without saying so. + status=0 + pytest conformance-tests/ > "$RUNNER_TEMP/pinned-suite.log" 2>&1 || status=$? + if [ "$status" -ne 0 ]; then + # Not this job's signal to raise: a suite that fails against its own + # pinned catalog is red on every PR in ci.yml already. Surfaced, and + # the id list still comes from THIS run's report, never an older one. + echo "::warning::The conformance suite did not pass against the pinned catalog (exit $status). This job reports catalog drift, not suite health — see ci.yml. Log tail follows." + tail -n 40 "$RUNNER_TEMP/pinned-suite.log" + fi + + .github/scripts/conformance-registered-case-ids.py \ + > "$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() + env: + PINNED_CATALOG: ${{ runner.temp }}/conformance-pinned/oauth-sdk-conformance-catalog.yaml + TIP_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml + # Both of the next two are inputs the generic script knows nothing + # about, so both are passed explicitly. Its header still describes the + # layout it was written against — it names the id producer as a `.sh` + # and defaults COVERAGE_DIR to a directory this tree does not have — + # and is left alone on purpose: the values below decide what runs, and + # the byte-identity of a verbatim copy is worth more than correcting + # two comment lines here and forking it. + REGISTERED_IDS: ${{ runner.temp }}/registered-case-ids.txt + COVERAGE_DIR: conformance-tests/ + run: | + DRIFT_SUMMARY="$GITHUB_STEP_SUMMARY" \ + .github/scripts/conformance-case-body-drift.sh - name: Report drift if: always() run: | + # The body check writes its own detail into the step summary when it + # finds something. Recorded here either way, so a green run states + # that both checks ran rather than only the one that prints on + # success — a check whose silence is indistinguishable from its + # absence is how this class went unnoticed in the first place. + if [ "${{ steps.bodies.outcome }}" = "success" ]; then + echo "Conformance case bodies: no registered case changed shape under an unchanged id." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::The pinned case-body check did not pass (outcome: ${{ steps.bodies.outcome }}). Either a case this SDK registers was re-tightened under the same id, or the check could not run. Read its step log — it names the case." + fi + if [ "${{ steps.align.outcome }}" = "success" ]; then - echo "Conformance markers cover the latest catalog default branch." >> "$GITHUB_STEP_SUMMARY" + echo "Conformance markers agree with the latest catalog default branch in both directions." >> "$GITHUB_STEP_SUMMARY" + elif ! grep -qF "Conformance-catalog drift:" "$RUNNER_TEMP/align.log" 2>/dev/null; then + # Red, but neither assertion fired: an unparseable catalog, a failed + # clone, a collection error. Calling that "drift" sends the reader + # to reconcile markers against a catalog that was never read. + echo "::warning::Catalog alignment could not run: the step failed without either drift assertion firing — a build or harness problem, not catalog drift. Read the step log." + { + echo "## Catalog alignment could not run" + echo "" + echo "The alignment step failed, but neither disagreement assertion fired — so this is a **build or harness problem, not catalog drift**: an unparseable or unfetched catalog, or a collection error." + echo "" + echo "Read the step log. Nothing in \`conformance-tests/\` needs reconciling until this run can read a catalog." + } >> "$GITHUB_STEP_SUMMARY" else - echo "::warning::Conformance catalog drift detected: the SDK's @pytest.mark.conformance markers do not cover every case in the latest catalog default branch. Extend coverage in conformance-tests/, then bump .conformance-catalog-ref to adopt the new cases." + echo "::warning::Conformance catalog drift detected: the SDK's @pytest.mark.conformance markers and the latest catalog default branch disagree — either a case has no marker, or a marker names a case the catalog no longer carries. Read the pytest output for which direction failed, reconcile conformance-tests/, then bump .conformance-catalog-ref to adopt the new catalog." { echo "## Conformance catalog drift detected" echo "" - echo "The SDK's \`@pytest.mark.conformance\` markers do not cover every case in the **latest** conformance catalog default branch." + echo "The SDK's \`@pytest.mark.conformance\` markers and the **latest** conformance catalog default branch disagree. The step output names the direction:" + echo "" + echo "- a case in the latest catalog has no marker — extend coverage in \`conformance-tests/\`;" + echo "- a marker names a case id the latest catalog no longer carries — the case was renamed or dropped upstream, so update the marker to follow it." + echo "" echo "PR CI is unaffected — it runs against the pinned \`.conformance-catalog-ref\`." - echo "Extend coverage in \`conformance-tests/\`, then bump \`.conformance-catalog-ref\` to adopt the new cases." + echo "Reconcile \`conformance-tests/\`, then bump \`.conformance-catalog-ref\` to adopt the new catalog." } >> "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3cd8b3..e711e4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,27 +89,19 @@ jobs: token: ${{ steps.app_token.outputs.token }} # AuthPlane/conformance is the public sibling repo carrying the shared - # oauth-sdk-conformance-catalog.yaml. Cloned to $RUNNER_TEMP — outside + # oauth-sdk-conformance-catalog.yaml, pinned by SHA in the tracked + # .conformance-catalog-ref (read from the checked-out workspace, so the + # Checkout step above must precede this one). + # + # The read/guard/fetch sequence lives in the script rather than inline + # here: ci.yml and the drift workflow need the same three lines, and + # inline in each the 40-hex-SHA guard could be tightened in one copy and + # not the others. The script clones to $RUNNER_TEMP — outside # $GITHUB_WORKSPACE — so the catalog checkout never pollutes the working - # tree of the commit we tag and publish. Plain git clone is enough: - # actions/checkout disallows paths outside the workspace, and we don't - # need its auth/persist-credentials features for a public read-only repo. + # tree of the commit we tag and publish, and the `git add -A` below can + # never stage it as a gitlink. - name: Clone shared conformance catalog (out of tree) - run: | - # Conformance catalog pinned by SHA, single-sourced from the tracked - # .conformance-catalog-ref at the repo root (read from the checked-out - # workspace, so the Checkout step above must precede this one). Bump - # that file when adopting new catalog cases, together with the SDK-side - # conformance coverage, so a catalog change can never break CI on its - # own. Source: github.com/AuthPlane/conformance. - CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")" - 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 + run: .github/scripts/fetch-conformance-catalog.sh - name: Set up Python 3.11 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/workflows-lint.yml b/.github/workflows/workflows-lint.yml index 8d72b79..1f9ce6c 100644 --- a/.github/workflows/workflows-lint.yml +++ b/.github/workflows/workflows-lint.yml @@ -5,10 +5,19 @@ name: Release tooling # fails. The shell scripts under scripts/ are in the same category — a # break in them surfaces only when someone reaches for them after a # release, which is the worst moment to discover it — so they are linted -# and tested here too. Scoped to those two paths to keep CI overhead off +# and tested here too. Scoped to a few paths to keep CI overhead off # unrelated PRs. # -# The scripts trigger is `scripts/**`, not `scripts/*.sh`: a single-level +# `.github/scripts/**` is in scope on the same argument. 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: it matches PRs touching those +# scripts, not every PR touching `.github/**`. +# +# Linting alone would not have been enough for them, so they carry their own +# tests here as well, next to backport-fixes.test.sh. +# +# Every scripts trigger is `/**`, not `/*.sh`: a single-level # glob would leave a future scripts/lib/*.sh both untriggered here and # unlinted below, in each case silently. @@ -16,12 +25,14 @@ on: pull_request: paths: - ".github/workflows/**" + - ".github/scripts/**" - "scripts/**" push: branches: - main paths: - ".github/workflows/**" + - ".github/scripts/**" - "scripts/**" permissions: @@ -92,9 +103,34 @@ jobs: printf 'shellcheck: %s\n' "${sh_files[@]}" shellcheck "${sh_files[@]}" + - name: Shellcheck the workflow support scripts + # A second step rather than a second root on the find above, so an + # empty result names the directory that went missing. Merged, a renamed + # .github/scripts/ would still be covered by whatever scripts/ returned. + run: | + mapfile -d '' -t sh_files < <(find .github/scripts -type f -name '*.sh' -print0) + if [[ ${#sh_files[@]} -eq 0 ]]; then + echo "error: no shell scripts found under .github/scripts/" >&2 + exit 1 + fi + printf 'shellcheck: %s\n' "${sh_files[@]}" + shellcheck "${sh_files[@]}" + # backport-fixes.sh accepts a branch or a tag as --from, and only the # branch form has a remote-tracking ref. The tag form is what the release # flow tells you to use once release.yml has deleted the branch, so it is # the form least likely to be exercised before it is needed. - name: Test backport-fixes.sh run: scripts/backport-fixes.test.sh + + # Shellcheck above is the only other gate on the conformance drift + # scripts, and it cannot see the way they break: a loosened id regex that + # drops cases, or a `diff` whose exit code stops being read, is valid + # shell. The result is a check that reports green while guarding nothing, + # on a weekly schedule where nobody is watching — and it would stay + # unnoticed until a real re-tightening slipped through, which is the + # failure that check exists to prevent. These controls pin the detection + # itself. They need neither the catalog clone nor the SDK's dependencies, + # 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 1e7cf39..90a9046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +> **Versioning:** This entry contains breaking changes. The project is pre-1.0 (`0.x`); per SemVer, breaking changes on the `0.x` line ship in the next **minor** (targeting `0.5.0`), not a major bump. `RELEASE_POLICY.md`'s "major bump for breaking changes" rule takes effect once the project reaches `1.0.0`. + +### Added +- `AuthplaneClient.resource(...)`, `authplane_auth()` and `authplane_mcp_auth()` accept `resource_metadata_url=` — the RFC 9728 §5.1 URL to advertise when the PRM document is AS-hosted — read back through the new `AuthplaneResource.resource_metadata_url()`, which falls back to `prm_url()`. +- `AuthplaneClient.resource(...)` logs at INFO when a `revocation_checker` is configured while `fail_closed` is left at its fail-open default, so the posture appears in startup output rather than only in the docstring. INFO rather than a warning because that default is a documented choice — the no-op pairing 0.4.0 added a warning for — `fail_closed=True` with no `revocation_checker` — is the mistake, and that one warns. +- `read_dpop_header_from_scope`, `raw_request_path_from_scope` and `get_or_create_verify_cache_from_scope` are exported from the `authplane` package root, for middleware that cannot run under Starlette's `BaseHTTPMiddleware` because its response queue stalls a long-lived `text/event-stream` body. They are the raw-ASGI counterparts of the request-based helpers the adapters use, routing through the same rules, so the two entry points cannot drift. Exported rather than left in `authplane._dpop_adapter` because the consumer is third-party middleware outside this repository: a private-module import on a `0.x` package would break an installed resource server at import time the first time the module is renamed. +- `validate_prm_resource_identifier` is exported from the `authplane` package root: the construction-time gate itself (fragment, whitespace/control, absoluteness, userinfo, port), which the MCP adapters now call before `AuthplaneClient.create()` through the public API rather than through `authplane.internal`. The name is scoped to the **resource-server** identifier, the one that publishes Protected Resource Metadata (RFC 9728 §3): it rejects a valid RFC 8707 §2 resource indicator such as `urn:example:api`, and it does not gate a token request's `resource` parameter. +- `validate_resource_metadata_url` is exported from the `authplane` package root, alongside `validate_prm_resource_identifier` and `validate_issuer_identifier`. It is the construction-time gate for `resource_metadata_url=` (absolute `http`/`https` URL with a host, no fragment, no userinfo, no whitespace or control character, no `"` or `\` anywhere — RFC 9728 §3/§5.1, RFC 3986 §2, RFC 9110 §4.2.4/§11.2), so a consumer can check configuration before construction instead of importing it from `authplane.internal`. Both adapters call it through the package root for the same reason the two sibling gates are exported: a private-module import on a `0.x` package breaks an installed adapter/core pair the first time the module moves. +- `validate_issuer_identifier` is exported from the `authplane` package root, alongside `validate_prm_resource_identifier`. It is the construction-time issuer gate itself (query/fragment, whitespace/control, absoluteness, userinfo — RFC 8414 §2/§3.1, RFC 3986 §2, RFC 9110 §4.2.4), so a consumer can check configuration before calling `AuthplaneClient.create(...)` or `build_prm(...)` instead of importing it from `authplane.internal` — a private-module import on a `0.x` package, which breaks an installed application the first time the module moves. `InvalidIssuerError` was already exported; the predicate that raises it was not. +- `www_authenticate_challenges(error, *, schemes=None, algs=(), realm="", resource_metadata_url=None, scope=None, verbose_description=False)` — returns one `WWW-Authenticate` value per acceptable scheme (RFC 7235 §4.1), so a resource can advertise both `Bearer` and `DPoP` (RFC 9449 §7.1) with `algs` on the DPoP challenge. +- `AccessDeniedError` and `InvalidTargetError` — typed `AuthError` subclasses for the `access_denied` (403) and `invalid_target` (400, RFC 8707 §2.2) answers authserver 0.2.0 gives a token exchange; both are exported from the package root and neither counts toward the circuit breaker. +- `AuthplaneClient.resource(...)` logs a warning when `IntrospectionRevocation` is configured on a client created without `auth=`: authserver ≥ 0.1.2 answers `active: false` to unauthenticated introspection, so every token would be rejected as revoked. +- `AuthplaneResource` logs one warning per resource the first time introspection answers `active: false` for a token that passed local verification, naming the runtime-client requirement — a non-owner gets the same answer as a revocation. + +### Deprecated +- `VerifiedClaims.may_act` — now emits `DeprecationWarning`; authserver 0.2.0 no longer issues `may_act`; removed in the next minor. + +### Security +- `www_authenticate()` no longer copies the exception message into `error_description`; the description is now a fixed sentence chosen by the RFC 6750 §3.1 error code, and `www_authenticate()` / `response_headers_for()` take `verbose_description=True` to opt the message back onto the wire for development. + +### Fixed +- A rotated `jwks_uri` is now followed on ordinary verification traffic, not only when the rotation also introduces a new `kid`. **Impact:** a key the AS published at the old location and left out of the new one now stops verifying as soon as the rotation is observed, rather than after up to `jwks_refresh_seconds` (one hour by default). Rotating `jwks_uri` was never a way to migrate keys gradually. +- The `InvalidResourceError` / `InvalidIssuerError` messages now echo the identifier faithfully: only the components the input actually has are rendered (still userinfo-redacted, query/fragment still dropped). The previous fixed `scheme://host/path` template invented the missing half on exactly the inputs the new absoluteness gate rejects — `/mcp` echoed as `':///mcp'`, and `urn:example:api` as `'urn://example:api'`, an opaque identifier shown as though it had the host (and port) the message says is missing — and made the scheme-less and scheme-relative forms indistinguishable. +- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth(...)` / `authplane_mcp_auth(...)` close the `AuthplaneClient` they created when anything raises between `AuthplaneClient.create()` and the return — e.g. `client.resource(...)`'s `ValueError` for an out-of-range `allowed_algorithms` — instead of stranding it un-`aclose()`d behind the configuration error. The close is best-effort (`contextlib.suppress`): a failure in `aclose()` itself no longer replaces the configuration error the handler exists to surface. +- The `InvalidResourceError` echo no longer drops the `//` from an identifier whose authority is present but empty: `https://` echoed as `'https:'` (indistinguishable from the opaque form) and `file:///x` as `'file:/x'`. The `//` is now rendered when the raw string spells one out, not only when the parsed netloc is non-empty. +- AS metadata is now re-read on the verify path, so `metadata_refresh_seconds` is no longer inert on a resource server that only verifies tokens, and a rotated `jwks_uri` is followed. A verify-only deployment previously fetched metadata exactly once, at construction. The re-read runs after the token's header, `alg` and `typ` checks, so a structurally invalid token from an unauthenticated caller reaches no network I/O, and a failed refresh is logged with the current key set kept, so it cannot fail a verification that used to pass. +- A failed document refresh now backs off for `max(1, min(30, refresh_seconds))` seconds instead of being retried by the next reader, for the JWKS cache as well as the metadata cache. **Impact:** a one-second blip at a newly advertised `jwks_uri` now rejects tokens signed by the rotated key for up to `min(30, jwks_refresh_seconds)` seconds, where the retry used to be immediate; lower `jwks_refresh_seconds` to shorten it. +- `jwks_uri` is now resolved from the metadata document on every key-set fetch instead of being captured at construction and rebound by a change callback, so a rotation takes effect on the next fetch and a newly advertised URI that turns out to be unreachable leaves the working key set serving. The guarantee is "on the next fetch", not atomic: a `kid` miss re-reads the metadata document at most once per `min(metadata_refresh_seconds, 60)` seconds, so a rotation arriving inside a window a previous miss already consumed waits out the remainder. +- A fetched AS metadata document is validated before it is cached, not after it is read. Committing first left a document failing the RFC 8414 §3.3 issuer check visible to everything reading the cache — including where the key set is fetched from — while the error surfaced to a caller that had already acted on it. A document that fails the issuer check, or that names a non-HTTPS or malformed endpoint, now reaches nothing: the previously accepted one stays in place and verification continues against the key set it was already using. +- `authplane-fastmcp`, `authplane-mcp`: the path used to build the DPoP `htu` now drops everything from the first `?` on, so the reader's contract matches what RFC 9449 §4.2 defines `htu` to be. No verification behaviour changes: `dpop_verification` already normalized both the request URL and the proof's `htu` through `normalize_dpop_htu`, which strips query and fragment, so a query-carrying `raw_path` was discarded at the comparison boundary anyway. What this fixes is a helper that returned more than it promised — relied on by any future consumer that does not normalize. uvicorn (h11 and httptools) and Starlette's `TestClient` strip the query before the helper ever sees it. + +### Changed +- Docs: `IntrospectionRevocation`, `AuthplaneClient.resource(...)` and the three user guides now lead with the consequence — an introspection error lets the token through unless `fail_closed=True` — and say when each direction is right; the fail-open default is unchanged. +- **BREAKING (pre-1.0)** `ASCredentials` raises `ValueError` at construction when `client_id` or `client_secret` is empty; an empty secret authenticates as a public client, which cannot introspect at all. **Migration:** pass the real secret, or omit `ASCredentials` entirely. +- Docs: the introspection sections state that the resource server's client must be confidential and either the issuing client or a runtime-client of the Resource (`authserver admin resource runtime-client add`); the token-exchange sections explain `access_denied` versus `consent_required`, `invalid_target`, and the `PATCH /admin/resources/{id}` exchange allowlist step; the README carries the authserver compatibility statement. +- `scripts/manual-e2e-setup.sh` no longer sets `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED` (on by default since authserver 0.2.0) and takes an optional `AUTHSERVER_REF` to check out before building; `scripts/manual-e2e-smoke.sh` no longer calls `POST /admin/scopes`, which authserver 0.2.0 does not serve. +- **BREAKING (pre-1.0)** A resource identifier carrying userinfo — including the empty form `https://@api.example.com/mcp` — is now rejected with `InvalidResourceError` at the same construction-time gate as the fragment and absoluteness checks, citing RFC 9110 §4.2.4. The rejection message is itself userinfo-redacted. **Migration**: remove the credentials from the configured resource; a resource identifier names an endpoint, it does not authenticate to one. +- **BREAKING (pre-1.0)** A resource identifier containing whitespace or a control character anywhere in the raw string (`https://api.exa\tmple.com/mcp`, a leading space, a trailing space) is now rejected with `InvalidResourceError` at the same gate. CPython's `urlsplit` silently *cleans* such input — tab/CR/LF are removed anywhere, and since 3.11 any leading C0-control-or-space is stripped — so a parse-time check would pass a value whose derived well-known URL diverges byte-for-byte from the identifier the SDK stores and the adapters advertise verbatim, which RFC 9728 §3.3 obliges a conformant client to discard. **Migration:** strip stray whitespace from the configured resource string. +- The construction-gate messages now say `resource identifier` (was `resource indicator`), matching the conformance catalog's wording — and the distinction is real, not cosmetic: what this gate requires is a resource *server* identifier that can publish Protected Resource Metadata (RFC 9728 §3), which is stricter than RFC 8707 §2's resource indicator. Code matching on the old operand text must match on `identifier` (or on the axis clause, e.g. `absolute URL with a scheme and a host`, which is unchanged). +- **BREAKING (pre-1.0)** A resource identifier whose port does not parse — non-numeric, or outside 0-65535 — is now rejected with `InvalidResourceError`, citing RFC 3986 §3.2.3. `SplitResult.port` parses lazily, so such an authority previously passed the scheme-and-host check and then produced an unusable PRM URL. The message renders the port the operator wrote when it is all digits and marks it malformed otherwise — a non-digit port has the same shape as a userinfo whose `@` was forgotten, so quoting it back would defeat the message's own redaction. **Migration**: correct the port. +- **BREAKING (pre-1.0)** A resource identifier must now be an absolute URL with a scheme and a host, rejected with `InvalidResourceError` at the same construction-time gate as the fragment check — `AuthplaneClient.resource(...)`, `AuthplaneResource(...)`, and `authplane_mcp_auth(...)` / `authplane_auth(...)` before metadata discovery. A relative or opaque identifier (`/mcp`, `//api.example.com/mcp`, `urn:example:api`) previously produced a malformed metadata URL and, in the MCP adapter, a malformed DPoP `htu` origin (the literal `"://"`), so DPoP-bound requests could not verify. `http` hosts are still accepted for local development. **Migration**: configure `resource` as the full URL clients address it by — `https://api.example.com/mcp`, or `http://localhost:8080/mcp` in development — not a bare path or URN. +- **BREAKING (pre-1.0)** `DocumentCache`'s and `MetadataCache`'s `on_change` keyword argument is removed, along with the `DocumentChangeCallback` alias and its `authplane.internal.__all__` entry. It existed only to rebind the JWKS cache when `jwks_uri` changed, and `jwks_uri` is now resolved per fetch instead. **Migration:** none expected — the module is named `internal` and nothing here is re-exported from the package root — but passing `on_change=` is now a `TypeError` rather than being ignored. +- **BREAKING (pre-1.0)** `AuthplaneClient.create(...)` and `build_prm(...)` now raise `InvalidIssuerError` for an issuer that is not an absolute URL with a scheme and a host (RFC 8414 §2, §3.1) or that carries a userinfo subcomponent (RFC 9110 §4.2.4) — `build_prm` previously published it unchecked to unauthenticated callers. **Migration:** configure the issuer as scheme + host + path, no credentials. +- **BREAKING (pre-1.0)** A resource identifier whose host holds a literal `"` or `\` is now rejected with `InvalidResourceError` at construction: both are `WWW-Authenticate` quoted-string delimiters (RFC 9110 §5.6.4, §11.2), so the `resource_metadata` challenge advertised a URL clients could not parse or silently unescaped into a different host. **Migration:** percent-encode or remove the character. +- **BREAKING (pre-1.0)** An issuer identifier containing whitespace or a control character (RFC 3986 §2) is now rejected with `InvalidIssuerError` at `AuthplaneClient.create(...)`, `build_metadata_url(...)` and `build_prm(...)`, using the same class as the resource gate — C0 and space, DEL, the C1 controls, Unicode whitespace and U+FEFF — with the offending codepoint and offset named. `urlsplit` removes tab, CR and LF from anywhere in the input, so such an issuer derived a fetch target the AS-metadata comparison could no longer match and failed as a confusing `AS metadata issuer mismatch`. **Migration:** remove the invisible character the offset points at. +- **BREAKING (pre-1.0)** `build_prm(...)` now also raises `InvalidResourceError` for a `resource` that is not a usable resource-server identifier — fragment, whitespace or control character, non-absolute, userinfo, quoted-string delimiter in the host, or malformed port — applying the same construction gate as `AuthplaneResource`. It was copied unchecked into the document RFC 9728 §3 serves to unauthenticated callers, beside the issuer, and §3.3 has a client discard a document whose `resource` does not match the identifier it dereferenced. **Migration:** pass the identifier you configure on `AuthplaneClient.resource(...)`; every in-SDK caller already does. +- **BREAKING (pre-1.0)** The resource identifier's whitespace and control rejection now covers DEL, the C1 controls (U+007F-U+009F) and U+FEFF, none of which is a URI character (RFC 3986 §2) and none of which `urlsplit` strips; the message names the offending codepoint and its offset. **Migration:** remove the invisible character the offset points at. + ## [0.4.0] - 2026-08-28 > **Versioning:** This entry contains breaking changes. The project is pre-1.0 (`0.x`); per SemVer, breaking changes on the `0.x` line ship in the next **minor** (targeting `0.4.0`), not a major bump. `RELEASE_POLICY.md`'s "major bump for breaking changes" rule takes effect once the project reaches `1.0.0`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55f246a..e7797a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,7 +159,7 @@ The end-to-end demo exercises FastMCP and MCP adapters against a local Authplane Prerequisites: -1. OAuth server running locally on `:9000`/`:9001` with client credentials, token exchange, and DPoP enabled. +1. OAuth server running locally on `:9000`/`:9001` (client credentials, token exchange, and DPoP are on by default since authserver 0.2.0). 2. Demo client registration with required grant types and scopes. 3. Adapter demo server (`authplane-fastmcp/demo/run.sh` or `authplane-mcp/demo/run.sh`). 4. Demo client execution (Python or TypeScript matrix client). @@ -187,12 +187,13 @@ bash scripts/manual-e2e-smoke.sh --adapter fastmcp --skip-setup Optional overrides: - `AUTHSERVER_DIR=/path/to/authserver` +- `AUTHSERVER_REF=v0.2.0` — check out that ref of the authserver repo before building (default: leave the checkout as is) - `ISSUER_URL=http://localhost:9000` - `RESOURCE_URL=http://localhost:8080/mcp` ### Common demo failures -- `client_credentials grant is not enabled` — OAuth server is missing `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true`. +- `access_denied` on a token exchange — the exchanging client is not in the target Resource's `policy.exchange.allowed_client_ids`; `PATCH /admin/resources/{id}` to add it. - `client is not authorized for this grant type` — client registration is missing `urn:ietf:params:oauth:grant-type:token-exchange`. - `requested scope is invalid or not allowed` — requested scopes are not registered or assigned to the demo client. - `invalid API key` — admin API requests are using a different key than the server startup key. diff --git a/README.md b/README.md index 2e339c5..2271f1c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Adapter packages depend on `authplane-sdk`, so installing one adapter brings the Requires Python 3.11+. +## Compatibility + +Tested against authserver 0.2.0. Introspection-based revocation (`IntrospectionRevocation`) requires authserver ≥ 0.1.2 and a confidential resource-server client that is either the issuing client or a runtime-client of the Resource — see the [user guide](authplane/docs/user-guide.md#5-revocation-checking). + ## Capabilities ### Standards and RFCs diff --git a/authplane-fastmcp/authplane_fastmcp/auth.py b/authplane-fastmcp/authplane_fastmcp/auth.py index fb71660..c25101a 100644 --- a/authplane-fastmcp/authplane_fastmcp/auth.py +++ b/authplane-fastmcp/authplane_fastmcp/auth.py @@ -5,6 +5,7 @@ FastMCP server in a single call. """ +import contextlib from collections.abc import Iterator from typing import Any @@ -16,6 +17,8 @@ InboundDPoPOptions, IntrospectionRevocation, RevocationChecker, + validate_prm_resource_identifier, + validate_resource_metadata_url, ) from authplane.oauth import TokenExchangeOptions, TokenResponse from fastmcp.server.auth import RemoteAuthProvider @@ -173,6 +176,7 @@ async def authplane_auth( mcp_path: str = "/mcp", revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, fail_closed: bool = False, + resource_metadata_url: str | None = None, ) -> AuthplaneAuthResult: """Build the kwargs to enable Authplane auth on a FastMCP server. @@ -260,21 +264,48 @@ async def authplane_auth( - ``IntrospectionRevocation()``: calls the AS ``introspection_endpoint`` (RFC 7662) discovered from AS metadata. Raises ``TokenRevokedError`` if ``active=false``. - Pass ``as_credentials`` for authenticated introspection. - Fails open if the endpoint is unavailable, unless - ``fail_closed=True``. + ``as_credentials`` is required: authserver >= 0.1.2 answers + ``active=false`` to an unauthenticated call, or to a client + that is neither the issuing client nor a runtime-client of + the resource, so every token would be rejected as revoked. + An introspection error lets the token through unless + ``fail_closed=True`` is passed. - async callable: custom checker called with ``(VerifiedClaims, raw_token)``; return ``True`` to reject the token (raises ``TokenRevokedError``). fail_closed: Policy applied when the configured ``revocation_checker`` itself fails (e.g. the introspection endpoint is unreachable). ``False`` (default) accepts the - token — offline signature/claims validation still applies. - ``True`` rejects it with ``TokenRevokedError``, trading - availability during an AS outage for a hard revocation - guarantee. Only consulted when a ``revocation_checker`` is - configured; note that once the client's circuit breaker - opens, every request is rejected until the cooldown elapses. + token — offline signature/claims validation still applies — + and logs at INFO on construction so the posture is visible + in startup output. ``True`` rejects it with + ``TokenRevokedError``, trading availability during an AS + outage for a hard revocation guarantee. Only consulted when a + ``revocation_checker`` is configured; note that once the + client's circuit breaker opens, every request is rejected + until the cooldown elapses. + resource_metadata_url: URL to advertise as RFC 9728 §5.1 + ``resource_metadata`` instead of the §3.1 derivation of the + resource. For a deployment where the PRM document is served by + the authorization server — authserver >= 0.2.0 serves one per + registered Resource — rather than by this server. Forwarded to + ``AuthplaneClient.resource(...)`` and surfaced by + :meth:`AuthplaneTokenVerifier.resource_metadata_url`. + + **It does not reach the challenge FastMCP emits.** No upstream + parameter accepts a metadata URL: ``AuthProvider`` takes + ``base_url`` and ``resource_base_url``, both resource + *identifiers*, and ``fastmcp.server.http`` derives the challenge + URL from ``AuthProvider._get_resource_url(path)`` through + ``mcp.server.auth.routes.build_resource_metadata_url`` before + handing it to ``fastmcp.server.auth.middleware.RequireAuthMiddleware``. + So the 401 and the 403 ``insufficient_scope`` that middleware + sends always carry the derived, resource-hosted URL — and + overriding ``_get_resource_url`` would move the PRM route this + adapter serves along with it, which is a different change. + Middleware of your own that calls ``response_headers_for`` + carries this value; the upstream one cannot until it accepts a + URL. Returns: ``AuthplaneAuthResult`` with ``auth`` (``RemoteAuthProvider``), @@ -285,6 +316,21 @@ async def authplane_auth( ``result.client.exchange()``. Raises: + InvalidResourceError: If the resource derived from ``base_url`` and + ``mcp_path`` carries a fragment component, contains whitespace or + a control character, is not an absolute URL with a scheme and a + host (e.g. a ``base_url`` of ``localhost:8000``, whose + ``localhost`` parses as the scheme), carries a userinfo + subcomponent (RFC 9110 §4.2.4 — the identifier becomes the DPoP + ``htu`` origin and the advertised PRM ``resource``, so embedded + credentials are rejected outright), or carries a port that does + not parse (RFC 3986 §3.2.3). Raised before metadata + discovery, so the server fails at startup with the configuration + error rather than after a network round trip — or, worse, at + first request. Subclasses ``ValueError``. Also raised, before + discovery and for the same reason, when ``resource_metadata_url`` + is not an absolute ``http`` / ``https`` URL or carries any of the + same defects. ValueError: If configuration is invalid (bad algorithms, etc.). JWKSFetchError: If metadata discovery or JWKS fetching fails. """ @@ -292,6 +338,21 @@ async def authplane_auth( resource = _derive_resource_url(base_url, mcp_path) + # Gate the resource before AuthplaneClient.create(). The authoritative + # check is AuthplaneResource.__init__, reached through client.resource() + # below — but by then metadata discovery has already run, and the raise + # path would strand a client whose caches this function never aclose()s. + # A misconfigured base_url must not need a reachable AS to be diagnosed, + # and the verifier's htu origin is reconstructed from this identifier, so + # a non-absolute one would also leave DPoP-bound requests unverifiable. + validate_prm_resource_identifier(resource) + + # The override travels to the same challenge parameter the derived URL + # would, so it is gated in the same place and for the same reason. The + # authoritative call is AuthplaneResource.__init__, past create(). + if resource_metadata_url is not None: + validate_resource_metadata_url(resource_metadata_url) + # Prepare client-level kwargs, filtering out None to use SDK defaults client_kwargs_raw: dict[str, Any] = { "dpop": dpop, @@ -312,6 +373,7 @@ async def authplane_auth( "allowed_algorithms": allowed_algorithms, "clock_skew_seconds": clock_skew_seconds, "inbound_dpop": inbound_dpop, + "resource_metadata_url": resource_metadata_url, } verifier_kwargs: dict[str, Any] = { k: v for k, v in verifier_kwargs_raw.items() if v is not None @@ -324,43 +386,58 @@ async def authplane_auth( **client_kwargs, ) - # Translate ConsentRequiredError → MCP UrlElicitationRequiredError at the - # client boundary, before user tool code sees it. Tool authors don't need - # to wrap handlers or import elicitation primitives — the MCP wire-format - # mapping is owned by the adapter that constructs the client. - client = _wrap_client_for_elicitation(client) - - # Create the resource from the client - verifier = client.resource( - resource=resource, - scopes=resolved_scopes, - revocation_checker=revocation_checker, - fail_closed=fail_closed, - **verifier_kwargs, - ) + # Everything between create() and the return runs with a live client whose + # caches this function owns. Any raise on this path — client.resource()'s + # ValueError for an out-of-range allowed_algorithms, an upstream + # constructor's — would otherwise strand it un-aclose()d, so close it and + # re-raise rather than leak the metadata/JWKS caches behind an exception + # the operator sees as a plain configuration error. + try: + # Translate ConsentRequiredError → MCP UrlElicitationRequiredError at the + # client boundary, before user tool code sees it. Tool authors don't need + # to wrap handlers or import elicitation primitives — the MCP wire-format + # mapping is owned by the adapter that constructs the client. + client = _wrap_client_for_elicitation(client) + + # Create the resource from the client + verifier = client.resource( + resource=resource, + scopes=resolved_scopes, + revocation_checker=revocation_checker, + fail_closed=fail_closed, + **verifier_kwargs, + ) - # Wrap in AuthplaneTokenVerifier - # Note: FastMCP uses token_verifier.base_url for PRM generation if provided - token_verifier = AuthplaneTokenVerifier(verifier, base_url=base_url) - - # Wrap in RemoteAuthProvider to get PRM routes. - # - # ``authorization_servers`` and ``base_url`` must be ``AnyHttpUrl`` — the - # upstream framework requires the URL type internally. That construction - # normalizes an empty-path authority with a trailing slash, so the served - # PRM would otherwise advertise ``https://auth.example.com/`` for an issuer - # configured as ``https://auth.example.com``. ``VerbatimPRMRemoteAuthProvider`` - # rewrites the served ``authorization_servers`` / ``resource`` back to the - # verbatim configured strings so they match the core SDK's byte-for-byte - # comparison (RFC 8414 §3.3, RFC 9728 §3.3). - auth_provider = VerbatimPRMRemoteAuthProvider( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(issuer)], - base_url=AnyHttpUrl(base_url), - scopes_supported=resolved_scopes, - verbatim_issuer=issuer, - verbatim_resource=resource, - ) + # Wrap in AuthplaneTokenVerifier + # Note: FastMCP uses token_verifier.base_url for PRM generation if provided + token_verifier = AuthplaneTokenVerifier(verifier, base_url=base_url) + + # Wrap in RemoteAuthProvider to get PRM routes. + # + # ``authorization_servers`` and ``base_url`` must be ``AnyHttpUrl`` — the + # upstream framework requires the URL type internally. That construction + # normalizes an empty-path authority with a trailing slash, so the served + # PRM would otherwise advertise ``https://auth.example.com/`` for an issuer + # configured as ``https://auth.example.com``. ``VerbatimPRMRemoteAuthProvider`` + # rewrites the served ``authorization_servers`` / ``resource`` back to the + # verbatim configured strings so they match the core SDK's byte-for-byte + # comparison (RFC 8414 §3.3, RFC 9728 §3.3). + auth_provider = VerbatimPRMRemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(issuer)], + base_url=AnyHttpUrl(base_url), + scopes_supported=resolved_scopes, + verbatim_issuer=issuer, + verbatim_resource=resource, + ) + except Exception: + # Suppressed close: if aclose() itself raises, the operator would see + # the close failure and the configuration error this block exists to + # surface would survive only as __context__ — the opposite of the + # block's intent. + with contextlib.suppress(Exception): + await client.aclose() + raise return AuthplaneAuthResult( auth=auth_provider, diff --git a/authplane-fastmcp/authplane_fastmcp/url_elicitation.py b/authplane-fastmcp/authplane_fastmcp/url_elicitation.py index 4439251..23e88fa 100644 --- a/authplane-fastmcp/authplane_fastmcp/url_elicitation.py +++ b/authplane-fastmcp/authplane_fastmcp/url_elicitation.py @@ -84,15 +84,15 @@ def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: # Fail fast at import: the installed mcp must expose a known elicitation-id # spelling. Resolution is otherwise lazy (see _build_url_elicitation_params) so -# tests can patch the model without re-triggering this. The result is discarded -# — the resolver is called here purely for its import-time validation. +# tests can patch the model without re-triggering this. The name below is never +# read — it is bound only so this validation runs as an import-time side effect. # # The resolver raises RuntimeError because it is also called lazily, where the # package imported fine and the failure is a runtime schema mismatch. At *this* # call site the failure really is "the installed distribution is unusable", so # translate it to the shape a reader expects from a failing import. try: - _resolve_elicitation_id_kwarg(ElicitRequestURLParams) + _ELICITATION_ID_KWARG = _resolve_elicitation_id_kwarg(ElicitRequestURLParams) except RuntimeError as exc: # pragma: no cover - exercised via importlib.reload raise ImportError(str(exc)) from exc diff --git a/authplane-fastmcp/authplane_fastmcp/verifier.py b/authplane-fastmcp/authplane_fastmcp/verifier.py index 25a86ac..58133e8 100644 --- a/authplane-fastmcp/authplane_fastmcp/verifier.py +++ b/authplane-fastmcp/authplane_fastmcp/verifier.py @@ -62,8 +62,8 @@ class AuthplaneTokenVerifier(TokenVerifier): call's in-flight verify task is stashed on ``request.state`` keyed by the access token; any subsequent invocation within the same request awaits the same task instead of re-entering the inbound DPoP replay - store. The cache is defensive: it mirrors the TS adapter's - ``AsyncLocalStorage`` pattern and pre-empts a class of regressions + store. The cache is defensive: it stashes the in-flight verify task on + ``request.state`` and pre-empts a class of regressions where a future framework change (transport rewrite, custom auth provider, ASGI wrapper) would silently double-call ``verify_token`` and the second call's proof would be rejected as @@ -111,6 +111,15 @@ def __init__( raise TypeError( f"verifier.resource must be a str URI, got {type(verifier.resource).__name__}" ) + # Well-formed by construction: ``AuthplaneResource.__init__`` rejects + # a resource without a scheme and a host, so this origin — the fixed + # half of every ``htu`` this verifier checks proofs against — cannot + # degrade to the literal ``"://"`` a relative or opaque identifier + # used to produce (against which no honest proof could ever verify). + # The same gate rejects a userinfo subcomponent (RFC 9110 §4.2.4), so + # reassembling from ``netloc`` cannot put credentials into the origin + # either — an ``htu`` no honest proof could match, which was the + # "://" failure mode on an input the scheme+host check alone admits. split = urlsplit(verifier.resource) self._resource_origin = f"{split.scheme}://{split.netloc}" @@ -129,6 +138,17 @@ def scopes_supported(self) -> list[str]: """ return list(self._verifier.scopes) + def resource_metadata_url(self) -> str: + """Return the URL to advertise as RFC 9728 §5.1 ``resource_metadata``. + + Delegates to the wrapped resource, so middleware composing its own + challenge reads the configured override — or, with none configured, + the RFC 9728 §3.1 derivation — instead of rebuilding either. FastMCP's + own 401/403 does not go through here; see the user guide's "Where the + PRM document lives". + """ + return self._verifier.resource_metadata_url() + async def verify_token(self, token: str) -> AccessToken | None: """Validate a JWT and return a FastMCP ``AccessToken``. @@ -205,18 +225,16 @@ def _build_dpop_request_context(self, request: Request) -> DPoPRequestContext: not configured for inbound DPoP, the verifier's Mode-3 path rejects any DPoP signal regardless of what is passed here. - Cross-SDK note: the TS sibling ``buildDpopRequestContext`` - returns ``undefined`` when no ``DPoP`` header is present; - Python intentionally always builds the context with - ``proof=None``. Both shapes are behaviorally equivalent in - the core verifier (Mode 3 path treats absent and ``None`` - proofs the same), but a DPoP-bound token with no proof - yields a more specific ``DPoPProofMissingError`` here - instead of ``DPoPBindingMismatchError``. The error-type - contract is pinned per language by design. + Note: the context is always built, with ``proof=None`` when no + ``DPoP`` header is present, rather than omitted. Both shapes are + behaviorally equivalent in the core verifier (the Mode 3 path + treats absent and ``None`` proofs the same), but building it + unconditionally means a DPoP-bound token with no proof yields the + more specific ``DPoPProofMissingError`` instead of + ``DPoPBindingMismatchError``. """ - # ``raw_request_path`` reads ``scope["raw_path"]`` to preserve - # percent-encoding for DPoP ``htu`` parity with the TS sibling. + # ``raw_request_path`` reads ``scope["raw_path"]`` so percent-encoding + # is preserved in the DPoP ``htu`` (RFC 9449 §4.3, RFC 3986 §6.2.2.2). # ``request.url.query`` is sourced from ``scope["query_string"]`` # without percent-decoding, so it is already on-wire-safe. url = f"{self._resource_origin}{raw_request_path(request)}" diff --git a/authplane-fastmcp/docs/user-guide.md b/authplane-fastmcp/docs/user-guide.md index 56c11b4..73a33b8 100644 --- a/authplane-fastmcp/docs/user-guide.md +++ b/authplane-fastmcp/docs/user-guide.md @@ -86,6 +86,7 @@ All parameters of `authplane_auth()`: | `revocation_checker` | see [below](#token-revocation-checking) | `None` | Token revocation strategy | | `fail_closed` | `bool` | `False` | Reject tokens when the revocation check itself fails, instead of accepting them (see [below](#failure-policy-fail-open-vs-fail-closed)) | | `fetch_settings` | `FetchSettings` | `None` | Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) | +| `resource_metadata_url` | `str` | `None` | URL to advertise as RFC 9728 §5.1 `resource_metadata` instead of the derivation of the resource, for an AS-hosted PRM document. Does **not** reach the challenge FastMCP emits — see [below](#where-the-prm-document-lives) | | `inbound_dpop` | `InboundDPoPOptions` | `None` | Per-resource inbound DPoP policy (replay store, max proof age, clock skew, accepted proof algorithms, `required`). When set, the resource advertises DPoP support in PRM (RFC 9728 §2). See **Inbound DPoP through the FastMCP adapter** below for current limitations. | ### Inbound DPoP through the FastMCP adapter @@ -183,6 +184,31 @@ The response includes: No additional configuration is needed; PRM is served automatically. +### Where the PRM document lives + +Two topologies, and the adapter supports both: + +**(a) Resource-hosted — the default.** This server serves the document itself at the well-known path derived from the resource (`base_url` + `mcp_path`, RFC 9728 §3.1), exactly as the table above shows. Nothing to configure. + +**(b) AS-hosted.** The authorization server serves the document for every registered Resource — authserver >= 0.2.0 serves one at `/.well-known/oauth-protected-resource/{ref}`, where `ref` is the RFC 9728 §3.1 path suffix of the Resource URI (or its slug) — and this server only points clients at it. Useful when the resource server cannot host well-known paths. Pass `resource_metadata_url=`: + +```python +mcp = FastMCP( + "My MCP Server", + **await authplane_auth( + issuer="https://auth.company.com", + base_url="https://mcp.company.com", + resource_metadata_url="https://auth.company.com/.well-known/oauth-protected-resource/mcp", + ), +) +``` + +**FastMCP's own challenge keeps the derived URL.** No upstream parameter accepts a metadata URL: `AuthProvider` takes `base_url` and `resource_base_url`, both resource *identifiers*, and `fastmcp.server.http` derives the challenge URL from `AuthProvider._get_resource_url(path)` through `mcp.server.auth.routes.build_resource_metadata_url` before passing it to `fastmcp.server.auth.middleware.RequireAuthMiddleware`. So the 401 and the 403 `insufficient_scope` that middleware sends carry the resource-hosted URL whatever you configure here — and `resource_base_url` is not the lever either, since it also moves the PRM route this adapter serves. What the option does reach is every challenge this SDK composes: `AuthplaneTokenVerifier.resource_metadata_url()`, and through it any middleware of your own calling `response_headers_for(...)`. Until upstream accepts a URL, option (b) is only fully effective for a server whose 401/403 you emit yourself. + +**RFC 9728 §3.3 constrains topology (b).** The rule binds the document's `resource` value to *the URL the document was fetched from*, not to the API URL the client called: the value "MUST be identical to the protected resource's resource identifier value into which the well-known URI path suffix was inserted to create the URL used to retrieve the metadata", and otherwise "MUST NOT be used". Those two readings coincide only when the metadata URL is the §3.1 derivation of the resource — i.e. in topology (a). An AS-hosted document on a different origin is therefore usable only against clients that do not enforce §3.3, and that check is what stops a resource server from pointing a client at metadata for somebody else's resource. See the [core user guide](../../authplane/docs/user-guide.md) for the worked example. + +Whichever topology you pick, the Resource URI registered at the AS, the resource derived here from `base_url` + `mcp_path`, and this server's public URL must be one identical string — a trailing slash or a `:443` spelled out on one side only is a mismatch. + ## Token Revocation Checking By default, tokens are validated offline (signature + claims only). You can enable revocation checking to catch tokens that have been revoked before they expire. @@ -217,8 +243,14 @@ await authplane_auth( - The introspection endpoint is automatically discovered from AS metadata. - If the endpoint returns `active=false`, the token is rejected with `TokenRevokedError`. -- **Fails open by default**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). Pass `fail_closed=True` to reject instead (see [below](#failure-policy-fail-open-vs-fail-closed)). -- `as_credentials` enables authenticated introspection (recommended for production). +- **An introspection error lets the token through**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). This is the default; pass `fail_closed=True` to refuse instead (see [below](#failure-policy-fail-open-vs-fail-closed)). +- `as_credentials` is required. The resource server's client must be **confidential** (it has a secret) and must be either the client the token was issued to or a runtime-client of the Resource named in the token's `aud`. Register it once per resource: + + ```bash + authserver admin resource runtime-client add --client-id --slug + ``` + + A public client cannot introspect at all. Since authserver 0.1.2 an unauthenticated introspection call, or one from a client that is neither the issuer nor a runtime-client, is answered with `{"active": false}` — not an error — so every token is rejected as revoked. The SDK warns at startup when `IntrospectionRevocation` is configured without `as_credentials`, and once per resource the first time `active=false` comes back for a token that passed local verification. ### Failure Policy: Fail-Open vs Fail-Closed @@ -243,9 +275,10 @@ await authplane_auth( Trade-offs to understand before enabling `fail_closed=True`: - **Availability**: an authorization server or introspection outage makes every request fail with 401 until the outage resolves. Once the client's circuit breaker opens, checks fail fast and all tokens are rejected until the cooldown elapses. -- **Credentials**: authorization servers commonly require authenticated introspection; without valid `as_credentials` the introspection call fails, which under `fail_closed=True` means every token is rejected. Verify credentials as part of deployment, not just at rollout. +- **Credentials**: without valid `as_credentials`, or with a client that is not a runtime-client of the resource, authserver ≥ 0.1.2 answers `active=false` rather than an error — so every token is rejected under **both** policies, and `fail_closed` does not change that. Verify the credentials and the runtime-client registration as part of deployment, not just at rollout. - **Metadata**: an AS whose metadata document does not advertise `introspection_endpoint` fails every introspection attempt. Under the default that check is skipped and every request logs a `Revocation check failed (fail-open)` warning — for a missing endpoint that is every request, permanently, since the condition never clears; under `fail_closed=True` every token is rejected — and unlike an outage this never self-recovers, because the missing endpoint is a permanent property of the AS configuration. Confirm the endpoint is present in AS metadata before enabling. -- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning at resource construction when it detects this misconfiguration. +- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning when it detects this misconfiguration. +- The SDK also logs at INFO when a revocation checker is configured fail-open, so the posture in effect appears in startup output rather than only in the docs. See the core SDK user guide for why that one is not a warning. ### Custom Revocation Checker @@ -310,7 +343,23 @@ downstream = await result.client.exchange( | `resources` | `tuple[str, ...]` | Target resource identifiers (RFC 8707). Binds the downstream token's audience. | | `audiences` | `tuple[str, ...]` | Explicit audiences when not using `resources`. | -`client.exchange()` raises `InvalidGrantError` on a rejected grant, `ConsentRequiredError` when the AS requires interactive user consent before issuance, `CircuitOpenError` when the AS circuit is open, and other `AuthplaneError` subclasses for transport/protocol failures. See [Error Handling](#error-handling) and [URL Elicitation for Consent](#url-elicitation-for-consent) below. +**Operator step — allowlist the exchanging client.** authserver 0.2.0 only honours a cross-client exchange when the exchanging client is allowlisted on the target Resource. For each MCP server that exchanges for a downstream resource it does not itself act as, add its client id to that Resource's exchange policy: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token that was issued to itself, a fronted exchange, and a Broker resource need nothing. + +`client.exchange()` raises: + +- `AccessDeniedError` (`access_denied`, HTTP 403) — the exchanging client is not allowlisted on the target Resource. This is an operator-side fix (the `PATCH` above); re-prompting the user will not clear it, which is why it is a distinct class from `ConsentRequiredError`. +- `InvalidTargetError` (`invalid_target`, HTTP 400, RFC 8707 §2.2) — the `resource` string does not match a granted resource byte for byte; a trailing slash is enough. +- `ConsentRequiredError` — the AS requires interactive user consent before issuance. +- `InvalidGrantError` on a rejected grant, `CircuitOpenError` when the AS circuit is open, and other `AuthplaneError` subclasses for transport/protocol failures. `AccessDeniedError` and `InvalidTargetError` never trip the circuit breaker. + +See [Error Handling](#error-handling) and [URL Elicitation for Consent](#url-elicitation-for-consent) below. ## URL Elicitation for Consent @@ -445,6 +494,10 @@ Scope checks happen *after* token validation succeeds and are a separate enforce When you handle an `AuthplaneError` outside the verifier — typically because you are wrapping the adapter in your own middleware or calling `AuthplaneResource.verify()` directly — use `response_headers_for(error, …)` to map the error to `(status, {"WWW-Authenticate": challenge})` in one call. It forwards `realm`, `resource_metadata_url`, and `scope` into the underlying `www_authenticate()` helper, which sanitizes every interpolated value against header injection. +`error_description` is a fixed sentence chosen by the error code, never the exception's message — the challenge goes to a caller who has not authenticated, and the SDK's messages name the `kid`, claim, or expected audience that failed. The message stays on the exception and is logged at `DEBUG` on the `authplane.errors` logger; `verbose_description=True` puts it back on the wire for development only. + +A resource running `inbound_dpop` in optional mode accepts both `Bearer` and `DPoP` and should advertise both (RFC 9449 §7.1). `response_headers_for()` cannot express that — a dict holds one value per header name — so pair `http_status()` with `www_authenticate_challenges(error, schemes=("Bearer", "DPoP"), algs=…)` and emit one `WWW-Authenticate` header value per element. See the core SDK user guide for the full example. + ```python from authplane import AuthplaneError, response_headers_for @@ -454,7 +507,7 @@ except AuthplaneError as error: status, headers = response_headers_for( error, realm="api.example.com", - resource_metadata_url=resource.prm_url(), + resource_metadata_url=resource.resource_metadata_url(), ) return Response(status_code=status, headers=headers) ``` @@ -514,6 +567,7 @@ async def authplane_auth( inbound_dpop: InboundDPoPOptions | None = None, revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, fail_closed: bool = False, + resource_metadata_url: str | None = None, ) -> AuthplaneAuthResult ``` @@ -568,6 +622,7 @@ FastMCP `TokenVerifier` implementation. | `verify_token(token: str) -> AccessToken \| None` | Validate JWT, return `AccessToken` or `None` | | `verifier` (property) | Access underlying `AuthplaneResource` | | `scopes_supported` (property) | Scopes configured in the verifier | +| `resource_metadata_url() -> str` | URL to advertise as RFC 9728 §5.1 `resource_metadata` — the configured override, or the derivation | ### Core SDK types diff --git a/authplane-fastmcp/tests/conftest.py b/authplane-fastmcp/tests/conftest.py index 9507198..60171a5 100644 --- a/authplane-fastmcp/tests/conftest.py +++ b/authplane-fastmcp/tests/conftest.py @@ -205,8 +205,7 @@ class TokenVerifierFactory(Protocol): def __call__( self, base_url: str, resource: str, *, scopes: list[str] | None = None - ) -> AuthplaneTokenVerifier: - """Build a verifier for ``resource`` against the server at ``base_url``.""" + ) -> AuthplaneTokenVerifier: ... @pytest.fixture diff --git a/authplane-fastmcp/tests/test_auth_factory.py b/authplane-fastmcp/tests/test_auth_factory.py index d26fb2e..d7000a5 100644 --- a/authplane-fastmcp/tests/test_auth_factory.py +++ b/authplane-fastmcp/tests/test_auth_factory.py @@ -10,6 +10,7 @@ DPoPProvider, FetchSettings, IntrospectionRevocation, + InvalidResourceError, VerifiedClaims, ) from pydantic import AnyHttpUrl @@ -302,7 +303,7 @@ def _upstream_resource_url(provider: VerbatimPRMRemoteAuthProvider, mcp_path: st ("https://api.example.com", "/mcp/"), ("https://api.example.com/base", "/mcp"), ("https://api.example.com/base", "api/v1/mcp"), - # Root mount: the input neither this SDK nor the TS sibling pinned. + # Root mount: the input this SDK had not pinned before. ("https://api.example.com", "/"), ("https://api.example.com/", "/"), ("https://api.example.com/base", "/"), @@ -554,6 +555,97 @@ async def test_authplane_auth_result_aclose_idempotent(): assert mock_client.aclose.await_count == 2 +@pytest.mark.asyncio +async def test_authplane_auth_rejects_non_absolute_resource_at_startup(): + """A base_url that derives a non-absolute resource fails before create(). + + ``localhost:8000`` is the canonical operator mistake: urlsplit parses + ``localhost`` as the *scheme*, so the derived ``localhost:8000/mcp`` has + no host. The raise comes from the core SDK's real construction-time gate; + ``AuthplaneClient`` is patched only to pin the ordering claim — the gate + fires before ``create()``, so the misconfiguration is diagnosed without a + reachable AS and without a metadata + JWKS round trip, and the factory + never strands a client its raise path would not ``aclose()``. Same gate, + same rationale, as ``authplane_mcp_auth``'s. + """ + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock() + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + await authplane_auth( + issuer="https://auth.example.com", + base_url="localhost:8000", + ) + mock_client_cls.create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_authplane_auth_rejects_userinfo_resource_at_startup(): + """A credential-bearing base_url fails the factory before create(). + + Same gate, same ordering claim as the non-absolute rejection above. The + sink this closes here is the verifier's DPoP ``htu`` origin, which is + reassembled as ``scheme://netloc`` from the derived resource — with + userinfo admitted, that origin is one no honest client proof can ever + match. The secret must not survive into the error message either. + """ + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock() + with pytest.raises(InvalidResourceError, match="userinfo") as exc: + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://svc:s3cr3t@api.example.com", + ) + mock_client_cls.create.assert_not_awaited() + assert "s3cr3t" not in str(exc.value) + + +@pytest.mark.asyncio +async def test_authplane_auth_closes_client_when_resource_construction_raises(): + """A raise after create() closes the client the factory owns. + + The early resource gate fires before create(), but it is not the only + raise on the post-create path: client.resource() still raises ValueError + for an out-of-range allowed_algorithms, and any constructor after it can + raise too. The factory created the client, so its raise path — not the + operator's — must aclose() it; the operator only ever sees the exception. + """ + mock_client = MagicMock() + mock_client.aclose = AsyncMock() + mock_client.resource = MagicMock(side_effect=ValueError("allowed_algorithms out of range")) + + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + with pytest.raises(ValueError, match="allowed_algorithms"): + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://api.example.com", + ) + mock_client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_authplane_auth_close_failure_does_not_mask_the_configuration_error(): + """aclose() raising in the handler must not replace the original exception. + + The handler exists so "the operator only ever sees the exception" — if the + best-effort close itself fails, surfacing the close failure would bury the + configuration error as mere __context__, the opposite of the intent. The + close failure is suppressed; the close is still attempted. + """ + mock_client = MagicMock() + mock_client.aclose = AsyncMock(side_effect=RuntimeError("close failed")) + mock_client.resource = MagicMock(side_effect=ValueError("allowed_algorithms out of range")) + + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + with pytest.raises(ValueError, match="allowed_algorithms"): + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://api.example.com", + ) + mock_client.aclose.assert_awaited_once() + + # --------------------------------------------------------------------------- # Resource URL alignment # --------------------------------------------------------------------------- @@ -601,8 +693,68 @@ def test_public_names_are_importable_from_the_package_root() -> None: # imports them from the root — conftest reaches into .auth — so without this # the __all__ entries could rot without a test noticing. import authplane_fastmcp + from authplane_fastmcp import VerbatimPRMRemoteAuthProvider, rewrite_prm_routes_verbatim assert "VerbatimPRMRemoteAuthProvider" in authplane_fastmcp.__all__ assert "rewrite_prm_routes_verbatim" in authplane_fastmcp.__all__ - assert authplane_fastmcp.VerbatimPRMRemoteAuthProvider is not None - assert authplane_fastmcp.rewrite_prm_routes_verbatim is not None + assert VerbatimPRMRemoteAuthProvider is not None + assert rewrite_prm_routes_verbatim is not None + + +@pytest.mark.asyncio +async def test_authplane_auth_resource_metadata_url_defaults_to_unset(): + """Without the option, nothing is forwarded and the SDK derives the URL.""" + mock_client = MagicMock() + _mock_resource = MagicMock() + _mock_resource.resource = "https://api.example.com/mcp" + mock_client.resource = MagicMock(return_value=_mock_resource) + + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://api.example.com", + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert "resource_metadata_url" not in verifier_kwargs + + +@pytest.mark.asyncio +async def test_authplane_auth_resource_metadata_url_forwarded(): + """The AS-hosted PRM URL is forwarded to client.resource().""" + as_hosted = "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + mock_client = MagicMock() + _mock_resource = MagicMock() + _mock_resource.resource = "https://api.example.com/mcp" + mock_client.resource = MagicMock(return_value=_mock_resource) + + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://api.example.com", + resource_metadata_url=as_hosted, + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert verifier_kwargs["resource_metadata_url"] == as_hosted + + +@pytest.mark.asyncio +async def test_authplane_auth_rejects_invalid_resource_metadata_url_at_startup(): + """A malformed override fails before create(), like the resource itself. + + Same ordering claim as the resource gate: the value is advertised to an + unauthenticated caller from a challenge path, so it is diagnosed at startup + without a reachable AS. + """ + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock() + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://api.example.com", + resource_metadata_url="/.well-known/oauth-protected-resource/mcp", + ) + mock_client_cls.create.assert_not_awaited() diff --git a/authplane-fastmcp/tests/test_url_elicitation.py b/authplane-fastmcp/tests/test_url_elicitation.py index b13720a..e97f2c1 100644 --- a/authplane-fastmcp/tests/test_url_elicitation.py +++ b/authplane-fastmcp/tests/test_url_elicitation.py @@ -20,7 +20,7 @@ from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams from pydantic import BaseModel -from authplane_fastmcp import url_elicitation +import authplane_fastmcp.url_elicitation as url_elicitation from authplane_fastmcp.auth import ( _wrap_client_for_elicitation, # pyright: ignore[reportPrivateUsage] ) diff --git a/authplane-fastmcp/tests/test_verifier.py b/authplane-fastmcp/tests/test_verifier.py index ec406c7..02258b3 100644 --- a/authplane-fastmcp/tests/test_verifier.py +++ b/authplane-fastmcp/tests/test_verifier.py @@ -1,6 +1,7 @@ """Unit tests for AuthplaneTokenVerifier.""" import logging +from typing import TYPE_CHECKING from unittest.mock import AsyncMock import pytest @@ -8,6 +9,11 @@ from authplane_fastmcp import AuthplaneTokenVerifier +# Type-only, and below the first-party import for the reason +# ``test_auth_factory.py`` spells out: ruff sorts contiguous blocks only. +if TYPE_CHECKING: + from conftest import TokenVerifierFactory + @pytest.mark.asyncio async def test_verify_token_valid( @@ -145,3 +151,26 @@ async def test_verify_token_failure_silent_above_debug( assert result is None assert not [r for r in caplog.records if r.name == "authplane_fastmcp.verifier"] + + +def test_resource_metadata_url_delegates_to_the_resource( + token_verifier_factory: "TokenVerifierFactory", +) -> None: + """The accessor answers from the wrapped resource, not from a rebuild. + + Middleware composing its own challenge reads one value; FastMCP's own + 401/403 does not pass through here (no upstream parameter accepts a + metadata URL — see the factory's docstring). + """ + verifier = token_verifier_factory( + base_url="https://api.example.com", + resource="https://api.example.com/mcp", + ) + verifier.verifier.resource_metadata_url.return_value = ( # type: ignore[attr-defined] + "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + ) + + assert ( + verifier.resource_metadata_url() + == "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + ) diff --git a/authplane-fastmcp/tests/test_verifier_dpop_cache.py b/authplane-fastmcp/tests/test_verifier_dpop_cache.py index f0c40f2..e8b76c4 100644 --- a/authplane-fastmcp/tests/test_verifier_dpop_cache.py +++ b/authplane-fastmcp/tests/test_verifier_dpop_cache.py @@ -272,11 +272,8 @@ async def test_htu_origin_from_configured_resource_not_host_header() -> None: await verifier.verify_token("valid_token") ctx = mock.verify.await_args.kwargs["dpop_request"] - # Exact htu: the configured resource origin plus the request path, with no - # trace of the attacker-controlled Host / X-Forwarded-Proto headers. A - # prefix or substring check could pass on a URL that merely embeds the - # expected origin. - assert ctx.url == "https://api.example.com/mcp" + assert ctx.url.startswith("https://api.example.com") + assert "attacker" not in ctx.url @pytest.mark.asyncio @@ -433,9 +430,9 @@ async def test_cache_keyed_by_token_not_by_request_slot() -> None: A scenario that never arises on the standard FastMCP HTTP path (BearerAuthBackend extracts one Authorization per request), but - keying by token is cheap and removes a footgun the TS adapter - technically carries (a different ``verify_token(otherToken)`` call - inside one request would reuse the first call's result there). + keying by token is cheap and removes a footgun a request-scoped + cache would otherwise carry (a different ``verify_token(other_token)`` + call inside one request would reuse the first call's result). """ mock = _mock_verifier() request = _make_request(headers={"DPoP": "p"}) diff --git a/authplane-mcp/authplane_mcp/auth.py b/authplane-mcp/authplane_mcp/auth.py index 6174ab0..16df2b0 100644 --- a/authplane-mcp/authplane_mcp/auth.py +++ b/authplane-mcp/authplane_mcp/auth.py @@ -5,6 +5,7 @@ to an official MCP Python SDK server in a single call. """ +import contextlib import warnings from collections.abc import Iterator from typing import Any @@ -17,6 +18,8 @@ InboundDPoPOptions, IntrospectionRevocation, RevocationChecker, + validate_prm_resource_identifier, + validate_resource_metadata_url, ) from authplane.oauth import TokenExchangeOptions, TokenResponse from mcp.server.auth.middleware.auth_context import get_access_token as _get_access_token @@ -320,6 +323,7 @@ async def authplane_mcp_auth( inbound_dpop: InboundDPoPOptions | None = None, revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, fail_closed: bool = False, + resource_metadata_url: str | None = None, ) -> AuthplaneAuthResult: """Build the kwargs to enable Authplane auth on a FastMCP server. @@ -420,21 +424,46 @@ async def authplane_mcp_auth( - ``IntrospectionRevocation()``: calls the AS ``introspection_endpoint`` (RFC 7662) discovered from AS metadata. Raises ``TokenRevokedError`` if ``active=false``. - Pass ``as_credentials`` for authenticated introspection. - Fails open if the endpoint is unavailable, unless - ``fail_closed=True``. + ``as_credentials`` is required: authserver >= 0.1.2 answers + ``active=false`` to an unauthenticated call, or to a client + that is neither the issuing client nor a runtime-client of + the resource, so every token would be rejected as revoked. + An introspection error lets the token through unless + ``fail_closed=True`` is passed. - async callable: custom checker called with ``(VerifiedClaims, raw_token)``; return ``True`` to reject the token (raises ``TokenRevokedError``). fail_closed: Policy applied when the configured ``revocation_checker`` itself fails (e.g. the introspection endpoint is unreachable). ``False`` (default) accepts the - token — offline signature/claims validation still applies. - ``True`` rejects it with ``TokenRevokedError``, trading - availability during an AS outage for a hard revocation - guarantee. Only consulted when a ``revocation_checker`` is - configured; note that once the client's circuit breaker - opens, every request is rejected until the cooldown elapses. + token — offline signature/claims validation still applies — + and logs at INFO on construction so the posture is visible + in startup output. ``True`` rejects it with + ``TokenRevokedError``, trading availability during an AS + outage for a hard revocation guarantee. Only consulted when a + ``revocation_checker`` is configured; note that once the + client's circuit breaker opens, every request is rejected + until the cooldown elapses. + resource_metadata_url: URL to advertise as RFC 9728 §5.1 + ``resource_metadata`` instead of the §3.1 derivation of + ``resource``. For a deployment where the PRM document is served + by the authorization server — authserver >= 0.2.0 serves one per + registered Resource — rather than by this server. Forwarded to + ``AuthplaneClient.resource(...)`` and surfaced by + :meth:`AuthplaneTokenVerifier.resource_metadata_url`. + + **It does not reach the challenge the MCP SDK emits.** + ``mcp.server.auth.settings.AuthSettings`` has no metadata-URL + field: its only resource parameter is ``resource_server_url``, a + resource *identifier*, and + ``mcp.server.fastmcp.server.FastMCP.streamable_http_app()`` + derives the challenge URL from it through + ``mcp.server.auth.routes.build_resource_metadata_url`` before + handing it to ``RequireAuthMiddleware``. So the 401 and the 403 + ``insufficient_scope`` that middleware sends always carry the + derived, resource-hosted URL. Middleware of your own that calls + ``response_headers_for`` carries this value; the upstream one + cannot until it accepts a URL. Returns: ``AuthplaneAuthResult`` with ``token_verifier`` (``AuthplaneTokenVerifier``), @@ -444,11 +473,39 @@ async def authplane_mcp_auth( for RFC 8693 token exchange via ``result.client.exchange()``. Raises: + InvalidResourceError: If ``resource`` carries a fragment component, + contains whitespace or a control character, is not an absolute + URL with a scheme and a host, carries a userinfo subcomponent + (RFC 9110 §4.2.4 — the identifier becomes the DPoP ``htu`` + origin and the advertised PRM ``resource``, so embedded + credentials are rejected outright), or carries a port that does + not parse (RFC 3986 §3.2.3). Raised before metadata + discovery, so the server fails at startup with the configuration + error rather than after a network round trip — or, worse, at + first request. Subclasses ``ValueError``. Also raised, before + discovery and for the same reason, when ``resource_metadata_url`` + is not an absolute ``http`` / ``https`` URL or carries any of the + same defects. ValueError: If configuration is invalid (bad algorithms, etc.). JWKSFetchError: If metadata discovery or JWKS fetching fails. """ resolved_scopes = scopes or [] + # Gate the resource before AuthplaneClient.create(). The authoritative + # check is AuthplaneResource.__init__, reached through client.resource() + # below — but by then metadata discovery has already run, and the raise + # path would strand a client whose caches this function never aclose()s. + # A misconfigured resource must not need a reachable AS to be diagnosed, + # and the verifier's htu origin is reconstructed from this identifier, so + # a non-absolute one would also leave DPoP-bound requests unverifiable. + validate_prm_resource_identifier(resource) + + # The override travels to the same challenge parameter the derived URL + # would, so it is gated in the same place and for the same reason. The + # authoritative call is AuthplaneResource.__init__, past create(). + if resource_metadata_url is not None: + validate_resource_metadata_url(resource_metadata_url) + # Prepare client-level kwargs, filtering out None to use SDK defaults client_kwargs_raw: dict[str, Any] = { "dpop": dpop, @@ -469,6 +526,7 @@ async def authplane_mcp_auth( "allowed_algorithms": allowed_algorithms, "clock_skew_seconds": clock_skew_seconds, "inbound_dpop": inbound_dpop, + "resource_metadata_url": resource_metadata_url, } verifier_kwargs: dict[str, Any] = { k: v for k, v in verifier_kwargs_raw.items() if v is not None @@ -481,45 +539,60 @@ async def authplane_mcp_auth( **client_kwargs, ) - # Translate ConsentRequiredError → MCP UrlElicitationRequiredError at the - # client boundary, before user tool code sees it. Tool authors don't need - # to wrap handlers or import elicitation primitives — the MCP wire-format - # mapping is owned by the adapter that constructs the client. - client = _wrap_client_for_elicitation(client) - - # Create the resource from the client - verifier = client.resource( - resource=resource, - scopes=resolved_scopes, - revocation_checker=revocation_checker, - fail_closed=fail_closed, - **verifier_kwargs, - ) + # Everything between create() and the return runs with a live client whose + # caches this function owns. Any raise on this path — client.resource()'s + # ValueError for an out-of-range allowed_algorithms, an upstream + # constructor's — would otherwise strand it un-aclose()d, so close it and + # re-raise rather than leak the metadata/JWKS caches behind an exception + # the operator sees as a plain configuration error. + try: + # Translate ConsentRequiredError → MCP UrlElicitationRequiredError at the + # client boundary, before user tool code sees it. Tool authors don't need + # to wrap handlers or import elicitation primitives — the MCP wire-format + # mapping is owned by the adapter that constructs the client. + client = _wrap_client_for_elicitation(client) + + # Create the resource from the client + verifier = client.resource( + resource=resource, + scopes=resolved_scopes, + revocation_checker=revocation_checker, + fail_closed=fail_closed, + **verifier_kwargs, + ) - # Wrap in AuthplaneTokenVerifier. The verbatim issuer / resource ride - # along on the verifier so ``install_request_context`` can advertise them - # unchanged in the served PRM — the MCP SDK builds that document from - # ``AuthSettings`` ``AnyHttpUrl`` fields, which normalize an empty-path - # authority with a trailing slash (RFC 8414 §3.3, RFC 9728 §3.3). - token_verifier = AuthplaneTokenVerifier( - verifier, - verbatim_issuer=issuer, - verbatim_resource=resource, - ) + # Wrap in AuthplaneTokenVerifier. The verbatim issuer / resource ride + # along on the verifier so ``install_request_context`` can advertise them + # unchanged in the served PRM — the MCP SDK builds that document from + # ``AuthSettings`` ``AnyHttpUrl`` fields, which normalize an empty-path + # authority with a trailing slash (RFC 8414 §3.3, RFC 9728 §3.3). + token_verifier = AuthplaneTokenVerifier( + verifier, + verbatim_issuer=issuer, + verbatim_resource=resource, + ) - # Create AuthSettings for FastMCP. - # - # The MCP SDK's AuthSettings has no separate "supported" field — it uses - # ``required_scopes`` for both PRM ``scopes_supported`` advertisement - # AND RequireAuthMiddleware enforcement. See the docstring on - # ``enforce_scopes_on_all_requests`` above for the trade-off and why - # this flag exists. Per-tool ``require_scope()`` is the intended - # granular pattern in either mode. - auth_settings = AuthSettings( - issuer_url=AnyHttpUrl(issuer), - resource_server_url=AnyHttpUrl(resource), - required_scopes=resolved_scopes if enforce_scopes_on_all_requests else None, - ) + # Create AuthSettings for FastMCP. + # + # The MCP SDK's AuthSettings has no separate "supported" field — it uses + # ``required_scopes`` for both PRM ``scopes_supported`` advertisement + # AND RequireAuthMiddleware enforcement. See the docstring on + # ``enforce_scopes_on_all_requests`` above for the trade-off and why + # this flag exists. Per-tool ``require_scope()`` is the intended + # granular pattern in either mode. + auth_settings = AuthSettings( + issuer_url=AnyHttpUrl(issuer), + resource_server_url=AnyHttpUrl(resource), + required_scopes=resolved_scopes if enforce_scopes_on_all_requests else None, + ) + except Exception: + # Suppressed close: if aclose() itself raises, the operator would see + # the close failure and the configuration error this block exists to + # surface would survive only as __context__ — the opposite of the + # block's intent. + with contextlib.suppress(Exception): + await client.aclose() + raise return AuthplaneAuthResult( token_verifier=token_verifier, diff --git a/authplane-mcp/authplane_mcp/url_elicitation.py b/authplane-mcp/authplane_mcp/url_elicitation.py index d3859e7..bfebbfe 100644 --- a/authplane-mcp/authplane_mcp/url_elicitation.py +++ b/authplane-mcp/authplane_mcp/url_elicitation.py @@ -84,15 +84,15 @@ def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: # Fail fast at import: the installed mcp must expose a known elicitation-id # spelling. Resolution is otherwise lazy (see _build_url_elicitation_params) so -# tests can patch the model without re-triggering this. The result is discarded -# — the resolver is called here purely for its import-time validation. +# tests can patch the model without re-triggering this. The name below is never +# read — it is bound only so this validation runs as an import-time side effect. # # The resolver raises RuntimeError because it is also called lazily, where the # package imported fine and the failure is a runtime schema mismatch. At *this* # call site the failure really is "the installed distribution is unusable", so # translate it to the shape a reader expects from a failing import. try: - _resolve_elicitation_id_kwarg(ElicitRequestURLParams) + _ELICITATION_ID_KWARG = _resolve_elicitation_id_kwarg(ElicitRequestURLParams) except RuntimeError as exc: # pragma: no cover - exercised via importlib.reload raise ImportError(str(exc)) from exc diff --git a/authplane-mcp/authplane_mcp/verifier.py b/authplane-mcp/authplane_mcp/verifier.py index 5d27946..887af40 100644 --- a/authplane-mcp/authplane_mcp/verifier.py +++ b/authplane-mcp/authplane_mcp/verifier.py @@ -127,6 +127,18 @@ def __init__( raise TypeError( f"verifier.resource must be a str URI, got {type(verifier.resource).__name__}" ) + # Well-formed by construction: ``AuthplaneResource.__init__`` rejects + # a resource without a scheme and a host, so this origin — the fixed + # half of every ``htu`` this verifier checks proofs against — cannot + # degrade to the literal ``"://"`` a relative or opaque identifier + # used to produce (against which no honest proof could ever verify). + # The same gate rejects a userinfo subcomponent (RFC 9110 §4.2.4), so + # reassembling from ``netloc`` cannot put credentials into the origin + # either — an ``htu`` no honest proof could match, which was the + # "://" failure mode on an input the scheme+host check alone admits. + # That gate is what makes the docstring's claim true that the origin + # comes from operator configuration rather than from anything an + # upstream can influence. split = urlsplit(verifier.resource) self._resource_origin = f"{split.scheme}://{split.netloc}" @@ -154,6 +166,17 @@ def verbatim_identifiers(self) -> tuple[str, str] | None: return None return self._verbatim_issuer, self._verbatim_resource + def resource_metadata_url(self) -> str: + """Return the URL to advertise as RFC 9728 §5.1 ``resource_metadata``. + + Delegates to the wrapped resource, so middleware composing its own + challenge reads the configured override — or, with none configured, + the RFC 9728 §3.1 derivation — instead of rebuilding either. The MCP + SDK's own 401/403 does not go through here; see the user guide's + "Where the PRM document lives". + """ + return self._verifier.resource_metadata_url() + async def verify_token(self, token: str) -> AccessToken | None: """Validate a JWT and return an MCP ``AccessToken``. diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index 5e9c19b..d7fa33c 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -91,6 +91,7 @@ All parameters of `authplane_mcp_auth()`: | `revocation_checker` | see [below](#token-revocation-checking) | `None` | Token revocation strategy | | `fail_closed` | `bool` | `False` | Reject tokens when the revocation check itself fails, instead of accepting them (see [below](#failure-policy-fail-open-vs-fail-closed)) | | `fetch_settings` | `FetchSettings` | `None` | Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) | +| `resource_metadata_url` | `str` | `None` | URL to advertise as RFC 9728 §5.1 `resource_metadata` instead of the derivation of `resource`, for an AS-hosted PRM document. Does **not** reach the challenge the MCP SDK emits — see [below](#where-the-prm-document-lives) | | `inbound_dpop` | `InboundDPoPOptions` | `None` | Per-resource inbound DPoP policy (replay store, max proof age, clock skew, accepted proof algorithms, `required`). When set, the resource advertises DPoP support in PRM (RFC 9728 §2). See **Inbound DPoP through the MCP adapter** below for current limitations. | ### Inbound DPoP through the MCP adapter @@ -175,6 +176,29 @@ The response includes: No additional configuration is needed; PRM is served automatically by the MCP SDK. +### Where the PRM document lives + +Two topologies, and the adapter supports both: + +**(a) Resource-hosted — the default.** This server serves the document itself at the well-known path derived from `resource` (RFC 9728 §3.1), exactly as the table above shows. Nothing to configure. + +**(b) AS-hosted.** The authorization server serves the document for every registered Resource — authserver >= 0.2.0 serves one at `/.well-known/oauth-protected-resource/{ref}`, where `ref` is the RFC 9728 §3.1 path suffix of the Resource URI (or its slug) — and this server only points clients at it. Useful when the resource server cannot host well-known paths. Pass `resource_metadata_url=`: + +```python +auth_result = await authplane_mcp_auth( + issuer="https://auth.company.com", + resource="https://mcp.company.com/mcp", + resource_metadata_url="https://auth.company.com/.well-known/oauth-protected-resource/mcp", +) +mcp = FastMCP("My MCP Server", **auth_result) +``` + +**The MCP SDK's own challenge keeps the derived URL.** `mcp.server.auth.settings.AuthSettings` has no metadata-URL field: its only resource parameter is `resource_server_url`, a resource *identifier*, and `mcp.server.fastmcp.server.FastMCP.streamable_http_app()` derives the challenge URL from it through `mcp.server.auth.routes.build_resource_metadata_url` before passing it to `RequireAuthMiddleware`. So the 401 and the 403 `insufficient_scope` that middleware sends carry the resource-hosted URL whatever you configure here. What the option does reach is every challenge this SDK composes: `AuthplaneTokenVerifier.resource_metadata_url()`, and through it any middleware of your own calling `response_headers_for(...)`. Until upstream accepts a URL, option (b) is only fully effective for a server whose 401/403 you emit yourself. + +**RFC 9728 §3.3 constrains topology (b).** The rule binds the document's `resource` value to *the URL the document was fetched from*, not to the API URL the client called: the value "MUST be identical to the protected resource's resource identifier value into which the well-known URI path suffix was inserted to create the URL used to retrieve the metadata", and otherwise "MUST NOT be used". Those two readings coincide only when the metadata URL is the §3.1 derivation of the resource — i.e. in topology (a). An AS-hosted document on a different origin is therefore usable only against clients that do not enforce §3.3, and that check is what stops a resource server from pointing a client at metadata for somebody else's resource. See the [core user guide](../../authplane/docs/user-guide.md) for the worked example. + +Whichever topology you pick, the Resource URI registered at the AS, the `resource=` passed here, and this server's public URL must be one identical string — a trailing slash or a `:443` spelled out on one side only is a mismatch. + ## Token Revocation Checking By default, tokens are validated offline (signature + claims only). You can enable revocation checking to catch tokens that have been revoked before they expire. @@ -209,8 +233,14 @@ await authplane_mcp_auth( - The introspection endpoint is automatically discovered from AS metadata. - If the endpoint returns `active=false`, the token is rejected with `TokenRevokedError`. -- **Fails open by default**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). Pass `fail_closed=True` to reject instead (see [below](#failure-policy-fail-open-vs-fail-closed)). -- `as_credentials` enables authenticated introspection (recommended for production). +- **An introspection error lets the token through**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). This is the default; pass `fail_closed=True` to refuse instead (see [below](#failure-policy-fail-open-vs-fail-closed)). +- `as_credentials` is required. The resource server's client must be **confidential** (it has a secret) and must be either the client the token was issued to or a runtime-client of the Resource named in the token's `aud`. Register it once per resource: + + ```bash + authserver admin resource runtime-client add --client-id --slug + ``` + + A public client cannot introspect at all. Since authserver 0.1.2 an unauthenticated introspection call, or one from a client that is neither the issuer nor a runtime-client, is answered with `{"active": false}` — not an error — so every token is rejected as revoked. The SDK warns at startup when `IntrospectionRevocation` is configured without `as_credentials`, and once per resource the first time `active=false` comes back for a token that passed local verification. ### Failure Policy: Fail-Open vs Fail-Closed @@ -235,9 +265,10 @@ await authplane_mcp_auth( Trade-offs to understand before enabling `fail_closed=True`: - **Availability**: an authorization server or introspection outage makes every request fail with 401 until the outage resolves. Once the client's circuit breaker opens, checks fail fast and all tokens are rejected until the cooldown elapses. -- **Credentials**: authorization servers commonly require authenticated introspection; without valid `as_credentials` the introspection call fails, which under `fail_closed=True` means every token is rejected. Verify credentials as part of deployment, not just at rollout. +- **Credentials**: without valid `as_credentials`, or with a client that is not a runtime-client of the resource, authserver ≥ 0.1.2 answers `active=false` rather than an error — so every token is rejected under **both** policies, and `fail_closed` does not change that. Verify the credentials and the runtime-client registration as part of deployment, not just at rollout. - **Metadata**: an AS whose metadata document does not advertise `introspection_endpoint` fails every introspection attempt. Under the default that check is skipped and every request logs a `Revocation check failed (fail-open)` warning — for a missing endpoint that is every request, permanently, since the condition never clears; under `fail_closed=True` every token is rejected — and unlike an outage this never self-recovers, because the missing endpoint is a permanent property of the AS configuration. Confirm the endpoint is present in AS metadata before enabling. -- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning at resource construction when it detects this misconfiguration. +- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning when it detects this misconfiguration. +- The SDK also logs at INFO when a revocation checker is configured fail-open, so the posture in effect appears in startup output rather than only in the docs. See the core SDK user guide for why that one is not a warning. ### Custom Revocation Checker @@ -302,7 +333,23 @@ downstream = await result.client.exchange( | `resources` | `tuple[str, ...]` | Target resource identifiers (RFC 8707). Binds the downstream token's audience. | | `audiences` | `tuple[str, ...]` | Explicit audiences when not using `resources`. | -`client.exchange()` raises `InvalidGrantError` on a rejected grant, `ConsentRequiredError` when the AS requires interactive user consent before issuance, `CircuitOpenError` when the AS circuit is open, and other `AuthplaneError` subclasses for transport/protocol failures. See [Error Handling](#error-handling). +**Operator step — allowlist the exchanging client.** authserver 0.2.0 only honours a cross-client exchange when the exchanging client is allowlisted on the target Resource. For each MCP server that exchanges for a downstream resource it does not itself act as, add its client id to that Resource's exchange policy: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token that was issued to itself, a fronted exchange, and a Broker resource need nothing. + +`client.exchange()` raises: + +- `AccessDeniedError` (`access_denied`, HTTP 403) — the exchanging client is not allowlisted on the target Resource. This is an operator-side fix (the `PATCH` above); re-prompting the user will not clear it, which is why it is a distinct class from `ConsentRequiredError`. +- `InvalidTargetError` (`invalid_target`, HTTP 400, RFC 8707 §2.2) — the `resource` string does not match a granted resource byte for byte; a trailing slash is enough. +- `ConsentRequiredError` — the AS requires interactive user consent before issuance. +- `InvalidGrantError` on a rejected grant, `CircuitOpenError` when the AS circuit is open, and other `AuthplaneError` subclasses for transport/protocol failures. `AccessDeniedError` and `InvalidTargetError` never trip the circuit breaker. + +See [Error Handling](#error-handling). ## URL Elicitation for Consent @@ -448,6 +495,10 @@ Scope checks happen *after* token validation succeeds and are a separate enforce When you handle an `AuthplaneError` outside the verifier — typically because you are wrapping the adapter in your own middleware or calling `AuthplaneResource.verify()` directly — use `response_headers_for(error, …)` to map the error to `(status, {"WWW-Authenticate": challenge})` in one call. It forwards `realm`, `resource_metadata_url`, and `scope` into the underlying `www_authenticate()` helper, which sanitizes every interpolated value against header injection. +`error_description` is a fixed sentence chosen by the error code, never the exception's message — the challenge goes to a caller who has not authenticated, and the SDK's messages name the `kid`, claim, or expected audience that failed. The message stays on the exception and is logged at `DEBUG` on the `authplane.errors` logger; `verbose_description=True` puts it back on the wire for development only. + +A resource running `inbound_dpop` in optional mode accepts both `Bearer` and `DPoP` and should advertise both (RFC 9449 §7.1). `response_headers_for()` cannot express that — a dict holds one value per header name — so pair `http_status()` with `www_authenticate_challenges(error, schemes=("Bearer", "DPoP"), algs=…)` and emit one `WWW-Authenticate` header value per element. See the core SDK user guide for the full example. + ```python from authplane import AuthplaneError, response_headers_for @@ -457,7 +508,7 @@ except AuthplaneError as error: status, headers = response_headers_for( error, realm="api.example.com", - resource_metadata_url=resource.prm_url(), + resource_metadata_url=resource.resource_metadata_url(), ) return Response(status_code=status, headers=headers) ``` @@ -517,6 +568,7 @@ async def authplane_mcp_auth( inbound_dpop: InboundDPoPOptions | None = None, revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, fail_closed: bool = False, + resource_metadata_url: str | None = None, ) -> AuthplaneAuthResult ``` @@ -542,6 +594,7 @@ MCP SDK `TokenVerifier` implementation. |-----------------|-------------| | `verify_token(token: str) -> AccessToken \| None` | Validate JWT, return `AccessToken` or `None` | | `verifier` (property) | Access underlying `AuthplaneResource` | +| `resource_metadata_url() -> str` | URL to advertise as RFC 9728 §5.1 `resource_metadata` — the configured override, or the derivation | ### `AuthplaneAuthResult` diff --git a/authplane-mcp/tests/test_auth.py b/authplane-mcp/tests/test_auth.py index f2fdb98..3b738f8 100644 --- a/authplane-mcp/tests/test_auth.py +++ b/authplane-mcp/tests/test_auth.py @@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from authplane import DPoPProvider, FetchSettings, IntrospectionRevocation, VerifiedClaims +from authplane import ( + DPoPProvider, + FetchSettings, + IntrospectionRevocation, + InvalidResourceError, + VerifiedClaims, +) from mcp.server.auth.provider import AccessToken from mcp.server.auth.settings import AuthSettings @@ -185,6 +191,76 @@ async def test_authplane_mcp_auth_none_filtering(): assert "allowed_algorithms" not in verifier_kwargs +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource", + ["/mcp", "//api.example.com/mcp", "urn:example:api"], + ids=["relative", "scheme-relative", "opaque-urn"], +) +async def test_authplane_mcp_auth_rejects_non_absolute_resource_at_startup(resource: str): + """A non-absolute resource fails the factory before metadata discovery. + + The raise comes from the core SDK's real construction-time gate — nothing + on that path is stubbed here. ``AuthplaneClient`` is patched only to pin + the *ordering* claim: the gate fires before ``create()``, so the operator + sees the configuration error without a reachable AS, and the factory never + strands a client whose caches its raise path would not ``aclose()``. With + the early gate deleted, the factory would sail through the mocked create + into a mocked ``client.resource`` — no gate anywhere — and this test's + ``InvalidResourceError`` expectation goes red. + + A verifier built from such a resource would also reconstruct its DPoP + ``htu`` origin as the literal ``"://"``, leaving DPoP-bound requests + unverifiable at first request — the failure mode "at startup, not at first + request" is about. + """ + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock() + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource=resource, + ) + mock_client_cls.create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_authplane_mcp_auth_rejects_userinfo_resource_at_startup(): + """A credential-bearing resource fails the factory before create(). + + Same gate, same ordering claim as the non-absolute rejection above. The + sink this closes here is the verifier's DPoP ``htu`` origin, which is + reassembled as ``scheme://netloc`` from this identifier — with userinfo + admitted, that origin is one no honest client proof can ever match. The + secret must not survive into the error message either. + """ + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock() + with pytest.raises(InvalidResourceError, match="userinfo") as exc: + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://svc:s3cr3t@api.example.com/mcp", + ) + mock_client_cls.create.assert_not_awaited() + assert "s3cr3t" not in str(exc.value) + + +@pytest.mark.asyncio +async def test_authplane_mcp_auth_accepts_http_localhost_resource(): + """Absoluteness is required, https is not — local development keeps working.""" + mock_client = MagicMock() + mock_client.resource = MagicMock(return_value=MagicMock(resource="http://localhost:8080/mcp")) + + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + result = await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="http://localhost:8080/mcp", + dev_mode=True, + ) + assert isinstance(result, AuthplaneAuthResult) + + @pytest.mark.asyncio async def test_authplane_mcp_auth_revocation_checker_default_is_none(): """When revocation_checker is not passed, None is forwarded (no revocation checking).""" @@ -322,6 +398,53 @@ async def test_authplane_auth_result_aclose_idempotent(): assert mock_client.aclose.await_count == 2 +@pytest.mark.asyncio +async def test_authplane_mcp_auth_closes_client_when_resource_construction_raises(): + """A raise after create() closes the client the factory owns. + + The early resource gate fires before create(), but it is not the only + raise on the post-create path: client.resource() still raises ValueError + for an out-of-range allowed_algorithms, and any constructor after it can + raise too. The factory created the client, so its raise path — not the + operator's — must aclose() it; the operator only ever sees the exception. + """ + mock_client = MagicMock() + mock_client.aclose = AsyncMock() + mock_client.resource = MagicMock(side_effect=ValueError("allowed_algorithms out of range")) + + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + with pytest.raises(ValueError, match="allowed_algorithms"): + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://api.example.com/mcp", + ) + mock_client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_authplane_mcp_auth_close_failure_does_not_mask_the_configuration_error(): + """aclose() raising in the handler must not replace the original exception. + + The handler exists so "the operator only ever sees the exception" — if the + best-effort close itself fails, surfacing the close failure would bury the + configuration error as mere __context__, the opposite of the intent. The + close failure is suppressed; the close is still attempted. + """ + mock_client = MagicMock() + mock_client.aclose = AsyncMock(side_effect=RuntimeError("close failed")) + mock_client.resource = MagicMock(side_effect=ValueError("allowed_algorithms out of range")) + + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + with pytest.raises(ValueError, match="allowed_algorithms"): + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://api.example.com/mcp", + ) + mock_client.aclose.assert_awaited_once() + + # --------------------------------------------------------------------------- # Scopes are NOT passed as required_scopes (MCP SDK enforces globally) # --------------------------------------------------------------------------- @@ -374,3 +497,58 @@ async def test_verify_token_non_authplane_error_propagates(): tv = AuthplaneTokenVerifier(mock_verifier) with pytest.raises(RuntimeError, match="unexpected"): await tv.verify_token("some_token") + + +@pytest.mark.asyncio +async def test_authplane_mcp_auth_resource_metadata_url_defaults_to_unset(): + """Without the option, nothing is forwarded and the SDK derives the URL.""" + mock_client = MagicMock() + mock_client.resource = MagicMock(return_value=MagicMock(resource="https://api.example.com")) + + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://api.example.com", + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert "resource_metadata_url" not in verifier_kwargs + + +@pytest.mark.asyncio +async def test_authplane_mcp_auth_resource_metadata_url_forwarded(): + """The AS-hosted PRM URL is forwarded to client.resource().""" + as_hosted = "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + mock_client = MagicMock() + mock_client.resource = MagicMock(return_value=MagicMock(resource="https://api.example.com/mcp")) + + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://api.example.com/mcp", + resource_metadata_url=as_hosted, + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert verifier_kwargs["resource_metadata_url"] == as_hosted + + +@pytest.mark.asyncio +async def test_authplane_mcp_auth_rejects_invalid_resource_metadata_url_at_startup(): + """A malformed override fails before create(), like the resource itself. + + Same ordering claim as the resource gate above: the value is advertised to + an unauthenticated caller from a challenge path, so it is diagnosed at + startup without a reachable AS. + """ + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock() + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://api.example.com/mcp", + resource_metadata_url="/.well-known/oauth-protected-resource/mcp", + ) + mock_client_cls.create.assert_not_awaited() diff --git a/authplane-mcp/tests/test_url_elicitation.py b/authplane-mcp/tests/test_url_elicitation.py index fba044e..7a76dc6 100644 --- a/authplane-mcp/tests/test_url_elicitation.py +++ b/authplane-mcp/tests/test_url_elicitation.py @@ -20,7 +20,7 @@ from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams from pydantic import BaseModel -from authplane_mcp import url_elicitation +import authplane_mcp.url_elicitation as url_elicitation from authplane_mcp.auth import _wrap_client_for_elicitation # pyright: ignore[reportPrivateUsage] from authplane_mcp.url_elicitation import ( _resolve_elicitation_id_kwarg, # pyright: ignore[reportPrivateUsage] diff --git a/authplane-mcp/tests/test_verifier.py b/authplane-mcp/tests/test_verifier.py index 88b2f71..71cfd07 100644 --- a/authplane-mcp/tests/test_verifier.py +++ b/authplane-mcp/tests/test_verifier.py @@ -136,3 +136,23 @@ async def test_verify_token_failure_silent_above_debug( assert result is None assert not [r for r in caplog.records if r.name == "authplane_mcp.verifier"] + + +def test_resource_metadata_url_delegates_to_the_resource() -> None: + """The accessor answers from the wrapped resource, not from a rebuild. + + Middleware composing its own challenge reads one value; the MCP SDK's own + 401/403 does not pass through here (``AuthSettings`` carries no + metadata-URL field — see the factory's docstring). + """ + resource = _make_resource_mock("https://api.example.com/mcp") + resource.resource_metadata_url.return_value = ( + "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + ) + + verifier = AuthplaneTokenVerifier(resource) + + assert ( + verifier.resource_metadata_url() + == "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + ) diff --git a/authplane-mcp/tests/test_verifier_dpop_cache.py b/authplane-mcp/tests/test_verifier_dpop_cache.py index 3727a22..786a809 100644 --- a/authplane-mcp/tests/test_verifier_dpop_cache.py +++ b/authplane-mcp/tests/test_verifier_dpop_cache.py @@ -31,7 +31,12 @@ from unittest.mock import AsyncMock, PropertyMock import pytest -from authplane import AuthplaneResource, DPoPReplayDetectedError, VerifiedClaims +from authplane import ( + AuthplaneResource, + DPoPReplayDetectedError, + VerifiedClaims, + get_or_create_verify_cache_from_scope, +) from authplane._dpop_adapter import get_or_create_verify_cache from starlette.requests import Request @@ -260,7 +265,7 @@ async def test_comma_joined_dpop_value_fails_auth() -> None: async def test_htu_origin_from_configured_resource_not_host_header() -> None: """htu's origin comes from the configured resource, never from Host. - Mirrors the TS sibling: an upstream that controls the Host / + An upstream that controls the Host / X-Forwarded-Proto headers must not be able to decide which htu the DPoP proof is validated against. """ @@ -278,11 +283,8 @@ async def test_htu_origin_from_configured_resource_not_host_header() -> None: await verifier.verify_token("valid_token") ctx = mock.verify.await_args.kwargs["dpop_request"] - # Exact htu: the configured resource origin plus the request path, with no - # trace of the attacker-controlled Host / X-Forwarded-Proto headers. A - # prefix or substring check could pass on a URL that merely embeds the - # expected origin. - assert ctx.url == "https://api.example.com/mcp" + assert ctx.url.startswith("https://api.example.com") + assert "attacker" not in ctx.url @pytest.mark.asyncio @@ -543,3 +545,28 @@ def broken_get_http_request() -> Request: with pytest.raises(RuntimeError, match="scope not initialized"): await verifier.verify_token("t") assert mock.verify.await_count == 0 + + +def test_scope_and_request_caches_are_one_slot_under_real_starlette() -> None: + """The shared-slot claim, asserted against Starlette rather than a stand-in. + + The core package pins this with a hand-written ``State`` look-alike, which + can only fail if the look-alike is wrong — it cannot catch Starlette + changing how ``Request.state`` reaches ``scope["state"]``. That is the whole + claim the raw-ASGI helpers rest on, so it is asserted here, where Starlette + is already a dependency. + + If these ever diverge, a raw-ASGI middleware and a Starlette layer above it + each re-enter the inbound DPoP replay store for one ``jti`` — a + ``DPoPReplayDetectedError`` on an honest request. + """ + request = _make_request() + scope = request.scope + + from_request = get_or_create_verify_cache(request) + assert get_or_create_verify_cache_from_scope(scope) is from_request + + # And in the other order, on a fresh request. + other = _make_request() + from_scope = get_or_create_verify_cache_from_scope(other.scope) + assert get_or_create_verify_cache(other) is from_scope diff --git a/authplane/__init__.py b/authplane/__init__.py index 2693d96..e206bf6 100644 --- a/authplane/__init__.py +++ b/authplane/__init__.py @@ -10,6 +10,19 @@ # Client # Authentication +# Raw-ASGI glue. Supported API, unlike the rest of `_dpop_adapter`: these three +# exist for third-party middleware that cannot run under Starlette's +# `BaseHTTPMiddleware` (its response queue stalls a long-lived +# `text/event-stream` body), so the consumer is outside this repository. Leaving +# them reachable only as `authplane._dpop_adapter` would make the sole way to +# use them a private-module import on a `0.x` package, where a rename in a patch +# release breaks an installed resource server at import time — the same hazard +# that promoted `validate_prm_resource_identifier` to this module. +from ._dpop_adapter import ( + get_or_create_verify_cache_from_scope, + raw_request_path_from_scope, + read_dpop_header_from_scope, +) from .auth_provider import AuthProvider, ClientCredentialsProvider from .cache import TokenCache from .client import AuthplaneClient @@ -29,6 +42,7 @@ # Errors (base + the one that changes HTTP status) from .errors import ( + AccessDeniedError, AuthError, AuthplaneError, CircuitOpenError, @@ -49,6 +63,7 @@ InvalidResourceError, InvalidScopeError, InvalidSignatureError, + InvalidTargetError, JWKSFetchError, MetadataFetchError, MissingMetadataEndpointError, @@ -63,6 +78,41 @@ http_status, response_headers_for, www_authenticate, + www_authenticate_challenges, +) + +# Identifier validation. The construction-time gate itself — re-exported +# because the MCP adapters call it before AuthplaneClient.create(), which makes +# it part of their contract with the core SDK: a public name cannot be moved or +# renamed without a deprecation cycle, where an `authplane.internal` path could +# break an installed adapter/core pair at import time with no resolver signal. +# +# "prm_resource_identifier", not "resource_indicator": the gate requires a host, +# which RFC 8707 §2 does not — that section requires an absolute URI (RFC 3986 +# §4.3), which `urn:example:api` is. The host requirement is RFC 9728 §3's and +# binds a resource that publishes PRM. Since the same deprecation cycle would +# apply to a wrong name, it is worth spending the accuracy before the first +# release that carries it. +# +# ``validate_issuer_identifier`` is exported for the same reason and alongside +# it, rather than left reachable only as ``authplane.internal``. ``build_prm`` +# is public and now raises ``InvalidIssuerError``, and that class is exported +# from here — so without the predicate a consumer who wants to check its +# configuration before constructing anything has the error but no way to +# provoke it except by calling a builder, or by importing a private module on a +# ``0.x`` package. The pair is also the thing to keep symmetric: one identifier +# with a public gate and one without is how the two drifted apart in the first +# place. +# +# ``validate_resource_metadata_url`` joins them for the third operator-supplied +# URL the SDK gates at construction: the ``resource_metadata_url`` override on +# ``AuthplaneClient.resource(...)``. The MCP adapters check it ahead of +# ``AuthplaneClient.create()``, through this public name, for the same reason +# they check the resource identifier there. +from .internal.urls import ( + validate_issuer_identifier, + validate_prm_resource_identifier, + validate_resource_metadata_url, ) # Configuration @@ -76,6 +126,7 @@ __all__ = [ "SUPPORTED_DPOP_ALGORITHMS", "ASCredentials", + "AccessDeniedError", "AuthError", "AuthProvider", "AuthplaneClient", @@ -110,6 +161,7 @@ "InvalidResourceError", "InvalidScopeError", "InvalidSignatureError", + "InvalidTargetError", "JWKSFetchError", "MetadataFetchError", "MissingMetadataEndpointError", @@ -126,7 +178,14 @@ "VerifiedDPoPProof", "VerifierRuntimeError", "__version__", + "get_or_create_verify_cache_from_scope", "http_status", + "raw_request_path_from_scope", + "read_dpop_header_from_scope", "response_headers_for", + "validate_issuer_identifier", + "validate_prm_resource_identifier", + "validate_resource_metadata_url", "www_authenticate", + "www_authenticate_challenges", ] diff --git a/authplane/_dpop_adapter.py b/authplane/_dpop_adapter.py index 4de88ed..4416836 100644 --- a/authplane/_dpop_adapter.py +++ b/authplane/_dpop_adapter.py @@ -7,31 +7,52 @@ * a concrete ``DPoPRequestContext`` shape built from the active request, * a case-insensitive ``DPoP`` header reader, * a path reader that preserves the on-wire percent-encoding (so - ``htu`` binds identically against the TS sibling), and + ``htu`` binds against the target the client actually signed), and * a per-request verify-task cache anchored on ``request.state`` so repeated ``verify_token`` invocations within one HTTP request do not re-enter the inbound DPoP replay store. -These live here so the two adapters do not drift. The module is -underscore-prefixed and intentionally not re-exported from -``authplane.__init__``; nothing outside the adapter packages should -import from it. +These live here so the two adapters do not drift. The module stays +underscore-prefixed and the request-based helpers are internal to the +adapter packages. + +The three ``*_from_scope`` helpers are the exception: they are +re-exported from ``authplane.__init__`` and are supported API. They +exist for third-party raw-ASGI middleware, which is code outside this +repository — so leaving them reachable only as +``authplane._dpop_adapter`` would mean the only way to use them is to +import a private module of a ``0.x`` package, and a rename in any patch +release would break an installed resource server at import time. That is +the same reasoning that promoted ``validate_prm_resource_identifier`` to +the package root. Import them from ``authplane``, not from here. The core ``authplane-sdk`` wheel does *not* take a runtime dependency on Starlette. The helpers below duck-type the few attributes they need; the ``_RequestLike`` Protocol pins that contract structurally so the adapter packages can pass ``starlette.requests.Request`` and type-check without forcing Starlette into the core's import graph. + +Each helper comes in two shapes: a request-based one taking +``_RequestLike``, and a ``*_from_scope`` one taking the raw ASGI +``scope`` mapping. The scope shape exists because a resource server +protecting a long-lived ``text/event-stream`` body cannot go through +Starlette's ``BaseHTTPMiddleware`` — it pumps the response through an +internal queue, which buffers and stalls the stream — so that +middleware has to be raw ASGI, where there is no ``Request`` and no +``request.state``. Both shapes route through one implementation of +each rule, so the two entry points cannot drift from each other. """ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol +from collections.abc import MutableMapping +from typing import TYPE_CHECKING, Any, Protocol, cast from .errors import DPoPMultipleProofsError if TYPE_CHECKING: import asyncio + from collections.abc import Iterable, Mapping from .verifier import VerifiedClaims @@ -40,8 +61,11 @@ "BuiltDPoPRequestContext", "VerifyTaskCache", "get_or_create_verify_cache", + "get_or_create_verify_cache_from_scope", "raw_request_path", + "raw_request_path_from_scope", "read_dpop_header", + "read_dpop_header_from_scope", ] @@ -52,6 +76,20 @@ (not per ``Request`` instance), so a ``WeakKeyDictionary[Request, ...]`` would split the cache across middleware-built and handler-built ``Request`` objects pointing at the same scope. + +The same name is the key used inside ``scope["state"]`` by +:func:`get_or_create_verify_cache_from_scope`; Starlette's +``request.state`` is a thin wrapper over that dict, so both accessors +address one slot. +""" + + +SCOPE_STATE_KEY = "state" +"""ASGI ``scope`` key holding the per-connection state mapping. + +The lifespan-state extension defines it, and Starlette's +``request.state`` wraps ``scope.setdefault("state", {})`` — which is +what lets the request-based and scope-based cache accessors agree. """ @@ -112,30 +150,28 @@ def __init__(self, method: str, url: str, proof: str | None) -> None: self.proof = proof -def read_dpop_header(request: _RequestLike) -> str | None: - """Read the ``DPoP`` request header, enforcing RFC 9449 §4.3 #1. +def _single_proof(values: Iterable[str]) -> str | None: + """Reduce the raw ``DPoP`` header values to the one proof, or reject. - Returns the single proof JWT when exactly one non-empty ``DPoP`` - header value is present, or ``None`` when no ``DPoP`` header is - present. Raises :class:`DPoPMultipleProofsError` when the request - carries more than one ``DPoP`` header value. + The RFC 9449 §4.3 #1 cardinality rule itself, shared by both header + readers so the request-based and scope-based entry points cannot + disagree about what counts as one proof. Two on-wire shapes are rejected: - 1. Multiple ``DPoP`` headers on the request (``headers.getlist`` - returns ≥ 2 non-empty entries). + 1. Multiple ``DPoP`` headers on the request (``values`` yields ≥ 2 + non-empty entries). 2. A single ``DPoP`` header value pre-joined with ``,`` by an upstream proxy or framework — RFC 9110 §5.3 permits combining repeated headers this way. JWS compact serialization never contains a literal comma, so split-on-comma is sound. - Trimming and empty-piece filtering mirror the cross-language - cardinality boundary so a request carrying ``"DPoP: "`` (whitespace - only) is treated as header-absent rather than as one value. + Trimming and empty-piece filtering are what make a request carrying + ``"DPoP: "`` (whitespace only) count as header-absent rather than as + one value. """ - raw_values = request.headers.getlist("dpop") filtered: list[str] = [] - for raw in raw_values: + for raw in values: trimmed = raw.strip() if not trimmed: continue @@ -153,23 +189,150 @@ def read_dpop_header(request: _RequestLike) -> str | None: return filtered[0] if filtered else None +def read_dpop_header_from_scope(scope: Mapping[str, Any]) -> str | None: + """Read the ``DPoP`` header out of a raw ASGI ``scope``. + + Pure function of ``scope``, for middleware that has no ``Request`` + to hand. ``scope["headers"]`` is the ASGI iterable of + ``(name, value)`` byte pairs; names arrive lowercased per the + spec, and are lowercased again here so a server that does not is + not a silent auth bypass. Values decode as latin-1, matching the + HTTP/1.1 field-value encoding Starlette's ``Headers`` uses. + + Same return and rejection contract as :func:`read_dpop_header`. + """ + values: list[str] = [] + for name, value in scope.get("headers") or (): + if bytes(name).lower() == b"dpop": + values.append(bytes(value).decode("latin-1")) + return _single_proof(values) + + +def read_dpop_header(request: _RequestLike) -> str | None: + """Read the ``DPoP`` request header, enforcing RFC 9449 §4.3 #1. + + Returns the single proof JWT when exactly one non-empty ``DPoP`` + header value is present, or ``None`` when no ``DPoP`` header is + present. Raises :class:`DPoPMultipleProofsError` when the request + carries more than one ``DPoP`` header value; see + :func:`_single_proof` for the shapes that count as more than one. + + Reads through ``request.headers`` rather than ``request.scope``: + ``_RequestLike`` is a structural Protocol, so an integration may + well satisfy ``.headers`` without carrying ASGI-shaped raw headers + in its ``scope``. Raw ASGI callers want + :func:`read_dpop_header_from_scope`. + """ + return _single_proof(request.headers.getlist("dpop")) + + +def _decode_raw_path(raw: bytes | bytearray) -> str: + """Decode ``scope["raw_path"]`` into the path component of ``htu``. + + Percent-encoding is preserved — ASGI populates ``scope["path"]`` + percent-*decoded*, but the client signed its ``htu`` over the + on-wire target, so a path containing e.g. ``%2F`` has to bind here + exactly as it appeared in the request line. + + Everything from the first ``?`` on is dropped: RFC 9449 §4.2 + defines ``htu`` as the target URI *without* query or fragment, and + the ASGI spec's wording on whether ``raw_path`` carries the query + string has historically been read both ways. Every server in use + strips it already — uvicorn on h11 and on httptools, and + Starlette's ``TestClient`` — so this is hardening against a + conforming-but-different server rather than a fix for a live + break, and it is a no-op where the server already stripped it. + """ + return bytes(raw).partition(b"?")[0].decode("latin-1") + + +def raw_request_path_from_scope(scope: Mapping[str, Any]) -> str: + """Return the request path with percent-encoding preserved, from a raw ASGI ``scope``. + + Pure function of ``scope``, for middleware that has no ``Request`` + to hand. Falls back to the percent-decoded ``scope["path"]`` for + the rare ASGI server that omits ``raw_path``. + + On that fallback branch the two entry points agree because + Starlette's ``request.url.path`` is ``scope["path"]`` verbatim, + including under a ``Mount`` with a non-empty ``root_path`` — + measured on Starlette 1.3.1, since the two have historically been + read as disagreeing about whether ``root_path`` is a prefix. Should + a future version prefix it, the two would produce different ``htu`` + paths under a sub-mount on a server that omits ``raw_path``; every + server this SDK targets sets ``raw_path``, so the branch is + unreachable in practice. + """ + raw = scope.get("raw_path") + if isinstance(raw, (bytes, bytearray)): + return _decode_raw_path(raw) + path = scope.get("path") + return path if isinstance(path, str) else "" + + def raw_request_path(request: _RequestLike) -> str: """Return the request path with percent-encoding preserved. - ASGI populates ``scope["path"]`` as the percent-decoded path, but - the client signed its DPoP ``htu`` over the on-wire (percent-encoded) - target. Prefer ``scope["raw_path"]`` (raw bytes) so a path - containing e.g. ``%2F`` binds identically here and in the TS - sibling, which builds ``htu`` from ``IncomingMessage.url``. Falls - back to the decoded path for the rare ASGI server that omits - ``raw_path``. + Prefers ``scope["raw_path"]`` and decodes it through + :func:`_decode_raw_path`, which is where the percent-encoding and + query-string rules live. Falls back to ``request.url.path`` — not + to ``scope["path"]``, so that a ``_RequestLike`` implementation + carrying a non-ASGI ``scope`` keeps reporting the path its own URL + reports. """ raw = request.scope.get("raw_path") if isinstance(raw, (bytes, bytearray)): - return bytes(raw).decode("latin-1") + return _decode_raw_path(raw) return request.url.path +def get_or_create_verify_cache_from_scope(scope: MutableMapping[str, Any]) -> VerifyTaskCache: + """Return the per-request verify-task cache for a raw ASGI ``scope``. + + Anchored on ``scope["state"]``, which is the dict Starlette's + ``request.state`` wraps — so a raw-ASGI middleware and any + Starlette layer above it in the same request share one cache + instead of each re-entering the inbound DPoP replay store, and + this helper is interchangeable with + :func:`get_or_create_verify_cache` on the requests where both + apply. + + The cache is per *request* only insofar as the server gives each + request its own ``state`` mapping — the ASGI lifespan-state + extension requires a shallow copy per connection, and the + request-based accessor already depends on exactly that invariant. + A caller on a server that cannot promise it should keep the cache + itself and skip this helper; the header and path readers above are + the parts of this module a raw-ASGI integration actually needs. + """ + # Only create when absent, and accept any mutable mapping. The ASGI + # lifespan-state extension specifies `scope["state"]` as a *mapping*, not a + # `dict`, so a server handing over some other MutableMapping used to get its + # state object replaced — which drops whatever the application put in + # lifespan state for the rest of the request, and breaks the invariant this + # helper is built on: Starlette's `Request.state` uses `scope.setdefault`, + # so if a `Request` was constructed first, `State` would wrap the original + # mapping while this helper pointed at a fresh dict. The two layers would + # then each re-enter the inbound DPoP replay store for the same `jti`, + # which is `DPoPReplayDetectedError` on an honest request — precisely the + # failure the shared slot exists to prevent. + existing: object = scope.get(SCOPE_STATE_KEY) + state: MutableMapping[str, Any] + if isinstance(existing, MutableMapping): + # `isinstance` cannot check the parameters; the ASGI extension specifies + # str keys, and a server that violates that would break every other + # reader of this slot too. + state = cast("MutableMapping[str, Any]", existing) + else: + state = {} + scope[SCOPE_STATE_KEY] = state + cache: VerifyTaskCache | None = state.get(REQ_STATE_KEY) + if cache is None: + cache = {} + state[REQ_STATE_KEY] = cache + return cache + + def get_or_create_verify_cache(request: _RequestLike) -> VerifyTaskCache: """Return the per-request verify-task cache, creating it on first access. @@ -177,6 +340,9 @@ def get_or_create_verify_cache(request: _RequestLike) -> VerifyTaskCache: disappears with it; cross-request replay protection is preserved. The adapters' tests use this accessor to inspect the cache without manipulating ``request.state`` or the stringly-typed slot directly. + + Raw ASGI callers want :func:`get_or_create_verify_cache_from_scope`, + which reaches the same slot through ``scope["state"]``. """ state = request.state cache: VerifyTaskCache | None = getattr(state, REQ_STATE_KEY, None) diff --git a/authplane/client.py b/authplane/client.py index 7d2235a..01ff8de 100644 --- a/authplane/client.py +++ b/authplane/client.py @@ -14,15 +14,18 @@ from .errors import CircuitOpenError, DPoPError, MetadataFetchError, ServerError from .internal import ( DocumentFetcher, + FetchResult, JWKSCache, MetadataCache, build_metadata_url, - validate_resource_indicator, + validate_prm_resource_identifier, + validate_resource_metadata_url, ) from .net import FetchSettings from .net.ssrf import SSRFError from .oauth import ( IntrospectionResponse, + IntrospectionRevocation, TokenExchangeOptions, TokenResponse, client_credentials_grant, @@ -32,7 +35,6 @@ ) if TYPE_CHECKING: - from .oauth.types import IntrospectionRevocation from .verifier import AuthplaneResource from .verifier.verifier import RevocationChecker @@ -76,7 +78,6 @@ def __init__(self) -> None: self._circuit_breaker: CircuitBreaker = CircuitBreaker() self._dev_mode: bool = False self._dpop: DPoPProvider | None = None - self._jwks_uri: str | None = None self._jwks_refresh_seconds: int = 300 self._metadata_refresh_seconds: int = 3600 @@ -99,7 +100,9 @@ async def create( ) -> Self: """Create and initialize the client. - Discovers AS metadata and starts JWKS background refresh. + Discovers AS metadata and primes the JWKS cache. Refreshes are driven by + traffic, not by a task started here: the first background refresh is + spawned by a read that finds the document past 80% of its TTL. Args: issuer: Authorization-server issuer URL (the prefix RFC 8414 metadata @@ -116,8 +119,10 @@ async def create( fetch_settings: Explicit :class:`FetchSettings` override. When None, derived from ``dev_mode``. jwks_refresh_seconds: Background JWKS refresh interval (must be > 0). - metadata_refresh_seconds: Background metadata refresh interval - (must be > 0). + metadata_refresh_seconds: AS metadata re-read interval (must be > 0). + Governs verification traffic as well as outbound AS calls: once + the interval has elapsed, the next ``verify()`` re-reads + metadata and follows a rotated ``jwks_uri``. cache_ttl_buffer_seconds: Safety margin subtracted from each token's lifetime before the entry is considered expired. Default 30s. default_ttl_seconds: Fallback lifetime applied when the AS omits @@ -133,8 +138,18 @@ async def create( circuit trips. Default 30s. Raises: - InvalidIssuerError: If ``issuer`` carries a query or fragment - component (RFC 8414 §2 forbids both). This fails fast at + InvalidIssuerError: If ``issuer`` carries a query or a fragment + component (RFC 8414 §2 forbids both), contains whitespace or + a control character (RFC 3986 §2 — ``urlsplit`` removes tab, + CR and LF from anywhere in the input, so the derived fetch + target would differ from the identifier the AS-metadata + comparison is seeded with), is not an absolute URL + with a scheme and a host (RFC 8414 §2 requires a URL; §3.1 + inserts the well-known suffix after the host, so without one + there is no derivable metadata URL), or carries a userinfo + subcomponent (RFC 9110 §4.2.4 — the issuer is published to + unauthenticated callers in the PRM document's + ``authorization_servers`` member). This fails fast at construction, before any network fetch. Subclasses ``ValueError``, so an existing ``except ValueError`` still catches it. """ @@ -218,83 +233,68 @@ async def _initialize_caches(self) -> None: allow_http=self._fetch_settings.allow_http, refresh_seconds=self._metadata_refresh_seconds, document_type="metadata", - on_change=self._on_metadata_changed, ) # Security-first: JWKS location is always discovery-derived; we do not - # fall back to a synthesized default path anymore. - self._jwks_uri = await self._metadata_cache.get_jwks_uri() + # fall back to a synthesized default path anymore. Read once here so a + # metadata document that is unreachable, rejected, or silent about + # ``jwks_uri`` fails ``create()`` as a metadata problem, rather than + # reaching the operator wrapped in whatever the first key-set fetch + # happened to raise. The value is not retained: every fetch resolves it + # again from the document current at that moment. + jwks_uri = await self._metadata_cache.get_jwks_uri() logger.info( "JWKS URI discovered from AS metadata", - extra={"jwks_uri": self._jwks_uri}, + extra={"jwks_uri": jwks_uri}, ) - # Start JWKS cache - jwks_fetcher = DocumentFetcher( - self._jwks_uri, - document_type="jwks", - settings=self._fetch_settings, - max_size=65536, # 64KB for JWKS - ) self._jwks_cache = JWKSCache( - fetcher=jwks_fetcher.fetch, + fetcher=self._fetch_jwks, refresh_seconds=self._jwks_refresh_seconds, document_type="jwks", + # The key set's TTL says when its *contents* may have changed. It + # says nothing about the AS having published them somewhere else, + # which is what a `jwks_uri` rotation is — so the cache is given the + # means to compare where its keys came from against where metadata + # currently says they live. + source_resolver=self._metadata_cache.get_jwks_uri, ) # Prime the cache await self._jwks_cache.get() - async def _on_metadata_changed( - self, - old_metadata: dict[str, object], - new_metadata: dict[str, object], - ) -> None: - """Handle metadata changes (e.g., JWKS URI rotation).""" - # Log introspection_endpoint changes - old_introspection = old_metadata.get("introspection_endpoint") - new_introspection = new_metadata.get("introspection_endpoint") - if old_introspection != new_introspection: - logger.info( - "introspection_endpoint changed in AS metadata", - extra={ - "old_introspection_endpoint": old_introspection, - "new_introspection_endpoint": new_introspection, - }, - ) - - new_jwks_uri = new_metadata.get("jwks_uri") - if self._jwks_uri == new_jwks_uri: - return - - logger.warning( - "JWKS URI changed in AS metadata, restarting JWKS cache", - extra={"old_jwks_uri": self._jwks_uri, "new_jwks_uri": new_jwks_uri}, + async def _fetch_jwks(self) -> FetchResult: + """Fetch the key set from wherever the current AS metadata says it lives. + + The location is resolved per fetch instead of being captured at + construction and rebound when the document changes. There is then no + second cache object to swap: a rotation takes effect on the next key-set + fetch, whichever path reaches it first. + + Resolving the URI and committing the key set are separate awaits, so a + metadata refresh that commits a rotated document in between still + leaves a key set fetched from the withdrawn URI in the cache. What no + longer follows is that it keeps being served: :class:`JWKSCache` is + given the means to compare where its key set came from against where + metadata currently says the keys live, and refetches when the two + disagree rather than waiting out its own TTL. Recovery is therefore not + contingent on the rotation also introducing a new `kid` — a re-key + under a stable `kid` produces no miss to recover on. + + The URI comes from the validated document, so a metadata response that + fails validation cannot redirect key retrieval; and if the newly + advertised URI is unreachable, the fetch fails and :class:`JWKSCache` + keeps serving the keys it already had rather than being left empty. + """ + if self._metadata_cache is None: # pragma: no cover - set before this is reachable + raise MetadataFetchError("authplane: AS metadata cache is not initialized") + jwks_uri = await self._metadata_cache.get_jwks_uri() + jwks_fetcher = DocumentFetcher( + jwks_uri, + document_type="jwks", + settings=self._fetch_settings, + max_size=65536, # 64KB for JWKS ) - - # Rotation is applied eagerly so subsequent verifications fetch from the - # newly advertised key set rather than silently continuing on stale metadata. - if self._jwks_cache is not None: - await self._jwks_cache.aclose() - - # Update URI and restart JWKS cache - self._jwks_uri = str(new_jwks_uri) if new_jwks_uri else None - if self._jwks_uri is not None: - jwks_fetcher = DocumentFetcher( - self._jwks_uri, - document_type="jwks", - settings=self._fetch_settings, - max_size=65536, - ) - self._jwks_cache = JWKSCache( - fetcher=jwks_fetcher.fetch, - refresh_seconds=self._jwks_refresh_seconds, - document_type="jwks", - ) - await self._jwks_cache.get() - logger.info( - "JWKS cache restarted with new URI", - extra={"jwks_uri": self._jwks_uri}, - ) + return await jwks_fetcher.fetch() # ----- Public API: Token operations ----- @@ -413,14 +413,27 @@ def resource( revocation_checker: "RevocationChecker | IntrospectionRevocation | None" = None, fail_closed: bool = False, inbound_dpop: InboundDPoPOptions | None = None, + resource_metadata_url: str | None = None, ) -> "AuthplaneResource": """Create a resource scoped to a URI. The resource uses this client's JWKS cache and metadata. - When *fail_closed* is True, the verifier rejects tokens when the - revocation checker raises an exception instead of the default - fail-open behaviour. + When a *revocation_checker* is configured and the check itself fails + — introspection unreachable, an error response, a custom checker + raising — **the token is let through** and a warning is logged. Pass + ``fail_closed=True`` to refuse it instead. The default keeps the + resource server answering while the AS is unreachable, which is what + local JWT validation is for; ``fail_closed=True`` trades that + availability for never honouring a token it could not confirm. See + :class:`~authplane.IntrospectionRevocation` for the trade-off in + full. + + ``fail_closed`` is only consulted when a revocation check actually + runs. Both halves of that pairing are logged at construction when + they point the surprising way: setting the flag without a checker + (nothing to fail), and configuring a checker while leaving the + fail-open default in place. Inbound DPoP enforcement (RFC 9449 § 7) is configured per-resource via :class:`InboundDPoPOptions` per RFC 9728 § 2. Passing any @@ -430,14 +443,45 @@ def resource( ``dpop_bound_access_tokens_required``; omitting the argument keeps DPoP fields out of PRM entirely. + ``resource_metadata_url`` overrides the URL advertised as RFC 9728 + §5.1 ``resource_metadata``, for a deployment where the Protected + Resource Metadata document is served by the authorization server + rather than by this resource — authserver >= 0.2.0 serves one per + registered Resource. It changes what + :meth:`~authplane.verifier.AuthplaneResource.resource_metadata_url` + returns, and nothing else: the resource identifier, the token + ``aud`` check, and the document :meth:`prm_response + ` builds are + untouched. Leave it unset — the default — and the advertised URL is + the RFC 9728 §3.1 derivation of the resource identifier, byte for + byte what it was before this option existed. + Raises: - InvalidResourceError: If *resource* carries a fragment component. - RFC 8707 §2 forbids one in a resource indicator. Rejected here, - at construction, for the same reason ``create()`` rejects a - malformed issuer — the alternative is surfacing it from - ``prm_url()`` while composing an RFC 9728 challenge, i.e. from - inside a 401 response path. Subclasses ``ValueError``, so an - existing ``except ValueError`` still catches it. + InvalidResourceError: If *resource* carries a fragment component + (RFC 8707 §2 forbids one in a resource indicator), contains + whitespace or a control character (RFC 3986 §2; parsing would + strip it, diverging from the identifier stored verbatim, + RFC 9728 §3.3), is not an absolute URL with a scheme and a + host (RFC 8707 §2 requires an absolute URI; RFC 9728 §3 + derives the metadata URL by inserting the well-known suffix + after the host), carries a userinfo subcomponent + (RFC 9110 §4.2.4 — the identifier reaches a 401 challenge, an + ``htu`` origin, and log records, so embedded credentials are + rejected outright), or carries a port that does not parse + (RFC 3986 §3.2.3). Rejected here, at construction, for the + same reason ``create()`` rejects a malformed issuer — the + alternative is surfacing it from ``prm_url()`` while composing + an RFC 9728 challenge, i.e. from inside a 401 response path. + Subclasses ``ValueError``, so an existing ``except + ValueError`` still catches it. Also raised when + *resource_metadata_url* is not an absolute ``http`` / + ``https`` URL, or carries a fragment, whitespace or a control + character, a userinfo subcomponent, a ``"`` or a ``\\``, or a + port that does not parse — see + :func:`~authplane.validate_resource_metadata_url`. Same + reasoning: the value is advertised in a challenge served to an + unauthenticated caller, so a bad one is a startup failure, not + a 401-path one. ValueError: If *allowed_algorithms* contains an algorithm outside ``("RS256", "ES256")``. Raised by :class:`~authplane.verifier.AuthplaneResource`'s constructor, @@ -454,7 +498,14 @@ def resource( # test_client_resource_rejects_fragment_at_construction, which asserts # the invoking frame — deleting this line turns that test red rather # than changing behaviour. - validate_resource_indicator(resource) + validate_prm_resource_identifier(resource) + + # Same pairing, same reason, for the override: redundant for the + # guarantee (the constructor gates it too) and load-bearing for the + # traceback. Kept on the same line of defence as the identifier so the + # two configured URLs of this factory are diagnosed together. + if resource_metadata_url is not None: + validate_resource_metadata_url(resource_metadata_url) # fail_closed is only consulted when a revocation check runs; setting # it without a checker means no revocation check happens at all, which @@ -466,6 +517,33 @@ def resource( extra={"resource": resource}, ) + # The mirror image, and the more surprising of the two: an operator who + # configured a revocation checker asked for a stricter posture, and the + # default answers an unanswerable "is this token still valid?" with yes. + # Say so at startup rather than only per failed check, where it arrives + # after the token was already accepted. + # + # INFO, not WARNING, and the difference is deliberate: the mirror case + # above is a mistake — the flag does nothing — while this one is a + # documented, defensible choice. `fail_closed=False` is the default and + # the user guide recommends keeping it when availability matters, so an + # operator who read that section and chose it would have no way to + # acknowledge a WARNING short of filtering this logger — the same logger + # carrying the mistake above and the per-check fail-open warning. The + # realistic outcome is that they filter it and lose all three. + if revocation_checker is not None and not fail_closed: + logger.info( + "Revocation checking is fail-open: a failed revocation check accepts " + "the token. Pass fail_closed=True to reject instead", + extra={"resource": resource}, + ) + + # The introspection-credentials warning is not duplicated here the way + # validate_prm_resource_identifier is: that one raises, so the extra + # frame buys a traceback at the operator's own line, while this one + # logs and a second copy would just be a duplicate record at startup. + # It lives on AuthplaneResource.__init__, which every path reaches. + return AuthplaneResource( client=self, resource=resource, @@ -475,6 +553,7 @@ def resource( revocation_checker=revocation_checker, fail_closed=fail_closed, inbound_dpop=inbound_dpop, + resource_metadata_url=resource_metadata_url, ) # ----- Internal: endpoint resolution ----- @@ -509,7 +588,10 @@ def _handle_failure(self, exc: Exception) -> None: if isinstance(exc, SSRFError): return # Transport failures and server-side failures are the outage signals the - # breaker should react to. + # breaker should react to. Every other AuthError — including the 403 + # `access_denied` a non-allowlisted cross-client exchange gets and the + # 400 `invalid_target` for a resource indicator that matches nothing — + # is the AS answering, not the AS failing, and stays out of the count. if isinstance(exc, (ServerError, httpx.RequestError)): self._circuit_breaker.record_failure() @@ -539,6 +621,11 @@ def issuer(self) -> str: def dev_mode(self) -> bool: return self._dev_mode + @property + def can_authenticate(self) -> bool: + """True when the client holds AS credentials it can introspect with.""" + return self._auth is not None + @property def dpop(self) -> DPoPProvider | None: return self._dpop diff --git a/authplane/credentials.py b/authplane/credentials.py index d490729..4f1c017 100644 --- a/authplane/credentials.py +++ b/authplane/credentials.py @@ -12,8 +12,10 @@ class ASCredentials: (RFC 8693). Configuring them once at the verifier level means both features share the same identity without repeating the secret. - Both fields are required; omit ``ASCredentials`` entirely for unauthenticated - introspection (accepted by some AS implementations but not recommended). + Both fields are required and must be non-empty; omit ``ASCredentials`` + entirely for unauthenticated introspection (an RFC 7662 shape some AS + implementations accept — authserver >= 0.1.2 answers ``active: false`` to + it, so every token is rejected as revoked). Example:: @@ -28,7 +30,19 @@ class ASCredentials: Attributes: client_id: OAuth client identifier registered with the AS. client_secret: Corresponding client secret. + + Raises: + ValueError: If either field is empty. An empty secret authenticates + as a public client, which cannot introspect at all, and the + failure would otherwise surface per request as a fail-open + warning or, under ``fail_closed=True``, as every token rejected. """ client_id: str client_secret: str + + def __post_init__(self) -> None: + if not self.client_id: + raise ValueError("authplane: ASCredentials.client_id must not be empty") + if not self.client_secret: + raise ValueError("authplane: ASCredentials.client_secret must not be empty") diff --git a/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index 0dcd653..c6083da 100644 --- a/authplane/docs/user-guide.md +++ b/authplane/docs/user-guide.md @@ -17,6 +17,7 @@ The SDK is built around these RFCs: ### Requirements - Python 3.11+ +- Tested against authserver 0.2.0; introspection-based revocation needs authserver ≥ 0.1.2 ### Installation @@ -73,13 +74,14 @@ client = await AuthplaneClient.create( 2. The metadata document must contain an `issuer` that exactly matches the normalized configured issuer. 3. Required discovered endpoints are trusted only from metadata. The SDK does not synthesize fallback token, introspection, or revocation endpoints. 4. The discovered `jwks_uri` is fetched and cached. -5. Background refresh tasks are started for metadata and JWKS. +5. Metadata and JWKS refresh on demand rather than on a timer: a cache re-reads its document when a lookup finds its TTL (`metadata_refresh_seconds`, `jwks_refresh_seconds`) elapsed, and refreshes ahead of expiry in the background when a lookup lands past 80% of it. Verifying a token counts as a lookup for both, so a resource server that never calls an AS endpoint still re-reads metadata and follows a rotated `jwks_uri`. Two bounds are worth knowing about. A token whose `kid` is not in the cached key set forces a metadata re-read, and that is floored at one per `min(metadata_refresh_seconds, 60)` seconds — the `kid` on an unverified token is attacker-controlled, so without a floor invalid tokens would drive discovery traffic at your AS one-for-one. And a *failed* refresh backs off for `max(1, min(30, refresh_seconds))` seconds rather than being retried by the next caller, which keeps an unreachable endpoint from costing a full timeout per verification; for the JWKS cache that means a blip at a newly advertised `jwks_uri` can delay a rotation by up to `min(30, jwks_refresh_seconds)` seconds. +6. `jwks_uri` is read from the metadata document on every key-set fetch rather than captured at creation, so a rotation takes effect on the next fetch with no window in which keys are still being pulled from the withdrawn URI. A token whose `kid` is absent from the cached key set re-reads metadata as well, so a rotation is followed on the request that first needs the new key rather than at the next interval boundary. If initial metadata or JWKS fetch fails and there is no cached value, the SDK raises `MetadataFetchError` or `JWKSFetchError`. ### Authentication to the AS -If you pass `ASCredentials`, the SDK wraps them in `ClientCredentialsProvider` and uses HTTP Basic authentication for AS-facing operations. +If you pass `ASCredentials`, the SDK wraps them in `ClientCredentialsProvider` and uses HTTP Basic authentication for AS-facing operations. Both fields must be non-empty — `ASCredentials` raises `ValueError` otherwise, because an empty secret authenticates as a public client, which cannot introspect at all. ```python from authplane import ASCredentials @@ -87,6 +89,8 @@ from authplane import ASCredentials creds = ASCredentials(client_id="my-resource", client_secret="s3cret") ``` +For introspection the client behind these credentials must be confidential and either the client the token was issued to or a runtime-client of the resource — see [Revocation Checking](#5-revocation-checking). + ### Cleanup Always call `await client.aclose()` during shutdown. @@ -101,7 +105,7 @@ res = client.resource( scopes=["read", "write"], allowed_algorithms=["RS256", "ES256"], clock_skew_seconds=30, - fail_closed=False, # default; set True to reject tokens when revocation check fails + fail_closed=False, # default: a failed revocation check accepts the token; True refuses it ) ``` @@ -221,9 +225,10 @@ claims.require_scope("tools/query") org_id = claims.raw.get("org_id") actor = claims.act -may_act = claims.may_act ``` +`claims.may_act` is deprecated and emits `DeprecationWarning`: authserver 0.2.0 no longer issues `may_act`; the accessor is removed in the next minor. + Because the object is immutable, post-verification mutations cannot change later authorization decisions. ## 5. Revocation Checking @@ -238,6 +243,7 @@ from authplane import IntrospectionRevocation res = client.resource( resource="https://api.example.com", revocation_checker=IntrospectionRevocation(), + fail_closed=True, # refuse tokens the introspection call could not confirm ) ``` @@ -245,21 +251,34 @@ This uses the RFC 7662 introspection endpoint after local JWT verification. Important behavior: -- by default it is **fail-open**: if introspection fails, the token is accepted and a warning is logged on every `verify()` -- set `fail_closed=True` to reject tokens when the revocation check fails -- the client must have AS credentials configured +- the client must have AS credentials configured, and the client behind them must be **confidential** and either the client the token was issued to or a runtime-client of the resource named in the token's `aud`: + + ```bash + authserver admin resource runtime-client add --client-id --slug + ``` + +- a public client cannot introspect at all. Since authserver 0.1.2 an unauthenticated call, or one from a client that is neither the issuer nor a runtime-client, is answered with `{"active": false}` — not an error — so every token is rejected as revoked under both failure policies. The SDK warns at `client.resource(...)` when `IntrospectionRevocation` is configured on a client created without `auth=`, and once per resource the first time `active=false` comes back for a token that passed local verification - the AS metadata must expose `introspection_endpoint` -- `fail_closed` has no effect when `revocation_checker` is `None` — there is no check to fail. The SDK logs a warning at resource construction if you set one without the other, so the no-op configuration is visible rather than silent. +- if the check fails, `fail_closed` decides whether the token is accepted or rejected — see below + +### Failure policy: fail-open vs fail-closed + +**An introspection error lets the token through unless you pass `fail_closed=True`.** The flag defaults to `False`, and that default is a deliberate trade, not an oversight: fail-closed means an introspection outage takes the resource server down with the AS, and local JWT validation exists precisely so the resource server keeps answering while the AS is unreachable. + +Which direction is right depends on what an unconfirmed token authorises. Pass `fail_closed=True` when it would authorise something you cannot take back — writes, payments, executing statements on the caller's behalf. Keep the default when serving through an AS outage matters more than closing the window in which an already-revoked token still works. ```python -# Fail-closed: reject tokens when introspection is unavailable +# Fail-open: the default — an unreachable AS does not take the resource server with it res = client.resource( resource="https://api.example.com", revocation_checker=IntrospectionRevocation(), - fail_closed=True, ) ``` +- under the default, a failed check accepts the token and logs a warning on every `verify()` +- the SDK logs it at INFO when a resource is built through `client.resource(...)` with a revocation checker configured fail-open, so the posture in effect shows up in startup output rather than only here. INFO rather than a warning: keeping the default is a documented choice, not a misconfiguration — the no-op pairing below is the one that warns +- `fail_closed` has no effect when `revocation_checker` is `None` — there is no check to fail. The SDK logs a warning when a resource is built through `client.resource(...)` with one set and not the other, so the no-op configuration is visible rather than silent. + ### Custom revocation checker ```python @@ -274,8 +293,8 @@ Return `True` to reject the token. Important behavior: -- by default it is **fail-open**: if the custom revocation callback fails, the token is accepted and the error is logged -- set `fail_closed=True` on `client.resource()` to reject tokens when the checker raises an exception +- a callback that raises lets the token through and logs the error; pass `fail_closed=True` on `client.resource()` to refuse it instead +- the same trade-off applies as for introspection, and the same construction-time warning fires when a custom checker is configured fail-open ## 6. Token Operations @@ -344,6 +363,23 @@ print(result.cnf_jkt) Token exchange responses only accept access-token-compatible `issued_token_type` values. +**Operator step — allowlist the exchanging client.** authserver 0.2.0 only honours a cross-client exchange when the exchanging client is allowlisted on the target Resource. For each resource server that exchanges for a downstream resource it does not itself act as, add its client id to that Resource's exchange policy: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token that was issued to itself, a fronted exchange, and a Broker resource need nothing. + +Exchange-specific errors: + +- `AccessDeniedError` (`access_denied`, HTTP 403) — the exchanging client is not allowlisted on the target Resource. This is an operator-side fix (the `PATCH` above); re-prompting the user will not clear it, which is why it is a distinct class from `ConsentRequiredError`. +- `InvalidTargetError` (`invalid_target`, HTTP 400, RFC 8707 §2.2) — the `resource` string does not match a granted resource byte for byte; a trailing slash is enough. +- `ConsentRequiredError` — the AS requires interactive user consent before issuance (`consent_required` / `interaction_required`). + +None of the three trips the circuit breaker — they are the AS answering, not the AS failing. + ### Token caching Client-credentials responses are cached in memory by `(scope, resource)`. Cached entries are evicted slightly before expiry based on `cache_ttl_buffer_seconds`. @@ -559,10 +595,11 @@ Common meanings: - `JWKSFetchError`: JWKS unavailable - `MissingMetadataEndpointError`: required discovered endpoint missing - `InvalidIssuerError`: the configured issuer carries a query or fragment component (RFC 8414 §2). Raised from `AuthplaneClient.create()`, at construction, before any network fetch. Subclasses `ValueError` as well as `AuthplaneError`, so an existing `except ValueError` still catches it -- `InvalidResourceError`: the configured resource indicator carries a fragment component (RFC 8707 §2). Raised at construction, from three call sites, of which one is authoritative: +- `InvalidResourceError`: the configured resource identifier is rejected on one of these axes, checked in that order — it carries a fragment component (RFC 8707 §2); it contains whitespace or a control character (RFC 3986 §2, and RFC 9728 §3.3 obliges a client to discard a PRM document naming a resource that differs from the URL it was fetched from, which is what `urlsplit`'s silent cleaning would produce); it is not an absolute URL with a scheme and a host (RFC 8707 §2 requires an absolute URI; RFC 9728 §3 derives the metadata URL by inserting the well-known suffix after the host); it carries a userinfo subcomponent (RFC 9110 §4.2.4); or its port does not parse (RFC 3986 §3.2.3 — `https://api.example.com:80O/mcp`, letter O for zero). The scheme is not narrowed to `https` — `http://localhost:8080/mcp` stays valid for local development. The rejection message echoes the identifier with any userinfo redacted. The same error type also covers `resource_metadata_url=`, on a slightly different list: the scheme *is* narrowed there, to `http`/`https`, and a `"` or `\` is rejected anywhere in the value rather than only in the host, because that one is spliced into a `WWW-Authenticate` quoted-string (RFC 9110 §11.2). It is raised from `AuthplaneResource.__init__`, `AuthplaneClient.resource()` and both adapter factories. Raised at construction, from these call sites, of which one is authoritative: - `AuthplaneResource.__init__` — the authoritative gate. Every construction path reaches it, including direct construction of the package-root export, so `AuthplaneResource(...)` built by hand raises here too. - `AuthplaneClient.resource()` — redundant for the guarantee, kept for the traceback: it raises at the line the operator wrote rather than one frame deeper in the constructor. - `build_prm_url()` — a defensive backstop only. Its production caller is `AuthplaneResource.prm_url()`, which operators invoke inside a 401 response path, so validating *only* there turned a configuration error into a 500 on the failure path. + - `authplane_mcp_auth()` (`authplane-mcp`) and `authplane_auth()` (`authplane-fastmcp`) — early gates ahead of `AuthplaneClient.create()`, so a misconfiguration is diagnosed without a reachable authorization server. These call the exported `authplane.validate_prm_resource_identifier`, which is the same gate; the name is scoped to the resource-*server* identifier, since the host requirement is RFC 9728 §3's rather than RFC 8707 §2's. Subclasses `ValueError` as well as `AuthplaneError`, on the same terms as `InvalidIssuerError` - `ProtocolError`: malformed successful OAuth response @@ -572,10 +609,17 @@ Common meanings: ### AS-facing errors ```python -from authplane import AuthError, CircuitOpenError, InvalidClientError, InvalidGrantError +from authplane import ( + AccessDeniedError, + AuthError, + CircuitOpenError, + InvalidClientError, + InvalidGrantError, + InvalidTargetError, +) ``` -The SDK maps OAuth error responses into typed `AuthError` subclasses. The circuit breaker fails fast with `CircuitOpenError` when the AS is considered unavailable. +The SDK maps OAuth error responses into typed `AuthError` subclasses — `access_denied` to `AccessDeniedError`, `invalid_target` to `InvalidTargetError`, `consent_required` / `interaction_required` to `ConsentRequiredError`, and so on. The circuit breaker fails fast with `CircuitOpenError` when the AS is considered unavailable. ### HTTP status mapping @@ -590,7 +634,7 @@ except AuthplaneError as e: status, headers = response_headers_for( e, realm="api.example.com", - resource_metadata_url=res.prm_url(), + resource_metadata_url=res.resource_metadata_url(), ) # status: int, headers: {"WWW-Authenticate": "Bearer error=..."} ``` @@ -604,13 +648,41 @@ except AuthplaneError as e: `www_authenticate()` selects the scheme (`Bearer` by default, `DPoP` for DPoP-flow errors except `DPoPNotSupportedError`, which stays `Bearer` because the resource is bearer-only). When `scope=` is omitted it auto-populates from `InsufficientScopeError.required_scopes`. Every interpolated value is sanitized against header injection. +`error_description` is a fixed sentence chosen by the error code — never the exception's message. The challenge is served to a caller who has not authenticated, and the SDK's messages name the detail that failed: the unknown `kid`, the claim that did not validate, or, for an `aud` mismatch, the exact audience the resource expects. The message stays on the exception for you to log, and the SDK also logs it at `DEBUG` on the `authplane.errors` logger. `verbose_description=True` puts it back on the wire; it is a development aid, not a production setting. + +### Advertising more than one scheme + +`www_authenticate()` derives the scheme from the error, so it always names exactly one. A resource running `inbound_dpop` in optional mode accepts both `Bearer` and `DPoP` and should advertise both, so a DPoP-capable client can discover that sender-constrained tokens are taken here (RFC 9449 §7.1; §7.2 covers running the two schemes side by side). Use `www_authenticate_challenges()` for that: + +```python +from authplane import AuthplaneError, http_status, www_authenticate_challenges + +try: + claims = await res.verify(token, dpop_request=request) +except AuthplaneError as e: + challenges = www_authenticate_challenges( + e, + schemes=("Bearer", "DPoP"), + algs=("ES256", "RS256"), # InboundDPoPOptions.allowed_proof_algorithms + realm="api.example.com", + resource_metadata_url=res.resource_metadata_url(), + ) + for challenge in challenges: + response.headers.append("WWW-Authenticate", challenge) + response.status_code = http_status(e) +``` + +The two challenges cannot be joined into one header value: the comma that would separate them is also the separator *between parameters inside* a challenge, so the result cannot be parsed unambiguously. RFC 7235 §4.1 permits the comma-joined form but warns about parsing it, so separate header values are the interoperable choice and this returns a list — emit one header value per element, using whatever your framework's append-a-header API is (`headers.append`, `add_header`, `MutableHeaders.append`). + +`algs=` is the RFC 9449 §7.1 parameter that tells a client which proof algorithms to sign with instead of guessing and retrying; it is emitted on the `DPoP` challenge only. Omitting `schemes=` derives the single scheme from the error, so `www_authenticate_challenges(e)` returns exactly what `www_authenticate(e)` would, in a one-element list. `response_headers_for()` stays single-scheme by construction — a dict holds one value per header name. + ## 12. Protected Resource Metadata Generate an RFC 9728 protected resource metadata document with: ```python prm = res.prm_response() # the document body (a dict) -url = res.prm_url() # the well-known URL where clients can fetch it +url = res.prm_url() # the well-known URL where clients can fetch that document ``` Example output: @@ -624,6 +696,35 @@ Example output: } ``` +### Where the PRM document lives + +Two topologies, and the SDK supports both: + +**(a) Resource-hosted — the default.** This resource serves the document itself at `/.well-known/oauth-protected-resource[/path]`, derived from the resource identifier per RFC 9728 §3.1. `prm_response()` builds the body, `prm_url()` gives the URL, and the challenge advertises that URL with no configuration. + +**(b) AS-hosted.** The authorization server serves the document for every registered Resource — authserver ≥ 0.2.0 serves one at `/.well-known/oauth-protected-resource/{ref}`, where `ref` is the RFC 9728 §3.1 path suffix of the Resource URI (or its slug) — and this SDK only points clients at it. Useful when the resource server cannot host well-known paths: a mount behind a path prefix it does not control, or a platform that owns the root of the origin. Pass `resource_metadata_url=` and the resource keeps everything else unchanged: + +```python +res = client.resource( + resource="https://api.example.com/mcp", + scopes=["read", "write"], + resource_metadata_url="https://auth.example.com/.well-known/oauth-protected-resource/mcp", +) + +res.resource_metadata_url() # the configured URL — advertise this one +res.prm_url() # still the §3.1 derivation of the identifier +``` + +`resource_metadata_url()` returns the override when one is configured and `prm_url()` otherwise, so middleware composing a challenge reads one accessor either way. The option is validated at construction — absolute `http`/`https` URL, no fragment, no userinfo, no whitespace, no quoted-string delimiter — because a bad value would otherwise surface from inside a 401. + +**RFC 9728 §3.3 constrains topology (b), and it is worth reading before choosing it.** The rule binds the document's `resource` value to *the URL the document was fetched from*, not to the API URL the client called: "The resource value returned MUST be identical to the protected resource's resource identifier value into which the well-known URI path suffix was inserted to create the URL used to retrieve the metadata. If these values are not identical, the data contained in the response MUST NOT be used." + +The two readings coincide only when the metadata URL is the §3.1 derivation of the resource identifier — which is topology (a). In topology (b) they cannot: a client that fetches `https://auth.example.com/.well-known/oauth-protected-resource/mcp` reverse-derives `https://auth.example.com/mcp` and compares it against the document's `resource`, `https://api.example.com/mcp`. Not identical, so a client enforcing §3.3 MUST NOT use the document. That check is load-bearing on the client side — it is what stops a resource server from pointing a client at metadata describing somebody else's resource — so it is not a check to design around. + +Concretely: **an AS-hosted document on an origin other than the resource's is usable only against clients that do not enforce §3.3.** Topology (a) is the conformant one, and it is the default for that reason. Both adapters keep serving their own document at the derived path and the upstream middleware's 401 keeps pointing there, so an adapter deployment is unaffected either way; the override reaches only challenges you compose yourself. + +Whichever topology you pick, the Resource URI registered at the AS, the identifier passed as `resource=` here, and the public URL of this server have to be one identical string — a trailing slash, a differing case in the host, or a `:443` spelled out on one side and not the other is a mismatch. + ## 13. Advanced Notes ### Circuit breaker behavior @@ -633,6 +734,7 @@ The circuit breaker protects AS-bound operations from cascading failure. - transient server-side failures count - transport failures such as connection and timeout errors count - SSRF validation failures do not count +- OAuth policy answers do not count — `access_denied`, `invalid_target`, `consent_required` and the other 4xx error codes are the AS responding, not failing - after cooldown expiry, only one half-open probe is allowed at a time ### Unknown `kid` diff --git a/authplane/dpop_verification.py b/authplane/dpop_verification.py index 65b1c95..91ed280 100644 --- a/authplane/dpop_verification.py +++ b/authplane/dpop_verification.py @@ -152,11 +152,13 @@ async def verify_dpop_proof( # RFC 9449 §9 (Resource Server-Provided Nonce). Opt-in: an empty # expected_nonce means no policy, so a proof carrying an AS-issued nonce - # still verifies. The message carries no values on purpose — errors on this - # path reach an unauthenticated caller through the `error_description` of a - # `WWW-Authenticate` challenge (see `www_authenticate` in errors.py), and - # echoing the server's expected nonce there would hand out a currently - # valid nonce without the challenge round trip the freshness proof rests on. + # still verifies. The message carries no values on purpose. `www_authenticate` + # no longer copies it into `error_description` by default, but it does under + # `verbose_description=True`, and the value would still reach any resource + # server that surfaces the exception message itself — so keeping the nonce + # out of the message is defence in depth, not a redundant precaution: + # echoing the server's expected nonce hands out a currently valid nonce + # without the challenge round trip the freshness proof rests on. if expected_nonce and str(claims.get("nonce", "")) != expected_nonce: raise InvalidDPoPProofError("DPoP proof nonce mismatch") diff --git a/authplane/errors.py b/authplane/errors.py index 576b5c6..6f74cbf 100644 --- a/authplane/errors.py +++ b/authplane/errors.py @@ -4,7 +4,11 @@ InsufficientScope is distinguishable for 403 HTTP status mapping. """ +import logging import re +from collections.abc import Sequence + +_LOGGER = logging.getLogger(__name__) _HEADER_VALUE_UNSAFE = re.compile(r'[\r\n"\\]+') @@ -13,6 +17,42 @@ def _sanitize_header_value(value: str) -> str: """Replace CR, LF, double-quote, and backslash with a single space so the value cannot break out of a quoted ``WWW-Authenticate`` parameter or inject additional header fields. Leading/trailing whitespace is stripped. + + This is a backstop and must not be read as the guarantee for + ``resource_metadata``. Substituting there was in fact the wrong remedy for + a configured identifier: the header stayed parseable, but it advertised a + URL that no longer matched the one a client derives from the identifier + this SDK also serves as the ``resource`` member of the PRM document, which + is the RFC 9728 §3.3 mismatch reached by another route — the challenge + looked fine and discovery failed anyway. A `"` or a `\\` in the **host** of + a configured resource identifier is now rejected at construction + (``internal/urls.py``), which is where that defect is worst: a backslash + there leaves the challenge well-formed and redirects a conformant client to + a different origin entirely. + + The host is the whole of that guarantee, and the scoping is deliberate + rather than incidental. The construction gate scans ``parsed.hostname``, + while ``build_prm_url`` splices the identifier's path and query into the + derived URL verbatim (RFC 9728 §3.1 inserts the well-known segment + *between* the host and them, so all three land inside this one + quoted-string). Measured on 3.12: ``https://api.example.com/m"cp`` + constructs, derives + ``https://api.example.com/.well-known/oauth-protected-resource/m"cp``, and + arrives here — where the substitution advertises ``.../m cp``, the same + RFC 9728 §3.3 mismatch one component over. Same-origin and ending in a + client-side discard rather than a redirect to another host, which is why + that axis is a separate decision with its own migration cost and is not + settled here. Until it is, this function is what stands between a path- or + query-borne delimiter and the challenge. + + It is kept rather than removed for three reasons, none of which the + construction gate covers. ``realm`` and ``scope`` pass through here and are + gated nowhere — they are free-form operator strings. So is + ``resource_metadata_url`` itself: it is a plain ``str`` parameter of the + public ``www_authenticate``/``response_headers_for``, and nothing obliges a + caller to have obtained it from ``AuthplaneResource.prm_url()``. And CR/LF + here defend against header-field injection, a different hazard from the + quoted-string one, on values whose provenance this module cannot see. """ return _HEADER_VALUE_UNSAFE.sub(" ", value).strip() @@ -236,6 +276,37 @@ def describe(self) -> str: return f"{self} ({sid}: {cause})" +class AccessDeniedError(AuthError): + """AS refused the request outright (``access_denied``, HTTP 403). + + No RFC section is cited because there is none for this endpoint: RFC 6749 + defines ``access_denied`` at the *authorization* endpoint (§4.1.2.1), and + neither the token-endpoint list (§5.2) nor RFC 8693 §2.2.2 includes it. + The code as used here is authserver's token-endpoint extension. + + On a token exchange this is a policy decision, not a consent gap: the + exchanging client is not in the target Resource's exchange allowlist + (``policy.exchange.allowed_client_ids`` / ``policy.runtime.client_ids``). + Re-prompting the user cannot fix it — the operator has to allowlist the + client on the Resource — which is why it is kept apart from + :class:`ConsentRequiredError`. Not an outage signal: it never trips the + circuit breaker. + """ + + pass + + +class InvalidTargetError(AuthError): + """The ``resource`` parameter names no granted resource (RFC 8707 §2.2 'invalid_target'). + + The AS compares the indicator byte for byte against the resources it + knows, so a trailing slash or a differing scheme is enough. Not an + outage signal: it never trips the circuit breaker. + """ + + pass + + class InvalidClientError(AuthError): """AS rejected the client credentials (RFC 6749 'invalid_client').""" @@ -284,12 +355,180 @@ class CircuitOpenError(AuthError): pass +# The challenge reaches a caller who by definition has not authenticated, so +# `error_description` is built from the RFC 6750 §3.1 / RFC 9449 §7.1 error +# code, never from the exception message. The SDK's own messages name the +# failing detail — the unknown `kid`, the claim that did not validate, the +# `typ` that was rejected — and an `aud` mismatch in particular would hand the +# caller the exact audience string 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 everything a conforming client needs to decide what to do next. +# `_sanitize_header_value` is not a defence here — it prevents header +# injection, not disclosure; a sanitized `kid` is still a `kid`. +# +# The descriptions carry no comma. A comma inside a quoted-string is legal +# RFC 7235, but it is also the separator between challenge parameters and +# between header values, so keeping it out of the one parameter whose text we +# choose leaves nothing for a lenient client-side parser to split on. +_SAFE_ERROR_DESCRIPTIONS: dict[str, str] = { + "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", +} + +# Fallback for an error code added without a matching entry above. Kept +# deliberately contentless for the same reason the table exists. +_FALLBACK_ERROR_DESCRIPTION = "The request could not be authenticated" + +# Authentication schemes this SDK can advertise. Unlike the quoted challenge +# parameters, the scheme is a bare RFC 7235 token, so an unrecognized value is +# rejected outright rather than sanitized into the header. +_SUPPORTED_SCHEMES: dict[str, str] = {"bearer": "Bearer", "dpop": "DPoP"} + + +def _error_code_for(error: AuthplaneError, scheme: str) -> str: + """Return the RFC 6750 §3.1 error code to advertise for ``error`` under ``scheme``.""" + if isinstance(error, InsufficientScopeError): + return "insufficient_scope" + if isinstance(error, DPoPMultipleProofsError) and scheme == "DPoP": + # RFC 9449 §7.1 prescribes `invalid_dpop_proof` for §4.3 + # cardinality rejections, not the SDK's historical `invalid_token` + # used by the other `DPoPError` shapes. Scoped to this error; + # a broader sweep is a separate change. The code is defined for the + # DPoP scheme, so a Bearer challenge emitted alongside it keeps + # `invalid_token` rather than naming a code Bearer does not define. + return "invalid_dpop_proof" + return "invalid_token" + + +def _scheme_for(error: AuthplaneError) -> str: + """Return the single scheme that matches ``error``'s type.""" + return ( + "DPoP" + if isinstance(error, DPoPError) and not isinstance(error, DPoPNotSupportedError) + else "Bearer" + ) + + +def _description_for(error: AuthplaneError, error_code: str, verbose: bool) -> str: + """Return the `error_description` value to emit for ``error``.""" + if verbose: + return _sanitize_header_value(str(error)) + return _SAFE_ERROR_DESCRIPTIONS.get(error_code, _FALLBACK_ERROR_DESCRIPTION) + + +def _normalize_schemes(schemes: Sequence[str]) -> list[str]: + """Canonicalize and de-duplicate ``schemes``, preserving caller order.""" + normalized: list[str] = [] + for scheme in schemes: + canonical = _SUPPORTED_SCHEMES.get(scheme.strip().lower()) + if canonical is None: + raise ValueError( + f"Unsupported authentication scheme {scheme!r}; " + f"only {sorted(_SUPPORTED_SCHEMES.values())} can be advertised" + ) + if canonical not in normalized: + normalized.append(canonical) + if not normalized: + raise ValueError("schemes must be non-empty; omit it to derive the scheme from the error") + return normalized + + +def _normalize_algs(algs: Sequence[str] | None) -> tuple[str, ...]: + """Resolve ``algs`` to the exact set to advertise, rejecting what cannot be. + + Three inputs, three defined meanings: + + * a bare ``str`` — rejected. ``str`` satisfies ``Sequence[str]``, so + ``algs="ES256"`` type-checks under pyright strict and then ``" ".join`` + iterates it into ``algs="E S 2 5 6"``: a challenge advertising + algorithms that do not exist, from which a conforming client concludes + it cannot sign a proof at all. ``schemes`` is protected against the same + slip by accident (``_normalize_schemes`` rejects ``'B'``). + * ``None`` — the default set, the same meaning + :class:`~authplane.dpop.InboundDPoPOptions` gives it. This is what makes + the documented ``algs=options.allowed_proof_algorithms`` call correct on + an options object built from defaults, where that attribute *is* ``None``: + it used to advertise nothing at all, and briefly raised ``TypeError`` + from inside the 401 handler, which turns an unauthenticated request into + a 500. + * a sequence — validated, not sanitized. These are bare RFC 7235 tokens, + the same shape as ``schemes``, so they get the same treatment: an + unusable value is refused rather than quietly rewritten. Escaping alone + let a comma through, and a comma is the one character the surrounding + code works to keep out of parameter text so that a lenient client-side + parser has nothing to split on. + + An empty sequence stays "omit the parameter", which is the parameter's own + default and what every caller that does not pass it relies on. + """ + # Imported here, not at module scope: `dpop` imports this module, so a + # top-level import would be circular. + from .dpop import SUPPORTED_DPOP_ALGORITHMS + + if isinstance(algs, str): + raise TypeError( + f"algs must be a sequence of algorithm names, not a bare str ({algs!r}); " + f"pass ({algs!r},) to advertise a single algorithm" + ) + if algs is None: + return tuple(SUPPORTED_DPOP_ALGORITHMS) + normalized = tuple(algs) + unsupported = [alg for alg in normalized if alg not in SUPPORTED_DPOP_ALGORITHMS] + if unsupported: + raise ValueError( + f"Unsupported DPoP proof algorithms {unsupported!r}; only " + f"{list(SUPPORTED_DPOP_ALGORITHMS)} can be advertised" + ) + return normalized + + +def _build_challenge( + error: AuthplaneError, + scheme: str, + *, + realm: str, + resource_metadata_url: str | None, + scope: Sequence[str] | None, + algs: Sequence[str], + verbose_description: bool, +) -> str: + """Assemble one ``WWW-Authenticate`` header value for a single scheme.""" + error_code = _error_code_for(error, scheme) + + parts: list[str] = [] + if realm: + parts.append(f'realm="{_sanitize_header_value(realm)}"') + parts.append(f'error="{error_code}"') + parts.append(f'error_description="{_description_for(error, error_code, verbose_description)}"') + if scope: + parts.append(f'scope="{_sanitize_header_value(" ".join(scope))}"') + if resource_metadata_url: + parts.append(f'resource_metadata="{_sanitize_header_value(resource_metadata_url)}"') + # RFC 9449 §7.1 defines `algs` for the DPoP challenge only, so a Bearer + # challenge in the same set never carries it. + if scheme == "DPoP" and algs: + # No escaping: `_normalize_algs` has already refused anything that is + # not one of the supported bare tokens, so there is nothing to escape. + parts.append(f'algs="{" ".join(algs)}"') + return f"{scheme} " + ", ".join(parts) + + +def _resolved_scope(error: AuthplaneError, scope: Sequence[str] | None) -> Sequence[str] | None: + """Fall back to ``InsufficientScopeError.required_scopes`` when no scope was passed.""" + if scope is None and isinstance(error, InsufficientScopeError) and error.required_scopes: + return list(error.required_scopes) + return scope + + def www_authenticate( error: AuthplaneError, *, realm: str = "", resource_metadata_url: str | None = None, - scope: list[str] | None = None, + scope: Sequence[str] | None = None, + verbose_description: bool = False, ) -> str: """Build an RFC 6750 §3 ``WWW-Authenticate`` header value. @@ -302,6 +541,18 @@ def www_authenticate( ``DPoP`` scheme with ``invalid_token`` - All other ``AuthplaneError`` → ``Bearer`` scheme with ``invalid_token`` + ``error_description`` is a fixed, caller-safe sentence chosen by the error + code — the exception's own message is never placed on the wire, because the + challenge is served to a caller who has not authenticated. The message stays + on the exception for the resource server to log, and is also emitted here at + ``DEBUG`` on the ``authplane.errors`` logger. + + ``verbose_description=True`` restores the previous behaviour of copying the + exception message into the challenge. It is a development aid: it discloses + SDK-internal detail (the unknown ``kid``, the claim that failed, the + expected audience) to unauthenticated callers, so do not enable it in + production. + If ``scope`` is provided (or the error is an :class:`InsufficientScopeError` carrying ``required_scopes``), an RFC 6750 §3 ``scope="…"`` challenge parameter is included. An explicit ``scope`` argument takes precedence. @@ -312,39 +563,117 @@ def www_authenticate( Every interpolated value is sanitized to prevent header injection. + A resource that accepts more than one scheme — ``inbound_dpop`` in optional + mode accepts both ``Bearer`` and ``DPoP`` — cannot be described by a single + header value; use :func:`www_authenticate_challenges` for that. + Returns: A header value like ``Bearer error="invalid_token", error_description="..."`` """ - if isinstance(error, InsufficientScopeError): - error_code = "insufficient_scope" - elif isinstance(error, DPoPMultipleProofsError): - # RFC 9449 §7.1 prescribes `invalid_dpop_proof` for §4.3 - # cardinality rejections, not the SDK's historical `invalid_token` - # used by the other `DPoPError` shapes. Scoped to this error; - # a broader sweep is a separate change. - error_code = "invalid_dpop_proof" - else: - error_code = "invalid_token" - - scheme = ( - "DPoP" - if isinstance(error, DPoPError) and not isinstance(error, DPoPNotSupportedError) - else "Bearer" + scheme = _scheme_for(error) + if not verbose_description: + _LOGGER.debug( + "www_authenticate: %s: %s", + type(error).__name__, + error, + extra={"scheme": scheme, "error_code": _error_code_for(error, scheme)}, + ) + return _build_challenge( + error, + scheme, + realm=realm, + resource_metadata_url=resource_metadata_url, + scope=_resolved_scope(error, scope), + algs=(), + verbose_description=verbose_description, ) - if scope is None and isinstance(error, InsufficientScopeError) and error.required_scopes: - scope = list(error.required_scopes) - parts: list[str] = [] - if realm: - parts.append(f'realm="{_sanitize_header_value(realm)}"') - parts.append(f'error="{error_code}"') - parts.append(f'error_description="{_sanitize_header_value(str(error))}"') - if scope: - parts.append(f'scope="{_sanitize_header_value(" ".join(scope))}"') - if resource_metadata_url: - parts.append(f'resource_metadata="{_sanitize_header_value(resource_metadata_url)}"') - return f"{scheme} " + ", ".join(parts) +def www_authenticate_challenges( + error: AuthplaneError, + *, + schemes: Sequence[str] | None = None, + algs: Sequence[str] | None = (), + realm: str = "", + resource_metadata_url: str | None = None, + scope: Sequence[str] | None = None, + verbose_description: bool = False, +) -> list[str]: + """Build one RFC 6750 §3 challenge per authentication scheme the resource accepts. + + :func:`www_authenticate` picks the scheme from the error's type, so it can + only ever name one. A resource running ``inbound_dpop`` in optional mode + accepts both ``Bearer`` and ``DPoP`` and should advertise both, so that a + DPoP-capable client can discover that sender-constrained tokens are taken + here (RFC 9449 §7.1; §7.2 covers running the two schemes side by side). + + Two challenges cannot be joined with a comma: the comma is also the + separator *between parameters inside* a challenge, so the result cannot be + parsed unambiguously. RFC 7235 §4.1 permits the comma-joined form but + warns about parsing it, so separate ``WWW-Authenticate`` header values are + the interoperable choice: this returns a list and the caller emits one header value + per element:: + + for challenge in www_authenticate_challenges(error, schemes=("Bearer", "DPoP")): + response.headers.add("WWW-Authenticate", challenge) + + Args: + error: The error the challenge responds to. It selects the error code + the same way :func:`www_authenticate` does, per scheme: + ``invalid_dpop_proof`` is DPoP-specific, so a ``Bearer`` challenge + emitted alongside a DPoP one keeps ``invalid_token``. + schemes: The schemes to advertise, in the order they should appear. + ``Bearer`` and ``DPoP`` are recognized, case-insensitively; + duplicates collapse. Omit it to derive the single scheme from the + error's type, which returns exactly what + :func:`www_authenticate` would, in a one-element list. + algs: JOSE ``alg`` values accepted for DPoP proofs, emitted as the + RFC 9449 §7.1 ``algs`` parameter on the ``DPoP`` challenge only, + and ignored when ``DPoP`` is not among ``schemes``. Pass + ``options.allowed_proof_algorithms`` straight through: ``None`` + there means "the default set", and means the same here, so an + options object built from defaults advertises the + algorithms it actually accepts rather than nothing. The default ``()`` + omits the parameter. Values are validated against the supported + set, so an unusable one raises rather than reaching the wire. + realm: RFC 7235 ``realm``, emitted on every challenge when non-empty. + resource_metadata_url: RFC 9728 §5.1 ``resource_metadata``, emitted on + every challenge when provided. + scope: RFC 6750 §3 ``scope``, emitted on every challenge. Falls back to + :attr:`InsufficientScopeError.required_scopes` when not passed. + verbose_description: Development-only. See :func:`www_authenticate`. + + Returns: + One header value per scheme, in the order given. + + Raises: + ValueError: If ``schemes`` is empty or names a scheme this SDK cannot + advertise. + TypeError: If ``algs`` is a bare ``str`` rather than a sequence of + algorithm names. + """ + resolved_schemes = [_scheme_for(error)] if schemes is None else _normalize_schemes(schemes) + resolved_algs = _normalize_algs(algs) + if not verbose_description: + _LOGGER.debug( + "www_authenticate_challenges: %s: %s", + type(error).__name__, + error, + extra={"schemes": resolved_schemes}, + ) + resolved_scope = _resolved_scope(error, scope) + return [ + _build_challenge( + error, + scheme, + realm=realm, + resource_metadata_url=resource_metadata_url, + scope=resolved_scope, + algs=resolved_algs, + verbose_description=verbose_description, + ) + for scheme in resolved_schemes + ] def http_status(error: AuthplaneError) -> int: @@ -384,14 +713,20 @@ def response_headers_for( *, realm: str = "", resource_metadata_url: str | None = None, - scope: list[str] | None = None, + scope: Sequence[str] | None = None, + verbose_description: bool = False, ) -> tuple[int, dict[str, str]]: """Return ``(status, {"WWW-Authenticate": challenge})`` for an Authplane error. One call replaces the parallel use of :func:`http_status` and :func:`www_authenticate`. Forwards keyword arguments to :func:`www_authenticate` so callers can include ``realm``, - ``resource_metadata_url``, and ``scope`` without re-deriving the mapping. + ``resource_metadata_url``, ``scope``, and ``verbose_description`` without + re-deriving the mapping. + + A dict holds one value per header name, so this helper is single-scheme by + construction. A resource advertising both ``Bearer`` and ``DPoP`` pairs + :func:`http_status` with :func:`www_authenticate_challenges` instead. """ return ( http_status(error), @@ -401,6 +736,7 @@ def response_headers_for( realm=realm, resource_metadata_url=resource_metadata_url, scope=scope, + verbose_description=verbose_description, ) }, ) @@ -446,6 +782,8 @@ def map_oauth_error( "invalid_grant": InvalidGrantError, "unsupported_grant_type": UnsupportedGrantTypeError, "invalid_request": InvalidRequestError, + "access_denied": AccessDeniedError, + "invalid_target": InvalidTargetError, } if status_code >= 500: diff --git a/authplane/internal/__init__.py b/authplane/internal/__init__.py index daf4281..ec7a0f5 100644 --- a/authplane/internal/__init__.py +++ b/authplane/internal/__init__.py @@ -3,25 +3,33 @@ from .cache_headers import parse_expires_at from .document_cache import ( DocumentCache, - DocumentChangeCallback, DocumentFetcherCallable, + DocumentSourceResolver, JWKSCache, ) from .document_fetcher import DocumentFetcher from .fetch_result import FetchResult from .metadata import MetadataCache -from .urls import build_metadata_url, build_prm_url, validate_resource_indicator +from .urls import ( + build_metadata_url, + build_prm_url, + validate_issuer_identifier, + validate_prm_resource_identifier, + validate_resource_metadata_url, +) __all__ = [ "DocumentCache", - "DocumentChangeCallback", "DocumentFetcher", "DocumentFetcherCallable", + "DocumentSourceResolver", "FetchResult", "JWKSCache", "MetadataCache", "build_metadata_url", "build_prm_url", "parse_expires_at", - "validate_resource_indicator", + "validate_issuer_identifier", + "validate_prm_resource_identifier", + "validate_resource_metadata_url", ] diff --git a/authplane/internal/document_cache.py b/authplane/internal/document_cache.py index 1caaefd..212242e 100644 --- a/authplane/internal/document_cache.py +++ b/authplane/internal/document_cache.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable from typing import Any, cast -from ..errors import JWKSFetchError +from ..errors import JWKSFetchError, MetadataFetchError from .fetch_result import FetchResult logger = logging.getLogger(__name__) @@ -15,15 +15,26 @@ # A coroutine that loads and returns a FetchResult when called. DocumentFetcherCallable = Callable[[], Awaitable[FetchResult]] -# Callback invoked when a document changes: (old_doc, new_doc) -> None -DocumentChangeCallback = Callable[[dict[str, Any], dict[str, Any]], Awaitable[None]] DocumentErrorFactory = Callable[[str], Exception] +# A coroutine returning the URL a document should currently be fetched from. +DocumentSourceResolver = Callable[[], Awaitable[str]] + def _default_document_error(message: str) -> Exception: return JWKSFetchError(message) +#: Ceiling on the post-failure retry floor, in seconds. +_FAILURE_BACKOFF_SECONDS = 30.0 + +#: Defaults shared by `DocumentCache` and every subclass that forwards them. +#: Held here rather than repeated in each signature: a subclass that restates +#: the literal keeps the old value silently when the base one is changed. +_DEFAULT_REFRESH_SECONDS = 300 +_DEFAULT_DOCUMENT_TYPE = "document" + + class DocumentCache: """Base cache for JSON documents with TTL, refresh, and stale fallback. @@ -33,29 +44,74 @@ class DocumentCache: - Background refresh at 80% of effective TTL - Stale cache fallback on fetch errors - Lock-coordinated fetching - - Optional change notification callback + - Validation of a fetched document before it is committed """ def __init__( self, fetcher: DocumentFetcherCallable, - refresh_seconds: int = 300, - document_type: str = "document", - on_change: DocumentChangeCallback | None = None, + refresh_seconds: int = _DEFAULT_REFRESH_SECONDS, + document_type: str = _DEFAULT_DOCUMENT_TYPE, error_factory: DocumentErrorFactory | None = None, ) -> None: self._fetcher = fetcher self._refresh_seconds = refresh_seconds self._document_type = document_type - self._on_change = on_change self._error_factory: DocumentErrorFactory = error_factory or _default_document_error self._cache: dict[str, Any] | None = None self._cache_time: float = 0 + # Where the currently cached document came from. Only meaningful for a + # document whose location is itself discovered; see `_cache_is_usable`. + self._cache_source: str | None = None self._server_expires_at: float | None = None self._fetch_lock = asyncio.Lock() self._refresh_task: asyncio.Task[None] | None = None - self._change_task: asyncio.Task[None] | None = None + self._retry_not_before: float = 0.0 + + def _failure_backoff_seconds(self) -> float: + """Seconds to wait after a failed refresh before attempting another. + + A failed fetch does not advance ``_cache_time``, so without this the + document stays permanently expired and every reader takes the + synchronous refetch branch — against an unreachable endpoint that is a + full HTTP timeout per call, serialized behind ``_fetch_lock``. On the + verification path, which this cache now sits on, that turns an AS + outage into a resource-server latency collapse rather than a degraded + but serviceable one. + + Never longer than the configured interval: a cache asked to refresh + every five seconds must not be pinned to a thirty-second floor. + """ + return max(1.0, min(_FAILURE_BACKOFF_SECONDS, float(self._refresh_seconds))) + + def _serve_during_backoff(self) -> dict[str, Any] | None: + """The cached document while a failed refresh is still being backed off. + + A failed refresh leaves ``_cache_time`` where it was, so the document + reads as expired and every caller would otherwise take the refetch + branch again — one full HTTP timeout each, serialized behind + ``_fetch_lock``. Serve what we have until the floor elapses. + + ``None`` when the floor has passed or there is nothing cached to serve, + which is the caller's signal to go and fetch. + """ + if self._cache is None or time.time() >= self._retry_not_before: + return None + logger.debug( + "%s refresh backing off after a failed attempt, serving the cached document", + self._document_type.capitalize(), + ) + return self._cache + + def _is_passthrough_error(self, error: Exception, /) -> bool: + """Whether an error should surface as-is rather than be relabelled. + + Overridden by a subclass whose fetcher resolves its URL through another + document, where relabelling would point the operator at the wrong one. + The base cache has no such dependency and relabels everything. + """ + return False def _effective_expires_at(self) -> float: """Compute the effective cache expiry timestamp.""" @@ -66,10 +122,27 @@ def _effective_expires_at(self) -> float: async def get(self, force_refresh: bool = False) -> dict[str, Any]: """Return the cached document, fetching or refreshing as needed.""" + # Answered before the lock, and before the usability predicate: while a + # refresh is backing off, every branch below that can be reached with a + # document in hand returns that same document, so resolving the source, + # taking the lock and spawning a background refresh are all work whose + # result is already known. A rebind to an unreachable location holds + # this state for the whole backoff window and is re-entered by every + # read, so this is the path that has to stay cheap. The background + # refresh skipped here would reach this same floor and no-op anyway. + backoff_document = self._serve_during_backoff() + if backoff_document is not None: + return backoff_document + now = time.time() effective_expires = self._effective_expires_at() - if not force_refresh and self._cache is not None and now < effective_expires: + if ( + not force_refresh + and self._cache is not None + and now < effective_expires + and await self._cache_is_usable() + ): # Trigger background refresh at 80% of effective TTL effective_ttl = effective_expires - self._cache_time if ( @@ -84,42 +157,105 @@ async def get(self, force_refresh: bool = False) -> dict[str, Any]: async with self._fetch_lock: # Another coroutine may have already refreshed while we waited. effective_expires = self._effective_expires_at() - if not force_refresh and self._cache is not None and time.time() < effective_expires: + if ( + not force_refresh + and self._cache is not None + and time.time() < effective_expires + and await self._cache_is_usable() + ): + # Re-checked inside the lock, and re-checked including the + # usability predicate: when two readers both find the cached + # document unusable they queue here, and the second must see + # the document the first one committed rather than fetching it + # a second time. return self._cache + # Re-checked inside the lock: the coroutine we queued behind may + # have been the one whose failure set the floor. + backoff_document = self._serve_during_backoff() + if backoff_document is not None: + return backoff_document + try: fetch_result = await self._fetcher() + except Exception as e: + return self._handle_refresh_failure(e) - # Capture old cache before updating - old_cache = self._cache - new_document = fetch_result.document - - # Update cache - self._cache = new_document - self._cache_time = time.time() - self._server_expires_at = fetch_result.expires_at - logger.debug("%s fetched and cached", self._document_type.capitalize()) - - # Notify callback if document changed - if ( - self._on_change is not None - and old_cache is not None - and old_cache != new_document - ): - self._change_task = asyncio.create_task( - self._safe_invoke_callback(old_cache, new_document) - ) - - return new_document + try: + # Validate before committing, never after reading. `_cache` is + # what every reader sees, so a document that fails validation + # must not reach it even transiently: anything derived from the + # cached document — the URL a dependent cache fetches from, for + # one — would otherwise be taken from a rejected document. + new_document = self._validate_fetched(fetch_result.document) except Exception as e: - if self._cache is not None: - logger.warning( - "%s fetch failed, using stale cache: %s", - self._document_type.capitalize(), - e, - ) - return self._cache - raise self._error_factory(f"Failed to fetch {self._document_type}: {e}") from e + # Separated from the transport failure above so a rejection is + # logged as one. A bug in a validator lands here too, and used + # to be indistinguishable from an unreachable endpoint except by + # reading the message. + return self._handle_refresh_failure(e, rejected=True) + + previous_source = self._cache_source + self._cache = new_document + self._cache_time = time.time() + self._server_expires_at = fetch_result.expires_at + self._cache_source = fetch_result.source + self._retry_not_before = 0.0 + if previous_source is not None and fetch_result.source != previous_source: + # The rebind is the event; the mismatch that drives it is a + # per-read predicate and logs at debug. Emitted here, where + # `_cache_source` actually moves, this is one line per rotation + # rather than one per read for as long as the new location + # stays unreachable. A `None` previous source is the cold-boot + # bind, not a rotation. + logger.info( + "%s rebound to the newly advertised location", + self._document_type.capitalize(), + extra={"previous": previous_source, "current": fetch_result.source}, + ) + logger.debug("%s fetched and cached", self._document_type.capitalize()) + return new_document + + def _handle_refresh_failure( + self, error: Exception, /, *, rejected: bool = False + ) -> dict[str, Any]: + """Apply the retry floor, then serve stale or re-raise.""" + self._retry_not_before = time.time() + self._failure_backoff_seconds() + if self._cache is not None: + logger.warning( + "%s %s, keeping the cached document for up to %.0fs: %s", + self._document_type.capitalize(), + "document rejected" if rejected else "refresh failed", + self._failure_backoff_seconds(), + error, + ) + return self._cache + if self._is_passthrough_error(error): + raise error + raise self._error_factory(f"Failed to fetch {self._document_type}: {error}") from error + + async def _cache_is_usable(self) -> bool: + """Whether the cached document may still be served without refetching. + + Consulted only when the document is otherwise fresh, so this is the one + way a cache can declare its contents stale ahead of their TTL. It exists + for a document whose *location* is discovered rather than configured: a + TTL says when the contents may have changed, and says nothing about the + authorization server having moved them somewhere else in the meantime. + + The base cache fetches from a fixed URL, so its contents can never be + from the wrong place and it always answers yes. + """ + return True + + def _validate_fetched(self, document: dict[str, Any], /) -> dict[str, Any]: + """Check a freshly fetched document before it is committed to the cache. + + Raising here leaves the previously cached document in place, so a + rejected document is never observable. The base implementation accepts + every document; subclasses that carry a document format override it. + """ + return document async def aclose(self) -> None: """Cancel any pending background refresh task.""" @@ -129,28 +265,104 @@ async def aclose(self) -> None: await self._refresh_task async def _background_refresh(self) -> None: - """Background refresh task.""" + """Background refresh task. + + Calls ``DocumentCache.get`` rather than ``self.get`` on purpose. A + subclass may gate a forced read — ``MetadataCache`` floors it, because a + forced read is externally triggered by a ``kid`` miss on an + unauthenticated token. This caller is not that: it is the cache + refreshing itself at 80% of its own TTL, so the anti-abuse gate does not + apply to it and going through the override would make this a silent + no-op whenever ``0.8 x refresh_seconds`` falls below the floor — the + downgraded read takes the fast path on a document that is by definition + still valid, returns, and logs a refresh that never happened. For any + interval at or below 75 s that would defeat stale-while-revalidate for + the rest of the interval and hand the next ``verify()`` a blocking + synchronous fetch, which is what the retry floor exists to prevent. + """ try: - await self.get(force_refresh=True) + await DocumentCache.get(self, force_refresh=True) logger.debug("Background %s refresh completed", self._document_type) except Exception as e: logger.warning("Background %s refresh failed: %s", self._document_type, e) finally: self._refresh_task = None - async def _safe_invoke_callback(self, old_doc: dict[str, Any], new_doc: dict[str, Any]) -> None: - """Safely invoke the on_change callback with error handling.""" + +class JWKSCache(DocumentCache): + """JWKS cache with key ID lookup methods, bound to a discovered location.""" + + def __init__( + self, + fetcher: DocumentFetcherCallable, + refresh_seconds: int = _DEFAULT_REFRESH_SECONDS, + document_type: str = _DEFAULT_DOCUMENT_TYPE, + error_factory: DocumentErrorFactory | None = None, + *, + source_resolver: DocumentSourceResolver | None = None, + ) -> None: + super().__init__( + fetcher, + refresh_seconds=refresh_seconds, + document_type=document_type, + error_factory=error_factory, + ) + self._source_resolver = source_resolver + + async def _cache_is_usable(self) -> bool: + """Whether the cached key set still came from the advertised `jwks_uri`. + + A key set is only as current as the location it was fetched from. When + an authorization server rotates `jwks_uri`, the document naming the new + location is re-read on its own interval, but the key set fetched from + the withdrawn location stays fresh by its own TTL — so without this + check the rotation is not followed until that TTL lapses, and a rotation + that reuses a `kid` is not followed at all: the retired key is found + under the requested `kid`, so nothing looks stale, and every + verification fails on the signature instead. + + Comparing the advertised location against the one the cached key set + came from closes that. Resolving the location reads a cached document + and costs no request of its own until that document's own interval is + up, and the answer can only change as often as that document is + re-read — so this cannot be driven faster than the metadata refresh + interval. A failed refresh is still held off by the retry floor in + `DocumentCache.get`, which is not conditioned on how the refresh was + triggered. + """ + if self._source_resolver is None or self._cache_source is None: + return True try: - if self._on_change is not None: - await self._on_change(old_doc, new_doc) + current_source = await self._source_resolver() except Exception as e: - logger.exception( - "%s change callback raised exception: %s", self._document_type.capitalize(), e + # Resolving the location means reading the document that names it, + # and that read can fail. A failure is not evidence the location + # moved, so keep serving the key set we have rather than treating + # an unreachable discovery document as a rotation. + logger.debug( + "Could not resolve the current %s location, serving the cached document: %s", + self._document_type, + e, ) + return True + if current_source != self._cache_source: + logger.debug( + "%s location no longer matches the advertised one, refetching", + self._document_type.capitalize(), + extra={"previous": self._cache_source, "current": current_source}, + ) + return False + return True + def _is_passthrough_error(self, error: Exception, /) -> bool: + """Let a metadata failure keep its own type. -class JWKSCache(DocumentCache): - """JWKS cache with key ID lookup methods.""" + This cache's fetcher resolves ``jwks_uri`` through the metadata cache, + so a metadata failure surfaces here. Relabelling it ``JWKSFetchError`` + would point the operator at the wrong document. The base cache has no + such dependency, which is why the check lives here rather than there. + """ + return isinstance(error, MetadataFetchError) async def contains_kid( self, diff --git a/authplane/internal/document_fetcher.py b/authplane/internal/document_fetcher.py index deed123..cb27735 100644 --- a/authplane/internal/document_fetcher.py +++ b/authplane/internal/document_fetcher.py @@ -102,6 +102,7 @@ async def fetch(self) -> FetchResult: return FetchResult( document=http_response.body, expires_at=parse_expires_at(http_response.headers), + source=self._url, ) else: # Direct fetch without SSRF protection — new client per call @@ -112,4 +113,5 @@ async def fetch(self) -> FetchResult: return FetchResult( document=response.json(), expires_at=parse_expires_at(dict(response.headers)), + source=self._url, ) diff --git a/authplane/internal/fetch_result.py b/authplane/internal/fetch_result.py index acab9a1..846ab32 100644 --- a/authplane/internal/fetch_result.py +++ b/authplane/internal/fetch_result.py @@ -13,7 +13,15 @@ class FetchResult: expires_at: Absolute Unix timestamp when the server considers the response stale, derived from HTTP cache headers (Cache-Control max-age or Expires). None if the server sent no cache headers. + source: The URL this document was actually retrieved from. Recorded + because a document whose location is itself discovered — the key set, + whose ``jwks_uri`` comes from AS metadata — can outlive the location + it was fetched from. Comparing the recorded source against the + currently advertised one is what lets a cache notice that its + contents came from a URL the authorization server has since + replaced. None when the fetcher has no single URL to report. """ document: dict[str, Any] expires_at: float | None = None + source: str | None = None diff --git a/authplane/internal/metadata.py b/authplane/internal/metadata.py index 49e86e5..a172a33 100644 --- a/authplane/internal/metadata.py +++ b/authplane/internal/metadata.py @@ -1,14 +1,20 @@ """Authorization Server Metadata cache (RFC 8414).""" import logging +import time from typing import Any from urllib.parse import urlsplit from ..errors import MetadataFetchError, MissingMetadataEndpointError -from .document_cache import DocumentCache, DocumentChangeCallback, DocumentFetcherCallable +from .document_cache import DocumentCache, DocumentFetcherCallable logger = logging.getLogger(__name__) +#: Ceiling on the interval between forced metadata reads. The floor itself is +#: ``min(refresh_seconds, this)``, so a deployment asking for fresher metadata +#: than a minute still gets it. +_FORCED_READ_FLOOR_CEILING_SECONDS = 60.0 + class MetadataCache(DocumentCache): """AS Metadata cache with RFC 8414 validation and field extraction.""" @@ -21,13 +27,11 @@ def __init__( allow_http: bool = False, refresh_seconds: int = 3600, document_type: str = "metadata", - on_change: DocumentChangeCallback | None = None, ) -> None: super().__init__( fetcher, refresh_seconds=refresh_seconds, document_type=document_type, - on_change=on_change, error_factory=lambda msg: MetadataFetchError(msg), ) # Identity: the expected issuer is stored verbatim. RFC 8414 §3.3 @@ -36,6 +40,59 @@ def __init__( # normalized away on either side of the comparison. self._expected_issuer = expected_issuer self._allow_http = allow_http + # A forced read bypasses the refresh interval by design, so on its own + # it is no rate limit. The caller that reaches it is a JWKS `kid` miss, + # and nothing upstream of that has authenticated anything — `verify()` + # has only decoded the header — so a well-formed header carrying an + # arbitrary `kid` would otherwise cost the AS one discovery fetch per + # request, on top of the pre-existing JWKS fetch, forever. + # + # The floor caps that while still following a real rotation promptly: + # the first miss after it elapses re-reads immediately. Refusing only + # downgrades the read to an ordinary one, which still serves a valid + # cached document or refetches an expired one — and, because the + # downgraded read takes the fast path, it also stops N concurrent + # bogus-`kid` requests serializing N forced fetches behind the fetch + # lock that legitimate verifications queue on. + # + # A floor of zero would opt out, but `AuthplaneClient` rejects + # `metadata_refresh_seconds <= 0` with `ValueError`, so that state is + # unreachable through the public API here — the branch is defensive. + # Note the rotation conformance case no longer reaches this floor at + # all: it rotates under a stable `kid`, so no miss is produced and the + # re-read it drives is the ordinary interval-driven one. The floor is + # covered by the unit suite instead. + self._forced_read_floor = min(float(refresh_seconds), _FORCED_READ_FLOOR_CEILING_SECONDS) + self._last_forced_read: float | None = None + + def _is_passthrough_error(self, error: Exception, /) -> bool: + """Let a missing-endpoint rejection keep its own type. + + `_error_factory` would relabel it `MetadataFetchError` on the cold-boot + path, which is the base class — so an operator's + `except MissingMetadataEndpointError` would stop catching the condition + it was written for the moment the check moved from read time to fetch + time. The stale-fallback path is unaffected either way, since the type + subclasses the one the cache already treats as a failed refresh. + """ + return isinstance(error, MissingMetadataEndpointError) + + def _admit_forced_read(self) -> bool: + """Whether a forced read is allowed now, recording it if so.""" + if self._forced_read_floor <= 0: + return True + now = time.time() + if ( + self._last_forced_read is not None + and now - self._last_forced_read < self._forced_read_floor + ): + return False + self._last_forced_read = now + return True + + async def get(self, force_refresh: bool = False) -> dict[str, Any]: + """Return the cached document, applying the forced-read floor.""" + return await super().get(force_refresh=force_refresh and self._admit_forced_read()) def _validate_endpoint_url(self, field: str, value: str) -> None: """Validate that a metadata endpoint URL is absolute and uses HTTPS. @@ -73,7 +130,18 @@ def _validate_endpoint_url(self, field: str, value: str) -> None: f"AS metadata field {field!r} must use HTTPS, got {parsed.scheme!r}: {value!r}" ) - def _validate_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: + def _validate_fetched(self, metadata: dict[str, Any], /) -> dict[str, Any]: + """Apply the RFC 8414 checks before the document reaches the cache. + + Running here rather than on the way out is what keeps a rejected + document from deciding anything: ``jwks_uri`` is read from the cached + document on every key-set fetch, so a document that fails the §3.3 + issuer identity check would otherwise steer key retrieval to whatever + it advertised — and a token signed by that key set would then verify + against the configured issuer. A rejection leaves the last accepted + document in place, so verification keeps working off the key set it was + already using. + """ issuer = str(metadata.get("issuer", "")) if not issuer: raise MetadataFetchError("AS metadata missing required 'issuer' field") @@ -86,6 +154,26 @@ def _validate_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: if issuer.rstrip("/") == self._expected_issuer.rstrip("/"): msg += " (identifiers are compared byte-for-byte; a trailing slash is significant)" raise MetadataFetchError(msg) + # Required, not merely validated-when-present. `create()` already fails + # without it, so this only changes what a *refresh* may do: before, a + # document that had dropped `jwks_uri` validated, was committed with a + # fresh timestamp, and displaced the good one — after which every JWKS + # fetch raised for the rest of the interval, the key set went stale, and + # the first genuinely new `kid` failed to verify. RFC 8414 §2 lists the + # field as OPTIONAL, but an AS issuing JWT access tokens has no way for + # this SDK to verify one without it. + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + # MissingMetadataEndpointError, not the base class: this is the error + # `get_required_endpoint` already raised for the same condition, it is + # a package-root export documented as "required discovered endpoint + # missing", and moving the check to fetch time must not quietly change + # what an operator's `except` clause catches. It subclasses + # MetadataFetchError, so the cache's stale-fallback path treats it as + # a failed refresh either way. + raise MissingMetadataEndpointError( + "AS metadata missing required 'jwks_uri' field; keeping the previously cached document" + ) for field in ( "jwks_uri", "token_endpoint", @@ -97,10 +185,6 @@ def _validate_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: self._validate_endpoint_url(field, str(value)) return metadata - async def get(self, force_refresh: bool = False) -> dict[str, Any]: - metadata = await super().get(force_refresh=force_refresh) - return self._validate_metadata(metadata) - async def get_required_endpoint(self, field: str, force_refresh: bool = False) -> str: """Return a required AS metadata field, raising MissingMetadataEndpointError if absent.""" metadata = await self.get(force_refresh=force_refresh) diff --git a/authplane/internal/urls.py b/authplane/internal/urls.py index b8bbd2e..05eab3f 100644 --- a/authplane/internal/urls.py +++ b/authplane/internal/urls.py @@ -25,7 +25,8 @@ def host_literal(hostname: str) -> str: def _redact_authority(raw: str) -> str: - """Return ``scheme://host[:port]/path`` for use in error messages. + """Return the identifier's ``scheme:``, ``//host[:port]`` and path — each + only if present — for use in error messages. Takes the raw string and parses defensively. Every caller is on an error path handling a malformed identifier, and ``urlsplit`` itself raises on some @@ -35,41 +36,197 @@ def _redact_authority(raw: str) -> str: fragment is ever separated. Parsing outside a guard would surface urllib's message in place of the RFC citation the caller wrote. - Uses ``hostname`` (never ``netloc``) so any userinfo embedded in the - authority — e.g. ``svc:s3cr3t@`` — is never echoed into a ``ValueError`` + Uses ``hostname`` (never the raw ``netloc``) so any userinfo embedded in + the authority — e.g. ``svc:s3cr3t@`` — is never echoed into a ``ValueError`` that may be logged. The path is kept because it is not credential-shaped; the query and fragment are dropped here, since callers pass the raw identifier and it is precisely a query- or fragment-bearing one that reaches these guards. + + Renders only the components the input actually has. The absoluteness gate + exists precisely for inputs missing the scheme or the authority, and a + fixed ``scheme://host/path`` template invents the missing half: it rendered + ``urn:example:api`` as ``'urn://example:api'`` — an opaque identifier shown + as though it had an authority, against a message saying one is required — + and made the scheme-less ``api.example.com/mcp`` indistinguishable from the + scheme-relative ``//api.example.com/mcp``. The operator must see the shape + they actually configured. + + Two deliberate exceptions to that transcription, both narrower than the + alternative: the host is rendered from ``hostname``, which case-normalizes + it (RFC 3986 §3.2.2 defines the host as case-insensitive), so an uppercase + host echoes lowercased; and a port that does not parse is marked rather + than quoted — see the port note below. """ try: parsed = urlsplit(raw) except ValueError: return "(unparseable identifier)" - host = parsed.hostname or "" - # ``ParseResult.port`` parses the port lazily and raises ValueError on a - # malformed authority — ``https://h:abc/`` is exactly the kind of input that - # reaches the guards below. Raising while *building* the error message would - # surface urllib's "Port could not be cast to integer value" instead of the - # RFC citation the caller wrote, so fall back to the bare hostname. + host = host_literal(parsed.hostname or "") + # ``SplitResult.port`` parses the port lazily and raises ValueError on a + # malformed authority — ``https://h:abc/`` is exactly the kind of input the + # guards below exist for, including the port guard itself. Raising while + # *building* the error message would surface urllib's "Port could not be + # cast to integer value" instead of the RFC citation the caller wrote. + # + # The unparsed port is still rendered rather than dropped: a message naming + # the port that echoes an authority *without* one shows the operator a + # string they did not write, which is the defect this renderer exists to + # avoid. It is echoed verbatim only when it is all digits — that is the + # out-of-range case, and digits are not credential-shaped. Anything else + # gets a marker instead, because ``https://user:pass/x`` — a userinfo whose + # "@" the operator forgot — has that exact shape and ``urlsplit`` reports no + # userinfo for it, so quoting it would leak the secret this function is + # here to redact. try: port = parsed.port except ValueError: - port = None - host = host_literal(host) - if port is not None: - host = f"{host}:{port}" - return f"{parsed.scheme}://{host}{parsed.path}" - - -def validate_resource_indicator(resource: str) -> None: - """Raise ``InvalidResourceError`` if *resource* is not a usable indicator. - - RFC 8707 §2 forbids a fragment component. ``urlunsplit`` drops one silently, - so an indicator carrying a fragment would resolve to a document it does not - actually name. - - This is the construction-time gate. It has three call sites, and which one is + raw_port = parsed.netloc.rpartition("@")[2].rpartition(":")[2] + host = f"{host}:{raw_port if raw_port.isdigit() else '(malformed port)'}" + else: + if port is not None: + host = f"{host}:{port}" + # Gate the "//" on the parse's netloc OR on the raw string spelling one out + # — "https://" and "file:///x" parse to an *empty* netloc, and gating on + # the netloc alone echoed them as 'https:' and 'file:/x', dropping a + # component the operator actually wrote: the same dropped-half defect the + # fixed template had, running in the other direction. Render the redacted + # `host` built above (hostname plus the re-rendered port), never the netloc + # itself — a netloc of only userinfo would otherwise echo the credentials, + # and one of only a port ("https://:8080/x") still renders faithfully as + # "https://:8080/x". + rest = raw[len(parsed.scheme) + 1 :] if parsed.scheme else raw + scheme_part = f"{parsed.scheme}:" if parsed.scheme else "" + auth_part = f"//{host}" if parsed.netloc or rest.startswith("//") else "" + return f"{scheme_part}{auth_part}{parsed.path}" + + +#: ZERO WIDTH NO-BREAK SPACE, the one member of the class below that +#: ``str.isspace()`` does not report and that no other branch covers. See +#: ``_first_whitespace_or_control``. +_ZERO_WIDTH_NO_BREAK_SPACE = "\ufeff" + + +def _first_whitespace_or_control(value: str) -> tuple[int, str] | None: + """Return ``(offset, character)`` for the first character in *value* that is + whitespace or a control, or ``None`` when there is none. + + RFC 3986 §2 is the grounding for treating the two as one class: it builds + every URI component out of ``unreserved``, ``reserved`` and + ``pct-encoded``, all of which are printable ASCII. A string carrying any + character below is therefore not a URI at all, whatever a permissive parser + makes of it. + + Four branches, and each one covers something the others miss — measured on + 3.12 rather than assumed: + + * ``ord(ch) <= 0x20`` — the C0 controls and space. These are the characters + ``urlsplit`` silently removes or lstrips, so they are the ones that make + the parse disagree with the stored identifier. + * ``0x7F <= ord(ch) <= 0x9F`` — DEL and the C1 controls. ``urlsplit`` + neither strips nor rejects these, which is exactly why they need naming: + ``str.isspace()`` reports only U+0085 out of the whole range, so + ``https://api.example.com/m\\x7fcp`` was accepted, stored and advertised + with an invisible byte in the middle of the path. + * ``ch.isspace()`` — the rest of Unicode whitespace: U+00A0, U+1680, + U+2000-U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 and the C0 members + the first branch already has. + * ``_ZERO_WIDTH_NO_BREAK_SPACE`` — U+FEFF, named on its own because nothing + else reaches it. CPython's ``str.isspace()`` returns **False** for it: the + Unicode ``White_Space`` property does not include it (it is ``Cf``, not + ``Zs``), so a class built on ``isspace()`` alone leaves a byte-order mark + pasted into the middle of a configured identifier accepted. Verified by + execution; the symmetric difference between the four branches above and + ``isspace()`` alone is exactly U+007F-U+009F plus this one codepoint, and + the widening removes nothing. + + Non-ASCII printable characters are deliberately **not** in the class. + U+00A1 and every other printable codepoint above U+007F stays accepted: + narrowing the identifier to ASCII is a separate axis with its own migration + cost, and it is not settled here by accident. + + Returns the offset as well as the character because the offset is most of + the diagnostic value. Every character this rejects is by definition one + nothing renders, so a message that merely quotes the identifier back shows + the operator a string that looks correct. The caller reports the codepoint + and this offset; neither leaks anything else about the value. + """ + for index, char in enumerate(value): + code = ord(char) + if ( + code <= 0x20 + or 0x7F <= code <= 0x9F + or char.isspace() + or char == _ZERO_WIDTH_NO_BREAK_SPACE + ): + return index, char + return None + + +def validate_prm_resource_identifier(resource: str) -> None: + """Raise ``InvalidResourceError`` if *resource* is not a usable identifier. + + This is the gate for a **resource-server identifier** — one that publishes + Protected Resource Metadata and whose well-known URL this module derives. + It is deliberately *not* the general RFC 8707 resource-indicator predicate, + and the name says so: RFC 8707 §2 requires only an absolute URI (RFC 3986 + §4.3), which ``urn:example:api`` satisfies. The host requirement below comes + from RFC 9728 §3, which binds a resource that publishes PRM. A token + request's ``resource`` parameter (``oauth/token_exchange.py``) carries + indicators in the general sense and is not gated by this function; if a gate + for that axis is ever wanted it is a different, weaker predicate and needs a + name of its own. + + Six checks, in a deterministic order: + + 1. RFC 8707 §2 forbids a fragment component. ``urlunsplit`` drops one + silently, so an identifier carrying a fragment would resolve to a + document it does not actually name. + 2. No whitespace or control characters, checked on the raw string — + C0 and space, DEL and the C1 controls, every Unicode whitespace + character and U+FEFF; see ``_first_whitespace_or_control``. None is a + URI character (RFC 3986 §2), and ``urlsplit`` silently *cleans* or + passes through rather than rejects them — so a later check would judge + a cleaned string while the identifier is stored and advertised + verbatim, and the served PRM would name a resource differing + byte-for-byte from the URL it was fetched from (RFC 9728 §3.3 obliges + a client to discard exactly that). The message names the offending + codepoint and its offset, because every character in the class is one + nothing renders. + 3. The identifier must be an absolute URL with a scheme **and** a host. + The scheme is required by RFC 8707 §2 ("MUST be an absolute URI, as + specified by Section 4.3 of [RFC3986]", whose grammar is + ``absolute-URI = scheme ":" hier-part [ "?" query ]``). The host is + required by RFC 9728 §3: the well-known suffix is inserted after the + host component, so without one there is no derivable metadata URL — + and in the MCP adapters no derivable DPoP ``htu`` origin, which is + reconstructed as ``scheme://netloc`` from this identifier and + degraded to the literal string ``"://"``. + 4. No userinfo subcomponent. RFC 9110 §4.2.4: "a sender MUST NOT + generate the userinfo subcomponent" in an http(s) URI. The identifier + feeds three sinks that reassemble the authority verbatim — the PRM + URL handed to unauthenticated callers in a 401 ``WWW-Authenticate`` + challenge, the adapters' DPoP ``htu`` origin (which no honest proof + could then match), and a log record — so credentials embedded in it + are rejected at construction rather than redacted at each sink. + 5. No literal `"` or `\\` in the host. RFC 9110 §11.2 makes the + ``resource_metadata`` parameter of the ``WWW-Authenticate`` challenge a + quoted-string, and §5.6.4 makes those two octets its delimiters — the + first closes the string at the host, the second opens a quoted-pair a + conformant client unescapes into a different host. ``urlsplit`` admits + both and ``urlunsplit`` writes them back out unescaped. + 6. The port, if present, must parse. RFC 3986 §3.2.3 gives it as + ``*DIGIT``; ``SplitResult.port`` parses lazily, so a non-numeric or + out-of-range port ("https://api.example.com:80O/mcp", letter O for + zero) passes checks 3 and 4 — ``hostname`` and ``scheme`` are both + present — and then yields exactly the "no derivable origin" outcome + those checks exist to reject. + + The scheme is deliberately **not** narrowed to ``https``: ``http`` + identifiers stay accepted so local development works + (``http://localhost:8080/mcp``), matching the SDK's dev-mode fetch policy. + + This is the construction-time gate. It has five call sites, and which one is authoritative matters: * ``AuthplaneResource.__init__`` — the authoritative one. Every construction @@ -83,21 +240,200 @@ def validate_resource_indicator(resource: str) -> None: challenge — i.e. inside a 401 response path. Validating only there turns a configuration error into a 500 on the failure path, which is the worst place to discover it. + * ``authplane_mcp_auth(...)`` (authplane-mcp) and ``authplane_auth(...)`` + (authplane-fastmcp) — early gates ahead of ``AuthplaneClient.create()``, + so a misconfiguration is diagnosed without a reachable AS and without + stranding a client whose caches the raise path would not close. These + two are why the name is exported from the package root. Args: - resource: The resource indicator, as configured by the operator. + resource: The resource identifier, as configured by the operator. Raises: - InvalidResourceError: If the indicator carries a fragment component. - Subclasses ``ValueError``, so an existing ``except ValueError`` still - catches it. + InvalidResourceError: If the identifier carries a fragment component, + contains whitespace or a control character, is not an absolute URL + with a scheme and a host, carries a userinfo subcomponent, carries + a literal `"` or `\\` in its host, or carries a port that does not + parse. Subclasses ``ValueError``, so an existing + ``except ValueError`` still catches it. """ + # Fragment first, absoluteness second — pinned, so "/mcp#frag" reports the + # fragment. The fragment check stays on the raw string (a bare "#" is an + # empty component urlsplit reports as empty) and stays ahead of any + # parsing, so urlsplit's own ValueError on e.g. an unclosed IPv6 bracket + # cannot mask the RFC citation. if "#" in resource: safe = _redact_authority(resource) raise InvalidResourceError( - f"resource indicator must not contain a fragment component (RFC 8707 §2): {safe!r}" + f"resource identifier must not contain a fragment component (RFC 8707 §2): {safe!r}" + ) + + # Whitespace and control characters, also on the RAW string and also ahead + # of any parsing — because ``urlsplit`` silently cleans them instead of + # rejecting: it removes tab/CR/LF anywhere in the input, and since CPython + # 3.11 (the ``requires-python`` floor) additionally lstrips any leading + # C0-control-or-space, per the WHATWG alignment. Both behaviours verified + # by execution on 3.11 and 3.12. Gating on the parse result would + # therefore pass "https://api.exa\tmple.com/mcp" while the identifier is + # stored (``AuthplaneResource``) and advertised (the adapters' + # ``verbatim_resource``) byte-for-byte — the served PRM would then name a + # resource differing from the URL it was fetched from, which RFC 9728 + # §3.3 obliges a conformant client to discard. The class the scan applies + # is spelled out on ``_first_whitespace_or_control``; the echo is redacted + # like every other, and parsing for it strips the offending character, so + # the repr shows what survives and the offence names what does not. + offence = _first_whitespace_or_control(resource) + if offence is not None: + index, char = offence + safe = _redact_authority(resource) + raise InvalidResourceError( + "resource identifier must not contain whitespace or control characters " + f"(RFC 3986 §2, RFC 9728 §3.3) — invalid character U+{ord(char):04X} " + f"at offset {index}: {safe!r}" + ) + + # Scheme AND host, both checked explicitly. A guard phrased as "opaque or + # authority-less" would wrongly admit the scheme-relative form + # "//api.example.com/mcp", which urlsplit gives a netloc but no scheme. + # Each of the three rejected shapes is missing a different half: + # + # "/mcp" — relative reference: no scheme, no host. + # "//api.example.com/mcp" — scheme-relative: host but no scheme. + # "urn:example:api" — opaque: scheme but no host. Without the gate + # this derived the nonsense metadata URL + # "urn:/.well-known/oauth-protected-resource/example:api". + # + # ``hostname`` rather than ``netloc``: an authority of only userinfo or a + # port ("https://:8080/x") names no host either, so it has no RFC 9728 §3 + # insertion point — a `netloc`-based guard would admit both, which is why + # the port-only rejection is pinned by tests of its own rather than left to + # read as an accident. An identifier urlsplit itself refuses to parse + # establishes no host a fortiori, so it falls under the same rejection — + # with the redacted placeholder, not urllib's message. + try: + parsed = urlsplit(resource) + except ValueError: + parsed = None + if parsed is None or not parsed.scheme or not parsed.hostname: + safe = _redact_authority(resource) + raise InvalidResourceError( + "resource identifier must be an absolute URL with a scheme and a host " + f"(RFC 8707 §2, RFC 9728 §3): {safe!r}" ) + # Userinfo next — after absoluteness has established there is an authority + # to carry one, and ahead of the port so that credentials are the finding + # the operator is told to act on first. ``is not None`` rather than + # truthiness: "https://@api.example.com/mcp" parses with ``username == ""`` + # — the subcomponent is *present* (the "@" delimiter sits in the authority + # the sinks reassemble and the adapters advertise verbatim) even though it + # is empty, and RFC 9110 §4.2.4 forbids generating the subcomponent, not + # merely non-empty credentials. + if parsed.username is not None or parsed.password is not None: + safe = _redact_authority(resource) + raise InvalidResourceError( + f"resource identifier must not contain a userinfo component (RFC 9110 §4.2.4): {safe!r}" + ) + + # Host characters next, once the authority is known to exist and no + # credentials are left in it to be reported first. + # + # RFC 9110 §11.2 gives the auth-param value of a ``WWW-Authenticate`` + # challenge as a quoted-string, and §5.6.4 gives its content: + # ``qdtext`` is every visible octet EXCEPT the two delimiters `"` and `\`, + # and ``quoted-pair`` is `\` followed by one more octet. ``errors.py`` + # interpolates the derived PRM URL into that quoted-string as the + # ``resource_metadata`` parameter, and every derivation in this module + # carries the authority verbatim, so either delimiter in the host corrupts + # the challenge — by two different mechanisms, both of which end in the + # client addressing something other than this resource: + # + # Bearer resource_metadata="https://api"example.com/.well-known/..." + # + # closes the quoted-string at the host and re-reads the rest of the URL as + # garbage auth-params, while + # + # Bearer resource_metadata="https://api\example.com/.well-known/..." + # + # is well-formed and therefore worse: `\e` is a quoted-pair, so a + # conformant client unescapes it and fetches + # "https://apiexample.com/.well-known/..." — a different host entirely, + # with nothing in the challenge to suggest anything went wrong. + # + # ``urlsplit`` stops neither. It admits both octets into the authority and + # ``hostname`` returns them unchanged, and ``urlunsplit`` writes them back + # out unescaped, so they survive every step between this boundary and the + # header. A sweep of all 256 byte values injected into the host position on + # 3.12 — checking which construct, which survive verbatim into the derived + # PRM URL, and which are outside ``qdtext`` — leaves exactly these two plus + # DEL, and DEL is already rejected by the whitespace/control gate above. + # `[` and `]` are the only values ``urlsplit`` itself refuses. So the gate + # is two characters wide because two characters are the whole live gap; a + # general "invalid host character" sweep would be dead code on the rest. + # + # Read off ``parsed.hostname``, and that is a decision rather than a + # convenience. ``hostname`` in CPython is NOT percent-decoded — measured on + # 3.12, ``urlsplit("https://api%22example.com/mcp").hostname`` is the + # literal ``'api%22example.com'`` — so an escaped `%22` is neither decoded + # into a delimiter here nor anywhere downstream, and the derived URL + # carries the three characters ``%22``, which are ``qdtext``. It is a + # useless host, but it is not this defect, and rejecting it would be a + # rejection this rationale does not support. ``hostname`` also excludes the + # userinfo and the port, which is what makes this gate disjoint from the + # two around it: a `"` in the userinfo is caught by the gate above as an + # `@`, and one in the port by the gate below as a non-digit. + # + # ``hostname`` also excludes the path and the query, and nothing else + # covers them. RFC 9728 §3.1 inserts the well-known segment *between* the + # host and them, so all three components land inside the one + # ``resource_metadata`` quoted-string, and ``build_prm_url`` splices the + # path and the query into the derived URL verbatim — so + # `https://api.example.com/m"cp` still constructs and still reaches + # ``errors.py``'s substitution. That is asserted below, so the scope of + # this gate is pinned rather than left to be inferred. It stops at the host + # because the consequence differs in kind rather than in degree: a + # delimiter in the host sends a conformant client to a different origin, + # while one in the path or the query keeps it on this one and ends in the + # RFC 9728 §3.3 discard. Closing the rest is also a wider rejection than + # two characters — the query's own grammar (RFC 3986 §3.4) is the honest + # boundary there — and that is a decision with its own migration cost. + # + # The echo is the redacted authority like every other branch, which renders + # the host — that is the component at fault, it is what the operator has to + # change, and ``_redact_authority`` already deems it safe to print. The + # offending character is named separately because the two have different + # fixes and a bare echo makes them look like one finding. + hostname = parsed.hostname or "" + for delimiter in ('"', "\\"): + if delimiter in hostname: + safe = _redact_authority(resource) + raise InvalidResourceError( + f"resource identifier host must not contain a literal {delimiter!r} — it " + "corrupts the WWW-Authenticate quoted-string carrying resource_metadata " + f"(RFC 9110 §5.6.4, §11.2): {safe!r}" + ) + + # Port last, because it is the only check that needs a host to have been + # established AND no userinfo to be in the way. ``SplitResult.port`` parses + # lazily, so nothing above touches it: for "https://h:abc/mcp" the scheme is + # "https" and the hostname is "h", and both halves of the absoluteness gate + # pass. RFC 3986 §3.2.3 gives the port as ``*DIGIT``, so such an authority + # is not one — and the consequence is the same one the gate exists for: + # build_prm_url would derive + # "https://h:abc/.well-known/oauth-protected-resource/mcp", and the MCP + # adapters' "{scheme}://{netloc}" htu origin would be "https://h:abc", a + # value no honest client proof can match. The realistic spelling is a typo + # inside a real authority ("https://api.example.com:80O/mcp", letter O for + # zero), not a hand-written "h:abc". ``from None``: urllib's "Port could not + # be cast to integer value" is the mechanism, not the diagnosis. + try: + _ = parsed.port + except ValueError: + safe = _redact_authority(resource) + raise InvalidResourceError( + f"resource identifier must not contain a malformed port (RFC 3986 §3.2.3): {safe!r}" + ) from None + def build_prm_url(resource: str) -> str: """Build the RFC 9728 well-known Protected Resource Metadata URL. @@ -130,22 +466,31 @@ def build_prm_url(resource: str) -> str: The fully constructed PRM discovery URL. Raises: - InvalidResourceError: If the resource indicator carries a fragment - component. RFC 8707 §2 forbids a fragment in a resource indicator; - it is rejected here rather than silently discarded by - ``urlunsplit``. Subclasses ``ValueError``, so an existing - ``except ValueError`` still catches it. + InvalidResourceError: If the resource identifier carries a fragment + component (RFC 8707 §2 forbids one; it is rejected here rather than + silently discarded by ``urlunsplit``), contains whitespace or a + control character (RFC 3986 §2; ``urlsplit`` would strip it and + derive a URL diverging from the identifier stored verbatim, + RFC 9728 §3.3), is not an absolute URL with a scheme and a host + (RFC 8707 §2 requires an absolute URI; RFC 9728 §3 inserts the + well-known suffix after the host, so without one there is nothing + to derive), carries a userinfo subcomponent (RFC 9110 §4.2.4), + carries a literal `"` or `\\` in its host (RFC 9110 §5.6.4/§11.2 — + both corrupt the quoted-string this URL is interpolated into), or + carries a port that does not parse (RFC 3986 §3.2.3). Subclasses + ``ValueError``, so an existing ``except ValueError`` still catches + it. """ - # Defensive backstop. The authoritative gate is validate_resource_indicator - # called from AuthplaneResource.__init__, which every construction path - # reaches — see its docstring for the full call-site map and for why this - # must not be the only check. (A query IS preserved below per RFC 9728 §3.1; + # Defensive backstop. The authoritative gate is the same function called + # from AuthplaneResource.__init__, which every construction path reaches — + # see validate_prm_resource_identifier's docstring for the full call-site + # map and for why this must not be the only check. (A query IS preserved below per RFC 9728 §3.1; # the two components are treated asymmetrically.) # # Before parsing, for the same reason build_metadata_url checks first: # urlsplit raises on some malformed inputs, so parsing above the guard # surfaces urllib's message in place of the RFC citation. - validate_resource_indicator(resource) + validate_prm_resource_identifier(resource) # urlsplit, not urlparse: urlparse peels an RFC 3986 ";params" segment off the # last path segment, and urlunparse's params slot then has to be filled or the @@ -160,12 +505,14 @@ def build_prm_url(resource: str) -> str: # two distinct identifiers collapsing onto one document. §3.1 only speaks of # the *terminating* slash. # - # The separator is re-added when the parsed path has none: nothing upstream - # requires the identifier to be absolute, and for a scheme-less input - # urlsplit puts the whole authority in ``path`` with no leading slash. + # No separator re-add: behind the gate above the path is either empty or + # already starts with "/", because RFC 3986 §3.3 gives a path following an + # authority as exactly that ("path-abempty"), and the gate guarantees an + # authority by requiring a host. The branch that used to add one back was + # unreachable in both builders and is gone rather than kept for textual + # parallelism — two copies of a branch neither derivation can take, each + # with a coverage pragma, cost a reader more than the symmetry returns. path = parsed.path.rstrip("/") - if path and not path.startswith("/"): - path = "/" + path well_known_path = "/.well-known/oauth-protected-resource" + path # RFC 9728 §3.1 inserts the well-known segment between the host and "the path @@ -183,6 +530,167 @@ def build_prm_url(resource: str) -> str: ) +def validate_issuer_identifier(issuer: str) -> None: + """Raise ``InvalidIssuerError`` if *issuer* is not a usable issuer identifier. + + The issuer has two consumers in this SDK and they had nothing in common. + ``build_metadata_url`` checked a query and a fragment; ``build_prm`` + (``oauth/prm.py``) checked nothing at all and copied the value straight into + the ``authorization_servers`` member of the Protected Resource Metadata + document, which RFC 9728 §3 serves to **unauthenticated** callers. So an + issuer such as ``https://svc:s3cr3t@auth.example.com`` was published + verbatim to anyone who fetched the document. One predicate with both + consumers routed through it is the point of this function: a gate that only + one of two sinks applies is the shape that produced the disclosure. + + Four checks, in a deterministic order: + + 1. No query and no fragment component. RFC 8414 §2 gives the issuer + identifier as "a URL that uses the https scheme and has no query or + fragment components". Both are rejected symmetrically and on the raw + string, so a bare ``?`` or ``#`` — an empty component ``urlsplit`` + reports as empty — is rejected too: the delimiter must never survive + into the derived ``.well-known`` URL, and ``urlunsplit`` drops a + fragment silently rather than preserving it. Reconciling instead of + rejecting would let a malformed identifier resolve to a document it does + not name, which RFC 8414 §3.3 then has the client reject for an + ``issuer`` mismatch — the real cause hidden behind a confusing symptom. + 2. No whitespace or control characters, checked on the raw string, with + the same class the resource gate applies — see + ``_first_whitespace_or_control``. None is a URI character (RFC 3986 §2), + so an issuer carrying one is not the URL RFC 8414 §2 requires. The + consequence is the symptom check 1 exists to prevent, reached by a + different route: ``urlsplit`` removes tab, CR and LF from anywhere in + the input (measured on 3.12), so a configured issuer carrying one + derives a fetch target *without* it, the AS answers with its real + ``issuer``, and the byte-for-byte comparison in ``internal/metadata.py`` + — seeded from this same configured value — rejects the document as an + "AS metadata issuer mismatch". The characters ``urlsplit`` does not + strip (DEL, the C1 controls, U+00A0, U+FEFF) survive instead into the + derived ``.well-known`` URL, which is no more fetchable for it. The + message names the offending codepoint and its offset, because every + character in the class is one nothing renders. + 3. The identifier must be an absolute URL with a scheme **and** a host. + The scheme is RFC 8414 §2 (the issuer identifier is a URL). The host is + RFC 8414 **§3.1**, not §2: §3.1 derives the metadata location by + inserting ``/.well-known/oauth-authorization-server`` between the host + and the issuer's path, so with no host there is nothing for that + insertion to anchor to and the derivation yields a string no client can + fetch. ``build_metadata_url`` demonstrated exactly that — a scheme-less + ``api.example.com/mcp`` derived the relative, unfetchable + ``/.well-known/oauth-authorization-server/api.example.com/mcp``. + Checked on ``hostname`` rather than ``netloc`` for the reason the + resource gate gives: an authority of only userinfo or only a port + (``https://:8080``) names no host either. + 4. No userinfo subcomponent. RFC 9110 §4.2.4: "a sender MUST NOT generate + the userinfo subcomponent" in an http(s) URI, and RFC 3986 §3.2.1 notes + it routinely carries a credential in clear text. The issuer reaches two + sinks that reassemble the authority verbatim — the PRM document's + ``authorization_servers`` member described above, and the metadata fetch + target ``build_metadata_url`` returns — so credentials embedded in it + are rejected at construction rather than redacted at each sink. Checked + last, so an issuer that is also scheme-relative reports the missing + scheme first: that is the defect an operator fixes first. + + The scheme is deliberately **not** narrowed to ``https`` even though RFC + 8414 §2 would support it, matching the resource gate's identical relaxation + so local development works (``http://localhost:8080``). + + The whitespace class is shared with the resource gate rather than narrowed + for this one. An earlier reading of this function held that the divergence + had no equivalent on the issuer, on the grounds that the issuer's derived + URL is only ever fetched and never compared against the issuer + byte-for-byte. The derived URL is not what is compared — the *issuer + itself* is, in ``internal/metadata.py``, against the configured value — so + the divergence exists and only the symptom differs. Check 2 above carries + the mechanism. + + Args: + issuer: The authorization server issuer identifier, as configured by + the operator. + + Raises: + InvalidIssuerError: If the issuer carries a query or a fragment + component, contains whitespace or a control character, is not an + absolute URL with a scheme and a host, or carries a userinfo + subcomponent. Subclasses ``ValueError``, so an existing + ``except ValueError`` still catches it. + """ + # The raw-string check comes first: urlsplit itself raises on some + # malformed identifiers (an unclosed IPv6 bracket, for one), and this guard + # exists to report the RFC violation, not urllib's parse error. Report only + # scheme://host/path (bare hostname, never netloc) so a credential-shaped + # query (`?token=...`) or embedded userinfo does not leak into the message. + if any(c in issuer for c in ("?", "#")): + safe = _redact_authority(issuer) + raise InvalidIssuerError( + "issuer identifier must not contain a query or fragment component " + f"(RFC 8414 §2): {safe!r}" + ) + + # Whitespace and controls, on the raw string and before the parse, for the + # same reason the check above runs there: ``urlsplit`` removes tab, CR and + # LF from anywhere in the input and lstrips a leading C0-control-or-space, + # so a gate reading the parse result judges a string the operator never + # configured. The same class as the resource gate, deliberately — the two + # identifiers are configured side by side out of the same environment, and + # a byte-order mark pasted onto the end of one is not a different mistake + # from one pasted onto the end of the other. + # + # The consequence here is not RFC 9728 §3.3 but the confusing symptom + # check 1 above exists to prevent. ``internal/metadata.py`` compares the + # AS-advertised ``issuer`` against the configured one byte-for-byte, seeded + # with this very value, so a configured issuer carrying a tab fetches from + # a target that has had the tab removed, the AS answers honestly with its + # own identifier, and the two disagree — surfacing as "AS metadata issuer + # mismatch", which points the operator at the AS rather than at the + # invisible character in their own configuration. The members of the class + # ``urlsplit`` leaves alone fail earlier and more plainly: they ride into + # the derived ``.well-known`` URL and the fetch simply does not resolve. + offence = _first_whitespace_or_control(issuer) + if offence is not None: + index, char = offence + safe = _redact_authority(issuer) + raise InvalidIssuerError( + "issuer identifier must not contain whitespace or control characters " + f"(RFC 3986 §2, RFC 8414 §2) — invalid character U+{ord(char):04X} " + f"at offset {index}: {safe!r}" + ) + + # Scheme AND host, both explicit, for the reasons in the docstring. An + # identifier urlsplit itself refuses to parse establishes no host a + # fortiori, so it falls under the same rejection — with the redacted + # placeholder, not urllib's message. CPython's urlsplit invents no + # authority for a scheme it recognises: "https:auth.example.com" and + # "https:/auth.example.com" both parse with an empty netloc and no + # hostname, so reading `hostname` settles the question and no separate + # check for the authority's "//" on the raw string is needed. Verified by + # execution on 3.12. A parser that filled the authority in would need that + # extra check, because an identifier RFC 3986 §3 gives no authority at all + # would otherwise be read as carrying the host this gate requires. + try: + parsed = urlsplit(issuer) + except ValueError: + parsed = None + if parsed is None or not parsed.scheme or not parsed.hostname: + safe = _redact_authority(issuer) + raise InvalidIssuerError( + "issuer identifier must be an absolute URL with a scheme and a host " + f"(RFC 8414 §2, §3.1): {safe!r}" + ) + + # ``is not None`` rather than truthiness, for the reason the resource gate + # spells out: "https://@auth.example.com" parses with ``username == ""`` and + # the subcomponent is *present* — the "@" delimiter sits in the authority + # both sinks reassemble — even though it is empty, and RFC 9110 §4.2.4 + # forbids generating the subcomponent, not merely non-empty credentials. + if parsed.username is not None or parsed.password is not None: + safe = _redact_authority(issuer) + raise InvalidIssuerError( + f"issuer identifier must not contain a userinfo component (RFC 9110 §4.2.4): {safe!r}" + ) + + def build_metadata_url(issuer: str) -> str: """Build the OAuth 2.0 Authorization Server Metadata URL per RFC 8414. @@ -209,42 +717,43 @@ def build_metadata_url(issuer: str) -> str: The fully constructed metadata discovery URL. Raises: - InvalidIssuerError: If the issuer carries a query or fragment component. - Subclasses ``ValueError``, so existing ``except ValueError`` - handlers are unaffected. RFC - 8414 §2 requires the issuer identifier to have neither. This raises - at construction rather than silently discarding the component — that - reconciliation would let a malformed identifier resolve to a - document it does not actually name, and would later surface as a - confusing "issuer mismatch" instead of the real cause. (A fragment - is not preserved by ``urlunsplit`` at all, so without this gate a - fragment-bearing issuer would be silently dropped.) + InvalidIssuerError: If the issuer carries a query or a fragment + component (RFC 8414 §2 forbids both; they are rejected here rather + than silently reconciled — that reconciliation would let a malformed + identifier resolve to a document it does not actually name, and + would later surface as a confusing "issuer mismatch" instead of the + real cause, and a fragment is not preserved by ``urlunsplit`` at + all), contains whitespace or a control character (RFC 3986 §2 — + ``urlsplit`` strips some of the class and passes the rest through, + so either way the derived URL stops agreeing with the configured + identifier), is not an absolute URL with a scheme and a host + (RFC 8414 §2 requires a URL; §3.1 inserts the well-known suffix + after the host, so without one there is nothing to derive), or + carries a userinfo subcomponent (RFC 9110 §4.2.4). Subclasses + ``ValueError``, so existing ``except ValueError`` handlers are + unaffected. """ - # The raw-string check comes first: urlsplit itself raises on some malformed - # identifiers (an unclosed IPv6 bracket, for one), and this guard exists to - # report the RFC violation, not urllib's parse error. - # - # RFC 8414 §2: the issuer identifier MUST NOT contain a query OR fragment - # component. Gate on the raw string so a bare `?`/`#` (empty component, which - # urlsplit reports as empty) is rejected too — the delimiter must never - # survive into the derived `.well-known` URL, and urlunsplit silently drops a - # fragment entirely. Report only scheme://host/path (bare hostname, never - # netloc) so a credential-shaped query (e.g. `?token=...`) or embedded - # userinfo does not leak into the message. - if any(c in issuer for c in ("?", "#")): - safe = _redact_authority(issuer) - raise InvalidIssuerError( - "issuer identifier must not contain a query or fragment component " - f"(RFC 8414 §2): {safe!r}" - ) + # One gate, shared with build_prm. This function used to carry its own + # inline query/fragment check, which left the other consumer of an issuer — + # oauth/prm.py's build_prm, which copies it into the PRM document's + # `authorization_servers` member — with no check at all, and let the two + # drift. The shared gate is a superset of what was here: it still rejects a + # query or a fragment, and it additionally rejects an issuer that is not an + # absolute URL with a scheme and a host, or that carries userinfo. Both + # additions are reachable from here — the derivation below concatenates the + # parsed path onto the well-known suffix, so a host-less issuer produced a + # relative string no client could fetch, and a `svc:s3cr3t@` issuer was + # carried verbatim into the metadata fetch target. + validate_issuer_identifier(issuer) parsed = urlsplit(issuer) - # Keep the path's own leading slash and remove only terminating ones, and - # re-add the separator when there is none — see the note in build_prm_url. + # Keep the path's own leading slash and remove only terminating ones — see + # the note in build_prm_url, including why there is no separator re-add + # here either. Before the shared gate this builder accepted a scheme-less + # identifier, which put the whole authority in ``path`` with no leading + # slash; requiring a host is what made that branch unreachable. path = parsed.path.rstrip("/") - if path and not path.startswith("/"): - path = "/" + path well_known_path = "/.well-known/oauth-authorization-server" + path return urlunsplit( @@ -256,3 +765,110 @@ def build_metadata_url(issuer: str) -> str: "", # fragment ) ) + + +_RESOURCE_METADATA_URL_SCHEMES = ("http", "https") + + +def validate_resource_metadata_url(url: str) -> None: + """Raise ``InvalidResourceError`` if *url* cannot be advertised as ``resource_metadata``. + + This gates the operator-configured override for the RFC 9728 §5.1 + ``resource_metadata`` challenge parameter — the case where the Protected + Resource Metadata document is hosted by the authorization server rather + than derived from the resource identifier (``build_prm_url``). The derived + URL inherits its guarantees from ``validate_prm_resource_identifier``; a + configured one has to earn the same ones here, because it reaches the same + sink: the quoted-string of a ``WWW-Authenticate`` challenge served to an + unauthenticated caller, which then fetches it. + + The checks mirror the issuer gate — absolute URL with a scheme and a host, + no whitespace or control characters, no userinfo, a parseable port — with + two differences that follow from this being a URL to *fetch* rather than an + identifier to *compare*: + + * The scheme is narrowed to ``http`` / ``https``. The identifier gates leave + the scheme open because their value is compared, not dereferenced; this + one is dereferenced by every client that honours the challenge, and + RFC 9728 §5.1 gives the parameter as a URL of the metadata document. + ``http`` stays accepted for the same reason it does on the issuer: + local development against a loopback AS. + * A literal ``"`` or ``\\`` is rejected **anywhere** in the value, not only + in the host. The whole string lands inside one quoted-string (RFC 9110 + §5.6.4, §11.2), and ``_sanitize_header_value`` would otherwise replace + the delimiter with a space and advertise a URL that fetches nothing, + silently. The identifier gate stops at the host because the path and + query of a derived URL are the operator's identifier and rejecting them + is a migration; a configured override has no such constraint. + + A query is accepted, as it is on a derived URL. A fragment is rejected: + it is never sent on the wire, so a value carrying one names a document the + client does not fetch. + + Args: + url: The metadata URL, as configured by the operator. + + Raises: + InvalidResourceError: If the URL carries a fragment, contains + whitespace or a control character, is not an absolute ``http`` / + ``https`` URL with a host, carries a userinfo subcomponent, carries + a literal ``"`` or ``\\``, or carries a port that does not parse. + Subclasses ``ValueError``, so an existing ``except ValueError`` + still catches it. + """ + if "#" in url: + safe = _redact_authority(url) + raise InvalidResourceError( + f"resource metadata URL must not contain a fragment component (RFC 9728 §5.1): {safe!r}" + ) + + offence = _first_whitespace_or_control(url) + if offence is not None: + index, char = offence + safe = _redact_authority(url) + raise InvalidResourceError( + "resource metadata URL must not contain whitespace or control characters " + f"(RFC 3986 §2) — invalid character U+{ord(char):04X} at offset {index}: {safe!r}" + ) + + # Both delimiters, over the whole string — see the docstring for why this + # is wider than the identifier gate's host-only scan. + for delimiter in ('"', "\\"): + if delimiter in url: + safe = _redact_authority(url) + raise InvalidResourceError( + f"resource metadata URL must not contain a literal {delimiter!r} — it corrupts " + "the WWW-Authenticate quoted-string carrying resource_metadata " + f"(RFC 9110 §5.6.4, §11.2): {safe!r}" + ) + + try: + parsed = urlsplit(url) + except ValueError: + parsed = None + if parsed is None or not parsed.scheme or not parsed.hostname: + safe = _redact_authority(url) + raise InvalidResourceError( + "resource metadata URL must be an absolute URL with a scheme and a host " + f"(RFC 9728 §5.1): {safe!r}" + ) + + if parsed.scheme not in _RESOURCE_METADATA_URL_SCHEMES: + safe = _redact_authority(url) + raise InvalidResourceError( + f"resource metadata URL scheme must be http or https (RFC 9728 §5.1): {safe!r}" + ) + + if parsed.username is not None or parsed.password is not None: + safe = _redact_authority(url) + raise InvalidResourceError( + f"resource metadata URL must not contain a userinfo component (RFC 9110 §4.2.4): {safe!r}" + ) + + try: + _ = parsed.port + except ValueError: + safe = _redact_authority(url) + raise InvalidResourceError( + f"resource metadata URL must not contain a malformed port (RFC 3986 §3.2.3): {safe!r}" + ) from None diff --git a/authplane/oauth/prm.py b/authplane/oauth/prm.py index c1d11b6..338c591 100644 --- a/authplane/oauth/prm.py +++ b/authplane/oauth/prm.py @@ -5,6 +5,8 @@ from collections.abc import Sequence +from ..internal.urls import validate_issuer_identifier, validate_prm_resource_identifier + def build_prm( issuer: str, @@ -28,7 +30,48 @@ def build_prm( Returns: Dictionary containing RFC 9728 compliant PRM document + + Raises: + InvalidIssuerError: If *issuer* carries a query or a fragment component + (RFC 8414 §2), contains whitespace or a control character + (RFC 3986 §2), is not an absolute URL with a scheme and a host + (RFC 8414 §2, §3.1), or carries a userinfo subcomponent + (RFC 9110 §4.2.4). Subclasses ``ValueError``, so an existing + ``except ValueError`` still catches it. + InvalidResourceError: If *resource* is not a usable resource-server + identifier — see ``validate_prm_resource_identifier`` for the six + checks (RFC 8707 §2, RFC 3986 §2/§3.2.3, RFC 9728 §3/§3.3, + RFC 9110 §4.2.4/§5.6.4). Also subclasses ``ValueError``. The issuer + is checked first, so an argument list with both defects reports the + issuer — the member served to unauthenticated callers. """ + # This builder is exported and its documented use is to serve the returned + # document directly from a `/.well-known/oauth-protected-resource` + # endpoint, so it is a publication boundary in its own right — and RFC 9728 + # §3 has that endpoint answer **unauthenticated** callers. The issuer was + # copied into `authorization_servers` below with no check whatsoever, so + # `https://svc:s3cr3t@auth.example.com` was handed verbatim to every client + # that asked. Redacting at this one sink would not be a fix: the same + # identifier is also the metadata fetch target `build_metadata_url` + # derives, and a gate applied at one of two sinks is exactly the shape that + # produced this. Rejecting at the boundary is what makes the guarantee, + # so both consumers now route through the one predicate. + # + # `resource` is gated by the same argument rather than left to the caller. + # It is the very next member of the same published document, and it is the + # one RFC 9728 §3.3 makes load-bearing: a client MUST discard a document + # whose `resource` does not match the identifier it dereferenced. Nothing + # in this signature guarantees the caller derived the value from a gated + # `AuthplaneResource` — it is a plain `str` on a public builder — so an + # identifier carrying a fragment, whitespace, userinfo or an unparseable + # port could be published here while the well-known URL a client reached it + # by could never have been derived from it. Every in-SDK path already + # reaches `validate_prm_resource_identifier` in `AuthplaneResource.__init__` + # (`verifier/verifier.py` forwards the already-gated `self._resource`), so + # the new rejection lands exactly on the direct callers of this builder, + # which is the set that had no gate at all. + validate_issuer_identifier(issuer) + validate_prm_resource_identifier(resource) doc: dict[str, object] = { "resource": resource, "authorization_servers": [issuer], diff --git a/authplane/oauth/types.py b/authplane/oauth/types.py index 47644fb..cca10b9 100644 --- a/authplane/oauth/types.py +++ b/authplane/oauth/types.py @@ -1,6 +1,8 @@ """OAuth protocol types and constants.""" +import logging from dataclasses import dataclass, field +from typing import Any, Protocol # --------------------------------------------------------------------------- # Introspection-based revocation marker (sentinel) @@ -12,7 +14,26 @@ class IntrospectionRevocation: """Marker that triggers RFC 7662 introspection-based revocation checking. Pass an instance to ``AuthplaneClient.resource(revocation_checker=...)`` - to enable fail-open introspection checking for every ``verify()`` call. + to introspect the token at the AS after local JWT verification, on + every ``verify()`` call. + + **An introspection error lets the token through.** Pass + ``fail_closed=True`` alongside this marker to refuse it instead. The + two directions trade different things away, and neither is safe in the + abstract: + + * **Fail-open** (the default) keeps the resource server serving when the + AS is unreachable — which is what local JWT validation exists for — at + the cost of honouring a token that may already have been revoked, for + as long as the outage lasts. + * **Fail-closed** (``fail_closed=True``) never honours a token it could + not confirm, at the cost of taking the resource server down with the + introspection endpoint. + + Pick fail-closed when an unconfirmed token would authorise something you + cannot take back — writes, payments, executing statements on the + caller's behalf. Pick the default when availability during an AS outage + matters more than closing the revocation window. """ @@ -91,3 +112,42 @@ class IntrospectionResponse: # Authplane extensions agent_id: str = "" agent_chain: tuple[str, ...] = field(default_factory=tuple) + + +# --------------------------------------------------------------------------- +# Startup diagnostics shared by the client factory and the resource gate +# --------------------------------------------------------------------------- + +logger = logging.getLogger("authplane.client") + + +class _AuthCapableClient(Protocol): + @property + def can_authenticate(self) -> bool: ... + + +def warn_unauthenticated_introspection( + client: "_AuthCapableClient", + revocation_checker: Any, + resource: str, +) -> None: + """Warn when introspection is configured on a client with no AS credentials. + + Unauthenticated introspection is not an error path — the AS answers 200 — + so nothing downstream will flag it. authserver >= 0.1.2 answers + ``active: false`` unless the caller is the issuing client or a + runtime-client of the Resource in ``aud``, and a checker that cannot + authenticate therefore rejects every token as revoked. Not rejected + outright because the unauthenticated request is a documented RFC 7662 + shape other servers accept; said once, at startup, where the operator who + omitted ``auth=`` will see it. + """ + if isinstance(revocation_checker, IntrospectionRevocation) and not client.can_authenticate: + logger.warning( + "IntrospectionRevocation configured without AS credentials: authserver " + ">= 0.1.2 answers active=false to unauthenticated introspection, so every " + "token will be rejected as revoked. Pass auth=ASCredentials(...) to " + "AuthplaneClient.create() for a confidential client that is the issuing " + "client or a runtime-client of this resource", + extra={"resource": resource}, + ) diff --git a/authplane/verifier/claims.py b/authplane/verifier/claims.py index e85991c..67e079d 100644 --- a/authplane/verifier/claims.py +++ b/authplane/verifier/claims.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from types import MappingProxyType @@ -72,8 +73,10 @@ def require_scopes(self, scopes: Iterable[str]) -> None: requested tuple on ``required_scopes`` (not just the missing ones, so adapters that surface ``scope="…"`` in the WWW-Authenticate challenge keep emitting the complete required set), and its message names every - missing scope plus the scopes the token carries — rendered verbatim - into the RFC 6750 ``error_description``. + missing scope plus the scopes the token carries. That message stays on + the exception for the resource server to log; the RFC 6750 + ``error_description`` carries a fixed, caller-safe sentence instead, + unless the challenge is built with ``verbose_description=True``. """ # Materialise once: the caller may pass any iterable (generator, set, # frozenset, etc.). We need to iterate twice — once to find missing @@ -112,7 +115,19 @@ def act(self) -> Mapping[str, Any] | None: @property def may_act(self) -> Mapping[str, Any] | None: - """Return the ``may_act`` claim, or None if absent (RFC 8693 Section 4.4).""" + """Return the ``may_act`` claim, or None if absent (RFC 8693 Section 4.4). + + .. deprecated:: + authserver 0.2.0 no longer issues ``may_act``; removed in the next + minor. The claim is still read when present, so the accessor keeps + working against a server that emits it. + """ + warnings.warn( + "VerifiedClaims.may_act is deprecated: authserver 0.2.0 no longer issues " + "may_act; removed in the next minor", + DeprecationWarning, + stacklevel=2, + ) v: object = self.raw.get("may_act") if not isinstance(v, Mapping): return None diff --git a/authplane/verifier/verifier.py b/authplane/verifier/verifier.py index 7632a7d..8333080 100644 --- a/authplane/verifier/verifier.py +++ b/authplane/verifier/verifier.py @@ -30,15 +30,23 @@ InvalidClaimsError, InvalidSignatureError, JWKSFetchError, + MetadataFetchError, TokenExpiredError, TokenMissingError, TokenRevokedError, VerifierRuntimeError, ) from ..internal.jwt import decode_jwt_header -from ..internal.urls import build_prm_url, validate_resource_indicator +from ..internal.urls import ( + build_prm_url, + validate_prm_resource_identifier, + validate_resource_metadata_url, +) from ..oauth.prm import build_prm -from ..oauth.types import IntrospectionRevocation +from ..oauth.types import ( + IntrospectionRevocation, + warn_unauthenticated_introspection, +) from .claims import VerifiedClaims, freeze_value if TYPE_CHECKING: @@ -59,12 +67,23 @@ class AuthplaneResource: construction-time guarantee does not depend on which path was taken. Raises: - InvalidResourceError: If *resource* carries a fragment component. - RFC 8707 §2 forbids one in a resource indicator. Rejected here, at - construction, rather than from ``prm_url()`` while composing an - RFC 9728 challenge — i.e. from inside a 401 response path. - Subclasses ``ValueError``, so an existing ``except ValueError`` - still catches it. + InvalidResourceError: If *resource* carries a fragment component + (RFC 8707 §2 forbids one in a resource indicator), contains + whitespace or a control character (RFC 3986 §2; parsing would + strip it, diverging from the identifier stored verbatim, + RFC 9728 §3.3), is not an absolute URL with a scheme and a host + (RFC 8707 §2 requires an absolute URI; RFC 9728 §3 derives the + metadata URL by inserting the well-known suffix after the host), + carries a userinfo subcomponent (RFC 9110 §4.2.4), or carries a + port that does not parse (RFC 3986 §3.2.3). Rejected + here, at construction, rather than from ``prm_url()`` while + composing an RFC 9728 challenge — i.e. from inside a 401 response + path. Subclasses ``ValueError``, so an existing ``except + ValueError`` still catches it. Also raised when + *resource_metadata_url* is not an absolute ``http`` / ``https`` + URL, or carries a fragment, whitespace or a control character, a + userinfo subcomponent, a ``"`` or a ``\\``, or a port that does not + parse — see :func:`validate_resource_metadata_url`. ValueError: If *allowed_algorithms* contains an algorithm outside ``("RS256", "ES256")``. """ @@ -79,6 +98,7 @@ def __init__( revocation_checker: RevocationChecker | IntrospectionRevocation | None = None, fail_closed: bool = False, inbound_dpop: InboundDPoPOptions | None = None, + resource_metadata_url: str | None = None, ) -> None: # THIS is the authoritative resource gate — every construction path # goes through it. The class is exported from the package root, so @@ -93,7 +113,15 @@ def __init__( # by test_client_resource_rejects_fragment_at_construction, which # asserts the invoking frame — do not dedupe the pair without reading # it. build_prm_url's call is the third, a defensive backstop. - validate_resource_indicator(resource) + validate_prm_resource_identifier(resource) + + # Same argument, one sink further along: the override is advertised in + # the same challenge parameter the derived URL would be, so it is gated + # at construction rather than on the 401 path. The derived URL gets its + # guarantees from the identifier gate above; a configured one has none + # until this call. + if resource_metadata_url is not None: + validate_resource_metadata_url(resource_metadata_url) invalid = [alg for alg in allowed_algorithms if alg not in _ALLOWED_ALGORITHMS] if invalid: @@ -103,6 +131,7 @@ def __init__( self._client = client self._resource = resource + self._resource_metadata_url = resource_metadata_url self._scopes = tuple(scopes) self._allowed_algorithms = allowed_algorithms self._clock_skew_seconds = clock_skew_seconds @@ -135,6 +164,15 @@ def __init__( else: self._revocation_checker = revocation_checker + # Authoritative site for the same reason the identifier gate above is: + # direct construction is supported, and a check living only in + # AuthplaneClient.resource() would leave that path silent about the one + # misconfiguration that produces no error anywhere — `active: false` on + # every token. The factory keeps a copy for the traceback. + warn_unauthenticated_introspection(client, revocation_checker, resource) + # Once per resource: see _introspection_checker. + self._introspection_ownership_warned = False + @property def scopes(self) -> tuple[str, ...]: """Return the scopes configured for this verifier.""" @@ -304,6 +342,39 @@ async def _maybe_verify_dpop( allowed_algorithms=self._dpop_allowed_proof_algorithms, ) + async def _refresh_metadata(self, *, force: bool = False) -> None: + """Keep the AS metadata document warm from the verification path. + + Verification is the one path through the SDK that never calls an AS + endpoint, so on a resource server that only verifies tokens nothing + else re-enters the metadata cache after the client is created. Without + this hop ``metadata_refresh_seconds`` would never elapse into a fetch + and a rotated ``jwks_uri`` would never be followed — the key set would + keep being fetched from the URI the document named at construction. + + :meth:`MetadataCache.get` is TTL-gated, so ordinary traffic pays a + comparison until the interval is up and one fetch after that. + + A metadata problem must not fail a verification the cached key set can + still satisfy: token-level issuer identity is checked against the + configured issuer, not against metadata, and the cache keeps serving + the last document it accepted when a refresh fails. So a refresh error + is logged and verification continues. + """ + metadata_cache = self._client.metadata_cache + if metadata_cache is None: + return + try: + await metadata_cache.get(force_refresh=force) + except MetadataFetchError as exc: + # The only error the metadata cache raises: its error factory wraps + # transport, parse and validation failures alike. + logger.warning( + "AS metadata refresh failed during verification, " + "continuing with the cached key set", + extra={"issuer": self._client.issuer, "error": str(exc)}, + ) + async def _verify_token_core(self, token: str) -> VerifiedClaims: jwks_cache = self._client.jwks_cache if not jwks_cache: @@ -325,11 +396,21 @@ async def _verify_token_core(self, token: str) -> VerifiedClaims: if header.get("typ") != "at+jwt": raise InvalidClaimsError(f"Token type must be 'at+jwt', got '{header.get('typ')}'") + # After the header checks above, so a structurally invalid token from an + # unauthenticated caller is rejected without reaching network I/O. + await self._refresh_metadata() + key_dict = await jwks_cache.get_key_by_kid(kid, algorithm=alg) if key_dict is None: logger.info("Kid not found in JWKS, forcing refresh", extra={"kid": kid}) - # A single forced refresh covers normal key rotation without letting - # an attacker turn every unknown kid into repeated network churn. + # A kid miss says the cached answer is stale, and the cached + # metadata document that names where keys live is no more current + # than the key set it produced. Re-read it first, so a rotation is + # followed on the request that first needs the new key instead of + # at the next interval boundary. A single forced refresh of each + # covers normal key rotation without letting an attacker turn every + # unknown kid into repeated network churn. + await self._refresh_metadata(force=True) key_dict = await jwks_cache.get_key_by_kid(kid, force_refresh=True, algorithm=alg) if key_dict is None: raise InvalidSignatureError(f"Token kid '{kid}' not found in JWKS after refresh") @@ -427,7 +508,27 @@ async def _introspection_checker(self, claims: VerifiedClaims, raw_token: str) - policy configured via the ``fail_closed`` parameter. """ result = await self._client.introspect(raw_token) - return not result.active + if result.active: + return False + # The token already passed local verification, so `active: false` is + # either a real revocation or the AS declining to answer: authserver + # >= 0.1.2 returns it to any introspecting client that is neither the + # issuing client nor a runtime-client of the Resource in `aud`. The two + # are indistinguishable on the wire, and the second rejects every token + # with no error anywhere — so name the cause once, on the first + # rejection, rather than on each of the thousands that follow. + if not self._introspection_ownership_warned: + self._introspection_ownership_warned = True + logger.warning( + "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: the introspecting client " + "must be confidential and either the issuing client or a runtime-client " + "of the resource (authserver admin resource runtime-client add " + "--client-id --slug )", + extra={"jti": claims.jti, "resource": self._resource}, + ) + return True def prm_response(self) -> dict[str, object]: """Build protected resource metadata for this verifier scope.""" @@ -447,9 +548,40 @@ def prm_response(self) -> dict[str, object]: def prm_url(self) -> str: """Return the RFC 9728 well-known PRM discovery URL for this resource. - Symmetric with :meth:`prm_response`: this is the URL clients can fetch - to retrieve that document, suitable for the ``resource_metadata`` - challenge parameter (:func:`authplane.www_authenticate`, - :func:`authplane.response_headers_for`). + Symmetric with :meth:`prm_response`: this is the URL a client fetches + to retrieve *that* document — the one this SDK builds — derived from + the resource identifier per RFC 9728 §3.1. + + This is the derivation, not the advertisement. Use + :meth:`resource_metadata_url` for the ``resource_metadata`` challenge + parameter (:func:`authplane.www_authenticate`, + :func:`authplane.response_headers_for`): the two differ exactly when + the resource was configured to point clients at a PRM document it does + not host itself. """ return build_prm_url(self._resource) + + def resource_metadata_url(self) -> str: + """Return the URL to advertise as RFC 9728 §5.1 ``resource_metadata``. + + The configured ``resource_metadata_url`` when the resource was built + with one, and :meth:`prm_url` otherwise — so a caller composing a + challenge reads one accessor and gets whichever topology the resource + is deployed in. + + .. warning:: + + RFC 9728 §3.3 binds the served document's ``resource`` member to the + URL the document was fetched from, not to the API URL the client + called: the value "MUST be identical to the protected resource's + resource identifier value into which the well-known URI path suffix + was inserted to create the URL used to retrieve the metadata", and + otherwise "MUST NOT be used". Those coincide only when the metadata + URL is the §3.1 derivation of the resource identifier. Nothing in + §5.1 exempts a challenge-supplied URL from that rule, so an + override pointing at a document on a different origin is usable + only against clients that do not enforce §3.3 — and that check is + what stops a resource server from pointing a client at metadata + describing somebody else's resource. Prefer the derived URL. + """ + return self._resource_metadata_url or self.prm_url() diff --git a/conformance-tests/README.md b/conformance-tests/README.md index 20accc8..398f21f 100644 --- a/conformance-tests/README.md +++ b/conformance-tests/README.md @@ -134,9 +134,25 @@ After each run, two reports are generated in the project root: | `test_jwt_and_dpop_conformance.py` | RFC 9068, RFC 8725, RFC 9449, RFC 9728 | | `test_oauth_protocol_conformance.py` | RFC 6749, RFC 7009, RFC 7662, RFC 8693, RFC 8707 | | `test_rfc8414_conformance.py` | RFC 8414 | -| `test_catalog_alignment.py` | Meta-test: ensures every catalog case has a `@pytest.mark.conformance` marker | +| `test_catalog_alignment.py` | Meta-test: catalog and `@pytest.mark.conformance` markers agree in both directions | | `conftest.py` | Harness: marker extraction, result collection, report generation | ## Catalog Alignment -`test_catalog_alignment.py` uses AST parsing to verify that every case ID in the shared catalog has a corresponding `@pytest.mark.conformance("case-id")` marker somewhere in the suite. If a new case is added to the catalog without a matching test, this check fails. +`test_catalog_alignment.py` uses AST parsing to compare the case IDs in the +shared catalog against the `@pytest.mark.conformance("case-id")` markers in the +suite, and asserts that they agree in **both** directions: + +| Test | Fails when | +|------|------------| +| `test_catalog_case_ids_are_represented_in_conformance_tests` | A catalog case has no marker — the SDK does not cover it, and the report carries it as `not_run`. | +| `test_conformance_markers_name_only_catalog_case_ids` | A marker names a case ID the catalog does not carry — a typo, a renamed case, or a case dropped from the catalog. | + +Neither direction implies the other. The report in `conftest.py` is built by +iterating the *catalog's* case IDs, so a marker naming an unknown ID is dropped +from it silently: without the second check the run stays green while the catalog +case that marker was meant to cover has no coverage at all. + +Together they are what makes a `.conformance-catalog-ref` bump safe in both +directions — bumping the pin without adding markers goes red on the first check, +and adding markers without bumping the pin goes red on the second. diff --git a/conformance-tests/_catalog.py b/conformance-tests/_catalog.py new file mode 100644 index 0000000..1733fd9 --- /dev/null +++ b/conformance-tests/_catalog.py @@ -0,0 +1,77 @@ +"""One reader for the shared conformance catalog. + +``conftest.py`` builds the conformance report by iterating the catalog's case +ids, and ``test_catalog_alignment.py`` asserts that those ids and the suite's +``@pytest.mark.conformance`` markers agree in both directions. That assertion is +only worth anything if the two are reading the *same* mapping — otherwise the +test certifies a catalog the report never saw. + +They used to be separate copies held together by a docstring saying "resolve the +catalog the same way conftest does", and had already drifted three ways: one +used ``partition`` and the other ``split(...)[1]``, one returned a ``set`` and +the other a ``list``, and only one carried the empty-parse guard. This module is +the single copy, so the agreement is structural rather than asserted in prose. + +Importing ``conftest`` from a test module is not an alternative: pytest has +already loaded it under its own module name, and a second import re-runs its +module-level fixture setup. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] + +# Default layout: python-sdk and conformance cloned as siblings (see README +# `Catalog path` section). Contributors with a different layout override via +# AUTHPLANE_CONFORMANCE_CATALOG. +_DEFAULT_CATALOG_PATH = _ROOT.parent / "conformance" / "oauth-sdk-conformance-catalog.yaml" + +_CATALOG_CASE_ID_RE = re.compile(r'^\s+- id: "([^"]+)"\s*$', re.MULTILINE) +_CATALOG_VERSION_RE = re.compile(r'^catalog_version:\s*"([^"]+)"\s*$', re.MULTILINE) + + +def catalog_path() -> Path: + """The catalog this suite reads, honouring the environment override.""" + override = os.environ.get("AUTHPLANE_CONFORMANCE_CATALOG") + return Path(override) if override else _DEFAULT_CATALOG_PATH + + +def catalog_path_is_explicit() -> bool: + """Whether the path came from the environment rather than the default layout.""" + return "AUTHPLANE_CONFORMANCE_CATALOG" in os.environ + + +def load_catalog_case_ids(path: Path | None = None) -> set[str]: + """Every case id under ``cases:``. + + Raises ``AssertionError`` naming the file when the parse yields nothing. A + catalog that parsed to nothing passes the coverage direction vacuously and + reports every marker as an orphan — drift-shaped output from what is really + a broken harness, so it has to say which it is. + """ + resolved = path or catalog_path() + text = resolved.read_text(encoding="utf-8") + # ``partition`` rather than ``split(...)[1]``: a catalog with no ``cases:`` + # key at all is the clearest form of the fault the assert below names, and + # indexing would raise a bare ``IndexError`` before it could be reached. + _, separator, cases_text = text.partition("cases:") + case_ids: set[str] = set(_CATALOG_CASE_ID_RE.findall(cases_text)) if separator else set() + assert case_ids, ( + f"No case ids parsed out of {resolved}. The catalog failed to parse or the " + "wrong file was resolved — this is a harness problem, not catalog drift." + ) + return case_ids + + +def load_catalog_version(path: Path | None = None) -> str: + """The ``catalog_version`` string, for the report header.""" + resolved = path or catalog_path() + text = resolved.read_text(encoding="utf-8") + match = _CATALOG_VERSION_RE.search(text) + if match is None: # pragma: no cover - defensive guard + raise RuntimeError(f"Unable to locate catalog_version in {resolved}") + return match.group(1) diff --git a/conformance-tests/conftest.py b/conformance-tests/conftest.py index 32c32e3..4c04958 100644 --- a/conformance-tests/conftest.py +++ b/conformance-tests/conftest.py @@ -56,7 +56,7 @@ async def test_...(...): import json import os -import re +import sys from datetime import UTC, datetime from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path @@ -66,11 +66,23 @@ async def test_...(...): import authplane +# `--import-mode=importlib` (pyproject) does not put a test directory on +# sys.path, so the sibling `_catalog` module is not importable by name without +# this. conftest is imported before the test modules in its directory, so doing +# it here covers `test_catalog_alignment.py` too. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _catalog import ( + catalog_path, + load_catalog_case_ids, + load_catalog_version, +) + _ROOT = Path(__file__).resolve().parents[1] # Default layout: python-sdk and conformance cloned as siblings (see README # `Catalog path` section). Contributors with a different layout override via # AUTHPLANE_CONFORMANCE_CATALOG. -_DEFAULT_CATALOG_PATH = _ROOT.parent / "conformance" / "oauth-sdk-conformance-catalog.yaml" +_DEFAULT_CATALOG_PATH = catalog_path() _CATALOG_PATH = ( Path(os.environ["AUTHPLANE_CONFORMANCE_CATALOG"]) if "AUTHPLANE_CONFORMANCE_CATALOG" in os.environ @@ -101,6 +113,7 @@ async def test_...(...): _uncatalogued_results: dict[str, dict[str, Any]] = {} jwks_keypair = _MODULE.jwks_keypair +signing_key_factory = _MODULE.signing_key_factory token_factory = _MODULE.token_factory mock_jwks = _MODULE.mock_jwks mock_as_metadata = _MODULE.mock_as_metadata @@ -108,17 +121,20 @@ async def test_...(...): verifier = _MODULE.verifier client_with_discovery = _MODULE.client_with_discovery verifier_with_discovery = _MODULE.verifier_with_discovery +# Deliberately NOT re-exported here: the unit suite's seam for bringing a +# refresh interval forward reaches into the cache to do it. That is fine where +# it lives, but the rotation case in this suite names reflection into cache +# internals among the mechanisms it prohibits — a conformance test that used it +# would be asserting the SDK can do something only the test can reach. Shorten +# the interval through the constructor and let real time pass instead. def _load_catalog_metadata() -> tuple[str, list[str]]: - text = _CATALOG_PATH.read_text(encoding="utf-8") - version_match = re.search(r'^catalog_version:\s*"([^"]+)"\s*$', text, flags=re.MULTILINE) - if version_match is None: # pragma: no cover - defensive guard - raise RuntimeError(f"Unable to locate catalog_version in {_CATALOG_PATH}") - catalog_ids = re.findall( - r'^\s+- id: "([^"]+)"\s*$', text.split("cases:", 1)[1], flags=re.MULTILINE - ) - return version_match.group(1), catalog_ids + # Through `_catalog` so the report iterates exactly the ids + # `test_catalog_alignment` checks the markers against. Two copies of this + # parse can drift, and then the alignment test certifies a mapping the + # report never used. + return load_catalog_version(_CATALOG_PATH), sorted(load_catalog_case_ids(_CATALOG_PATH)) def _extract_conformance_marker(item: Any) -> tuple[str | None, dict[str, Any]]: diff --git a/conformance-tests/test_catalog_alignment.py b/conformance-tests/test_catalog_alignment.py index 73842c7..47510b6 100644 --- a/conformance-tests/test_catalog_alignment.py +++ b/conformance-tests/test_catalog_alignment.py @@ -1,10 +1,35 @@ -"""Ensure every catalog case id is covered by a @pytest.mark.conformance marker.""" +"""Ensure the catalog and the ``@pytest.mark.conformance`` markers agree. + +Both directions are asserted, because each catches a different way the +mapping rots and neither implies the other: + +* catalog -> marker: a catalog case nothing claims is a case this SDK does + not cover, reported as ``not_run`` rather than as a failure. +* marker -> catalog: a marker naming a case the catalog does not carry — a + typo, a renamed case, a case dropped from the catalog — claims coverage + that maps to nothing. The report is built by iterating the catalog ids, so + such a marker is silently dropped: the run stays green and the catalog case + it was meant to cover is left uncovered while the report says otherwise. + +Together they are what makes bumping ``.conformance-catalog-ref`` safe in +both directions: a pin bump without markers goes red on the first, and +markers without a pin bump go red on the second. + +The scan is over ``conformance-tests/test_*.py`` source, so a marker on a +deselected or collection-erroring test still counts. +""" import ast -import os -import re from pathlib import Path +from _catalog import load_catalog_case_ids + +# Marks the two assertions that mean the catalog and the markers disagree, so +# the drift workflow can tell them from a harness fault (an unparseable catalog, +# a collection error) and stop labelling everything "drift detected". +# Deliberately NOT on the harness assert in ``_catalog.load_catalog_case_ids``. +_DRIFT_PREFIX = "Conformance-catalog drift:" + def _collect_conformance_case_ids() -> set[str]: """Walk all test files and extract case IDs from @pytest.mark.conformance markers.""" @@ -13,34 +38,47 @@ def _collect_conformance_case_ids() -> set[str]: for path in suite_dir.glob("test_*.py"): tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in ast.walk(tree): - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + # ClassDef too: pytest applies a class-level marker to every test in + # the class, so a marker there is as real as one on a function. A + # scan that cannot see it reports the id as uncovered in one + # direction and misses a bogus id in the other. + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): continue for decorator in node.decorator_list: # Match @pytest.mark.conformance("case-id") - if ( + if not ( isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute) and decorator.func.attr == "conformance" and decorator.args - and isinstance(decorator.args[0], ast.Constant) ): - case_ids.add(str(decorator.args[0].value)) + continue + first = decorator.args[0] + # A computed id is invisible to this scan but visible to pytest + # at runtime — the precise shape of "claims coverage that maps + # to nothing". Refuse it rather than skip it. + assert isinstance(first, ast.Constant) and isinstance(first.value, str), ( + f"{path.name}:{decorator.lineno}: @pytest.mark.conformance needs a literal " + "string case id; a computed one cannot be checked against the catalog." + ) + case_ids.add(first.value) return case_ids def test_catalog_case_ids_are_represented_in_conformance_tests() -> None: - root = Path(__file__).resolve().parents[1] - default_catalog_path = root.parent / "conformance" / "oauth-sdk-conformance-catalog.yaml" - catalog_path = ( - Path(os.environ["AUTHPLANE_CONFORMANCE_CATALOG"]) - if "AUTHPLANE_CONFORMANCE_CATALOG" in os.environ - else default_catalog_path + missing = sorted(load_catalog_case_ids() - _collect_conformance_case_ids()) + assert missing == [], ( + f"{_DRIFT_PREFIX} {len(missing)} catalog case(s) have no @pytest.mark.conformance marker in " + "conformance-tests/. Add SDK-side coverage for each, then bump " + f".conformance-catalog-ref: {missing}" ) - catalog_text = catalog_path.read_text(encoding="utf-8") - cases_text = catalog_text.split("cases:", 1)[1] - catalog_ids = re.findall(r'^\s+- id: "([^"]+)"\s*$', cases_text, flags=re.MULTILINE) - marker_ids = _collect_conformance_case_ids() - missing = [case_id for case_id in catalog_ids if case_id not in marker_ids] - assert missing == [], f"Catalog cases without @pytest.mark.conformance marker: {missing}" +def test_conformance_markers_name_only_catalog_case_ids() -> None: + unknown = sorted(_collect_conformance_case_ids() - load_catalog_case_ids()) + assert unknown == [], ( + f"{_DRIFT_PREFIX} {len(unknown)} @pytest.mark.conformance marker(s) in conformance-tests/ name a " + "case id absent from the catalog, so they claim coverage that maps to nothing and " + "is dropped from the report. Correct the id, drop the marker, or bump " + f".conformance-catalog-ref to a revision that carries the case: {unknown}" + ) diff --git a/conformance-tests/test_jwt_and_dpop_conformance.py b/conformance-tests/test_jwt_and_dpop_conformance.py index 3bab28d..b0bd51d 100644 --- a/conformance-tests/test_jwt_and_dpop_conformance.py +++ b/conformance-tests/test_jwt_and_dpop_conformance.py @@ -29,6 +29,7 @@ InsufficientScopeError, InvalidClaimsError, InvalidDPoPProofError, + InvalidResourceError, InvalidSignatureError, TokenExpiredError, www_authenticate, @@ -1165,6 +1166,82 @@ async def test_rfc9728_well_known_path_must_derive_from_resource_uri() -> None: build_prm_url("https://api.example.com/v2/mcp") == "https://api.example.com/.well-known/oauth-protected-resource/v2/mcp" ) + # The catalog's fourth resource: identifiers differing only by a + # terminating slash resolve to the same metadata document (RFC 9728 §3.1 + # removes the slash following the host when a path is present). + assert ( + build_prm_url("https://api.example.com/mcp/") + == "https://api.example.com/.well-known/oauth-protected-resource/mcp" + ) + + +@pytest.mark.conformance("rfc9728-well-known-url-must-preserve-the-resource-query-component") +async def test_rfc9728_well_known_url_must_preserve_the_resource_query_component() -> None: + # RFC 9728 §3 inserts the well-known suffix between the host and "the path + # and/or query components", so the query survives onto the derived URL. + # Dropping it would collapse every tenant on a host onto one metadata + # document — the multi-tenant case RFC 8707 §2 names as the reason a query + # is permitted at all — and the client would then have to discard the + # response under §3.3 with a 200 and no server-side signal. + # + # The full URL rather than the path alone, because a path-only accessor + # cannot express a query; the sibling path-only case above stays unchanged. + assert { + resource: build_prm_url(resource) + for resource in ( + "https://api.example.com/mcp?tenant=a", + "https://api.example.com/mcp?tenant=b", + "https://api.example.com?x=1", + ) + } == { + "https://api.example.com/mcp?tenant=a": ( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a" + ), + "https://api.example.com/mcp?tenant=b": ( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=b" + ), + "https://api.example.com?x=1": ( + "https://api.example.com/.well-known/oauth-protected-resource?x=1" + ), + } + + +@pytest.mark.conformance("rfc8707-resource-indicator-must-not-contain-a-fragment") +async def test_rfc8707_resource_indicator_must_not_contain_a_fragment( + client: AuthplaneClient, +) -> None: + # RFC 8707 §2 forbids a fragment in the resource indicator and RFC 9728 §1.2 + # defines the resource identifier as carrying none. The catalog case puts the + # gate at construction: accepting the value and stripping the fragment later, + # while deriving the well-known URL, does not satisfy it — the served PRM + # would name a resource differing from the URL it was fetched from, which + # RFC 9728 §3.3 obliges the client to discard silently. + with pytest.raises(InvalidResourceError, match="must not contain a fragment"): + client.resource("https://api.example.com/mcp#section") + + +@pytest.mark.conformance("rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host") +async def test_rfc9728_resource_identifier_must_be_an_absolute_url_with_scheme_and_host( + client: AuthplaneClient, +) -> None: + # RFC 8707 §2 requires an absolute URI (RFC 3986 §4.3, whose grammar makes + # the scheme mandatory); RFC 9728 §3 requires a host to insert the well-known + # suffix after. Both shapes are rejected at construction, not at first use. + # + # Looped rather than parametrized: the harness maps one catalog case to + # exactly one test function, and parametrizing would declare the marker on + # several items. The two values are not redundant — a guard phrased as + # "opaque or authority-less" would admit the scheme-relative form, whose + # authority parses non-empty, so each must reject on its own. + for resource in ("/mcp", "//api.example.com/mcp"): + with pytest.raises( + InvalidResourceError, match="must be an absolute URL with a scheme and a host" + ): + client.resource(resource) + + # The check is absoluteness, not https: the case explicitly keeps a loopback + # http identifier acceptable, so pin that this does not become https-only. + assert client.resource("http://localhost:8080/mcp").resource == "http://localhost:8080/mcp" @pytest.mark.conformance("rfc9728-prm-must-contain-required-fields") diff --git a/conformance-tests/test_oauth_protocol_conformance.py b/conformance-tests/test_oauth_protocol_conformance.py index c6d4c1c..cac8ce7 100644 --- a/conformance-tests/test_oauth_protocol_conformance.py +++ b/conformance-tests/test_oauth_protocol_conformance.py @@ -3,7 +3,6 @@ import base64 from typing import Any from unittest.mock import AsyncMock, patch -from urllib.parse import parse_qs import httpx import pytest @@ -450,15 +449,10 @@ async def test_rfc8693_multiple_resource_parameters_must_be_emitted() -> None: {}, _NO_SSRF, ) - # Parse the form body and compare the exact parameter values: this proves - # two separate `resource` parameters were emitted with precisely these - # identifiers, where a substring check would also pass on longer URLs that - # merely contain the expected hosts. - body = parse_qs(route.calls.last.request.content.decode()) - assert body["resource"] == [ - "https://api-one.example.com", - "https://api-two.example.com", - ] + body = route.calls.last.request.content.decode() + assert body.count("resource=") == 2 + assert "api-one.example.com" in body + assert "api-two.example.com" in body @respx.mock @@ -491,15 +485,10 @@ async def test_rfc8693_multiple_audience_parameters_must_be_emitted() -> None: {}, _NO_SSRF, ) - # Parse the form body and compare the exact parameter values: this proves - # two separate `audience` parameters were emitted with precisely these - # identifiers, where a substring check would also pass on longer URLs that - # merely contain the expected hosts. - body = parse_qs(route.calls.last.request.content.decode()) - assert body["audience"] == [ - "https://api-one.example.com", - "https://api-two.example.com", - ] + body = route.calls.last.request.content.decode() + assert body.count("audience=") == 2 + assert "api-one.example.com" in body + assert "api-two.example.com" in body @respx.mock @@ -625,10 +614,7 @@ async def test_rfc8707_verifier_must_accept_resource_when_present_in_aud_array( claims = await verifier.verify( token_factory(aud=["https://api.example.com", "https://other.example.com"]) ) # type: ignore[arg-type] - # Compare the full audience rather than testing membership: the exact - # tuple also proves the configured resource was matched as a whole value, - # not as a substring of a longer audience entry. - assert claims.audience == ("https://api.example.com", "https://other.example.com") + assert "https://api.example.com" in claims.audience # --------------------------------------------------------------------------- diff --git a/conformance-tests/test_rfc8414_conformance.py b/conformance-tests/test_rfc8414_conformance.py index 576cbe7..16dfbe0 100644 --- a/conformance-tests/test_rfc8414_conformance.py +++ b/conformance-tests/test_rfc8414_conformance.py @@ -1,7 +1,10 @@ """RFC 8414 conformance tests.""" +import asyncio +from collections.abc import Callable from typing import Any +import httpx import pytest import respx @@ -215,45 +218,107 @@ async def test_rfc8414_discovery_url_must_insert_well_known_before_issuer_path( @pytest.mark.conformance("rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache") async def test_rfc8414_jwks_uri_rotation_must_reconfigure_jwks_cache( - jwks_keypair: dict[str, Any], + signing_key_factory: Callable[[str], Any], ) -> None: + """Follow a ``jwks_uri`` rotation on nothing but ordinary verify() traffic. + + Every mechanism here is one a deployment already has: the refresh interval + is shortened through the documented constructor argument, real time is + allowed to elapse, and the rotation is then followed by a second + ``verify()``. No force-refresh argument, no test-only hook, no reflection + into cache internals, and no assertion against a locally built metadata + object — the case rules all four out, and they are the reason it was + written: a verify-only resource server repeats no discovery of its own, so + a rotation it cannot follow from the verification path is a rotation it + never follows in production. + + Both key sets publish the **same** ``kid`` deliberately. Were the rotated + key introduced under a new ``kid``, the unknown-``kid`` refresh would + follow the rotation on its own and this would silently become a test of + that separate requirement instead. Holding the ``kid`` fixed removes that + explanation: a verifier still bound to the withdrawn URI finds the retired + key under exactly the id it is looking for, uses it, and fails on the + signature. Only a genuine rebind to the rotated URI can make this pass. + """ + # Two seconds rather than one. `DocumentCache.get` spawns a background + # refresh once 80% of the effective TTL has elapsed, so the + # inside-the-interval assertion below requires `create()` and the two + # `verify()` calls that follow it to finish within that window — 0.8s at a + # one-second interval, which is a non-deterministic failure on a loaded CI + # runner rather than a clean one. Two seconds buys 1.6s of slack and the + # rotation is still driven well inside the case's two-interval bound. + metadata_refresh_seconds = 2 + kid = "rotating-key" + old_key = signing_key_factory(kid) + new_key = signing_key_factory(kid) + + rotated = False + metadata_calls = 0 + + def metadata_response(request: httpx.Request) -> httpx.Response: + nonlocal metadata_calls + metadata_calls += 1 + uri = ( + "https://auth.example.com/jwks-v2.json" + if rotated + else "https://auth.example.com/jwks-v1.json" + ) + return httpx.Response( + 200, + json={"issuer": "https://auth.example.com", "jwks_uri": uri}, + ) + with respx.mock: respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( - return_value=respx.MockResponse( - 200, - json={ - "issuer": "https://auth.example.com", - "jwks_uri": "https://auth.example.com/jwks-v1.json", - }, - ) + side_effect=metadata_response ) - respx.get("https://auth.example.com/jwks-v1.json").mock( - return_value=respx.MockResponse(200, json=jwks_keypair["jwks"]) + # The withdrawn URI stays reachable and keeps serving the retired key. + # A 404 there would let the case pass on the fetch failing rather than + # on the rotation being followed. + old_route = respx.get("https://auth.example.com/jwks-v1.json").mock( + return_value=respx.MockResponse(200, json=old_key.jwks) ) - respx.get("https://auth.example.com/jwks-v2.json").mock( - return_value=respx.MockResponse(200, json=jwks_keypair["jwks"]) + new_route = respx.get("https://auth.example.com/jwks-v2.json").mock( + return_value=respx.MockResponse(200, json=new_key.jwks) ) client = await AuthplaneClient.create( issuer="https://auth.example.com", fetch_settings=_NO_SSRF, + metadata_refresh_seconds=metadata_refresh_seconds, + # Deliberately far longer than the metadata interval. If the key set + # could fall out of cache on its own, the rotation would be followed + # by that expiry rather than by the rotation being noticed, and the + # case would pass without demonstrating the requirement. + jwks_refresh_seconds=3600, ) + verifier = client.resource(resource="https://api.example.com") try: - old_metadata: dict[str, object] = { - "issuer": "https://auth.example.com", - "jwks_uri": "https://auth.example.com/jwks-v1.json", - } - new_metadata: dict[str, object] = { - "issuer": "https://auth.example.com", - "jwks_uri": "https://auth.example.com/jwks-v2.json", - } - - # Drive the rotation directly via the internal hook. A full end-to- - # end test would simulate a metadata-refresh tick, but the SDK - # exposes no public seam for that yet; using the private hook here - # is a deliberate trade-off. Follow-up: lift this to a public - # test seam when the metadata-cache lifecycle is refactored. - await client._on_metadata_changed(old_metadata, new_metadata) # pyright: ignore[reportPrivateUsage] - assert client._jwks_uri == "https://auth.example.com/jwks-v2.json" # pyright: ignore[reportPrivateUsage] + assert (await verifier.verify(old_key.sign())).kid == kid + + # Ordinary traffic inside the interval must not re-read metadata. + # The interval is the SDK's side of the contract the case's bound is + # written against, and following a rotation promptly is worth + # nothing if the price is a discovery fetch per verification. + # Exact equality, not a tolerance: the 80%-of-TTL background + # refresh is the only other thing that could move this counter, + # and the interval above is sized so it cannot have fired yet. + metadata_calls_inside_interval = metadata_calls + assert (await verifier.verify(old_key.sign(jti="inside-interval"))).kid == kid + assert metadata_calls == metadata_calls_inside_interval + + # The AS begins serving the rotated document. + rotated = True + await asyncio.sleep(metadata_refresh_seconds + 0.2) + old_route_calls_at_rotation = old_route.call_count + + assert (await verifier.verify(new_key.sign())).kid == kid + # Metadata re-fetched once the interval elapsed, with no explicit + # refresh call anywhere... + assert metadata_calls > metadata_calls_inside_interval + # ...JWKS fetched from the rotated location... + assert new_route.called + # ...and the withdrawn one not touched again once the rebind happened. + assert old_route.call_count == old_route_calls_at_rotation finally: await client.aclose() diff --git a/llm-full.txt b/llm-full.txt index 2d45b5b..d97b68e 100644 --- a/llm-full.txt +++ b/llm-full.txt @@ -93,7 +93,8 @@ twine check dist/* - `await client.revoke(token)` — RFC 7009 - `await client.aclose()` — required on shutdown. - `resource.prm_response()` — RFC 9728 PRM document (dict body). -- `resource.prm_url()` — the well-known URL where clients fetch that document; pass to `response_headers_for(... , resource_metadata_url=...)`. +- `resource.prm_url()` — the RFC 9728 §3.1 derivation: where clients fetch the document this SDK builds. +- `resource.resource_metadata_url()` — what to advertise as RFC 9728 §5.1 `resource_metadata`: the `resource_metadata_url=` configured on `client.resource(...)` (an AS-hosted document) or `prm_url()`; pass to `response_headers_for(..., resource_metadata_url=...)`. ### Errors (root export) @@ -105,26 +106,71 @@ DPoP family (`DPoPError` and subclasses). AS-facing: `AuthError`, `InvalidClientError`, `InvalidGrantError`, `InvalidScopeError`, `UnauthorizedClientError`, `UnsupportedGrantTypeError`, -`InvalidRequestError`, `ServerError`, `ConsentRequiredError`, -`CircuitOpenError`. `client.exchange()` raises `InvalidGrantError` for RFC -6749 `invalid_grant`, `ConsentRequiredError` for `consent_required` / -`interaction_required`. +`InvalidRequestError`, `AccessDeniedError`, `InvalidTargetError`, +`ServerError`, `ConsentRequiredError`, `CircuitOpenError`. `client.exchange()` +raises `InvalidGrantError` for RFC 6749 `invalid_grant`, +`ConsentRequiredError` for `consent_required` / `interaction_required`, +`AccessDeniedError` for `access_denied` (403 — the exchanging client is not in +the target Resource's `policy.exchange.allowed_client_ids`; an operator fix, +re-prompting the user does not clear it) and `InvalidTargetError` for +`invalid_target` (400, RFC 8707 §2.2 — `resource` does not match a granted +resource byte for byte). None of the four trips the circuit breaker. + +Introspection (`IntrospectionRevocation`): the RS client must be confidential +and either the issuing client or a runtime-client of the Resource +(`authserver admin resource runtime-client add --client-id +--slug `). authserver >= 0.1.2 answers `{"active": false}` to +an unauthenticated or non-owner call, so a public client cannot introspect +at all and a misconfigured one rejects every token. `ASCredentials` rejects +empty fields at construction; `client.resource(...)` warns when +`IntrospectionRevocation` is configured without `auth=`. + +Token exchange operator step: for each MCP server that exchanges for a +downstream resource it does not act as, `PATCH /admin/resources/{id}` with +`{"policy": {"exchange": {"allowed_client_ids": [""]}}}`. +Self-exchange, fronted exchanges and Broker resources need nothing. + +`VerifiedClaims.may_act` is deprecated (`DeprecationWarning`): authserver +0.2.0 no longer issues `may_act`; removed in the next minor. `http_status(error)` maps any `AuthplaneError` to an HTTP status: 403 only for `InsufficientScopeError`; 503 for `JWKSFetchError`, `MetadataFetchError`, and `CircuitOpenError` (temporary AS unavailability); 401 for token failures; 500 for `ProtocolError` / `VerifierRuntimeError` / unknown. -`www_authenticate(error, *, realm="", resource_metadata_url=None, scope=None)` -builds the matching `WWW-Authenticate` header. Scheme: `DPoP` for `DPoPError` -subclasses except `DPoPNotSupportedError` (which stays `Bearer` because the -resource is bearer-only); `Bearer` otherwise. When `scope=` is omitted, it -auto-populates from `InsufficientScopeError.required_scopes`. Every -interpolated value is sanitized (`\r\n"\\` stripped) so attacker-influenced -error messages cannot break out of the quoted parameter or inject headers. +`www_authenticate(error, *, realm="", resource_metadata_url=None, scope=None, +verbose_description=False)` builds the matching `WWW-Authenticate` header. +Scheme: `DPoP` for `DPoPError` subclasses except `DPoPNotSupportedError` +(which stays `Bearer` because the resource is bearer-only); `Bearer` +otherwise. When `scope=` is omitted, it auto-populates from +`InsufficientScopeError.required_scopes`. Every interpolated value is +sanitized (`\r\n"\\` stripped) so attacker-influenced error messages cannot +break out of the quoted parameter or inject headers. + +`error_description` is a fixed sentence chosen by the error code, never the +exception's message: the challenge is served to a caller who has not +authenticated, and the SDK's messages name the unknown `kid`, the claim that +failed, or the audience the resource expects. The message stays on the +exception and is logged at `DEBUG` on the `authplane.errors` logger. +`verbose_description=True` puts it back on the wire and is a development aid +only. + +`www_authenticate_challenges(error, *, schemes=None, algs=(), realm="", +resource_metadata_url=None, scope=None, verbose_description=False)` returns +one challenge per acceptable scheme, so a resource running `inbound_dpop` in +optional mode can advertise both `Bearer` and `DPoP` (RFC 9449 §7.1). Two +challenges cannot be comma-joined — the comma also separates parameters +inside a challenge — so RFC 7235 §4.1 advises separate header values and the +caller emits one per element. `algs=` (pass +`InboundDPoPOptions.allowed_proof_algorithms`) lands on the `DPoP` challenge +only. Omitting `schemes=` derives the single scheme from the error, matching +`www_authenticate`. `response_headers_for(error, *, realm="", resource_metadata_url=None, -scope=None)` returns `(status, {"WWW-Authenticate": challenge})` in one call. +scope=None, verbose_description=False)` returns +`(status, {"WWW-Authenticate": challenge})` in one call. A dict holds one +value per header name, so it is single-scheme by construction; pair +`http_status` with `www_authenticate_challenges` to advertise two. Both adapter verifiers log a `logging.DEBUG` event `authplane.token_verification_failed` (logger `authplane_mcp.verifier` or @@ -239,7 +285,7 @@ except AuthplaneError as e: status, headers = response_headers_for( e, realm="api.example.com", - resource_metadata_url=res.prm_url(), + resource_metadata_url=res.resource_metadata_url(), ) return Response(status_code=status, headers=headers) ``` @@ -312,7 +358,8 @@ bash scripts/manual-e2e-smoke.sh --skip-setup bash scripts/manual-e2e-smoke.sh --adapter fastmcp --skip-setup ``` -Optional overrides: `AUTHSERVER_DIR`, `ISSUER_URL`, `RESOURCE_URL`. +Optional overrides: `AUTHSERVER_DIR`, `AUTHSERVER_REF` (check out that ref +of the authserver repo before building), `ISSUER_URL`, `RESOURCE_URL`. ### Python client demo example (DPoP + token exchange) @@ -390,11 +437,10 @@ python path/to/demo_client.py ### Local OAuth server requirements for demos -Run the Authplane authserver on `http://127.0.0.1:9000` with these flags enabled: +Run the Authplane authserver 0.2.0 on `http://127.0.0.1:9000`. Client +credentials, token exchange and DPoP are on by default since 0.2.0; the demo +additionally needs: -- `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true` -- `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true` -- `AUTHPLANE_DPOP_ENABLED=true` - `AUTHPLANE_TOKEN_EXCHANGE_ALLOW_SELF_EXCHANGE=true` Register the demo client with: @@ -408,8 +454,12 @@ Register the demo client with: ## Common Local Demo Pitfalls -- `client_credentials grant is not enabled` - - `authserver` missing `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true` +- `access_denied` on a token exchange + - exchanging client not in the target Resource's + `policy.exchange.allowed_client_ids` (`PATCH /admin/resources/{id}`) +- every token rejected as revoked with introspection on + - RS client is public, or is neither the issuing client nor a runtime-client + of the Resource (authserver >= 0.1.2 answers `active: false`) - `client is not authorized for this grant type` - client missing `urn:ietf:params:oauth:grant-type:token-exchange` - `requested scope is invalid or not allowed` diff --git a/pyrightconfig.json b/pyrightconfig.json index 0ae04a1..cd1a477 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -7,7 +7,8 @@ "include": [ "authplane", "tests", - "conformance-tests" + "conformance-tests", + ".github/scripts" ], "exclude": [ "authplane-fastmcp", diff --git a/scripts/manual-e2e-setup.sh b/scripts/manual-e2e-setup.sh index 82c2b65..889481e 100755 --- a/scripts/manual-e2e-setup.sh +++ b/scripts/manual-e2e-setup.sh @@ -12,6 +12,8 @@ Usage: Environment (optional): AUTHSERVER_DIR Path to local authserver repo (default: ../authserver) + AUTHSERVER_REF Git ref of authserver to check out before building + (default: leave the checkout as is) EOF } @@ -25,9 +27,15 @@ if [ ! -d "${AUTHSERVER_DIR}" ]; then exit 1 fi -echo "==> Starting authserver demo server (client_credentials enabled)" +echo "==> Starting authserver demo server" ( cd "${AUTHSERVER_DIR}" + if [ -n "${AUTHSERVER_REF:-}" ]; then + echo "==> Checking out authserver ${AUTHSERVER_REF}" + git fetch --tags + git checkout "${AUTHSERVER_REF}" + rm -f bin/authserver + fi if [ ! -x "bin/authserver" ]; then if [ -d "cmd/authserver" ]; then go build -o bin/authserver ./cmd/authserver @@ -40,7 +48,7 @@ echo "==> Starting authserver demo server (client_credentials enabled)" echo "ERROR: authserver binary is not executable at ${AUTHSERVER_DIR}/bin/authserver" >&2 exit 1 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 8b4b6a0..7cb3a78 100755 --- a/scripts/manual-e2e-smoke.sh +++ b/scripts/manual-e2e-smoke.sh @@ -54,8 +54,6 @@ if [ "${RESOURCE_BASE}" = "${RESOURCE_URL}" ]; then exit 1 fi PRM_URL="${RESOURCE_BASE}/.well-known/oauth-protected-resource/mcp" -ADMIN_URL="${ADMIN_URL:-http://localhost:9001}" -ADMIN_KEY="${ADMIN_KEY:-b480b9760e730abe43b98d0ba01418961df392de0fc6358c36a9a62a8764a7c1}" cleanup() { if [ -n "${SERVER_PID:-}" ]; then @@ -67,22 +65,6 @@ 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 @@ -119,10 +101,6 @@ if [ ! -f /tmp/authserver-demo.client-id ] || [ ! -f /tmp/authserver-demo.key ]; exit 1 fi -echo "==> Ensuring authserver scopes for resource: ${RESOURCE_URL}" -register_scope "tools/add" -register_scope "tools/multiply" - CLIENT_ID="$(cat /tmp/authserver-demo.client-id)" CLIENT_SECRET="$(cat /tmp/authserver-demo.key)" diff --git a/tests/conftest.py b/tests/conftest.py index fc70163..ece92a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ """Shared test fixtures for Authplane SDK tests.""" import time -from collections.abc import AsyncGenerator, Generator -from typing import Any, Protocol +from collections.abc import AsyncGenerator, Callable, Generator +from dataclasses import dataclass +from typing import Any, Protocol, cast import pytest import respx @@ -38,49 +39,99 @@ def __call__( MockASMetadata = dict[str, Route] -@pytest.fixture -def jwks_keypair() -> JWKSKeypair: - """Generate an ES256 keypair and export as JWKS. +@dataclass(frozen=True) +class SigningKey: + """An ES256 signing key: its public JWK, its PEMs, and a token signer.""" - Returns: - dict with 'private_key', 'public_key', and 'jwks' (JWKS JSON dict) - """ - # Generate ES256 private key - private_key = ec.generate_private_key(ec.SECP256R1()) + kid: str + jwk: dict[str, Any] + private_pem: bytes + public_pem: bytes + + @property + def jwks(self) -> dict[str, Any]: + """The single-key JWKS document publishing this key.""" + return {"keys": [self.jwk]} + + def sign(self, **overrides: Any) -> str: + """Sign an otherwise-valid access token for the default test resource. + + Keyword arguments override individual claims. + """ + from authlib.jose import jwt + + now = int(time.time()) + claims: dict[str, Any] = { + "iss": "https://auth.example.com", + "aud": "https://api.example.com", + "sub": "user123", + "client_id": "client456", + "scope": "read:data write:data", + "exp": now + 3600, + "nbf": now, + "iat": now, + "jti": f"token-id-{self.kid}", + } + claims.update(overrides) + header = {"alg": "ES256", "typ": "at+jwt", "kid": self.kid} + token: bytes = jwt.encode(header, claims, self.private_pem) # pyright: ignore[reportUnknownMemberType] + return token.decode("utf-8") - # Get public key - public_key = private_key.public_key() - # Export private key as PEM +def _make_es256_key(kid: str) -> SigningKey: + """Generate an ES256 keypair and export its public half as a JWK.""" + private_key = ec.generate_private_key(ec.SECP256R1()) + private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) - - # Export public key as PEM - public_pem = public_key.public_bytes( + public_pem = private_key.public_key().public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) # Convert to authlib JsonWebKey jwk = JsonWebKey.import_key(public_pem, {"kty": "EC"}) # pyright: ignore[reportArgumentType] - jwk_dict: dict[str, Any] = jwk.as_dict() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] - jwk_dict["kid"] = "test-key-1" + # cast, not just an annotation: `as_dict()` is untyped, and the resulting + # value now flows into a typed constructor rather than into a dict literal + # that used to absorb the Unknown. + jwk_dict = cast("dict[str, Any]", jwk.as_dict()) # pyright: ignore[reportUnknownMemberType] + jwk_dict["kid"] = kid jwk_dict["alg"] = "ES256" jwk_dict["use"] = "sig" - # Build JWKS document - jwks: dict[str, Any] = {"keys": [jwk_dict]} + return SigningKey(kid=kid, jwk=jwk_dict, private_pem=private_pem, public_pem=public_pem) + + +@pytest.fixture +def jwks_keypair() -> JWKSKeypair: + """Generate an ES256 keypair and export as JWKS. + Returns: + dict with 'private_key', 'public_key', and 'jwks' (JWKS JSON dict) + """ + key = _make_es256_key("test-key-1") return { - "private_key": private_pem, - "public_key": public_pem, - "jwks": jwks, + "private_key": key.private_pem, + "public_key": key.public_pem, + "jwks": key.jwks, } +@pytest.fixture +def signing_key_factory() -> Callable[[str], SigningKey]: + """Mint an independent ES256 signing key under a caller-chosen ``kid``. + + ``jwks_keypair`` and ``token_factory`` cover the single-key case. This is + for tests that need a *second*, unrelated key set — a rotated ``jwks_uri`` + publishing a key the previous URI never served, say — where the point is + that a token is unverifiable unless the SDK fetched the right document. + """ + return _make_es256_key + + @pytest.fixture def token_factory(jwks_keypair: JWKSKeypair) -> TokenFactory: """Factory for creating signed JWTs with customizable claims. @@ -277,3 +328,29 @@ async def verifier_with_discovery( scopes=["read:data", "write:data"], ) yield v + + +def _expire_metadata_interval(client: AuthplaneClient) -> None: + """Bring the metadata refresh interval forward instead of sleeping through it. + + Only the cache's notion of when it last fetched is moved; ``verify()`` still + drives the refresh through the production path, and no caller passes + ``force_refresh``. Sleeping for a real interval both costs wall clock and + races the background refresh that opens at 80% of it, which makes exact + fetch-count assertions unreliable on a loaded runner. + """ + metadata_cache = client.metadata_cache + assert metadata_cache is not None + metadata_cache._cache_time = 0 # pyright: ignore[reportPrivateUsage] + + +@pytest.fixture +def expire_metadata_interval() -> Callable[[AuthplaneClient], None]: + """The documented seam for driving a metadata refresh without sleeping. + + Exposed as a fixture rather than a module-level function so the conformance + suite can re-export it alongside the others: the poke then has one + definition and one docstring, instead of being repeated inline wherever a + rotation is driven. + """ + return _expire_metadata_interval diff --git a/tests/internal/test_document_cache.py b/tests/internal/test_document_cache.py index f90ecf9..265cb06 100644 --- a/tests/internal/test_document_cache.py +++ b/tests/internal/test_document_cache.py @@ -7,13 +7,14 @@ """ import asyncio +import logging import time from typing import Any import pytest from authplane.errors import JWKSFetchError -from authplane.internal.document_cache import DocumentCache +from authplane.internal.document_cache import DocumentCache, DocumentFetcherCallable, JWKSCache from authplane.internal.fetch_result import FetchResult # --------------------------------------------------------------------------- @@ -347,61 +348,76 @@ async def test_no_server_cache_headers_uses_configured_ttl() -> None: # --------------------------------------------------------------------------- -# on_change callbacks +# Validation of a fetched document, before it is committed # --------------------------------------------------------------------------- -async def test_on_change_callback_fires_when_document_changes() -> None: - doc_v1: dict[str, Any] = {"v": 1} - doc_v2: dict[str, Any] = {"v": 2} - events: list[dict[str, Any]] = [] +class _RejectingCache(DocumentCache): + """Rejects every document whose ``v`` is in ``reject``, recording each check.""" + + def __init__(self, fetcher: DocumentFetcherCallable, reject: set[int]) -> None: + super().__init__(fetcher, document_type="test") + self.reject = reject + self.seen: list[dict[str, Any]] = [] + + def _validate_fetched(self, document: dict[str, Any], /) -> dict[str, Any]: + self.seen.append(document) + if document.get("v") in self.reject: + raise ValueError("rejected by validation") + return document - async def on_change(old: dict[str, Any], new: dict[str, Any]) -> None: - events.append({"old": old, "new": new}) +async def test_rejected_document_never_reaches_the_cache() -> None: + """A document that fails validation must not be observable, even transiently. + + Whatever is derived from the cached document — the URL a dependent cache + fetches from, above all — reads ``_cache``, so committing first and + validating afterwards would let a rejected document decide it. + """ call_count: dict[str, int] = {"n": 0} async def fetcher() -> FetchResult: call_count["n"] += 1 - return FetchResult(document=doc_v2 if call_count["n"] > 1 else doc_v1) + return FetchResult(document={"v": call_count["n"]}) - cache = DocumentCache(fetcher, document_type="test", on_change=on_change) + cache = _RejectingCache(fetcher, reject={2}) - await cache.get() - await asyncio.sleep(0.01) - assert len(events) == 0 # no callback on initial fetch + assert await cache.get() == {"v": 1} + # The second fetch is rejected; the first document stays in place. + assert await cache.get(force_refresh=True) == {"v": 1} + assert cache._cache == {"v": 1} # pyright: ignore[reportPrivateUsage] + assert cache.seen == [{"v": 1}, {"v": 2}] - await cache.get(force_refresh=True) - await asyncio.sleep(0.05) - assert len(events) == 1 - assert events[0]["old"] == doc_v1 - assert events[0]["new"] == doc_v2 + # A rejection is a failed refresh, so it backs off like one: the next + # caller is served the cached document without the fetcher being called + # again. Without this an endpoint serving a rejectable document costs a + # full fetch per verification. + assert await cache.get(force_refresh=True) == {"v": 1} + assert call_count["n"] == 2 - await cache.aclose() + # And a later acceptable document is taken normally, once the floor has + # elapsed. Poked rather than slept: the floor is 30s here. + cache._retry_not_before = 0.0 # pyright: ignore[reportPrivateUsage] + assert await cache.get(force_refresh=True) == {"v": 3} + await cache.aclose() -async def test_on_change_callback_not_fired_when_document_unchanged() -> None: - doc: dict[str, Any] = {"v": 1} - events: list[dict[str, Any]] = [] - async def on_change(old: dict[str, Any], new: dict[str, Any]) -> None: - events.append({"old": old, "new": new}) +async def test_rejected_first_document_raises_through_the_error_factory() -> None: + """With nothing cached to fall back on, a rejection surfaces to the caller.""" async def fetcher() -> FetchResult: - return FetchResult(document=doc) + return FetchResult(document={"v": 1}) - cache = DocumentCache(fetcher, document_type="test", on_change=on_change) + cache = _RejectingCache(fetcher, reject={1}) - await cache.get() - await cache.get(force_refresh=True) - await asyncio.sleep(0.05) - - assert len(events) == 0 # same document → no callback + with pytest.raises(JWKSFetchError, match="rejected by validation"): + await cache.get() await cache.aclose() -async def test_on_change_callback_error_does_not_break_fetch() -> None: +async def test_forced_refresh_serves_the_new_document() -> None: doc_v1: dict[str, Any] = {"v": 1} doc_v2: dict[str, Any] = {"v": 2} call_count: dict[str, int] = {"n": 0} @@ -410,37 +426,279 @@ async def fetcher() -> FetchResult: call_count["n"] += 1 return FetchResult(document=doc_v2 if call_count["n"] > 1 else doc_v1) - async def broken_callback(_old: dict[str, Any], _new: dict[str, Any]) -> None: - raise RuntimeError("callback exploded") + cache = DocumentCache(fetcher, document_type="test") + + r1 = await cache.get() + assert r1 == doc_v1 + + r2 = await cache.get(force_refresh=True) + assert r2 == doc_v2 + + await cache.aclose() + - cache = DocumentCache(fetcher, document_type="test", on_change=broken_callback) +# --------------------------------------------------------------------------- +# A cache whose document's location is discovered rather than configured +# --------------------------------------------------------------------------- + + +async def test_a_moved_source_invalidates_the_cached_document_before_its_ttl() -> None: + """A TTL cannot express "the server moved this document". + The cached key set is fresh by its own clock and wrong all the same, because + the location it came from is no longer the advertised one. Nothing else can + notice: a rotation that keeps the ``kid`` leaves the retired key sitting + under the requested id, so the lookup succeeds and only the signature check + fails. + """ + sources = ["https://as.example.com/jwks-v1.json"] + fetches: list[str] = [] + + async def fetcher() -> FetchResult: + source = sources[-1] + fetches.append(source) + return FetchResult(document={"keys": [{"kid": "k", "from": source}]}, source=source) + + async def resolver() -> str: + return sources[-1] + + # An hour-long TTL, so nothing here can be explained by expiry. + cache = JWKSCache(fetcher, refresh_seconds=3600, document_type="jwks", source_resolver=resolver) + first = await cache.get() + assert first["keys"][0]["from"] == "https://as.example.com/jwks-v1.json" + # Re-read while the source is unchanged: served from cache. await cache.get() - result = await cache.get(force_refresh=True) - await asyncio.sleep(0.05) + assert fetches == ["https://as.example.com/jwks-v1.json"] + + sources.append("https://as.example.com/jwks-v2.json") + second = await cache.get() + assert second["keys"][0]["from"] == "https://as.example.com/jwks-v2.json" + assert fetches == [ + "https://as.example.com/jwks-v1.json", + "https://as.example.com/jwks-v2.json", + ] + # And once refetched from the new location it is cached again, not refetched + # per read. + await cache.get() + assert len(fetches) == 2 - # Fetch still succeeded despite the callback error - assert result == doc_v2 + await cache.aclose() + + +async def test_concurrent_readers_of_a_moved_source_fetch_it_once() -> None: + """The move is re-checked inside the lock, so the burst coalesces. + + Every reader in a burst sees the same stale source and queues on the fetch + lock. Whichever one wins refetches; the rest must be served what it + committed rather than each repeating the fetch behind it. + """ + sources = ["https://as.example.com/jwks-v1.json"] + fetches: list[str] = [] + + async def fetcher() -> FetchResult: + source = sources[-1] + fetches.append(source) + await asyncio.sleep(0) + return FetchResult(document={"keys": [{"kid": "k", "from": source}]}, source=source) + + async def resolver() -> str: + return sources[-1] + + cache = JWKSCache(fetcher, refresh_seconds=3600, document_type="jwks", source_resolver=resolver) + await cache.get() + sources.append("https://as.example.com/jwks-v2.json") + + results = await asyncio.gather(*(cache.get() for _ in range(10))) + assert all(r["keys"][0]["from"] == "https://as.example.com/jwks-v2.json" for r in results) + assert len(fetches) == 2 await cache.aclose() -async def test_no_callback_when_on_change_is_none() -> None: - """Cache works normally when on_change is not provided.""" - doc_v1: dict[str, Any] = {"v": 1} - doc_v2: dict[str, Any] = {"v": 2} +async def test_an_unresolvable_source_keeps_the_cached_document() -> None: + """Resolving the location reads a document, and that read can fail. + + A failure is not evidence the location moved. Treating it as one would turn + every discovery-endpoint outage into an empty key set. + """ + fetches: list[str] = [] + + async def fetcher() -> FetchResult: + fetches.append("x") + return FetchResult(document={"keys": []}, source="https://as.example.com/jwks.json") + + resolver_broken = False + + async def resolver() -> str: + if resolver_broken: + raise RuntimeError("metadata endpoint down") + return "https://as.example.com/jwks.json" + + cache = JWKSCache(fetcher, refresh_seconds=3600, document_type="jwks", source_resolver=resolver) + await cache.get() + resolver_broken = True + assert await cache.get() == {"keys": []} + assert len(fetches) == 1 + + await cache.aclose() + + +async def test_the_retry_floor_also_covers_a_forced_refresh() -> None: + """The floor is not conditioned on how the refresh was triggered. + + A forced refresh is reachable from an unauthenticated caller — an unknown + ``kid`` in a token header is enough — so a floor that only covered + interval-driven refreshes would leave the amplifier it exists to close wide + open on the one path that can be driven from outside. + """ + calls = {"n": 0} + keys: dict[str, Any] = {"keys": [{"kid": "a"}]} + + async def flaky() -> FetchResult: + calls["n"] += 1 + if calls["n"] == 1: + return FetchResult(document=keys, source="https://as.example.com/jwks.json") + raise RuntimeError("jwks endpoint down") + + cache = JWKSCache(flaky, refresh_seconds=1, document_type="jwks") + assert await cache.get() == keys + + # The first forced refresh attempts a fetch and fails, arming the floor. + assert await cache.get(force_refresh=True) == keys + assert calls["n"] == 2 + + # Every subsequent forced refresh inside the floor is served from cache. + for _ in range(5): + assert await cache.get(force_refresh=True) == keys + assert calls["n"] == 2 + + await cache.aclose() + + +async def test_a_rejected_document_does_not_move_the_recorded_source() -> None: + """The base type's half of the same ordering. + + ``_cache_source`` is what a cache whose location is discovered compares + against to decide whether it is still correctly bound. It is committed in + the same step as the contents, and must be gated by the same validation: a + rejected document that moved it would leave the cache believing its + contents came from a location it had just refused, and the refetch that + should follow would never be triggered. + """ call_count: dict[str, int] = {"n": 0} async def fetcher() -> FetchResult: call_count["n"] += 1 - return FetchResult(document=doc_v2 if call_count["n"] > 1 else doc_v1) + return FetchResult( + document={"v": call_count["n"]}, + source=f"https://as.example.com/doc-v{call_count['n']}.json", + ) - cache = DocumentCache(fetcher, document_type="test") # no on_change + cache = _RejectingCache(fetcher, reject={2}) - r1 = await cache.get() - assert r1 == doc_v1 + assert await cache.get() == {"v": 1} + assert cache._cache_source == "https://as.example.com/doc-v1.json" # pyright: ignore[reportPrivateUsage] - r2 = await cache.get(force_refresh=True) - assert r2 == doc_v2 + # The second document is rejected: neither the contents nor the source move. + assert await cache.get(force_refresh=True) == {"v": 1} + assert cache._cache_source == "https://as.example.com/doc-v1.json" # pyright: ignore[reportPrivateUsage] + + # An accepted document moves both, together. + cache._retry_not_before = 0.0 # pyright: ignore[reportPrivateUsage] + assert await cache.get(force_refresh=True) == {"v": 3} + assert cache._cache_source == "https://as.example.com/doc-v3.json" # pyright: ignore[reportPrivateUsage] + + await cache.aclose() + + +async def test_a_failed_rebind_logs_once_at_the_commit_not_once_per_read( + caplog: pytest.LogCaptureFixture, +) -> None: + """The rebind is the event; the mismatch that drives it is a predicate. + + While the newly advertised location is unreachable the mismatch holds for + the whole retry-floor window, and it is evaluated on every read. Logging it + there at INFO is log volume proportional to request rate during exactly the + incident an operator is reading the log to understand. The operator-visible + line belongs where ``_cache_source`` actually moves, which happens once. + """ + sources = ["https://as.example.com/jwks-v1.json"] + reachable = {"https://as.example.com/jwks-v1.json"} + attempts: list[str] = [] + + async def fetcher() -> FetchResult: + source = sources[-1] + attempts.append(source) + if source not in reachable: + raise JWKSFetchError(f"unreachable: {source}") + return FetchResult(document={"keys": [{"kid": "k", "from": source}]}, source=source) + + resolver_calls: list[str] = [] + + async def resolver() -> str: + resolver_calls.append(sources[-1]) + return sources[-1] + + cache = JWKSCache(fetcher, refresh_seconds=3600, document_type="jwks", source_resolver=resolver) + await cache.get() + + # The AS rotates to a location that is down. The cached key set is now + # mismatched on every read, and stays that way until the fetch succeeds. + sources.append("https://as.example.com/jwks-v2.json") + with caplog.at_level(logging.INFO, logger="authplane.internal.document_cache"): + await cache.get() + work_after_the_failing_read = len(resolver_calls) + for _ in range(19): + await cache.get() + + info_lines = [r for r in caplog.records if r.levelno == logging.INFO] + assert info_lines == [], f"the failed rebind window logged at INFO: {info_lines}" + # One attempt for the whole window, not one per read. + assert attempts.count("https://as.example.com/jwks-v2.json") == 1 + # And the reads behind the floor do no work at all: they answer before + # the usability predicate, so they neither resolve the location nor + # queue on the fetch lock to reach a floor check whose answer is known. + assert len(resolver_calls) == work_after_the_failing_read + # And the failure itself is reported once, at the level it belongs on. + assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1 + + caplog.clear() + reachable.add("https://as.example.com/jwks-v2.json") + cache._retry_not_before = 0.0 # pyright: ignore[reportPrivateUsage] + + rebound = await cache.get() + assert rebound["keys"][0]["from"] == "https://as.example.com/jwks-v2.json" + # Reads after the rebind are ordinary cache hits and add nothing. + await cache.get() + await cache.get() + + rebind_lines = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(rebind_lines) == 1, f"expected one rebind line, got {rebind_lines}" + # `extra=` lands on the record's `__dict__`; both ends of the move are + # what makes the line actionable. + rebind_extra: dict[str, Any] = rebind_lines[0].__dict__ + assert rebind_extra["previous"] == "https://as.example.com/jwks-v1.json" + assert rebind_extra["current"] == "https://as.example.com/jwks-v2.json" + + await cache.aclose() + + +async def test_the_first_bind_is_not_logged_as_a_rotation( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cold boot moves ``_cache_source`` off ``None``, which is not a rebind. + + The commit site fires on a *change* of source, and the change out of the + unset state is the initial bind — reporting it as a location change would + put one spurious line in every process's startup output. + """ + + async def fetcher() -> FetchResult: + return FetchResult(document={"keys": []}, source="https://as.example.com/jwks.json") + + cache = JWKSCache(fetcher, refresh_seconds=3600, document_type="jwks") + with caplog.at_level(logging.INFO, logger="authplane.internal.document_cache"): + await cache.get() + assert [r for r in caplog.records if r.levelno == logging.INFO] == [] await cache.aclose() diff --git a/tests/internal/test_jwks.py b/tests/internal/test_jwks.py index 439bb4a..8b13ec8 100644 --- a/tests/internal/test_jwks.py +++ b/tests/internal/test_jwks.py @@ -367,21 +367,15 @@ async def test_background_refresh_uses_effective_ttl() -> None: # --------------------------------------------------------------------------- -# Change detection and callbacks +# Rotation of the served document # --------------------------------------------------------------------------- -async def test_change_callback_fires_when_document_changes() -> None: - """Test that on_change callback is invoked when document content changes.""" +async def test_refresh_serves_the_rotated_key_set() -> None: + """A refresh that returns a different key set replaces the served one.""" doc_v1: dict[str, Any] = {"keys": [{"kid": "key-1"}]} doc_v2: dict[str, Any] = {"keys": [{"kid": "key-2"}]} - change_events: list[dict[str, Any]] = [] - - async def on_change(old_doc: dict[str, Any], new_doc: dict[str, Any]) -> None: - change_events.append({"old": old_doc, "new": new_doc}) - - # Fetcher returns different docs on successive calls call_count: dict[str, int] = {"count": 0} async def fetcher() -> FetchResult: @@ -389,98 +383,27 @@ async def fetcher() -> FetchResult: doc = doc_v2 if call_count["count"] > 1 else doc_v1 return FetchResult(document=doc, expires_at=None) - cache = JWKSCache(fetcher, document_type="jwks", on_change=on_change) - - # First fetch: no callback (no previous cache) - await cache.get() - await asyncio.sleep(0.05) # Allow callback task to run - assert len(change_events) == 0 + cache = JWKSCache(fetcher, document_type="jwks") - # Second fetch: document changed, callback should fire - await cache.get(force_refresh=True) - await asyncio.sleep(0.05) # Allow callback task to run - assert len(change_events) == 1 - assert change_events[0]["old"] == doc_v1 - assert change_events[0]["new"] == doc_v2 + assert await cache.get() == doc_v1 + assert await cache.get(force_refresh=True) == doc_v2 + assert await cache.contains_kid("key-2") is True + assert await cache.contains_kid("key-1") is False await cache.aclose() -async def test_change_callback_not_fired_when_document_unchanged() -> None: - """Test that on_change callback is NOT invoked when document is identical.""" +async def test_refresh_returning_the_same_key_set_is_a_no_op() -> None: + """An unchanged key set leaves the cache serving exactly what it served.""" doc: dict[str, Any] = {"keys": [{"kid": "key-1"}]} - change_events: list[dict[str, Any]] = [] - async def on_change(old_doc: dict[str, Any], new_doc: dict[str, Any]) -> None: - change_events.append({"old": old_doc, "new": new_doc}) - - # Fetcher returns same doc every time async def fetcher() -> FetchResult: return FetchResult(document=doc, expires_at=None) - cache = JWKSCache(fetcher, document_type="jwks", on_change=on_change) - - # First fetch - await cache.get() - await asyncio.sleep(0.05) - assert len(change_events) == 0 - - # Second fetch: same document, callback should NOT fire - await cache.get(force_refresh=True) - await asyncio.sleep(0.05) - assert len(change_events) == 0 - - await cache.aclose() - - -async def test_change_callback_error_does_not_fail_fetch() -> None: - """Test that errors in on_change callback don't break cache operation.""" - doc_v1: dict[str, Any] = {"keys": [{"kid": "key-1"}]} - doc_v2: dict[str, Any] = {"keys": [{"kid": "key-2"}]} - - async def broken_callback(_old_doc: dict[str, Any], _new_doc: dict[str, Any]) -> None: - raise RuntimeError("Callback failed!") - - call_count: dict[str, int] = {"count": 0} - - async def fetcher() -> FetchResult: - call_count["count"] += 1 - doc = doc_v2 if call_count["count"] > 1 else doc_v1 - return FetchResult(document=doc, expires_at=None) - - cache = JWKSCache(fetcher, document_type="jwks", on_change=broken_callback) - - # First fetch - result1 = await cache.get() - assert result1 == doc_v1 - - # Second fetch: callback will error but fetch should succeed - result2 = await cache.get(force_refresh=True) - await asyncio.sleep(0.05) # Allow callback task to run - assert result2 == doc_v2 # Fetch succeeded despite callback error - - await cache.aclose() - - -async def test_no_callback_when_on_change_is_none() -> None: - """Test that cache works normally when on_change is not provided.""" - doc_v1: dict[str, Any] = {"keys": [{"kid": "key-1"}]} - doc_v2: dict[str, Any] = {"keys": [{"kid": "key-2"}]} - - call_count: dict[str, int] = {"count": 0} - - async def fetcher() -> FetchResult: - call_count["count"] += 1 - doc = doc_v2 if call_count["count"] > 1 else doc_v1 - return FetchResult(document=doc, expires_at=None) - - # No on_change parameter cache = JWKSCache(fetcher, document_type="jwks") - result1 = await cache.get() - assert result1 == doc_v1 - - result2 = await cache.get(force_refresh=True) - assert result2 == doc_v2 + assert await cache.get() == doc + assert await cache.get(force_refresh=True) == doc + assert await cache.contains_kid("key-1") is True await cache.aclose() diff --git a/tests/internal/test_jwks_fetcher.py b/tests/internal/test_jwks_fetcher.py index a2bc0a4..0580896 100644 --- a/tests/internal/test_jwks_fetcher.py +++ b/tests/internal/test_jwks_fetcher.py @@ -148,14 +148,9 @@ async def test_logs_ssrf_errors( with pytest.raises(SSRFError): await fetcher.fetch() - # Compare the whole logged message: only the SSRF-block error should - # have been recorded, naming the blocked URL exactly. A substring check - # on the log text could pass on a message naming a different URL that - # merely contains the expected host. - assert caplog.messages == [ - "SSRF protection blocked document fetch from " - "https://malicious.com/.well-known/jwks.json: DNS resolution failed" - ] + # Verify error was logged + assert "SSRF protection blocked" in caplog.text + assert "malicious.com" in caplog.text async def test_multiple_fetches(self) -> None: """Should handle multiple fetch calls.""" diff --git a/tests/internal/test_metadata.py b/tests/internal/test_metadata.py index 406aa4f..4f200fc 100644 --- a/tests/internal/test_metadata.py +++ b/tests/internal/test_metadata.py @@ -6,7 +6,7 @@ import pytest -from authplane.errors import MetadataFetchError +from authplane.errors import MetadataFetchError, MissingMetadataEndpointError from authplane.internal.fetch_result import FetchResult from authplane.internal.metadata import MetadataCache @@ -314,3 +314,262 @@ async def __call__(self, *_args: Any, **_kwargs: Any) -> FetchResult: with pytest.raises(MetadataFetchError): await cache.get_jwks_uri() + + +# --------------------------------------------------------------------------- +# Forced-read floor, retry floor, and required jwks_uri +# --------------------------------------------------------------------------- + + +async def test_forced_reads_are_capped_at_one_per_floor() -> None: + """A `kid` miss must not cost the AS a discovery fetch per request. + + The caller that reaches a forced read has had only its token *header* + decoded, so the `kid` is attacker-controlled and the forced read bypasses + `refresh_seconds` by design. Without a floor, invalid tokens drive AS + discovery traffic one-for-one. + """ + fetcher = TrackingFetcher() + cache = MetadataCache(fetcher, expected_issuer=SAMPLE_METADATA["issuer"], refresh_seconds=3600) + + await cache.get() + assert fetcher.calls["count"] == 1 + + # First miss after boot: admitted, so a real rotation is followed at once. + await cache.get(force_refresh=True) + assert fetcher.calls["count"] == 2 + + # A flood of misses inside the floor costs the AS nothing more. + for _ in range(20): + await cache.get(force_refresh=True) + assert fetcher.calls["count"] == 2 + + # Past the floor — min(refresh_seconds, 60) — the next miss re-reads. + cache._last_forced_read = time.time() - 61 # pyright: ignore[reportPrivateUsage] + await cache.get(force_refresh=True) + assert fetcher.calls["count"] == 3 + + await cache.aclose() + + +async def test_no_forced_read_floor_when_refresh_seconds_is_zero() -> None: + """`refresh_seconds=0` means "re-read every time" and opts out of the floor.""" + fetcher = TrackingFetcher() + cache = MetadataCache(fetcher, expected_issuer=SAMPLE_METADATA["issuer"], refresh_seconds=0) + + await cache.get() + await cache.get(force_refresh=True) + await cache.get(force_refresh=True) + assert fetcher.calls["count"] == 3 + + await cache.aclose() + + +async def test_failed_refresh_is_not_retried_on_every_read() -> None: + """An unreachable AS must not cost a full fetch per verification. + + A failed fetch does not advance `_cache_time`, so the document reads as + expired forever and every caller takes the synchronous refetch branch — + serialized behind the fetch lock, one HTTP timeout each. + """ + calls = {"count": 0} + + async def flaky() -> FetchResult: + calls["count"] += 1 + if calls["count"] == 1: + return FetchResult(document=SAMPLE_METADATA) + raise MetadataFetchError("endpoint down") + + cache = MetadataCache(flaky, expected_issuer=SAMPLE_METADATA["issuer"], refresh_seconds=1) + assert await cache.get() == SAMPLE_METADATA + + cache._cache_time = 0 # pyright: ignore[reportPrivateUsage] + assert await cache.get() == SAMPLE_METADATA + assert calls["count"] == 2 + + # Still expired, but inside the floor: served from cache, no second attempt. + for _ in range(5): + assert await cache.get() == SAMPLE_METADATA + assert calls["count"] == 2 + + await cache.aclose() + + +async def test_refresh_without_jwks_uri_does_not_displace_the_good_document() -> None: + """The one field the whole mechanism runs on. + + A document that has dropped `jwks_uri` used to validate, commit with a fresh + timestamp and displace the good one, after which every JWKS fetch raised for + the rest of the interval and the first genuinely new `kid` failed. + """ + calls = {"count": 0} + + async def withdrawing() -> FetchResult: + calls["count"] += 1 + if calls["count"] == 1: + return FetchResult(document=SAMPLE_METADATA) + return FetchResult(document={"issuer": SAMPLE_METADATA["issuer"]}) + + cache = MetadataCache(withdrawing, expected_issuer=SAMPLE_METADATA["issuer"], refresh_seconds=1) + assert await cache.get() == SAMPLE_METADATA + + cache._cache_time = 0 # pyright: ignore[reportPrivateUsage] + assert await cache.get_jwks_uri() == SAMPLE_METADATA["jwks_uri"] + assert calls["count"] == 2 + + await cache.aclose() + + +async def test_background_refresh_is_not_gated_by_the_forced_read_floor() -> None: + """The floor is an anti-abuse gate on an externally triggered read. + + A refresh the cache schedules for itself at 80% of its own TTL is not that + caller. Routing it through the floored override makes it a silent no-op + whenever 0.8 * refresh_seconds falls below the floor — the downgraded read + takes the fast path on a document that is by definition still valid, returns, + and logs a refresh that never happened. + """ + fetcher = TrackingFetcher() + cache = MetadataCache(fetcher, expected_issuer=SAMPLE_METADATA["issuer"], refresh_seconds=10) + + await cache.get() + assert fetcher.calls["count"] == 1 + + # Consume the forced-read slot the way a kid miss would. + await cache.get(force_refresh=True) + assert fetcher.calls["count"] == 2 + + # Past 80% of a 10 s TTL, and inside the 10 s floor. The background refresh + # must still reach the network. + cache._cache_time = time.time() - 9 # pyright: ignore[reportPrivateUsage] + await cache.get() + refresh_task = cache._refresh_task # pyright: ignore[reportPrivateUsage] + assert refresh_task is not None + await refresh_task + assert fetcher.calls["count"] == 3 + + await cache.aclose() + + +async def test_missing_jwks_uri_keeps_its_documented_error_type() -> None: + """`MissingMetadataEndpointError` is a package-root export. + + Moving the check from read time to fetch time must not change what an + operator's `except` clause catches. + """ + cache = MetadataCache( + TrackingFetcher({"issuer": SAMPLE_METADATA["issuer"]}), + expected_issuer=SAMPLE_METADATA["issuer"], + refresh_seconds=1, + ) + + with pytest.raises(MissingMetadataEndpointError): + await cache.get() + + await cache.aclose() + + +# --------------------------------------------------------------------------- +# Validation precedes the commit — the ordering, per rejectable field +# --------------------------------------------------------------------------- + + +_GOOD_JWKS_URI = "https://auth.example.com/.well-known/jwks.json" +_DISCOVERY_URL = "https://auth.example.com/.well-known/oauth-authorization-server" + + +@pytest.mark.parametrize( + ("rejected_document", "why"), + [ + ( + {"issuer": "https://auth.example.com", "jwks_uri": "http://auth.example.com/jwks.json"}, + "jwks_uri is not HTTPS", + ), + ( + {"issuer": "https://auth.example.com", "jwks_uri": "/relative-jwks"}, + "jwks_uri is not absolute", + ), + ( + { + "issuer": "https://evil.example.com", + "jwks_uri": "https://evil.example.com/jwks.json", + }, + "issuer does not match the configured one", + ), + ( + {"issuer": "https://auth.example.com"}, + "jwks_uri is absent", + ), + ( + {"jwks_uri": "https://auth.example.com/jwks.json"}, + "issuer is absent", + ), + ], +) +async def test_a_rejected_refresh_moves_neither_the_document_nor_the_key_source( + rejected_document: dict[str, Any], why: str +) -> None: + """A document that fails validation must decide nothing, for every field. + + The location key retrieval fetches from is read out of the cached document, + so committing first and validating afterwards would let a rejected document + name it for as long as it sat there. The recorded source is checked + alongside the contents because that is the value a dependent cache compares + against to decide whether it is still correctly bound: a rejected document + that moved it would leave that cache believing it was bound to a location + this cache had refused. + """ + calls = {"count": 0} + + async def then_rejected() -> FetchResult: + calls["count"] += 1 + if calls["count"] == 1: + return FetchResult(document=SAMPLE_METADATA, source=_DISCOVERY_URL) + return FetchResult(document=rejected_document, source="https://elsewhere.example.com/meta") + + cache = MetadataCache( + then_rejected, expected_issuer=SAMPLE_METADATA["issuer"], refresh_seconds=1 + ) + assert await cache.get() == SAMPLE_METADATA + assert cache._cache_source == _DISCOVERY_URL # pyright: ignore[reportPrivateUsage] + + # Expire the interval so the next read refetches and is rejected. + cache._cache_time = 0 # pyright: ignore[reportPrivateUsage] + + assert await cache.get_jwks_uri() == _GOOD_JWKS_URI, why + assert calls["count"] == 2, "the rejected document was never fetched" + assert await cache.get() == SAMPLE_METADATA, why + assert cache._cache_source == _DISCOVERY_URL, why # pyright: ignore[reportPrivateUsage] + + await cache.aclose() + + +async def test_a_rejected_refresh_surfaces_no_partial_document_to_a_concurrent_reader() -> None: + """Ten readers straddling a rejected refresh all see the accepted document. + + One of them takes the refetch and is rejected; the rest must be served the + document that was last accepted, never the one in the middle of being + checked. + """ + calls = {"count": 0} + + async def then_rejected() -> FetchResult: + calls["count"] += 1 + if calls["count"] == 1: + return FetchResult(document=SAMPLE_METADATA, source=_DISCOVERY_URL) + await asyncio.sleep(0) + return FetchResult( + document={"issuer": "https://evil.example.com", "jwks_uri": "https://evil/jwks.json"}, + source=_DISCOVERY_URL, + ) + + cache = MetadataCache( + then_rejected, expected_issuer=SAMPLE_METADATA["issuer"], refresh_seconds=1 + ) + assert await cache.get() == SAMPLE_METADATA + + cache._cache_time = 0 # pyright: ignore[reportPrivateUsage] + results = await asyncio.gather(*(cache.get_jwks_uri() for _ in range(10))) + assert results == [_GOOD_JWKS_URI] * 10 + + await cache.aclose() diff --git a/tests/internal/test_urls.py b/tests/internal/test_urls.py index 6694584..bc56f25 100644 --- a/tests/internal/test_urls.py +++ b/tests/internal/test_urls.py @@ -6,7 +6,9 @@ from authplane.internal.urls import ( build_metadata_url, build_prm_url, - validate_resource_indicator, + validate_issuer_identifier, + validate_prm_resource_identifier, + validate_resource_metadata_url, ) @@ -63,46 +65,597 @@ def test_http_issuer_with_path(self) -> None: assert result == "http://localhost:3000/.well-known/oauth-authorization-server/tenant1" -class TestResourceIndicatorValidation: +class TestResourceIdentifierValidation: """RFC 8707 §2 — a resource indicator MUST NOT carry a fragment.""" def test_validate_rejects_fragment(self) -> None: with pytest.raises(ValueError, match="must not contain a fragment"): - validate_resource_indicator("https://api.example.com/mcp#frag") + validate_prm_resource_identifier("https://api.example.com/mcp#frag") def test_validate_accepts_query(self) -> None: # A query is legal and is preserved by the derivation (RFC 9728 §3.1); # only the fragment is forbidden. The two are treated asymmetrically. - validate_resource_indicator("https://api.example.com/mcp?tenant=a") + validate_prm_resource_identifier("https://api.example.com/mcp?tenant=a") def test_validate_does_not_echo_credentials(self) -> None: with pytest.raises(ValueError) as exc: - validate_resource_indicator("https://svc:s3cr3t@api.example.com/mcp#frag") - message = str(exc.value) - assert "s3cr3t" not in message - # Compare the echoed identifier whole rather than asking whether the host - # appears somewhere in the message. A containment check cannot tell "the - # host is the identifier" from "the host occurs inside a longer one", so - # it would also pass on a message naming the wrong resource -- and it is - # the shape static analysis flags as incomplete URL sanitization. - assert message.rsplit(": ", 1)[-1] == repr("https://api.example.com/mcp") + validate_prm_resource_identifier("https://svc:s3cr3t@api.example.com/mcp#frag") + assert "s3cr3t" not in str(exc.value) + assert "api.example.com" in str(exc.value) def test_malformed_port_does_not_mask_the_rfc_error(self) -> None: # ParseResult.port raises ValueError on a non-integer port. Building the # redacted authority for the error message must not surface urllib's # "Port could not be cast to integer value" in place of the RFC citation. with pytest.raises(ValueError) as exc: - validate_resource_indicator("https://h:abc/mcp#frag") + validate_prm_resource_identifier("https://h:abc/mcp#frag") assert "must not contain a fragment" in str(exc.value) assert "cast to integer" not in str(exc.value) +class TestResourceIdentifierAbsoluteness: + """The identifier must be an absolute URL with a scheme AND a host. + + The two halves have different sources, which is why the exported gate is + named for the PRM axis rather than for RFC 8707's. The scheme is RFC 8707 + §2's requirement ("MUST be an absolute URI", RFC 3986 §4.3: + ``absolute-URI = scheme ":" hier-part [ "?" query ]``, which + ``urn:example:api`` satisfies); the host is RFC 9728 §3's — the well-known suffix is inserted after the host component, + so without one there is no derivable metadata URL, and in the MCP adapters + no derivable DPoP ``htu`` origin. The three rejects below are each missing + a different half, which is why all three are pinned independently. + """ + + def test_rejects_relative_reference(self) -> None: + # No scheme, no host. The echo must be the string the operator wrote — + # the fixed "scheme://host/path" template rendered this as ":///mcp". + with pytest.raises( + InvalidResourceError, match="absolute URL with a scheme and a host" + ) as exc: + validate_prm_resource_identifier("/mcp") + assert "'/mcp'" in str(exc.value) + + def test_rejects_scheme_relative_reference(self) -> None: + # urlsplit gives "//api.example.com/mcp" a netloc but no scheme — a + # guard phrased as "opaque or authority-less" would wrongly admit it. + # The scheme is the component missing from both this and the relative + # form, so it has to be checked explicitly. The echo keeps the "//" and + # no scheme, so it stays distinguishable from the scheme-less form + # below — the message has to tell the operator WHICH half is missing. + with pytest.raises( + InvalidResourceError, match="absolute URL with a scheme and a host" + ) as exc: + validate_prm_resource_identifier("//api.example.com/mcp") + assert "'//api.example.com/mcp'" in str(exc.value) + + def test_rejects_scheme_less_host_and_echoes_it_faithfully(self) -> None: + # "api.example.com/mcp" is all path to urlsplit: no scheme, no netloc. + # It must not render with an invented "//" — that made it identical to + # the scheme-relative echo above. + with pytest.raises( + InvalidResourceError, match="absolute URL with a scheme and a host" + ) as exc: + validate_prm_resource_identifier("api.example.com/mcp") + assert "'api.example.com/mcp'" in str(exc.value) + + def test_rejects_opaque_urn(self) -> None: + # Scheme but no host. Before the gate this derived the nonsense + # metadata URL "urn:/.well-known/oauth-protected-resource/example:api", + # and the MCP adapters' htu origin became the literal "://". + # The echo must stay opaque: the old template rendered it as + # "urn://example:api" — an identifier that *appears* to have the host + # (and port!) the message says is missing. + with pytest.raises( + InvalidResourceError, match="absolute URL with a scheme and a host" + ) as exc: + validate_prm_resource_identifier("urn:example:api") + assert "'urn:example:api'" in str(exc.value) + + def test_accepts_http_localhost(self) -> None: + # Deliberate profile relaxation: the scheme is not narrowed to https, + # so local development against a plain-http server keeps working. + validate_prm_resource_identifier("http://localhost:8080/mcp") + + def test_accepts_https_with_path_and_query(self) -> None: + # Absolute identifiers with paths and queries are untouched by the + # absoluteness gate — only the fragment axis rejects among them. + validate_prm_resource_identifier("https://api.example.com/v2/mcp?tenant=a") + + def test_fragment_is_reported_before_absoluteness(self) -> None: + # Deterministic ordering: an input violating both axes reports the + # fragment. The fragment check must stay ahead of any parsing anyway + # (see the unparseable-authority cases), so the order is not free. + with pytest.raises(InvalidResourceError, match="must not contain a fragment"): + validate_prm_resource_identifier("/mcp#frag") + + def test_unparseable_authority_is_rejected_with_the_redacted_placeholder(self) -> None: + # An unclosed IPv6 bracket makes urlsplit itself raise. No host can be + # established for such an identifier, so it falls under the same + # rejection — reported with the gate's own message and the redaction + # placeholder, not urllib's "Invalid IPv6 URL". + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://[::1") + assert "absolute URL with a scheme and a host" in str(exc.value) + assert "(unparseable identifier)" in str(exc.value) + assert "IPv6" not in str(exc.value) + + def test_rejection_does_not_echo_credentials(self) -> None: + # Same redaction contract as the fragment gate: userinfo embedded in + # the authority never reaches the error message. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("//svc:s3cr3t@api.example.com/mcp") + assert "s3cr3t" not in str(exc.value) + assert "api.example.com" in str(exc.value) + + def test_build_prm_url_backstop_rejects_too(self) -> None: + # The defensive backstop in the derivation shares the gate, same as it + # does for the fragment axis. + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + build_prm_url("/mcp") + + def test_userinfo_only_authority_is_rejected_without_echoing_the_secret(self) -> None: + # netloc present, hostname empty — the load-bearing case for rendering + # the echo from the redacted `host` rather than `host or parsed.netloc`: + # that rejected variant would have re-admitted "svc:s3cr3t@" into the + # very message the renderer exists to redact. Rejected on the + # absoluteness axis (no host), ahead of the userinfo check. + with pytest.raises( + InvalidResourceError, match="absolute URL with a scheme and a host" + ) as exc: + validate_prm_resource_identifier("https://svc:s3cr3t@/x") + assert "s3cr3t" not in str(exc.value) + assert "svc" not in str(exc.value) + assert "'https:///x'" in str(exc.value) + + def test_port_only_authority_is_rejected(self) -> None: + # The gate reads `hostname`, which is empty here, where `netloc` would + # be ":8080" and would admit the input. An authority of only a port + # names no host, so there is no RFC 9728 §3 insertion point — the + # strictness is intentional, not an accident to be "simplified" back + # to `netloc`. + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + validate_prm_resource_identifier("https://:8080/x") + + def test_port_only_authority_renders_faithfully(self) -> None: + # The redacted echo re-renders the port from `SplitResult.port`, so the + # operator sees the exact shape they configured. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://:8080/x") + assert "'https://:8080/x'" in str(exc.value) + + def test_empty_authority_echo_keeps_the_slashes(self) -> None: + # "https://" parses to an EMPTY netloc, so an echo gated on the netloc + # alone rendered it as 'https:' — indistinguishable from the opaque + # "https:", the dropped-half defect in the other direction. The "//" + # is now gated on the raw string spelling it out. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://") + assert "'https://'" in str(exc.value) + + def test_empty_host_file_url_echo_keeps_the_slashes(self) -> None: + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("file:///x") + assert "'file:///x'" in str(exc.value) + + +class TestResourceIdentifierUserinfo: + """RFC 9110 §4.2.4 — a sender MUST NOT generate the userinfo subcomponent. + + The identifier feeds three sinks that reassemble the authority from + ``netloc``, not ``hostname``: the derived PRM URL (handed to + unauthenticated callers in a 401 ``WWW-Authenticate`` challenge), the MCP + adapters' DPoP ``htu`` origin (which no honest proof could then match), + and the ``fail_closed`` warning's log record. Rejecting at construction + closes all three at once instead of redacting at each. + """ + + def test_rejects_credentials_and_names_the_userinfo_component(self) -> None: + with pytest.raises( + InvalidResourceError, match="must not contain a userinfo component" + ) as exc: + validate_prm_resource_identifier("https://svc:s3cr3t@api.example.com/mcp") + assert "RFC 9110" in str(exc.value) + + def test_rejection_does_not_echo_the_secret(self) -> None: + # Same redaction contract as every other axis: the echo renders the + # bare hostname, never the netloc the credentials live in. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://svc:s3cr3t@api.example.com/mcp") + assert "s3cr3t" not in str(exc.value) + assert "svc" not in str(exc.value) + assert "api.example.com" in str(exc.value) + + def test_rejects_username_only_userinfo(self) -> None: + # A password-less userinfo ("svc@") is still the subcomponent RFC 9110 + # §4.2.4 forbids generating. + with pytest.raises(InvalidResourceError, match="userinfo"): + validate_prm_resource_identifier("https://svc@api.example.com/mcp") + + def test_rejects_empty_userinfo(self) -> None: + # Decision, pinned: "https://@api.example.com/mcp" parses with + # username == "" — the subcomponent is PRESENT (the "@" delimiter sits + # in the authority the sinks reassemble and the adapters advertise + # verbatim) even though it is empty. The gate checks `is not None`, + # not truthiness, so the empty form is rejected too. + with pytest.raises(InvalidResourceError, match="userinfo"): + validate_prm_resource_identifier("https://@api.example.com/mcp") + + def test_userinfo_is_reported_after_absoluteness(self) -> None: + # Deterministic order: an input missing a host reports the + # absoluteness violation even when it also carries userinfo. + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + validate_prm_resource_identifier("https://svc:s3cr3t@/x") + + def test_build_prm_url_backstop_rejects_userinfo_too(self) -> None: + # The PRM-URL sink itself: build_prm_url passes `netloc` to urlunsplit, + # so without the shared gate this derived a credential-bearing URL for + # a 401 challenge header. + with pytest.raises(InvalidResourceError, match="userinfo"): + build_prm_url("https://svc:s3cr3t@api.example.com/mcp") + + +class TestResourceIdentifierPort: + """RFC 3986 §3.2.3 — ``port = *DIGIT``. + + ``SplitResult.port`` parses lazily, so a non-numeric or out-of-range port + survives the scheme/host checks: ``hostname`` is ``'h'`` and ``scheme`` is + ``'https'`` for ``https://h:abc/mcp``. Such an authority has the same + consequence as the relative and opaque shapes — no derivable origin — so it + is rejected at the same construction-time gate. + """ + + def test_rejects_non_numeric_port(self) -> None: + with pytest.raises(InvalidResourceError, match="must not contain a malformed port") as exc: + validate_prm_resource_identifier("https://h:abc/mcp") + assert "RFC 3986 §3.2.3" in str(exc.value) + + def test_rejects_the_realistic_typo(self) -> None: + # The shape an operator actually produces: letter O for zero in a real + # authority. Without the gate this derived the PRM URL + # "https://api.example.com:80O/.well-known/oauth-protected-resource/mcp" + # and an htu origin of "https://api.example.com:80O", neither of which + # any honest client proof can match. + with pytest.raises(InvalidResourceError, match="must not contain a malformed port"): + validate_prm_resource_identifier("https://api.example.com:80O/mcp") + + def test_rejects_out_of_range_port(self) -> None: + # The other way SplitResult.port raises: all digits, but outside + # 0-65535, which is not an addressable port either. + with pytest.raises(InvalidResourceError, match="must not contain a malformed port"): + validate_prm_resource_identifier("https://api.example.com:99999/mcp") + + def test_out_of_range_port_is_echoed_verbatim(self) -> None: + # An all-digit port is not credential-shaped, so it is echoed as + # written — a message about the port that renders the identifier + # WITHOUT one shows the operator a string they did not write. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://api.example.com:99999/mcp") + assert "'https://api.example.com:99999/mcp'" in str(exc.value) + + def test_non_numeric_port_echo_marks_the_port_instead_of_dropping_it(self) -> None: + # A port carrying non-digits cannot be echoed verbatim: an operator who + # forgot the "@" writes "https://user:pass/x", which urlsplit reports + # with NO userinfo and a port of "pass". The redaction contract wins, + # but the marker still shows the operator which component is at fault + # rather than silently rendering an authority with no port at all. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://h:abc/mcp") + assert "'https://h:(malformed port)/mcp'" in str(exc.value) + + def test_userinfo_shaped_port_does_not_echo_the_secret(self) -> None: + # The load-bearing half of the rule above. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://user:s3cr3t/x") + assert "s3cr3t" not in str(exc.value) + + def test_ipv6_authority_with_a_malformed_port_stays_bracketed(self) -> None: + # host_literal re-brackets the literal SplitResult.hostname strips, and + # the port marker is appended outside the brackets. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://[::1]:abc/x") + assert "'https://[::1]:(malformed port)/x'" in str(exc.value) + + def test_fragment_is_still_reported_before_the_port(self) -> None: + # Ordering pin: the fragment check stays first, so the pre-existing + # test_malformed_port_does_not_mask_the_rfc_error keeps its meaning. + with pytest.raises(InvalidResourceError, match="must not contain a fragment"): + validate_prm_resource_identifier("https://h:abc/mcp#frag") + + def test_userinfo_is_reported_before_the_port(self) -> None: + # Deterministic order: fragment, whitespace, absoluteness, userinfo, + # port. Credentials are the finding the operator has to act on first. + with pytest.raises(InvalidResourceError, match="userinfo"): + validate_prm_resource_identifier("https://svc:s3cr3t@api.example.com:abc/mcp") + + def test_valid_port_still_accepted(self) -> None: + validate_prm_resource_identifier("http://localhost:8080/mcp") + validate_prm_resource_identifier("https://api.example.com:8443/mcp") + + def test_build_prm_url_backstop_rejects_a_malformed_port_too(self) -> None: + # Without the gate this derived a PRM URL from an authority that has no + # usable origin, and returned it from AuthplaneResource.prm_url() into + # a 401 challenge header. + with pytest.raises(InvalidResourceError, match="must not contain a malformed port"): + build_prm_url("https://h:abc/mcp") + + +class TestResourceIdentifierHostDelimiters: + r"""A `"` or a `\` in the host corrupts the challenge that advertises it. + + RFC 9110 §11.2 makes ``resource_metadata`` a quoted-string and §5.6.4 makes + those two octets its delimiters. ``errors.py`` interpolates the derived PRM + URL into it, and every derivation here carries the authority verbatim — + ``urlsplit`` admits both octets into the host, ``hostname`` returns them + unchanged and ``urlunsplit`` writes them back out unescaped, all measured on + 3.12. A sweep of all 256 byte values in the host position leaves exactly + these two outside ``qdtext`` once DEL is accounted for by the + whitespace/control gate, which is why the gate is two characters wide and + not a general host character-set sweep. + """ + + def test_rejects_literal_quote_in_host(self) -> None: + # Without the gate the challenge ships as + # Bearer resource_metadata="https://api"example.com/.well-known/..." + # and a conformant client reads the quoted-string as ending at the host. + with pytest.raises(InvalidResourceError, match="host must not contain a literal") as exc: + validate_prm_resource_identifier('https://api"example.com/mcp') + assert "RFC 9110 §5.6.4, §11.2" in str(exc.value) + assert "'\"'" in str(exc.value) + + def test_rejects_literal_backslash_in_host(self) -> None: + # The worse of the two: the challenge stays well-formed, so nothing + # looks wrong, but `\e` is a quoted-pair and a client that unescapes it + # fetches https://apiexample.com/... — a different host. + with pytest.raises(InvalidResourceError, match="host must not contain a literal") as exc: + validate_prm_resource_identifier("https://api\\example.com/mcp") + assert "'\\\\'" in str(exc.value) + + def test_quote_is_reported_before_backslash(self) -> None: + # Deterministic when both are present, so the message does not depend + # on which one urlsplit happens to see first. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier('https://a"b\\c.example.com/mcp') + assert "'\"'" in str(exc.value) + + def test_the_gate_is_reached_through_build_prm_url(self) -> None: + # build_prm_url is the function that actually derives the advertised + # URL, and its production caller sits on a 401 response path. + with pytest.raises(InvalidResourceError, match="host must not contain a literal"): + build_prm_url('https://api"example.com/mcp') + + def test_rejection_message_shows_the_host_at_fault(self) -> None: + # The host is the component the operator has to change, and + # _redact_authority already deems it safe to print. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier('https://api"example.com/mcp') + assert 'api"example.com' in str(exc.value) + + def test_userinfo_is_reported_before_a_host_delimiter(self) -> None: + # Credentials stay the first finding: they are the more urgent fix, and + # the userinfo gate runs ahead of this one. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier('https://svc:s3cr3t@api"example.com/mcp') + assert "userinfo component" in str(exc.value) + assert "s3cr3t" not in str(exc.value) + + def test_host_delimiter_is_reported_before_a_malformed_port(self) -> None: + # The authority anchors every derived value — the PRM URL's origin and + # the adapters' htu origin — so a host defect is named before a port one. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier('https://a"b:80O/mcp') + assert "host must not contain a literal" in str(exc.value) + + def test_a_delimiter_in_the_userinfo_is_not_a_host_finding(self) -> None: + # hostname excludes the userinfo, so this gate is disjoint from the one + # above rather than shadowing it. Reported as a userinfo defect. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier('https://sv"c@api.example.com/mcp') + assert "userinfo component" in str(exc.value) + + def test_percent_escaped_quote_is_not_rejected(self) -> None: + # Measured on 3.12: CPython's `hostname` does NOT percent-decode, so + # urlsplit("https://api%22example.com/mcp").hostname is the literal + # 'api%22example.com'. Nothing downstream decodes it either, and the + # three characters "%22" are qdtext, so the challenge stays intact. + # A useless host, but not this defect — and rejecting it would be a + # rejection this gate's rationale does not support. + validate_prm_resource_identifier("https://api%22example.com/mcp") + assert "%22" in build_prm_url("https://api%22example.com/mcp") + + def test_accepts_the_shapes_an_operator_actually_configures(self) -> None: + # Acceptance control: the gate must not be rejecting everything, and in + # particular must not be rejecting the qdtext punctuation that is legal + # in a host or that this module already handles elsewhere. + for resource in ( + "https://api.example.com/mcp", + "https://api.example.com:8443/mcp", + "http://localhost:8080/mcp", + "https://[::1]:8443/mcp", + "https://api-01.example.com/v2/mcp?tenant=a", + "https://xn--80ak6aa92e.example.com/mcp", + ): + validate_prm_resource_identifier(resource) + # A `"` outside the host is not this gate's business: the path and the + # query are different components with different consumers, and + # narrowing them is a separate decision with its own migration cost. + validate_prm_resource_identifier('https://api.example.com/m"cp') + + +class TestResourceIdentifierWhitespace: + """Whitespace splits the gate's view from the stored verbatim identifier. + + ``urlsplit`` removes tab/CR/LF anywhere and, since CPython 3.11 (the + ``requires-python`` floor), lstrips leading C0-control-or-space — both + verified by execution on 3.11 and 3.12. A parse-time gate would therefore + pass a value whose derived PRM URL differs byte-for-byte from the + ``verbatim_resource`` the adapters advertise, which RFC 9728 §3.3 obliges + a conformant client to discard. The check runs on the raw string, before + parsing. + """ + + def test_rejects_internal_tab(self) -> None: + # urlsplit strips the tab and reports hostname='api.example.com', so + # without the raw-string check the gate passed this while the stored + # identifier kept the tab. + with pytest.raises(InvalidResourceError, match="must not contain whitespace") as exc: + validate_prm_resource_identifier("https://api.exa\tmple.com/mcp") + assert "RFC 9728 §3.3" in str(exc.value) + + def test_rejects_leading_space(self) -> None: + # The 3.11+ WHATWG lstrip: urlsplit(" https://...") parses as though + # the space were never there. + with pytest.raises(InvalidResourceError, match="must not contain whitespace"): + validate_prm_resource_identifier(" https://api.example.com/mcp") + + def test_rejects_trailing_space(self) -> None: + # A trailing space survives into `path` rather than being stripped, + # but it is not a URI character (RFC 3986 §2) and would ride into the + # derived well-known URL — rejected by the same raw-string check. + with pytest.raises(InvalidResourceError, match="must not contain whitespace"): + validate_prm_resource_identifier("https://api.example.com/mcp ") + + def test_rejects_leading_c0_control(self) -> None: + # The 3.11+ lstrip removes C0 controls, not only space — a leading + # \x01 is invisible to the parse but present in the stored identifier, + # the identical divergence through a character isspace() misses. + with pytest.raises(InvalidResourceError, match="whitespace or control characters"): + validate_prm_resource_identifier("\x01https://api.example.com/mcp") + + def test_fragment_still_reported_first(self) -> None: + # Order stays deterministic: fragment, then whitespace, then + # absoluteness, then userinfo. + with pytest.raises(InvalidResourceError, match="must not contain a fragment"): + validate_prm_resource_identifier(" /mcp#frag") + + def test_whitespace_reported_before_absoluteness(self) -> None: + # " /mcp" violates both the whitespace and the absoluteness axes. + with pytest.raises(InvalidResourceError, match="must not contain whitespace"): + validate_prm_resource_identifier(" /mcp") + + def test_whitespace_rejection_does_not_echo_credentials(self) -> None: + # The raw string may carry userinfo too; the echo stays redacted. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier(" https://svc:s3cr3t@api.example.com/mcp") + assert "s3cr3t" not in str(exc.value) + + +class TestWhitespaceAndControlClass: + """The rejected class is C0 + space, DEL + C1, Unicode whitespace, U+FEFF. + + RFC 3986 §2 builds every URI component out of ``unreserved``, ``reserved`` + and ``pct-encoded``, all printable ASCII, so none of these is a URI + character and an identifier carrying one is not a URI at all. The class was + ``isspace() or ord(ch) <= 0x20``, which left DEL and the C1 controls + accepted — ``urlsplit`` neither strips nor rejects those — and left U+FEFF + accepted too, because CPython's ``isspace()`` returns False for it. + + Every codepoint asserted here was checked by execution before being written + down; the widening adds exactly U+007F-U+009F and U+FEFF and removes + nothing. + """ + + @pytest.mark.parametrize( + ("codepoint", "why"), + [ + (0x007F, "DEL: not stripped by urlsplit and not matched by isspace()"), + (0x0080, "C1: first of the range isspace() does not report"), + (0x0085, "NEL: the ONE C1 control CPython's isspace() does report"), + (0x009F, "C1: last of the range"), + (0x00A0, "NO-BREAK SPACE: isspace() already reported it"), + (0x1680, "OGHAM SPACE MARK"), + (0x2028, "LINE SEPARATOR"), + (0x2029, "PARAGRAPH SEPARATOR"), + (0x202F, "NARROW NO-BREAK SPACE"), + (0x3000, "IDEOGRAPHIC SPACE"), + (0xFEFF, "ZERO WIDTH NO-BREAK SPACE: isspace() returns False, Cf not Zs"), + ], + ) + def test_rejects_codepoint_in_the_path(self, codepoint: int, why: str) -> None: + resource = f"https://api.example.com/m{chr(codepoint)}cp" + with pytest.raises(InvalidResourceError, match="whitespace or control characters"): + validate_prm_resource_identifier(resource) + + @pytest.mark.parametrize( + ("codepoint", "why"), + [ + (0x0021, "EXCLAMATION MARK: a sub-delim, legal in a URI"), + (0x00A1, "INVERTED EXCLAMATION MARK: printable non-ASCII, deliberately kept"), + (0x200B, "ZERO WIDTH SPACE: not White_Space and not in the class"), + ], + ) + def test_accepts_codepoint_in_the_path(self, codepoint: int, why: str) -> None: + # The acceptance control, and the second entry is the load-bearing one: + # narrowing the identifier to ASCII is a separate, undecided axis, so + # this gate must not settle it as a side effect of covering C1. + validate_prm_resource_identifier(f"https://api.example.com/m{chr(codepoint)}cp") + + def test_del_in_the_path_was_previously_accepted(self) -> None: + # The concrete regression: urlsplit passes DEL straight through, so the + # identifier was stored and advertised with an invisible byte in it. + with pytest.raises(InvalidResourceError, match="whitespace or control characters"): + validate_prm_resource_identifier("https://api.example.com/m\x7fcp") + + def test_message_names_the_codepoint_and_the_offset(self) -> None: + # The whole value of this gate is pointing at a byte nothing renders. + # An echo of the identifier is useless on its own: the repr shows what + # SURVIVES the parse, and what survives is the string minus the defect. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://api.example.com/m\x7fcp") + assert "U+007F" in str(exc.value) + assert "at offset 25" in str(exc.value) + + def test_offset_is_a_codepoint_index_not_a_byte_index(self) -> None: + # Python iterates by codepoint, so a multi-byte character ahead of the + # offence must not shift the reported offset. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://api.example.com/\u00e9\u00a0cp") + assert "U+00A0" in str(exc.value) + assert "at offset 25" in str(exc.value) + + def test_reports_the_first_offence_when_several_are_present(self) -> None: + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://api.example.com/\ufeffm\x7fcp") + assert "U+FEFF" in str(exc.value) + assert "at offset 24" in str(exc.value) + + def test_codepoint_is_reported_without_emitting_the_character(self) -> None: + # Naming it as U+XXXX rather than interpolating it keeps the invisible + # byte out of a startup log, which is where this message lands. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://api.example.com/m\ufeffcp") + assert "\ufeff" not in str(exc.value) + + def test_control_character_in_the_host_beats_the_delimiter_gate(self) -> None: + # DEL in the host is in BOTH this class and the set of octets that + # reach the WWW-Authenticate quoted-string. This gate runs first, on the + # raw string, so the finding is deterministic and names the codepoint. + with pytest.raises(InvalidResourceError) as exc: + validate_prm_resource_identifier("https://api\x7fexample.com/mcp") + assert "whitespace or control characters" in str(exc.value) + assert "U+007F" in str(exc.value) + + def test_the_fragment_gate_still_runs_first(self) -> None: + # Order is unchanged by the widening: fragment, then this class. + with pytest.raises(InvalidResourceError, match="must not contain a fragment"): + validate_prm_resource_identifier("https://api.example.com/m\x7fcp#frag") + + def test_accepts_the_shapes_an_operator_actually_configures(self) -> None: + # Acceptance control for the class as a whole. + for resource in ( + "https://api.example.com/mcp", + "https://api.example.com:8443/v2/mcp?tenant=a", + "http://localhost:8080/mcp", + "https://api.example.com/mcp%20with%20escapes", + ): + validate_prm_resource_identifier(resource) + + def test_unparseable_authority_does_not_mask_the_rfc_error() -> None: # urlparse raises ValueError("Invalid IPv6 URL") on an unclosed bracket — # before the fragment is ever split off. Both guards must still report the # RFC violation they were written for, not urllib's parse failure. with pytest.raises(ValueError) as exc: - validate_resource_indicator("https://[::1#frag") + validate_prm_resource_identifier("https://[::1#frag") assert "must not contain a fragment" in str(exc.value) assert "IPv6" not in str(exc.value) @@ -114,7 +667,7 @@ def test_unparseable_authority_does_not_mask_the_rfc_error() -> None: def test_build_prm_url_also_reports_the_rfc_error_not_urllib_s() -> None: # The third guard kept the original shape after the first two were fixed: - # build_prm_url parsed before calling validate_resource_indicator, so an + # build_prm_url parsed before calling validate_prm_resource_identifier, so an # unclosed IPv6 bracket surfaced "Invalid IPv6 URL" instead of the citation. with pytest.raises(ValueError) as exc: build_prm_url("https://[::1#frag") @@ -151,16 +704,22 @@ def test_issuer_error_is_matchable_and_still_a_value_error() -> None: build_metadata_url("https://auth.example.com/?x=1") -def test_scheme_less_identifier_keeps_the_path_separator() -> None: - # Nothing upstream requires the identifier to be absolute — create() gates - # only on ?/# and resource() only on #. For a scheme-less input urlparse - # puts the whole authority in `path` with no leading slash, so concatenating - # it directly mashed the segment onto the well-known suffix - # ("...oauth-authorization-serverapi.example.com/mcp"). - assert build_metadata_url("api.example.com/mcp").startswith( - "/.well-known/oauth-authorization-server/" - ) - assert build_prm_url("api.example.com/mcp").startswith("/.well-known/oauth-protected-resource/") +def test_neither_builder_derives_from_a_scheme_less_identifier() -> None: + # The asymmetry this test used to pin is gone. A scheme-less issuer was + # accepted, because build_metadata_url gated only on ?/#; urlsplit then put + # the whole authority in `path` with no leading slash, and the separator + # re-add turned it into the RELATIVE, unfetchable + # "/.well-known/oauth-authorization-server/api.example.com/mcp" — a string + # named as a derivation success while no client could ever fetch it. The + # issuer gate now requires a host for the reason RFC 8414 §3.1 gives (the + # well-known suffix is inserted after the host), so both builders reject + # the same shape, each with the error class for its own identifier. + with pytest.raises(InvalidIssuerError) as exc: + build_metadata_url("api.example.com/mcp") + assert "absolute URL with a scheme and a host" in str(exc.value) + assert "RFC 8414 §2, §3.1" in str(exc.value) + with pytest.raises(InvalidResourceError): + build_prm_url("api.example.com/mcp") class TestParamsSegmentIsNotCollapsed: @@ -196,9 +755,9 @@ def test_resource_error_is_matchable_and_still_a_value_error() -> None: # contract; what the class adds is telling an identifier misconfiguration # apart from any other ValueError the SDK raises. with pytest.raises(InvalidResourceError): - validate_resource_indicator("https://api.example.com/mcp#frag") + validate_prm_resource_identifier("https://api.example.com/mcp#frag") with pytest.raises(ValueError): - validate_resource_indicator("https://api.example.com/mcp#frag") + validate_prm_resource_identifier("https://api.example.com/mcp#frag") with pytest.raises(InvalidResourceError): build_prm_url("https://api.example.com/mcp#frag") @@ -207,10 +766,420 @@ def test_identifier_errors_are_exported_from_the_package_root() -> None: # Both classes exist to be caught by name, which only works if they are # importable from the root. Nothing else in the suite imports them from # there — the tests above reach into authplane.errors — so without this the - # __all__ entries could rot without anything noticing. + # __all__ entries could rot without anything noticing. The gate function + # is held to the same bar for a stronger reason than either class: both + # MCP adapters import it by this name through the package root, so a + # rotted entry breaks an installed adapter/core pair at import time. import authplane assert "InvalidIssuerError" in authplane.__all__ assert "InvalidResourceError" in authplane.__all__ + assert "validate_prm_resource_identifier" in authplane.__all__ + # Same rationale, same consumers: both adapters import this one through the + # package root as well, so a rotted entry breaks an installed pair. + assert "validate_resource_metadata_url" in authplane.__all__ assert authplane.InvalidIssuerError is InvalidIssuerError assert authplane.InvalidResourceError is InvalidResourceError + assert authplane.validate_prm_resource_identifier is validate_prm_resource_identifier + assert authplane.validate_resource_metadata_url is validate_resource_metadata_url + # The name is the contract, not just the object: it is exported under the + # PRM-scoped spelling because the gate requires a host, which RFC 8707 §2 + # does not (it requires an absolute URI, RFC 3986 §4.3 — "urn:example:api" + # is one). A later, weaker gate for the general resource-indicator axis + # would need its own name, so this one must not drift back to claiming it. + assert not hasattr(authplane, "validate_resource_indicator") + + +class TestIssuerIdentifierGate: + """The issuer had two consumers and only one of them validated anything. + + ``build_metadata_url`` carried an inline query/fragment check; + ``build_prm`` copied the issuer into the PRM document's + ``authorization_servers`` member with none. One exported predicate with both + consumers routed through it is what closes that, and it also lets the + metadata derivation reject two shapes it previously derived garbage from. + """ + + def test_rejects_userinfo_bearing_issuer(self) -> None: + # Previously carried verbatim into the metadata fetch target. + with pytest.raises(InvalidIssuerError, match="must not contain a userinfo component"): + build_metadata_url("https://svc:s3cr3t@auth.example.com/t") + + def test_userinfo_rejection_does_not_echo_the_credential(self) -> None: + with pytest.raises(InvalidIssuerError) as exc: + build_metadata_url("https://svc:s3cr3t@auth.example.com/t") + assert "s3cr3t" not in str(exc.value) + + def test_rejects_empty_userinfo_issuer(self) -> None: + # `username == ""` but the "@" delimiter is present in the authority + # both sinks reassemble — RFC 9110 §4.2.4 forbids the subcomponent. + with pytest.raises(InvalidIssuerError, match="must not contain a userinfo component"): + build_metadata_url("https://@auth.example.com/t") + + def test_rejects_opaque_issuer(self) -> None: + # "urn:example:as" has a scheme but no host, so RFC 8414 §3.1 has + # nothing to insert the well-known suffix after. It previously derived + # "urn:/.well-known/oauth-authorization-server/example:as". + with pytest.raises(InvalidIssuerError, match="absolute URL with a scheme and a host"): + build_metadata_url("urn:example:as") + + def test_rejects_scheme_relative_issuer(self) -> None: + # Has a host but no scheme, so a guard phrased as "authority-less" + # would wrongly admit it. Both halves are checked explicitly. + with pytest.raises(InvalidIssuerError, match="absolute URL with a scheme and a host"): + build_metadata_url("//auth.example.com/t") + + def test_rejects_authority_of_only_a_port(self) -> None: + # "https://:8080/t" has a netloc but names no host, so a netloc-based + # guard would admit it and derive a URL with no origin to fetch from. + with pytest.raises(InvalidIssuerError, match="absolute URL with a scheme and a host"): + build_metadata_url("https://:8080/t") + + def test_cites_section_3_1_for_the_host_requirement(self) -> None: + # The host requirement is RFC 8414 §3.1 — the clause that derives the + # metadata location by inserting the well-known string between the host + # and the issuer's path. §2 gives the scheme and the no-query/fragment + # rule; it is not where the host comes from, and citing it there would + # send an operator to a clause that does not say what the message says. + with pytest.raises(InvalidIssuerError) as exc: + build_metadata_url("urn:example:as") + assert "RFC 8414 §2, §3.1" in str(exc.value) + + def test_query_is_reported_before_absoluteness(self) -> None: + # Deterministic order: query/fragment, then absoluteness, then userinfo. + with pytest.raises(InvalidIssuerError, match="query or fragment component"): + build_metadata_url("//auth.example.com/t?x=1") + + def test_absoluteness_is_reported_before_userinfo(self) -> None: + # A scheme-relative identifier carrying credentials reports the missing + # scheme — the defect an operator fixes first — and still redacts. + with pytest.raises(InvalidIssuerError) as exc: + build_metadata_url("//svc:s3cr3t@auth.example.com/t") + assert "absolute URL with a scheme and a host" in str(exc.value) + assert "s3cr3t" not in str(exc.value) + + def test_unparseable_authority_still_reports_the_rfc_error(self) -> None: + # urlsplit raises ValueError("Invalid IPv6 URL") on an unclosed bracket. + # An identifier it refuses to parse establishes no host a fortiori, so + # it falls under the absoluteness rejection — with the redacted + # placeholder, not urllib's message. + with pytest.raises(InvalidIssuerError) as exc: + build_metadata_url("https://[::1/t") + assert "absolute URL with a scheme and a host" in str(exc.value) + assert "IPv6" not in str(exc.value) + + def test_accepts_the_shapes_an_operator_actually_configures(self) -> None: + # Acceptance control: the gate must not be rejecting everything, and + # every derivation this module documents still produces its URL. + assert ( + build_metadata_url("https://auth.example.com") + == "https://auth.example.com/.well-known/oauth-authorization-server" + ) + assert ( + build_metadata_url("https://auth.example.com/org/tenant1") + == "https://auth.example.com/.well-known/oauth-authorization-server/org/tenant1" + ) + assert ( + build_metadata_url("http://localhost:3000/t") + == "http://localhost:3000/.well-known/oauth-authorization-server/t" + ) + # An IPv6 literal keeps its brackets through the derivation. + assert ( + build_metadata_url("https://[::1]:8443/t") + == "https://[::1]:8443/.well-known/oauth-authorization-server/t" + ) + + def test_both_issuer_consumers_share_one_predicate(self) -> None: + # The property, not just the two symptoms: the same identifier is + # rejected on the same axis by both consumers. A check inlined into one + # of them is what let the two drift, so the test is written against the + # pair rather than against either one. + from authplane.oauth.prm import build_prm + + for issuer in ("https://svc:s3cr3t@auth.example.com", "urn:example:as", "//auth.ex.com"): + with pytest.raises(InvalidIssuerError) as from_metadata: + build_metadata_url(issuer) + with pytest.raises(InvalidIssuerError) as from_prm: + build_prm(issuer=issuer, resource="https://api.example.com", scopes=[]) + assert str(from_metadata.value) == str(from_prm.value) + + +class TestIssuerIdentifierWhitespace: + """The issuer's whitespace divergence is real; only its symptom differs. + + The resource identifier is re-advertised verbatim, so a character + ``urlsplit`` cleans makes it fail RFC 9728 §3.3 against itself. The issuer + is not re-advertised that way — but it *is* compared byte-for-byte, in + ``internal/metadata.py``, against the value the client was configured with. + So a configured issuer carrying a tab derives a fetch target without one, + the AS answers with its real ``issuer``, and the comparison rejects the + document as an "AS metadata issuer mismatch" — the confusing symptom the + query/fragment check of this same gate exists to prevent, reached by a + different route. + """ + + def test_urlsplit_removes_an_internal_tab_from_the_derivation(self) -> None: + # The measurement the gate rests on, asserted rather than described: + # the tab is gone from the parse while the configured string keeps it, + # so the two stop naming the same authorization server. + from urllib.parse import urlsplit + + assert urlsplit("https://auth.exa\tmple.com/t").geturl() == "https://auth.example.com/t" + assert urlsplit("https://auth.example.com/t\renant").geturl() == ( + "https://auth.example.com/tenant" + ) + + def test_the_configured_issuer_is_what_metadata_compares(self) -> None: + # The other half of the mechanism: the comparison is against the + # configured issuer, not against the derived URL — which is why the + # divergence has an equivalent here at all. + import inspect + + from authplane.internal import metadata + + source = inspect.getsource(metadata) + assert "issuer != self._expected_issuer" in source + + def test_rejects_internal_tab(self) -> None: + with pytest.raises(InvalidIssuerError, match="whitespace or control characters") as exc: + build_metadata_url("https://auth.exa\tmple.com/t") + assert "U+0009" in str(exc.value) + assert "RFC 8414 §2" in str(exc.value) + + def test_rejects_leading_space(self) -> None: + with pytest.raises(InvalidIssuerError, match="whitespace or control characters"): + build_metadata_url(" https://auth.example.com/t") + + def test_rejects_trailing_space(self) -> None: + with pytest.raises(InvalidIssuerError, match="whitespace or control characters"): + build_metadata_url("https://auth.example.com/t ") + + def test_rejects_the_codepoints_isspace_does_not_match(self) -> None: + # The members of the class that make it wider than `str.isspace()`. + # Executed, not assumed: each of these returns False from isspace(), + # and each survives urlsplit rather than being cleaned — so without an + # explicit branch it rides into the derived .well-known URL intact. + from urllib.parse import urlsplit + + for char in ("\x7f", "\x9f", ""): + assert char.isspace() is False + assert char in urlsplit(f"https://auth.example.com/t{char}x").geturl() + with pytest.raises(InvalidIssuerError, match="whitespace or control characters") as exc: + build_metadata_url(f"https://auth.example.com/t{char}x") + assert f"U+{ord(char):04X}" in str(exc.value) + + def test_message_names_the_codepoint_and_its_offset(self) -> None: + # The entire value of a gate whose subject is an invisible byte: the + # echo alone shows the operator a string that looks correct. + with pytest.raises(InvalidIssuerError) as exc: + build_metadata_url("https://auth.example.com/t") + assert "U+FEFF" in str(exc.value) + assert "at offset 25" in str(exc.value) + + def test_whitespace_rejection_does_not_echo_a_credential(self) -> None: + with pytest.raises(InvalidIssuerError) as exc: + build_metadata_url("https://svc:s3cr3t@auth.exa\tmple.com/t") + assert "s3cr3t" not in str(exc.value) + + def test_query_and_fragment_are_reported_before_whitespace(self) -> None: + # Deterministic order, pinned: the raw-string checks run in the order + # the docstring gives, and the query/fragment finding is the one the + # operator fixes first. + with pytest.raises(InvalidIssuerError, match="query or fragment component"): + build_metadata_url("https://auth.example.com/t ?x=1") + + def test_both_issuer_consumers_reject_whitespace_identically(self) -> None: + from authplane.oauth.prm import build_prm + + issuer = "https://auth.exa\tmple.com/t" + with pytest.raises(InvalidIssuerError) as from_metadata: + build_metadata_url(issuer) + with pytest.raises(InvalidIssuerError) as from_prm: + build_prm(issuer=issuer, resource="https://api.example.com", scopes=[]) + assert str(from_metadata.value) == str(from_prm.value) + + def test_accepts_the_shapes_an_operator_actually_configures(self) -> None: + # Acceptance control. Printable non-ASCII stays accepted: narrowing the + # identifier to ASCII is a separate axis, exactly as on the resource + # gate, and is not settled here by accident. + for issuer in ( + "https://auth.example.com", + "https://auth.example.com/", + "https://auth.example.com/org/tenant1", + "https://auth.example.com:8443/tenant1", + "http://localhost:8080", + "https://[::1]:8443/t", + "https://xn--80ak6aa92e.example.com/t", + "https://auth.example.com/t¡", + ): + assert "/.well-known/oauth-authorization-server" in build_metadata_url(issuer) + + +class TestIdentifierGatesArePubliclyReachable: + """Both gates are exported from the package root, or neither should be. + + The predicates are the supported way to check configuration before + constructing anything, and both corresponding error classes are already + exported. Exporting one and not the other is what leaves a consumer + reaching into ``authplane.internal`` on a ``0.x`` package — an import that + breaks an installed application the first time the module moves. + """ + + def test_both_predicates_are_exported_from_the_package_root(self) -> None: + import authplane + + assert "validate_issuer_identifier" in authplane.__all__ + assert "validate_prm_resource_identifier" in authplane.__all__ + assert authplane.validate_issuer_identifier is validate_issuer_identifier + assert authplane.validate_prm_resource_identifier is validate_prm_resource_identifier + + def test_the_exported_predicate_is_the_one_the_builders_apply(self) -> None: + # Not just a name that exists: the same object, so a consumer that + # pre-validates cannot get a different answer from the builder. + import authplane + + with pytest.raises(InvalidIssuerError) as pre: + authplane.validate_issuer_identifier("https://svc:s3cr3t@auth.example.com") + with pytest.raises(InvalidIssuerError) as built: + build_metadata_url("https://svc:s3cr3t@auth.example.com") + assert str(pre.value) == str(built.value) + + +class TestTheDerivationsNeedNoSeparatorReAdd: + """The invariant that let a branch be deleted, pinned so it stays true. + + Both builders used to re-add a leading "/" to the parsed path before + concatenating it onto the well-known suffix. Behind the gates that branch + cannot be taken: RFC 3986 §3.3 gives a path following an authority as + ``path-abempty`` — empty or beginning with "/" — and both gates require a + host, so an authority is always present. A relaxation that ever admits a + host-less identifier would need the branch back, and this fails if one + lands. + """ + + def test_an_accepted_identifier_never_has_a_separator_less_path(self) -> None: + from urllib.parse import urlsplit + + from authplane.errors import AuthplaneError + + alphabet = "".join(chr(c) for c in range(0x20, 0x7F)) + "\x00\t\n\r\x7f\x80 é" + seeds = ( + "https://api.example.com/mcp", + "https://api.example.com", + "https://api.example.com:8443/v2/mcp?t=a", + "http://localhost:8080/mcp/", + "https://[::1]:8443/t", + "//api.example.com/mcp", + "urn:example:api", + "api.example.com/mcp", + "https:/api.example.com/mcp", + "https:api.example.com/mcp", + ) + candidates: set[str] = set(seeds) | set(alphabet) + for seed in seeds: + for i in range(len(seed) + 1): + for char in alphabet: + candidates.add(seed[:i] + char + seed[i:]) + candidates.add(seed[:i] + char + seed[i + 1 :]) + + accepted = {"resource": 0, "issuer": 0} + for gate, name in ( + (validate_prm_resource_identifier, "resource"), + (validate_issuer_identifier, "issuer"), + ): + for candidate in candidates: + try: + gate(candidate) + except (AuthplaneError, ValueError): + continue + accepted[name] += 1 + path = urlsplit(candidate).path + assert not path or path.startswith("/"), (name, candidate, path) + + # Guard against the sweep passing vacuously if a gate ever rejects + # everything: it has to be accepting a substantial share of these. + assert accepted["resource"] > 1000 + assert accepted["issuer"] > 1000 + + +class TestResourceMetadataUrlValidation: + """RFC 9728 §5.1 — the ``resource_metadata`` override. + + The derived URL earns its guarantees from the identifier gate; a + configured one has none until ``validate_resource_metadata_url``. It + reaches the same sink — the quoted-string of a challenge served to an + unauthenticated caller — so the checks are the identifier's, plus the two + the docstring gives a reason for: a scheme narrowed to http/https because + this value is dereferenced rather than compared, and a delimiter scan over + the whole string rather than the host alone. + """ + + def test_accepts_the_as_hosted_document(self) -> None: + # The shape authserver >= 0.2.0 serves: the AS origin, the RFC 9728 + # well-known path, and the §3.1 path suffix of the Resource URI. + validate_resource_metadata_url( + "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + ) + + def test_accepts_loopback_http_for_development(self) -> None: + # Same relaxation the issuer and resource gates make, for the same + # reason: a dev AS on loopback speaks cleartext. + validate_resource_metadata_url("http://localhost:8080/.well-known/oauth-protected-resource") + + def test_accepts_a_query(self) -> None: + # A derived URL can carry one (build_prm_url splices the identifier's + # query in), so rejecting it here would be stricter than the default. + validate_resource_metadata_url("https://auth.example.com/prm?tenant=acme") + + def test_rejects_a_relative_url(self) -> None: + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + validate_resource_metadata_url("/.well-known/oauth-protected-resource/mcp") + + def test_rejects_a_non_http_scheme(self) -> None: + # The identifier gates accept any scheme because they compare rather + # than fetch. A client honouring the challenge fetches this one, and + # RFC 9728 §5.1 names an http(s) metadata document. + with pytest.raises(InvalidResourceError, match="scheme must be http or https"): + validate_resource_metadata_url("ftp://auth.example.com/prm") + + def test_rejects_an_opaque_url_as_non_absolute(self) -> None: + # An opaque form has a scheme but no host, so it is the absoluteness + # gate that answers, not the scheme gate — pinned so the two checks' + # ordering is stated rather than inferred. + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + validate_resource_metadata_url("urn:example:prm") + + def test_rejects_a_fragment(self) -> None: + # Never sent on the wire, so the client fetches a document other than + # the one the value names. + with pytest.raises(InvalidResourceError, match="fragment component"): + validate_resource_metadata_url("https://auth.example.com/prm#doc") + + def test_rejects_userinfo(self) -> None: + with pytest.raises(InvalidResourceError, match="userinfo") as exc: + validate_resource_metadata_url("https://svc:s3cr3t@auth.example.com/prm") + assert "s3cr3t" not in str(exc.value) + + def test_rejects_whitespace(self) -> None: + with pytest.raises(InvalidResourceError, match="whitespace or control characters"): + validate_resource_metadata_url("https://auth.example.com/p rm") + + def test_rejects_a_malformed_port(self) -> None: + with pytest.raises(InvalidResourceError, match="malformed port"): + validate_resource_metadata_url("https://auth.example.com:80O/prm") + + @pytest.mark.parametrize( + "url", + [ + 'https://auth"example.com/prm', + "https://auth.example.com/p\\rm", + 'https://auth.example.com/prm?a="b', + ], + ) + def test_rejects_a_quoted_string_delimiter_anywhere(self, url: str) -> None: + # Wider than the identifier gate's host-only scan, and deliberately: + # the whole value lands inside one quoted-string, and the path and + # query of a configured override are not an identifier anyone has + # already deployed, so rejecting them costs no migration. + with pytest.raises(InvalidResourceError, match="must not contain a literal"): + validate_resource_metadata_url(url) diff --git a/tests/oauth/test_prm.py b/tests/oauth/test_prm.py index 0ef8794..fc24c33 100644 --- a/tests/oauth/test_prm.py +++ b/tests/oauth/test_prm.py @@ -1,5 +1,8 @@ """Tests for Protected Resource Metadata (PRM) builder.""" +import pytest + +from authplane.errors import InvalidIssuerError, InvalidResourceError from authplane.oauth.prm import build_prm @@ -74,3 +77,187 @@ def test_build_prm_hardcoded_bearer_methods() -> None: ) assert prm["bearer_methods_supported"] == ["header"] + + +class TestBuildPrmGatesTheIssuer: + """The issuer is published, so it is gated at the boundary that publishes it. + + ``build_prm`` copied *issuer* straight into ``authorization_servers`` with + no check at all, and RFC 9728 §3 has the endpoint serving this document + answer **unauthenticated** callers — so a credential-bearing issuer was + handed verbatim to anyone who asked. The gate is the same predicate + ``build_metadata_url`` applies, which is the point: the issuer had two + consumers and only one of them validated anything. + """ + + def test_rejects_userinfo_bearing_issuer(self) -> None: + with pytest.raises(InvalidIssuerError, match="must not contain a userinfo component"): + build_prm( + issuer="https://svc:s3cr3t@auth.example.com", + resource="https://api.example.com", + scopes=[], + ) + + def test_userinfo_rejection_does_not_echo_the_credential(self) -> None: + # The whole point of rejecting here is that the value is disclosed; + # a message quoting it back would reintroduce the disclosure on the + # error path, where it lands in a startup log instead. + with pytest.raises(InvalidIssuerError) as exc: + build_prm( + issuer="https://svc:s3cr3t@auth.example.com", + resource="https://api.example.com", + scopes=[], + ) + assert "s3cr3t" not in str(exc.value) + assert "svc" not in str(exc.value) + + def test_rejects_empty_userinfo_issuer(self) -> None: + # The "@" delimiter marks the subcomponent present even with nothing in + # it, and RFC 9110 §4.2.4 forbids generating the subcomponent, not + # merely non-empty credentials. + with pytest.raises(InvalidIssuerError, match="must not contain a userinfo component"): + build_prm( + issuer="https://@auth.example.com", + resource="https://api.example.com", + scopes=[], + ) + + def test_rejects_host_less_issuer(self) -> None: + # RFC 8414 §3.1 inserts the well-known suffix after the host, so an + # opaque issuer names no metadata location — and the document would + # advertise an authorization server no client can discover. + with pytest.raises(InvalidIssuerError, match="absolute URL with a scheme and a host"): + build_prm( + issuer="urn:example:as", + resource="https://api.example.com", + scopes=[], + ) + + def test_rejects_scheme_less_issuer(self) -> None: + with pytest.raises(InvalidIssuerError, match="absolute URL with a scheme and a host"): + build_prm( + issuer="//auth.example.com", + resource="https://api.example.com", + scopes=[], + ) + + def test_rejects_query_bearing_issuer(self) -> None: + with pytest.raises(InvalidIssuerError, match="query or fragment component"): + build_prm( + issuer="https://auth.example.com?x=1", + resource="https://api.example.com", + scopes=[], + ) + + def test_rejects_fragment_bearing_issuer(self) -> None: + with pytest.raises(InvalidIssuerError, match="query or fragment component"): + build_prm( + issuer="https://auth.example.com#frag", + resource="https://api.example.com", + scopes=[], + ) + + def test_error_is_still_a_value_error(self) -> None: + # The gate is new on this function, but the class is not: an existing + # `except ValueError` around a PRM build still catches it. + with pytest.raises(ValueError): + build_prm( + issuer="https://svc:s3cr3t@auth.example.com", + resource="https://api.example.com", + scopes=[], + ) + + def test_accepts_the_shapes_an_operator_actually_configures(self) -> None: + # Acceptance control: the gate must not be rejecting everything. A + # tenant path, a port, a trailing slash (which is part of the identity + # and is NOT normalized away) and plain http for local development all + # keep building, and the issuer is still copied through byte-for-byte. + for issuer in ( + "https://auth.example.com", + "https://auth.example.com/", + "https://auth.example.com/org/tenant1", + "https://auth.example.com:8443/tenant1", + "http://localhost:8080", + "https://[::1]:8443/t", + ): + prm = build_prm(issuer=issuer, resource="https://api.example.com", scopes=["read"]) + assert prm["authorization_servers"] == [issuer] + + +class TestBuildPrmGatesTheResourceItPublishes: + """The `resource` member is gated by the same argument as the issuer. + + It sits in the same document, published from the same endpoint, and + RFC 9728 §3.3 makes it the member a client compares: a document whose + ``resource`` does not match the identifier the client dereferenced MUST be + discarded. The parameter is a plain ``str`` on a public builder, so nothing + obliges a caller to have obtained it from a gated ``AuthplaneResource``. + """ + + def test_rejects_fragment_bearing_resource(self) -> None: + # The shape that made this concrete: a document is returned, and the + # well-known URL a client would have reached it by cannot be derived + # from the identifier it names (RFC 8707 §2 forbids the fragment). + with pytest.raises(InvalidResourceError, match="must not contain a fragment"): + build_prm( + issuer="https://auth.example.com", + resource="https://api.example.com/mcp#frag", + scopes=[], + ) + + def test_rejects_userinfo_bearing_resource(self) -> None: + # Same disclosure as the issuer half, at the same sink: credentials + # copied verbatim into a document served to unauthenticated callers. + with pytest.raises(InvalidResourceError, match="must not contain a userinfo component"): + build_prm( + issuer="https://auth.example.com", + resource="https://svc:s3cr3t@api.example.com/mcp", + scopes=[], + ) + + def test_rejects_whitespace_bearing_resource(self) -> None: + with pytest.raises(InvalidResourceError, match="whitespace or control characters"): + build_prm( + issuer="https://auth.example.com", + resource="https://api.exa\tmple.com/mcp", + scopes=[], + ) + + def test_rejects_scheme_less_resource(self) -> None: + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + build_prm( + issuer="https://auth.example.com", + resource="api.example.com/mcp", + scopes=[], + ) + + def test_reports_the_issuer_first_when_both_are_defective(self) -> None: + # Deterministic order, pinned because the two errors are siblings and + # either could plausibly come first. The issuer wins: it is the member + # that was published with no check whatsoever, so it is the finding an + # operator should see before anything else. + with pytest.raises(InvalidIssuerError): + build_prm( + issuer="https://svc:s3cr3t@auth.example.com", + resource="https://api.example.com/mcp#frag", + scopes=[], + ) + + def test_accepts_the_resource_shapes_an_operator_actually_configures(self) -> None: + # Acceptance control. Every shape here is one the resource gate already + # accepts at `AuthplaneResource.__init__`, so routing the builder + # through the same predicate cannot reject a document the SDK's own + # construction path would have built. + for resource in ( + "https://api.example.com", + "https://api.example.com/mcp", + "https://api.example.com:8443/v2/mcp", + "http://localhost:8080/mcp", + "https://api.example.com/mcp?tenant=a", + ): + prm = build_prm( + issuer="https://auth.example.com", + resource=resource, + scopes=["read"], + ) + assert prm["resource"] == resource diff --git a/tests/oauth/test_token_exchange.py b/tests/oauth/test_token_exchange.py index ed604d1..39c57b5 100644 --- a/tests/oauth/test_token_exchange.py +++ b/tests/oauth/test_token_exchange.py @@ -9,12 +9,14 @@ from authplane import FetchSettings from authplane.errors import ( + AccessDeniedError, AuthError, AuthplaneError, ConsentRequiredError, InvalidClientError, InvalidGrantError, InvalidScopeError, + InvalidTargetError, ServerError, ) from authplane.net.http import build_basic_auth_header @@ -374,6 +376,59 @@ async def test_exchange_invalid_client() -> None: ) +@respx.mock +async def test_exchange_access_denied_maps_to_access_denied_error() -> None: + """403 access_denied maps to AccessDeniedError, not ConsentRequiredError. + + authserver 0.2.0 answers this when the exchanging client is not in the + target Resource's exchange allowlist; the caller has to tell it apart + from consent_required because re-prompting the user cannot fix it. + """ + respx.post(TOKEN_ENDPOINT).mock( + return_value=httpx.Response( + 403, + json={ + "error": "access_denied", + "error_description": "client not allowed to exchange for this resource", + }, + ) + ) + with pytest.raises(AccessDeniedError, match="not allowed to exchange") as exc: + await exchange_token( + TOKEN_ENDPOINT, + TokenExchangeOptions(subject_token=SUBJECT_TOKEN), + make_auth_header(), + _NO_SSRF, + ) + + assert exc.value.code == "access_denied" + assert exc.value.status_code == 403 + assert not isinstance(exc.value, ConsentRequiredError) + + +@respx.mock +async def test_exchange_invalid_target_maps_to_invalid_target_error() -> None: + """400 invalid_target (RFC 8707 §2.2) maps to InvalidTargetError.""" + respx.post(TOKEN_ENDPOINT).mock( + return_value=httpx.Response( + 400, + json={"error": "invalid_target", "error_description": "unknown resource"}, + ) + ) + with pytest.raises(InvalidTargetError, match="unknown resource") as exc: + await exchange_token( + TOKEN_ENDPOINT, + TokenExchangeOptions( + subject_token=SUBJECT_TOKEN, resources=("https://downstream.example/",) + ), + make_auth_header(), + _NO_SSRF, + ) + + assert exc.value.code == "invalid_target" + assert exc.value.status_code == 400 + + @respx.mock async def test_exchange_consent_required_maps_to_consent_required_error() -> None: """400 consent_required maps to ConsentRequiredError with metadata.""" diff --git a/tests/test_client.py b/tests/test_client.py index 6cd90d2..e35b2e2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,8 +6,13 @@ import pytest from authplane import ASCredentials, AuthplaneClient -from authplane.errors import CircuitOpenError, ServerError -from authplane.oauth.types import IntrospectionResponse, TokenResponse +from authplane.errors import ( + AccessDeniedError, + CircuitOpenError, + InvalidTargetError, + ServerError, +) +from authplane.oauth.types import IntrospectionResponse, TokenExchangeOptions, TokenResponse async def make_client(**kwargs: Any): @@ -111,6 +116,35 @@ async def test_circuit_breaker_opens_on_server_errors(): await client.client_credentials() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error", + [ + AccessDeniedError("not allowlisted", code="access_denied", status_code=403), + InvalidTargetError("unknown resource", code="invalid_target", status_code=400), + ], + ids=["access_denied", "invalid_target"], +) +async def test_circuit_breaker_ignores_policy_rejections(error: Exception): + """A 403 access_denied or 400 invalid_target is the AS answering, not failing. + + Neither counts toward the breaker: past the threshold the next exchange + must still reach the AS and surface the same typed error, not + CircuitOpenError. + """ + client = await make_client(circuit_breaker_threshold=2) + + with patch( + "authplane.client.exchange_token", + new_callable=AsyncMock, + side_effect=error, + ) as mock_exchange: + for _ in range(3): + with pytest.raises(type(error)): + await client.exchange(TokenExchangeOptions(subject_token="subject")) + assert mock_exchange.await_count == 3 + + @pytest.mark.asyncio async def test_revoke_calls_endpoint(): client = await make_client() diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..58c3c9d --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,26 @@ +"""Tests for ASCredentials construction.""" + +import pytest + +from authplane import ASCredentials + + +def test_credentials_accept_non_empty_fields() -> None: + creds = ASCredentials(client_id="rs", client_secret="s3cret") + assert creds.client_id == "rs" + assert creds.client_secret == "s3cret" + + +def test_credentials_reject_empty_client_id() -> None: + with pytest.raises(ValueError, match="client_id must not be empty"): + ASCredentials(client_id="", client_secret="s3cret") + + +def test_credentials_reject_empty_client_secret() -> None: + """An empty secret is a public client, which cannot introspect at all. + + Caught at construction rather than surfacing per request as a fail-open + warning or, under fail_closed=True, as every token rejected. + """ + with pytest.raises(ValueError, match="client_secret must not be empty"): + ASCredentials(client_id="rs", client_secret="") diff --git a/tests/test_dpop_adapter.py b/tests/test_dpop_adapter.py index 64829e7..77d46ce 100644 --- a/tests/test_dpop_adapter.py +++ b/tests/test_dpop_adapter.py @@ -4,13 +4,27 @@ reading) without requiring either adapter package's full plumbing — the helpers duck-type against a structural Protocol so tests can drive them with a small fake Headers/Request shape. + +The ``*_from_scope`` helpers take a raw ASGI ``scope`` mapping instead, +for middleware protecting a long-lived ``text/event-stream`` body, which +cannot run under Starlette's ``BaseHTTPMiddleware``. Those tests build +the scope dict directly. """ from __future__ import annotations +from collections.abc import Iterator, MutableMapping + import pytest -from authplane._dpop_adapter import read_dpop_header +from authplane._dpop_adapter import ( + get_or_create_verify_cache, + get_or_create_verify_cache_from_scope, + raw_request_path, + raw_request_path_from_scope, + read_dpop_header, + read_dpop_header_from_scope, +) from authplane.errors import DPoPMultipleProofsError @@ -102,3 +116,258 @@ def test_single_value_is_trimmed() -> None: def test_all_blank_pieces_treated_as_absent() -> None: """``", ,"`` carries no real proof — return ``None``, do not reject.""" assert read_dpop_header(_FakeRequest([" , , "])) is None # pyright: ignore[reportArgumentType] + + +class _FakeURL: + def __init__(self, path: str) -> None: + self.path = path + + +class _FakePathRequest: + """Request shape for the path helper: a ``scope`` and a ``url``.""" + + def __init__(self, scope: dict[str, object], url_path: str = "/decoded") -> None: + self._scope = scope + self._url = _FakeURL(url_path) + + @property + def headers(self) -> _FakeHeaders: # pragma: no cover - unused here + return _FakeHeaders([]) + + @property + def scope(self) -> dict[str, object]: + return self._scope + + @property + def state(self) -> object: # pragma: no cover - unused here + return object() + + @property + def url(self) -> _FakeURL: + return self._url + + +def _http_scope(**extra: object) -> dict[str, object]: + return {"type": "http", "method": "GET", "headers": [], **extra} + + +def test_scope_raw_path_strips_query_string() -> None: + """RFC 9449 §4.2: ``htu`` carries no query component. + + The MCP SSE transport puts a per-session id in the query string of + ``POST /messages/``, so a leaked query would change the URL on every + request and no proof would ever verify. + """ + scope = _http_scope(raw_path=b"/messages/?session_id=abc123", path="/messages/") + assert raw_request_path_from_scope(scope) == "/messages/" + + +def test_request_raw_path_strips_query_string() -> None: + """The request-based reader applies the same rule as the scope one.""" + request = _FakePathRequest(_http_scope(raw_path=b"/messages/?session_id=abc123")) + assert raw_request_path(request) == "/messages/" # pyright: ignore[reportArgumentType] + + +def test_raw_path_without_query_is_unchanged() -> None: + scope = _http_scope(raw_path=b"/mcp", path="/mcp") + assert raw_request_path_from_scope(scope) == "/mcp" + assert raw_request_path(_FakePathRequest(scope)) == "/mcp" # pyright: ignore[reportArgumentType] + + +def test_raw_path_preserves_percent_encoding() -> None: + """``%2F`` must survive: the client signed ``htu`` over the on-wire target. + + ASGI populates ``scope["path"]`` percent-*decoded*, which would turn + ``%2F`` into a real separator and bind an ``htu`` the client never + signed. + """ + scope = _http_scope(raw_path=b"/tools/a%2Fb", path="/tools/a/b") + assert raw_request_path_from_scope(scope) == "/tools/a%2Fb" + assert raw_request_path(_FakePathRequest(scope)) == "/tools/a%2Fb" # pyright: ignore[reportArgumentType] + + +def test_raw_path_query_strip_keeps_encoded_question_mark() -> None: + """Only the real delimiter splits; a percent-encoded ``?`` is path data.""" + scope = _http_scope(raw_path=b"/tools/a%3Fb?x=1") + assert raw_request_path_from_scope(scope) == "/tools/a%3Fb" + + +def test_scope_without_raw_path_falls_back_to_path() -> None: + """Rare ASGI server that omits ``raw_path``: use the decoded path.""" + assert raw_request_path_from_scope(_http_scope(path="/mcp")) == "/mcp" + + +def test_scope_without_raw_path_or_path_returns_empty() -> None: + assert raw_request_path_from_scope(_http_scope()) == "" + + +def test_request_without_raw_path_falls_back_to_url_path() -> None: + """The request reader falls back to ``request.url.path``, not ``scope["path"]``. + + ``_RequestLike`` is structural, so an implementation may carry a + non-ASGI ``scope``; its own URL stays authoritative there. + """ + request = _FakePathRequest(_http_scope(path="/from-scope"), url_path="/from-url") + assert raw_request_path(request) == "/from-url" # pyright: ignore[reportArgumentType] + + +def test_read_dpop_header_from_scope_picks_the_dpop_header() -> None: + """Case-insensitive pick out of a multi-header ASGI header list.""" + scope = _http_scope( + headers=[ + (b"host", b"api.example.com"), + (b"authorization", b"DPoP token"), + (b"DPoP", b"a.b.c"), + (b"accept", b"text/event-stream"), + ] + ) + assert read_dpop_header_from_scope(scope) == "a.b.c" + + +def test_read_dpop_header_from_scope_absent_returns_none() -> None: + scope = _http_scope(headers=[(b"host", b"api.example.com")]) + assert read_dpop_header_from_scope(scope) is None + + +def test_read_dpop_header_from_scope_rejects_two_headers() -> None: + """Duplicate ``DPoP`` headers fail §4.3 #1 on the scope path too.""" + scope = _http_scope(headers=[(b"dpop", b"a.b.c"), (b"dpop", b"x.y.z")]) + with pytest.raises(DPoPMultipleProofsError, match="2 DPoP proofs"): + read_dpop_header_from_scope(scope) + + +def test_read_dpop_header_from_scope_rejects_comma_joined() -> None: + scope = _http_scope(headers=[(b"dpop", b"a.b.c, x.y.z")]) + with pytest.raises(DPoPMultipleProofsError, match="2 DPoP proofs"): + read_dpop_header_from_scope(scope) + + +def test_read_dpop_header_from_scope_trims_and_ignores_blank() -> None: + assert read_dpop_header_from_scope(_http_scope(headers=[(b"dpop", b" a.b.c ")])) == "a.b.c" + assert read_dpop_header_from_scope(_http_scope(headers=[(b"dpop", b" ")])) is None + + +def test_read_dpop_header_from_scope_without_headers_key() -> None: + assert read_dpop_header_from_scope({"type": "http"}) is None + + +class _FakeState: + """Stand-in for ``starlette.datastructures.State``. + + Starlette's ``request.state`` is exactly this: attribute access over + the ``scope["state"]`` dict. Reproduced here so the shared-slot + contract is pinned without a Starlette dependency in the core tests. + """ + + _state: dict[str, object] + + def __init__(self, state: dict[str, object]) -> None: + object.__setattr__(self, "_state", state) + + def __setattr__(self, key: str, value: object) -> None: + self._state[key] = value + + def __getattr__(self, key: str) -> object: + try: + return self._state[key] + except KeyError: + raise AttributeError(key) from None + + +class _FakeStatefulRequest: + def __init__(self, scope: dict[str, object]) -> None: + self._scope = scope + scope.setdefault("state", {}) + self._state = _FakeState(scope["state"]) # type: ignore[arg-type] + + @property + def headers(self) -> _FakeHeaders: # pragma: no cover - unused here + return _FakeHeaders([]) + + @property + def scope(self) -> dict[str, object]: + return self._scope + + @property + def state(self) -> _FakeState: + return self._state + + @property + def url(self) -> _FakeURL: # pragma: no cover - unused here + return _FakeURL("/") + + +def test_scope_verify_cache_is_created_once() -> None: + scope = _http_scope() + first = get_or_create_verify_cache_from_scope(scope) + assert get_or_create_verify_cache_from_scope(scope) is first + + +def test_scope_verify_cache_is_not_shared_across_scopes() -> None: + assert get_or_create_verify_cache_from_scope(_http_scope()) is not ( + get_or_create_verify_cache_from_scope(_http_scope()) + ) + + +def test_scope_and_request_verify_caches_are_the_same_slot() -> None: + """A raw-ASGI middleware and a Starlette layer above it share one cache. + + Otherwise the second layer re-enters the inbound DPoP replay store + for a proof the first one already consumed. + """ + scope = _http_scope() + request = _FakeStatefulRequest(scope) + from_scope = get_or_create_verify_cache_from_scope(scope) + assert get_or_create_verify_cache(request) is from_scope # pyright: ignore[reportArgumentType] + + +def test_request_verify_cache_seen_from_scope() -> None: + """Same slot in the other direction: request first, scope second.""" + scope = _http_scope() + request = _FakeStatefulRequest(scope) + from_request = get_or_create_verify_cache(request) # pyright: ignore[reportArgumentType] + assert get_or_create_verify_cache_from_scope(scope) is from_request + + +def test_scope_verify_cache_preserves_a_non_dict_state_mapping() -> None: + """A non-``dict`` mapping in ``scope["state"]`` is used, not replaced. + + The ASGI lifespan-state extension specifies a *mapping*, not a ``dict``. + Replacing it drops whatever the application put in lifespan state for the + rest of the request, and splits the shared slot: Starlette's + ``Request.state`` uses ``scope.setdefault``, so a ``Request`` built first + would wrap the original while this helper pointed at a fresh dict — and the + two layers would each re-enter the replay store for one ``jti``. + """ + + class _MappingState(MutableMapping[str, object]): + """A MutableMapping that is deliberately not a ``dict``.""" + + def __init__(self) -> None: + self._data: dict[str, object] = {} + + def __getitem__(self, key: str) -> object: + return self._data[key] + + def __setitem__(self, key: str, value: object) -> None: + self._data[key] = value + + def __delitem__(self, key: str) -> None: + del self._data[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + state = _MappingState() + state["lifespan-value"] = "must survive" + scope = _http_scope() + scope["state"] = state + + cache = get_or_create_verify_cache_from_scope(scope) + + assert scope["state"] is state, "the server's own state mapping was replaced" + assert state["lifespan-value"] == "must survive" + assert get_or_create_verify_cache_from_scope(scope) is cache diff --git a/tests/test_dpop_and_security.py b/tests/test_dpop_and_security.py index 7725f4e..0234182 100644 --- a/tests/test_dpop_and_security.py +++ b/tests/test_dpop_and_security.py @@ -307,9 +307,11 @@ async def test_verify_dpop_proof_rejects_wrong_nonce_under_policy( expected_jkt=dpop_provider.key_material.thumbprint, expected_nonce="server-nonce-abc", ) - # The rejection reaches an unauthenticated caller via error_description in - # the WWW-Authenticate challenge, so it must not echo the nonce the server - # is expecting — that would supply a valid nonce without the round trip. + # The nonce must stay out of the message itself: `error_description` holds + # a fixed sentence by default, but `verbose_description=True` puts the + # message on the wire, and a resource server may log or surface it either + # way. Echoing the expected nonce would supply a valid one without the + # round trip. assert "server-nonce-abc" not in www_authenticate(excinfo.value) assert "stale" not in str(excinfo.value) diff --git a/tests/test_errors.py b/tests/test_errors.py index 8fe41ef..5613cba 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,8 +1,15 @@ """Tests for Authplane error hierarchy guarantees.""" +import contextlib +import logging +import re +from collections.abc import Generator + import pytest +from authplane.dpop import SUPPORTED_DPOP_ALGORITHMS, InboundDPoPOptions from authplane.errors import ( + AccessDeniedError, AuthError, AuthplaneError, CircuitOpenError, @@ -21,6 +28,7 @@ InvalidRequestError, InvalidScopeError, InvalidSignatureError, + InvalidTargetError, JWKSFetchError, MetadataFetchError, ProtocolError, @@ -31,9 +39,11 @@ UnauthorizedClientError, UnsupportedGrantTypeError, VerifierRuntimeError, + _description_for, # pyright: ignore[reportPrivateUsage] http_status, response_headers_for, www_authenticate, + www_authenticate_challenges, ) @@ -50,6 +60,8 @@ (ServerError, True), (CircuitOpenError, True), (ConsentRequiredError, True), + (AccessDeniedError, True), + (InvalidTargetError, True), (InsufficientScopeError, False), (DPoPError, False), (DPoPProofMissingError, False), @@ -66,6 +78,28 @@ def test_error_hierarchy_contract(error_type: type[Exception], is_auth_error: bo assert isinstance(error, AuthError) is is_auth_error +@contextlib.contextmanager +def caplog_at_debug() -> Generator[list[logging.LogRecord]]: + """Capture only ``authplane.errors`` DEBUG records, scoped to the block.""" + records: list[logging.LogRecord] = [] + + class _Collect(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + logger = logging.getLogger("authplane.errors") + handler = _Collect() + previous_level, previous_propagate = logger.level, logger.propagate + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + try: + yield records + finally: + logger.removeHandler(handler) + logger.setLevel(previous_level) + logger.propagate = previous_propagate + + def test_auth_error_preserves_message_code_and_status() -> None: error = InvalidClientError("bad credentials", code="invalid_client", status_code=401) assert str(error) == "bad credentials" @@ -240,7 +274,9 @@ def test_www_authenticate_no_scope_when_required_scopes_empty() -> None: def test_www_authenticate_sanitizes_error_description(message: str) -> None: # Regression: error message must not break out of the # quoted error_description parameter or inject additional headers. - header = www_authenticate(InvalidClaimsError(message)) + # verbose_description=True is the only path that still interpolates the + # exception message, so it is the path the sanitizer has to hold on. + header = www_authenticate(InvalidClaimsError(message), verbose_description=True) # CR/LF/quote/backslash are stripped from the emitted header value. assert "\r" not in header assert "\n" not in header @@ -259,6 +295,17 @@ def test_www_authenticate_sanitizes_realm() -> None: def test_www_authenticate_sanitizes_resource_metadata_url() -> None: + # Kept as a backstop, not as the guarantee. Substituting a space for a `"` + # leaves the header parseable while advertising a URL that no longer + # matches the one a client derives from the identifier this SDK also serves + # as the PRM document's `resource` member — the RFC 9728 §3.3 mismatch by + # another route. A host-borne delimiter can no longer reach it: + # `internal/urls.py` rejects a `"` or a `\\` in the host at construction. + # The path and the query are not covered by that gate and still arrive + # here. This parameter is also a plain `str` on a public function, so a + # caller can hand it a value that never passed any gate, and `realm`/ + # `scope` are free-form and gated nowhere — which is why the substitution + # stays. header = www_authenticate( TokenExpiredError("expired"), resource_metadata_url='https://x.example/.well-known/r"\r\nX: 1', @@ -268,6 +315,38 @@ def test_www_authenticate_sanitizes_resource_metadata_url() -> None: assert header.count('resource_metadata="') == 1 +def test_a_host_borne_delimiter_can_no_longer_reach_the_substitution() -> None: + # The construction gate covers the host, and only the host. Named that way + # on purpose: this backstop's docstring is where the next reader decides + # whether the substitution is still load-bearing, and a claim scoped wider + # than the gate would talk them out of a check that is still the only one + # covering two of the three components. + from authplane.errors import InvalidResourceError + from authplane.internal.urls import build_prm_url + + for resource in ('https://api"example.com/mcp', "https://api\\example.com/mcp"): + with pytest.raises(InvalidResourceError, match="host must not contain a literal"): + build_prm_url(resource) + + # And the residue, pinned rather than described: a delimiter in the path or + # the query still constructs, still derives, and still reaches the + # substitution — which rewrites it, so the advertised URL stops matching + # the `resource` member served from the same identifier (RFC 9728 §3.3). + # This is the assertion to delete when that axis is gated too. + for resource in ('https://api.example.com/m"cp', 'https://api.example.com/mcp?a="b'): + derived = build_prm_url(resource) + assert '"' in derived + challenge = www_authenticate(TokenExpiredError("expired"), resource_metadata_url=derived) + assert derived not in challenge + assert f'resource_metadata="{derived.replace(chr(34), " ")}"' in challenge + + # And the derivation an operator DOES configure survives the substitution + # byte-for-byte, so the advertised URL still matches the served `resource`. + url = build_prm_url("https://api.example.com/mcp") + header = www_authenticate(TokenExpiredError("expired"), resource_metadata_url=url) + assert f'resource_metadata="{url}"' in header + + def test_www_authenticate_sanitizes_scope_values() -> None: header = www_authenticate( InsufficientScopeError("nope"), @@ -276,6 +355,324 @@ def test_www_authenticate_sanitizes_scope_values() -> None: assert '"' not in header.split('scope="', 1)[1].split('"', 1)[0] +# --------------------------------------------------------------------------- +# www_authenticate() — error_description carries no internal detail +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("error", "expected_description"), + [ + ( + TokenExpiredError("expired"), + "The access token is missing or not valid for this resource", + ), + ( + InsufficientScopeError("need admin"), + "The access token does not carry the scope this operation requires", + ), + ( + DPoPMultipleProofsError("two proofs"), + "The DPoP proof is missing or not valid for this request", + ), + ], +) +def test_www_authenticate_description_is_fixed_per_error_code( + error: AuthplaneError, expected_description: str +) -> None: + # The description is chosen by the RFC 6750 §3.1 / RFC 9449 §7.1 error + # code, not by the exception, so it is identical for every error that maps + # to the same code. + assert f'error_description="{expected_description}"' in www_authenticate(error) + + +def test_unmapped_error_code_falls_back_to_a_contentless_description() -> None: + # Every code the mapper can currently produce has a table entry, so this + # guards the branch that catches a code added later without one: the + # fallback must still be contentless, never the exception's message. + error = InvalidClaimsError("Token kid 'signing-key-7' not found in JWKS after refresh") + description = _description_for(error, "some_future_error_code", verbose=False) + assert description == "The request could not be authenticated" + assert "signing-key-7" not in description + + +@pytest.mark.parametrize( + ("error", "secret"), + [ + # The verifier's real messages: each names a detail an unauthenticated + # caller must not learn. The aud case is the sharpest — it discloses + # the audience the caller would need to request a token for. + ( + InvalidClaimsError( + "Token claims validation failed: invalid_claim: aud " + "(expected 'https://mysql.internal.example/mcp')" + ), + "https://mysql.internal.example/mcp", + ), + (InvalidSignatureError("Token kid 'signing-key-7' not found in JWKS after refresh"), "kid"), + (InvalidClaimsError("Token type must be 'at+jwt', got 'JWT'"), "at+jwt"), + (InvalidDPoPProofError("DPoP proof nonce mismatch"), "nonce"), + ], +) +def test_www_authenticate_never_emits_the_internal_message( + error: AuthplaneError, secret: str +) -> None: + header = www_authenticate(error) + assert secret not in header + assert str(error) not in header + + +def test_www_authenticate_verbose_description_restores_the_internal_message() -> None: + error = InvalidClaimsError("Token claims validation failed: invalid_claim: aud") + header = www_authenticate(error, verbose_description=True) + assert f'error_description="{error}"' in header + + +def test_www_authenticate_safe_description_does_not_disturb_other_parameters() -> None: + header = www_authenticate( + InsufficientScopeError("missing 'admin'", required_scopes=("admin",)), + realm="api.example.com", + resource_metadata_url="https://api.example.com/.well-known/oauth-protected-resource", + ) + assert header.startswith("Bearer ") + assert 'realm="api.example.com"' in header + assert 'error="insufficient_scope"' in header + assert 'scope="admin"' in header + assert "resource_metadata=" in header + + +def test_response_headers_for_forwards_verbose_description() -> None: + error = InvalidClaimsError("Token kid 'k7' not found in JWKS after refresh") + _, safe_headers = response_headers_for(error) + assert "k7" not in safe_headers["WWW-Authenticate"] + _, verbose_headers = response_headers_for(error, verbose_description=True) + assert "k7" in verbose_headers["WWW-Authenticate"] + + +# --------------------------------------------------------------------------- +# www_authenticate_challenges() — one header value per acceptable scheme +# --------------------------------------------------------------------------- + + +def test_www_authenticate_challenges_defaults_to_the_single_derived_scheme() -> None: + error = TokenExpiredError("expired") + challenges = www_authenticate_challenges(error) + assert challenges == [www_authenticate(error)] + + +def test_www_authenticate_challenges_derived_scheme_follows_the_error_type() -> None: + assert www_authenticate_challenges(InvalidDPoPProofError("bad proof"))[0].startswith("DPoP ") + assert www_authenticate_challenges(DPoPNotSupportedError("no dpop"))[0].startswith("Bearer ") + + +def test_www_authenticate_challenges_advertises_both_schemes_in_order() -> None: + # inbound_dpop in optional mode accepts both; RFC 9449 §7.1 wants both + # advertised, and RFC 7235 §4.1 advises separate header values. + challenges = www_authenticate_challenges( + TokenMissingError("no token"), + schemes=("Bearer", "DPoP"), + realm="api.example.com", + ) + assert len(challenges) == 2 + assert challenges[0].startswith("Bearer ") + assert challenges[1].startswith("DPoP ") + for challenge in challenges: + assert 'realm="api.example.com"' in challenge + assert 'error="invalid_token"' in challenge + + +def test_www_authenticate_challenges_emits_algs_on_the_dpop_challenge_only() -> None: + bearer, dpop = www_authenticate_challenges( + TokenMissingError("no token"), + schemes=("Bearer", "DPoP"), + algs=("ES256", "RS256"), + ) + assert "algs=" not in bearer + assert 'algs="ES256 RS256"' in dpop + + +def test_www_authenticate_challenges_omits_algs_when_not_provided() -> None: + (dpop,) = www_authenticate_challenges(TokenMissingError("no token"), schemes=("DPoP",)) + assert "algs=" not in dpop + + +def test_www_authenticate_challenges_ignores_algs_without_a_dpop_scheme() -> None: + (bearer,) = www_authenticate_challenges( + TokenMissingError("no token"), schemes=("Bearer",), algs=("ES256",) + ) + assert "algs=" not in bearer + + +def test_www_authenticate_challenges_rejects_unsupported_algs() -> None: + # Validated, not escaped: these are bare RFC 7235 tokens, same as + # `schemes`, and escaping let a comma through — the one character the + # parameter text works to keep out so a lenient parser cannot split on it. + with pytest.raises(ValueError, match="Unsupported DPoP proof algorithms"): + www_authenticate_challenges( + TokenMissingError("no token"), + schemes=("DPoP",), + algs=['ES256", error="injected'], + ) + with pytest.raises(ValueError, match="Unsupported DPoP proof algorithms"): + www_authenticate_challenges( + TokenMissingError("no token"), schemes=("DPoP",), algs=("ES256,RS256",) + ) + + +def test_www_authenticate_challenges_algs_none_advertises_the_default_set() -> None: + # The documented call is `algs=options.allowed_proof_algorithms`, and that + # attribute is None on a default-constructed options object. It used to + # advertise nothing, then briefly raised TypeError from inside the 401 + # handler — a 500 on every unauthenticated request. + options = InboundDPoPOptions() + assert options.allowed_proof_algorithms is None + (dpop,) = www_authenticate_challenges( + TokenMissingError("no token"), + schemes=("DPoP",), + algs=options.allowed_proof_algorithms, + ) + assert f'algs="{" ".join(SUPPORTED_DPOP_ALGORITHMS)}"' in dpop + + +def test_www_authenticate_challenges_algs_default_still_omits_the_parameter() -> None: + # Passing nothing keeps the parameter off the challenge, which is what + # every existing caller relies on. Only an explicit None means "default set". + (dpop,) = www_authenticate_challenges(TokenMissingError("no token"), schemes=("DPoP",)) + assert "algs=" not in dpop + + +def test_the_withheld_message_is_logged_at_debug() -> None: + # Three user guides and llm-full.txt justify keeping the exception message + # off the wire by promising it is logged at DEBUG instead. Without a test, + # a refactor can drop the log while the docs keep promising it. + error = InvalidClaimsError("expected aud 'https://api.example.com/mcp', got 'other'") + with caplog_at_debug() as records: + challenge = www_authenticate(error) + assert "expected aud" not in challenge + assert any("expected aud" in r.getMessage() for r in records) + + with caplog_at_debug() as records: + (challenge,) = www_authenticate_challenges(error, schemes=("Bearer",)) + assert "expected aud" not in challenge + assert any("expected aud" in r.getMessage() for r in records) + + +def test_no_debug_log_when_the_message_already_goes_on_the_wire() -> None: + # verbose_description puts the message in error_description, so logging it + # a second time would be pure duplication. + error = InvalidClaimsError("expected aud 'https://api.example.com/mcp', got 'other'") + with caplog_at_debug() as records: + challenge = www_authenticate(error, verbose_description=True) + assert "expected aud" in challenge + assert not any("expected aud" in r.getMessage() for r in records) + + +def test_www_authenticate_challenges_rejects_a_bare_str_for_algs() -> None: + # `str` satisfies `Sequence[str]`, so this type-checks; joining it would + # emit algs="E S 2 5 6" and tell the client to sign with algorithms that + # do not exist. Loud, like the equivalent mistake on `schemes`. + with pytest.raises(TypeError, match="not a bare str"): + www_authenticate_challenges(TokenMissingError("no token"), schemes=("DPoP",), algs="ES256") + + +def test_www_authenticate_challenges_accepts_a_single_algorithm_as_a_sequence() -> None: + (dpop,) = www_authenticate_challenges( + TokenMissingError("no token"), schemes=("DPoP",), algs=("ES256",) + ) + assert 'algs="ES256"' in dpop + + +def test_www_authenticate_challenges_accepts_schemes_case_insensitively() -> None: + challenges = www_authenticate_challenges( + TokenMissingError("no token"), schemes=("bearer", "dpop") + ) + assert challenges[0].startswith("Bearer ") + assert challenges[1].startswith("DPoP ") + + +def test_www_authenticate_challenges_collapses_duplicate_schemes() -> None: + challenges = www_authenticate_challenges( + TokenMissingError("no token"), schemes=("DPoP", "dpop", "DPOP") + ) + assert len(challenges) == 1 + + +def test_www_authenticate_challenges_rejects_an_unsupported_scheme() -> None: + # The scheme is a bare RFC 7235 token, not a quoted parameter, so it is + # rejected rather than sanitized into the header. + with pytest.raises(ValueError, match="Unsupported authentication scheme"): + www_authenticate_challenges(TokenMissingError("no token"), schemes=("Basic",)) + + +def test_www_authenticate_challenges_rejects_empty_schemes() -> None: + with pytest.raises(ValueError, match="schemes must be non-empty"): + www_authenticate_challenges(TokenMissingError("no token"), schemes=()) + + +def test_www_authenticate_challenges_keeps_invalid_dpop_proof_on_the_dpop_challenge() -> None: + # RFC 9449 §7.1 defines invalid_dpop_proof for the DPoP scheme; a Bearer + # challenge alongside it must not name a code Bearer does not define. + bearer, dpop = www_authenticate_challenges( + DPoPMultipleProofsError("two proofs"), schemes=("Bearer", "DPoP") + ) + assert 'error="invalid_token"' in bearer + assert 'error="invalid_dpop_proof"' in dpop + + +def test_www_authenticate_challenges_maps_insufficient_scope_on_every_scheme() -> None: + challenges = www_authenticate_challenges( + InsufficientScopeError("missing 'admin'", required_scopes=("admin",)), + schemes=("Bearer", "DPoP"), + ) + for challenge in challenges: + assert 'error="insufficient_scope"' in challenge + assert 'scope="admin"' in challenge + + +def test_www_authenticate_challenges_uses_safe_descriptions_by_default() -> None: + error = InvalidClaimsError("Token kid 'signing-key-7' not found in JWKS after refresh") + for challenge in www_authenticate_challenges(error, schemes=("Bearer", "DPoP")): + assert "signing-key-7" not in challenge + assert ( + 'error_description="The access token is missing or not valid for this resource"' + in challenge + ) + + +def test_www_authenticate_challenges_honours_verbose_description() -> None: + error = InvalidClaimsError("Token kid 'signing-key-7' not found in JWKS after refresh") + (challenge,) = www_authenticate_challenges(error, schemes=("Bearer",), verbose_description=True) + assert "signing-key-7" in challenge + + +def test_www_authenticate_challenges_forwards_resource_metadata_to_every_scheme() -> None: + url = "https://api.example.com/.well-known/oauth-protected-resource" + challenges = www_authenticate_challenges( + TokenMissingError("no token"), schemes=("Bearer", "DPoP"), resource_metadata_url=url + ) + assert all(f'resource_metadata="{url}"' in challenge for challenge in challenges) + + +def test_www_authenticate_challenges_are_individually_parseable() -> None: + # The reason this returns a list rather than a comma-joined string: the + # comma also separates parameters inside a challenge, so a joined value + # would be ambiguous. Each element must stand alone as " ". + challenges = www_authenticate_challenges( + TokenMissingError("no token"), + schemes=("Bearer", "DPoP"), + realm="api", + algs=("ES256",), + ) + for challenge in challenges: + scheme, _, params = challenge.partition(" ") + assert scheme in {"Bearer", "DPoP"} + assert scheme not in params + # The parameter list is exactly a ", "-joined run of quoted + # name="value" pairs, with nothing between or around them. + pairs = re.findall(r'([A-Za-z_][A-Za-z0-9_]*)="([^"]*)"', params) + assert ", ".join(f'{name}="{value}"' for name, value in pairs) == params + + # --------------------------------------------------------------------------- # http_status() # --------------------------------------------------------------------------- diff --git a/tests/test_issuer_identity.py b/tests/test_issuer_identity.py index 3bad3a2..f6c38cd 100644 --- a/tests/test_issuer_identity.py +++ b/tests/test_issuer_identity.py @@ -14,7 +14,7 @@ import respx from authplane import AuthplaneClient, AuthplaneResource, FetchSettings -from authplane.errors import InvalidClaimsError, MetadataFetchError +from authplane.errors import InvalidClaimsError, InvalidResourceError, MetadataFetchError from authplane.internal.fetch_result import FetchResult from authplane.internal.metadata import MetadataCache from authplane.internal.urls import build_metadata_url, build_prm_url @@ -262,7 +262,7 @@ async def test_client_resource_rejects_fragment_at_construction( # still happen with the factory's own call deleted — just one frame deeper, # pointing at the constructor rather than at the line the operator wrote. # That is the whole reason the duplicate call is kept, so pin it: the raise - # itself is always in urls.py (validate_resource_indicator), and what this + # itself is always in urls.py (validate_prm_resource_identifier), and what this # asserts is which frame invoked it. # ``TracebackEntry.path`` is typed ``Path | str``, hence the round-trip. # @@ -314,3 +314,94 @@ async def test_client_resource_accepts_query(client: AuthplaneClient) -> None: # is legal on a resource indicator; only the fragment is forbidden. resource = client.resource("https://api.example.com/mcp?tenant=a") assert resource is not None + + +# Each of the three shapes is missing a different half of "scheme and host", +# which is why they are pinned independently rather than as one representative: +# a relative reference has neither, the scheme-relative form has a host but no +# scheme (an "opaque or authority-less" guard would wrongly admit it), and the +# URN has a scheme but no host (it used to derive +# "urn:/.well-known/oauth-protected-resource/example:api", and in the MCP +# adapters an htu origin of the literal "://"). +NON_ABSOLUTE_RESOURCES = pytest.mark.parametrize( + "resource", + ["/mcp", "//api.example.com/mcp", "urn:example:api"], + ids=["relative", "scheme-relative", "opaque-urn"], +) + + +@NON_ABSOLUTE_RESOURCES +async def test_client_resource_rejects_non_absolute_at_construction( + client: AuthplaneClient, resource: str +) -> None: + # Same gate, same site, same exception as the fragment rejection above: + # a resource that cannot derive a metadata URL (RFC 9728 §3) or an htu + # origin must fail at client.resource(...), not at first use. + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + client.resource(resource) + + +@NON_ABSOLUTE_RESOURCES +async def test_authplane_resource_rejects_non_absolute_when_constructed_directly( + client: AuthplaneClient, resource: str +) -> None: + # The authoritative gate is the constructor — direct construction of the + # package-root export must reject the same three shapes the factory does. + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + AuthplaneResource( + client, + resource=resource, + scopes=[], + allowed_algorithms=["RS256"], + ) + + +async def test_client_resource_accepts_http_localhost(client: AuthplaneClient) -> None: + # Deliberate profile relaxation: absoluteness is required, https is not — + # a plain-http identifier keeps local development working. + resource = client.resource("http://localhost:8080/mcp") + assert resource.resource == "http://localhost:8080/mcp" + + +# A userinfo-bearing identifier passes the scheme+host check (both present) but +# feeds three sinks that reassemble the authority from netloc, not hostname: +# build_prm_url → prm_url() → the resource_metadata parameter of a 401 +# WWW-Authenticate challenge; the MCP adapters' DPoP htu origin; and the +# fail_closed warning's log record. RFC 9110 §4.2.4 forbids generating the +# subcomponent, so it is rejected at the same construction-time gate. +CREDENTIALED_RESOURCE = "https://svc:s3cr3t@api.example.com/mcp" + + +async def test_client_resource_rejects_userinfo_at_construction( + client: AuthplaneClient, +) -> None: + with pytest.raises(InvalidResourceError, match="userinfo") as exc: + client.resource(CREDENTIALED_RESOURCE) + assert "s3cr3t" not in str(exc.value) + + +async def test_authplane_resource_rejects_userinfo_when_constructed_directly( + client: AuthplaneClient, +) -> None: + # The authoritative gate: with construction rejected, prm_url() — the + # 401-challenge sink — is unreachable for a credential-bearing identifier. + with pytest.raises(InvalidResourceError, match="userinfo"): + AuthplaneResource( + client, + resource=CREDENTIALED_RESOURCE, + scopes=[], + allowed_algorithms=["RS256"], + ) + + +async def test_userinfo_rejection_keeps_credentials_out_of_log_records( + client: AuthplaneClient, caplog: pytest.LogCaptureFixture +) -> None: + # The fail_closed warning attaches extra={"resource": resource} to a log + # record. The gate fires before that logger call, so a credential-bearing + # identifier can no longer reach it. + with caplog.at_level("DEBUG"), pytest.raises(InvalidResourceError): + client.resource(CREDENTIALED_RESOURCE, fail_closed=True) + for record in caplog.records: + assert "s3cr3t" not in record.getMessage() + assert "s3cr3t" not in str(getattr(record, "resource", "")) diff --git a/tests/verifier/test_claims.py b/tests/verifier/test_claims.py index fbf2f8a..7b7f0fb 100644 --- a/tests/verifier/test_claims.py +++ b/tests/verifier/test_claims.py @@ -345,7 +345,8 @@ def test_may_act_claim_present() -> None: kid="k", raw=freeze_value({"may_act": {"sub": "allowed-agent"}}), ) - assert claims.may_act == {"sub": "allowed-agent"} + with pytest.warns(DeprecationWarning, match="authserver 0.2.0 no longer issues may_act"): + assert claims.may_act == {"sub": "allowed-agent"} def test_may_act_claim_absent() -> None: @@ -362,4 +363,5 @@ def test_may_act_claim_absent() -> None: kid="k", raw=MappingProxyType({}), ) - assert claims.may_act is None + with pytest.warns(DeprecationWarning): + assert claims.may_act is None diff --git a/tests/verifier/test_revocation.py b/tests/verifier/test_revocation.py index d2e64b1..0d2b658 100644 --- a/tests/verifier/test_revocation.py +++ b/tests/verifier/test_revocation.py @@ -217,6 +217,42 @@ async def test_introspection_active_false_raises( await verifier_with_introspection.verify(token) +async def test_introspection_active_false_warns_about_ownership_once( + verifier_with_introspection: AuthplaneResource, + token_factory: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """active=false after a valid JWT names the runtime-client requirement, once. + + authserver >= 0.1.2 answers active=false to any client that is neither + the issuing client nor a runtime-client of the resource, and that is + indistinguishable from a revocation on the wire. The warning is the only + signal the operator gets; repeating it per token would drown the log. + """ + respx.post(INTROSPECTION_URL).mock(return_value=respx.MockResponse(200, json={"active": False})) + with caplog.at_level("WARNING", logger="authplane.verifier.verifier"): + for _ in range(3): + with pytest.raises(TokenRevokedError): + await verifier_with_introspection.verify(token_factory()) + + ownership = [ + r for r in caplog.records if "does not recognise this resource server" in r.message + ] + assert len(ownership) == 1 + assert "runtime-client add" in ownership[0].message + + +async def test_introspection_active_true_does_not_warn_about_ownership( + verifier_with_introspection: AuthplaneResource, + token_factory: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + respx.post(INTROSPECTION_URL).mock(return_value=respx.MockResponse(200, json={"active": True})) + with caplog.at_level("WARNING", logger="authplane.verifier.verifier"): + await verifier_with_introspection.verify(token_factory()) + assert not any("does not recognise" in r.message for r in caplog.records) + + async def test_introspection_http_error_fails_open( verifier_with_introspection: AuthplaneResource, token_factory: Any, @@ -362,6 +398,188 @@ async def test_fail_closed_without_checker_warns( await c.aclose() +async def test_fail_open_revocation_checker_warns( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + """A revocation checker left on the fail-open default warns at construction. + + The operator who configures a checker has opted into a stricter posture, + so the default answering an unanswerable check with "accept" is the + surprising direction. Surfacing it at startup is the point: the per-check + warning in verify() only fires once a check has already failed, and only + after the token was accepted. + """ + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + try: + with caplog.at_level("INFO", logger="authplane.client"): + c.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=IntrospectionRevocation(), + ) + assert any( + "Revocation checking is fail-open" in record.message and record.levelname == "INFO" + for record in caplog.records + ) + finally: + await c.aclose() + + +async def test_introspection_without_credentials_warns_at_construction( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + """IntrospectionRevocation on a client with no auth= warns at construction. + + Unauthenticated introspection is not an error path — the AS answers 200 + with active=false — so nothing later would flag it; every token would be + rejected as revoked with no explanation. + """ + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + try: + with caplog.at_level("WARNING", logger="authplane.client"): + c.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=IntrospectionRevocation(), + ) + matching = [ + r + for r in caplog.records + if "IntrospectionRevocation configured without AS credentials" in r.message + ] + assert len(matching) == 1 + assert matching[0].levelname == "WARNING" + assert "active=false" in matching[0].message + finally: + await c.aclose() + + +async def test_introspection_without_credentials_warns_on_direct_construction( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + """AuthplaneResource is exported, so direct construction must warn too. + + The gate lives on __init__ rather than on the client factory precisely so + this path is not silent: it is the same misconfiguration, and it produces + no error anywhere. + """ + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + try: + with caplog.at_level("WARNING", logger="authplane.client"): + AuthplaneResource( + client=c, + resource=RESOURCE, + scopes=["read:data"], + allowed_algorithms=["RS256"], + revocation_checker=IntrospectionRevocation(), + ) + matching = [ + r + for r in caplog.records + if "IntrospectionRevocation configured without AS credentials" in r.message + ] + assert len(matching) == 1 + finally: + await c.aclose() + + +async def test_introspection_with_credentials_does_not_warn_at_construction( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + c = await AuthplaneClient.create( + issuer=ISSUER, + auth=ASCredentials(client_id="rs", client_secret="s3cret"), + fetch_settings=FetchSettings(ssrf_protection=False), + ) + try: + with caplog.at_level("WARNING", logger="authplane.client"): + c.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=IntrospectionRevocation(), + ) + assert not any("without AS credentials" in r.message for r in caplog.records) + finally: + await c.aclose() + + +async def test_custom_checker_without_credentials_does_not_warn_about_introspection( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + """The credentials warning is about introspection; a custom checker needs none.""" + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + + async def never_revoked(claims: Any, raw_token: str) -> bool: + return False + + try: + with caplog.at_level("WARNING", logger="authplane.client"): + c.resource(resource=RESOURCE, scopes=["read:data"], revocation_checker=never_revoked) + assert not any("without AS credentials" in r.message for r in caplog.records) + finally: + await c.aclose() + + +async def test_fail_closed_revocation_checker_does_not_warn( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + """The pairing the operator meant to configure stays quiet.""" + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + try: + with caplog.at_level("INFO", logger="authplane.client"): + c.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=IntrospectionRevocation(), + fail_closed=True, + ) + assert not any( + "Revocation checking is fail-open" in record.message for record in caplog.records + ) + finally: + await c.aclose() + + +async def test_no_revocation_checker_does_not_warn( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + """No checker configured is not the fail-open posture — nothing to warn about.""" + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + try: + with caplog.at_level("INFO", logger="authplane.client"): + c.resource(resource=RESOURCE, scopes=["read:data"]) + assert not any( + "Revocation checking is fail-open" in record.message for record in caplog.records + ) + finally: + await c.aclose() + + async def test_introspection_sends_correct_token( verifier_with_introspection: AuthplaneResource, token_factory: Any, diff --git a/tests/verifier/test_verifier.py b/tests/verifier/test_verifier.py index dcc604f..7e2c5da 100644 --- a/tests/verifier/test_verifier.py +++ b/tests/verifier/test_verifier.py @@ -1,8 +1,9 @@ """Tests for AuthplaneResource core validation logic.""" +import asyncio import time from collections.abc import Callable -from typing import Any +from typing import Any, Protocol import httpx import pytest @@ -11,14 +12,35 @@ from authplane import AuthplaneClient, AuthplaneResource, FetchSettings, InboundDPoPOptions from authplane.errors import ( + InsufficientScopeError, InvalidClaimsError, + InvalidResourceError, InvalidSignatureError, JWKSFetchError, MetadataFetchError, TokenExpiredError, + response_headers_for, ) +class SigningKey(Protocol): + """Shape of the keys minted by the ``signing_key_factory`` fixture. + + Declared structurally here for the same reason ``TokenFactory`` is declared + in ``tests/conftest.py``: test modules are not a package, so the fixture's + concrete type cannot be imported. + """ + + @property + def jwks(self) -> dict[str, Any]: + """The single-key JWKS document publishing this key.""" + ... + + def sign(self, **overrides: Any) -> str: + """Sign an otherwise-valid access token for the default test resource.""" + ... + + async def test_valid_token(verifier: AuthplaneResource, token_factory: Callable[..., str]) -> None: """Should successfully verify a valid token.""" token = token_factory() @@ -436,6 +458,70 @@ async def test_prm_url_for_path_resource(client: AuthplaneClient) -> None: assert resource.prm_url() == "https://api.example.com/.well-known/oauth-protected-resource/mcp" +async def test_resource_metadata_url_defaults_to_the_derivation( + client: AuthplaneClient, +) -> None: + # No option set: the advertised URL is what prm_url() derives, byte for + # byte — the guarantee every existing deployment relies on. + resource = client.resource(resource="https://api.example.com/mcp", scopes=["read:data"]) + assert resource.resource_metadata_url() == resource.prm_url() + assert ( + resource.resource_metadata_url() + == "https://api.example.com/.well-known/oauth-protected-resource/mcp" + ) + + +async def test_resource_metadata_url_override_is_returned(client: AuthplaneClient) -> None: + # The AS-hosted topology: authserver >= 0.2.0 serves the document for the + # registered Resource, and the SDK only points at it. prm_url() keeps + # naming the document this SDK itself builds. + as_hosted = "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + resource = client.resource( + resource="https://api.example.com/mcp", + scopes=["read:data"], + resource_metadata_url=as_hosted, + ) + assert resource.resource_metadata_url() == as_hosted + assert resource.prm_url() == "https://api.example.com/.well-known/oauth-protected-resource/mcp" + + +async def test_resource_metadata_url_override_reaches_401_and_403_challenges( + client: AuthplaneClient, +) -> None: + # Both challenge paths the RFC 9728 §5.1 parameter appears on: the 401 for + # an unusable token and the 403 for insufficient scope. + as_hosted = "https://auth.example.com/.well-known/oauth-protected-resource/mcp" + resource = client.resource( + resource="https://api.example.com/mcp", + scopes=["read:data"], + resource_metadata_url=as_hosted, + ) + + for error, expected_status in ( + (TokenExpiredError("expired"), 401), + (InsufficientScopeError("nope", required_scopes=("read:data",)), 403), + ): + status, headers = response_headers_for( + error, + resource_metadata_url=resource.resource_metadata_url(), + ) + assert status == expected_status + assert f'resource_metadata="{as_hosted}"' in headers["WWW-Authenticate"] + assert "api.example.com" not in headers["WWW-Authenticate"] + + +async def test_resource_rejects_an_invalid_resource_metadata_url(client: AuthplaneClient) -> None: + # Construction-time, like the identifier itself: the value is advertised to + # an unauthenticated caller from a 401 path, which is the worst place to + # discover it is unusable. + with pytest.raises(InvalidResourceError, match="absolute URL with a scheme and a host"): + client.resource( + resource="https://api.example.com/mcp", + scopes=["read:data"], + resource_metadata_url="/.well-known/oauth-protected-resource/mcp", + ) + + async def test_prm_omits_dpop_fields_when_inbound_dpop_not_configured( client: AuthplaneClient, ) -> None: @@ -787,21 +873,33 @@ async def test_discovery_properties_before_initialization() -> None: @respx.mock -async def test_jwks_cache_restarts_on_uri_change( - jwks_keypair: dict[str, Any], token_factory: Callable[..., str] +async def test_verification_traffic_follows_a_rotated_jwks_uri( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], ) -> None: - """Should restart JWKS cache when metadata jwks_uri changes.""" - import asyncio + """Verification alone must re-read AS metadata and follow a rotated jwks_uri. + + A resource server that only verifies tokens never calls an AS endpoint, so + ``verify()`` is the only thing that can keep metadata warm. Nothing here + forces a refresh: the test brings the refresh interval forward and then + sends ordinary verification traffic carrying a token signed by a key + published *only* at the new URI. Unless the SDK re-read metadata and + resolved the key set against it on that call, the key is unreachable and + the verification fails. + """ + old_jwks_uri = "https://auth.example.com/jwks-v1.json" + new_jwks_uri = "https://auth.example.com/jwks-v2.json" - old_jwks_uri = "https://auth.example.com/old-jwks" - new_jwks_uri = "https://auth.example.com/new-jwks" + old_key = signing_key_factory("key-v1") + new_key = signing_key_factory("key-v2") - # Track metadata fetch calls - metadata_call_count: dict[str, int] = {"count": 0} + metadata_calls = 0 def metadata_response(request: httpx.Request) -> httpx.Response: - metadata_call_count["count"] += 1 - uri = new_jwks_uri if metadata_call_count["count"] > 1 else old_jwks_uri + nonlocal metadata_calls + metadata_calls += 1 + # The AS rotates: every read after the first advertises the new URI. + uri = old_jwks_uri if metadata_calls == 1 else new_jwks_uri return httpx.Response( 200, json={ @@ -809,113 +907,566 @@ def metadata_response(request: httpx.Request) -> httpx.Response: "jwks_uri": uri, "token_endpoint": "https://auth.example.com/token", }, - headers={"Cache-Control": "max-age=1"}, # Short TTL for testing ) respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( side_effect=metadata_response ) + old_route = respx.get(old_jwks_uri).mock( + return_value=respx.MockResponse(200, json=old_key.jwks) + ) + new_route = respx.get(new_jwks_uri).mock( + return_value=respx.MockResponse(200, json=new_key.jwks) + ) - # Mock JWKS responses for both URIs - old_jwks: Any = jwks_keypair["jwks"] - new_key: dict[str, Any] = {**jwks_keypair["jwks"]["keys"][0], "kid": "new-key-id"} - new_jwks: dict[str, list[dict[str, Any]]] = {"keys": [new_key]} + client = await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=FetchSettings(ssrf_protection=False), + ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) - respx.get(old_jwks_uri).mock(return_value=respx.MockResponse(status_code=200, json=old_jwks)) - respx.get(new_jwks_uri).mock(return_value=respx.MockResponse(status_code=200, json=new_jwks)) + try: + # Construction read metadata once and fetched keys from the URI it named. + assert metadata_calls == 1 + assert new_route.call_count == 0 + + claims = await verifier.verify(old_key.sign()) + assert claims.kid == "key-v1" + # Still inside the refresh interval, so no second read: the hop on the + # verify path is TTL-gated, not a fetch per verification. + assert metadata_calls == 1 + + expire_metadata_interval(client) + old_route_calls_at_rotation = old_route.call_count + + # Ordinary verification traffic. The token is signed by a key the old + # URI never served, so this can only pass off the rotated document. + claims = await verifier.verify(new_key.sign()) + assert claims.kid == "key-v2" + + assert metadata_calls >= 2 + assert new_route.call_count >= 1 + # The withdrawn URI was not fetched again once the rotation was read. + assert old_route.call_count == old_route_calls_at_rotation + + # Steady state stays on the new URI rather than drifting back. + claims = await verifier.verify(new_key.sign(jti="second-call")) + assert claims.kid == "key-v2" + assert old_route.call_count == old_route_calls_at_rotation + finally: + await client.aclose() + + +@respx.mock +async def test_verification_refresh_keeps_jwks_cache_when_uri_is_unchanged( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], +) -> None: + """A metadata re-read that leaves jwks_uri alone must not churn the JWKS cache. + + Same production path as the rotation test — the refresh is driven by the + elapsed interval and ordinary ``verify()`` calls — but here only + ``token_endpoint`` moves, so the JWKS cache instance must survive and the + key set must not be refetched. + """ + jwks_uri = "https://auth.example.com/jwks.json" + key = signing_key_factory("stable-key") + + metadata_calls = 0 + + def metadata_response(request: httpx.Request) -> httpx.Response: + nonlocal metadata_calls + metadata_calls += 1 + # jwks_uri is constant; only the token endpoint moves. + endpoint = ( + "https://auth.example.com/token" + if metadata_calls == 1 + else "https://auth.example.com/token-v2" + ) + return httpx.Response( + 200, + json={ + "issuer": "https://auth.example.com", + "jwks_uri": jwks_uri, + "token_endpoint": endpoint, + }, + ) + + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + side_effect=metadata_response + ) + jwks_route = respx.get(jwks_uri).mock(return_value=respx.MockResponse(200, json=key.jwks)) - # Create client with discovery and short metadata refresh - _no_ssrf = FetchSettings(ssrf_protection=False) client = await AuthplaneClient.create( issuer="https://auth.example.com", - metadata_refresh_seconds=1, # Very short for testing - fetch_settings=_no_ssrf, + fetch_settings=FetchSettings(ssrf_protection=False), ) - verifier = client.resource( - resource="https://api.example.com", - scopes=["read:data"], + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) + + try: + original_jwks_cache = client.jwks_cache + assert await verifier.verify(key.sign()) is not None + + expire_metadata_interval(client) + jwks_calls_before_refresh = jwks_route.call_count + + assert await verifier.verify(key.sign(jti="second-call")) is not None + + # The refresh did happen on the verify path... + assert metadata_calls == 2 + # ...but an unchanged jwks_uri leaves the cache instance and its + # document exactly where they were. + assert client.jwks_cache is original_jwks_cache + assert jwks_route.call_count == jwks_calls_before_refresh + finally: + await client.aclose() + + +@respx.mock +async def test_a_rejected_metadata_document_does_not_repoint_key_retrieval( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], +) -> None: + """A document that fails validation must not decide where keys come from. + + Putting the metadata read on the verification path makes this reachable on + every request a verify-only resource server serves, so the rejection has to + happen before the document is cached. Validating on the way out instead + leaves a rejected document naming the key set: a token minted by the key it + advertises then verifies, and the token's own ``iss`` claim does not help, + because whoever supplied the document also mints the token. + """ + honest_jwks_uri = "https://auth.example.com/jwks.json" + rogue_jwks_uri = "https://auth.example.com/jwks-rogue.json" + honest_key = signing_key_factory("key-honest") + rogue_key = signing_key_factory("key-rogue") + + serve_rogue = False + + def metadata_response(request: httpx.Request) -> httpx.Response: + if serve_rogue: + # RFC 8414 §3.3: the issuer is not the configured one, so this + # document is not about this authorization server at all. + return httpx.Response( + 200, + json={ + "issuer": "https://elsewhere.example.com", + "jwks_uri": rogue_jwks_uri, + }, + ) + return httpx.Response( + 200, + json={"issuer": "https://auth.example.com", "jwks_uri": honest_jwks_uri}, + ) + + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + side_effect=metadata_response + ) + respx.get(honest_jwks_uri).mock(return_value=respx.MockResponse(200, json=honest_key.jwks)) + rogue_route = respx.get(rogue_jwks_uri).mock( + return_value=respx.MockResponse(200, json=rogue_key.jwks) + ) + + client = await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=FetchSettings(ssrf_protection=False), ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) try: - # Initial JWKS URI should be old - assert client._jwks_uri == old_jwks_uri # pyright: ignore[reportPrivateUsage] + assert (await verifier.verify(honest_key.sign())).kid == "key-honest" - # Verify token works with old JWKS - token = token_factory() - claims = await verifier.verify(token) - assert claims.kid == "test-key-1" + serve_rogue = True + expire_metadata_interval(client) + + # Minted by the key the rejected document names, but carrying the real + # issuer — the shape a client presents once the metadata endpoint is + # under someone else's control. + with pytest.raises(InvalidSignatureError): + await verifier.verify(rogue_key.sign()) + assert rogue_route.call_count == 0 - # Force metadata refresh to get new URI - await client.metadata_cache.get(force_refresh=True) # pyright: ignore[reportOptionalMemberAccess] + # And the honest key still verifies: rejecting the document left the + # last accepted one in place rather than emptying anything. + assert (await verifier.verify(honest_key.sign(jti="after-rejection"))).kid == "key-honest" + finally: + await client.aclose() - # Give callback time to run - await asyncio.sleep(0.1) - # JWKS URI should have changed - assert client._jwks_uri == new_jwks_uri # pyright: ignore[reportPrivateUsage] +@respx.mock +async def test_rotation_to_an_unreachable_uri_keeps_the_working_key_set( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], +) -> None: + """Reading a rotation must not cost the keys that were already verifying. + + The newly advertised URI is dead. Nothing is swapped in on the strength of + a document alone, so the key set the cache already holds keeps serving and + tokens that verified a moment ago still verify. + """ + old_jwks_uri = "https://auth.example.com/jwks-v1.json" + dead_jwks_uri = "https://auth.example.com/jwks-v2.json" + old_key = signing_key_factory("key-v1") - # Verify JWKS cache was restarted with new URI - jwks = await client.jwks_cache.get() # pyright: ignore[reportOptionalMemberAccess] - assert jwks["keys"][0]["kid"] == "new-key-id" + metadata_calls = 0 + def metadata_response(request: httpx.Request) -> httpx.Response: + nonlocal metadata_calls + metadata_calls += 1 + uri = old_jwks_uri if metadata_calls == 1 else dead_jwks_uri + return httpx.Response( + 200, + json={"issuer": "https://auth.example.com", "jwks_uri": uri}, + ) + + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + side_effect=metadata_response + ) + respx.get(old_jwks_uri).mock(return_value=respx.MockResponse(200, json=old_key.jwks)) + respx.get(dead_jwks_uri).mock(return_value=respx.MockResponse(503)) + + client = await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=FetchSettings(ssrf_protection=False), + ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) + + try: + assert (await verifier.verify(old_key.sign())).kid == "key-v1" + + expire_metadata_interval(client) + + assert (await verifier.verify(old_key.sign(jti="after-rotation"))).kid == "key-v1" + assert metadata_calls >= 2 + + # Even a forced refetch, which has only the dead URI to go to, leaves + # the cached key set intact rather than emptying it. + jwks_cache = client.jwks_cache + assert jwks_cache is not None + assert await jwks_cache.contains_kid("key-v1", force_refresh=True) is True finally: await client.aclose() @respx.mock -async def test_metadata_change_without_jwks_uri_change( - jwks_keypair: dict[str, Any], token_factory: Callable[..., str] +async def test_concurrent_verifications_straddling_a_rotation_all_succeed( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], ) -> None: - """Should not restart JWKS cache when metadata changes but jwks_uri stays same.""" - import asyncio + """A rotation read by one caller must not break the others in flight. + + Ten verifications are in flight when the rotation becomes visible. One of + them wins the metadata read and the key-set refetch that follows it; the + other nine must be served what that one committed rather than each + repeating the fetch behind it. None of their tokens has been withdrawn, so + all ten must verify. + + The rotated location publishes the retired key alongside the new one, + because that is what an authorization server moving its ``jwks_uri`` has to + do: the new document is the only one clients will discover from now on, so + a key still signing live tokens has to be in it. An AS that drops such a + key from the new document has withdrawn it, and no verifier can be expected + to keep honouring a key set the AS has stopped publishing — + ``test_a_key_only_at_the_withdrawn_location_stops_verifying`` pins that + direction. + """ + old_jwks_uri = "https://auth.example.com/jwks-v1.json" + new_jwks_uri = "https://auth.example.com/jwks-v2.json" + old_key = signing_key_factory("key-v1") + new_key = signing_key_factory("key-v2") + + metadata_calls = 0 + + def metadata_response(request: httpx.Request) -> httpx.Response: + nonlocal metadata_calls + metadata_calls += 1 + uri = old_jwks_uri if metadata_calls == 1 else new_jwks_uri + return httpx.Response( + 200, + json={"issuer": "https://auth.example.com", "jwks_uri": uri}, + ) + + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + side_effect=metadata_response + ) + old_route = respx.get(old_jwks_uri).mock( + return_value=respx.MockResponse(200, json=old_key.jwks) + ) + new_route = respx.get(new_jwks_uri).mock( + return_value=respx.MockResponse( + 200, json={"keys": [old_key.jwks["keys"][0], new_key.jwks["keys"][0]]} + ) + ) + + client = await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=FetchSettings(ssrf_protection=False), + ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) + + try: + assert (await verifier.verify(old_key.sign())).kid == "key-v1" + + expire_metadata_interval(client) + old_route_calls_at_rotation = old_route.call_count + + # Ten in-flight verifications of tokens the AS has not withdrawn. One + # of them observes the rotation; none of them may be broken by it. + results = await asyncio.gather( + *(verifier.verify(old_key.sign(jti=f"burst-{n}")) for n in range(10)) + ) + assert [claims.kid for claims in results] == ["key-v1"] * 10 + assert metadata_calls >= 2 + # The rotation was followed, and the refetch it triggered was paid for + # once by the burst rather than ten times. Asserted, not assumed: + # without the in-lock re-check every one of the ten would refetch. + assert new_route.call_count == 1 + # And the withdrawn location was not touched again once the rebind + # happened. + assert old_route.call_count == old_route_calls_at_rotation + finally: + await client.aclose() - jwks_uri = "https://auth.example.com/jwks" - # Track metadata fetch calls - metadata_call_count: dict[str, int] = {"count": 0} +@respx.mock +async def test_a_key_only_at_the_withdrawn_location_stops_verifying( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], +) -> None: + """The cost of following a rotation, stated rather than left to be found. + + Once the rotation is observed, the key set is the rotated document's and + only its. A key the AS published at the old location and left out of the + new one no longer verifies anything — the AS stopped publishing it, which + is what withdrawing a key is, and continuing to honour it would mean + trusting a document the AS has replaced. Rotating ``jwks_uri`` is therefore + not a way to move keys gradually: whatever is still signing live tokens has + to appear at the new location. + """ + old_jwks_uri = "https://auth.example.com/jwks-v1.json" + new_jwks_uri = "https://auth.example.com/jwks-v2.json" + retired_key = signing_key_factory("retired-key") + new_key = signing_key_factory("new-key") + + rotated = False def metadata_response(request: httpx.Request) -> httpx.Response: - metadata_call_count["count"] += 1 - # Only token_endpoint changes, jwks_uri stays same - endpoint = ( - "https://auth.example.com/token-v2" - if metadata_call_count["count"] > 1 - else "https://auth.example.com/token" + uri = new_jwks_uri if rotated else old_jwks_uri + return httpx.Response( + 200, + json={"issuer": "https://auth.example.com", "jwks_uri": uri}, ) + + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + side_effect=metadata_response + ) + # Still reachable, still serving the retired key: the point is that it is + # no longer consulted, not that it became unreachable. + respx.get(old_jwks_uri).mock(return_value=respx.MockResponse(200, json=retired_key.jwks)) + respx.get(new_jwks_uri).mock(return_value=respx.MockResponse(200, json=new_key.jwks)) + + client = await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=FetchSettings(ssrf_protection=False), + ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) + + try: + assert (await verifier.verify(retired_key.sign())).kid == "retired-key" + + rotated = True + expire_metadata_interval(client) + + # The new location's key works. + assert (await verifier.verify(new_key.sign())).kid == "new-key" + # The one left behind at the withdrawn location does not. + with pytest.raises(InvalidSignatureError): + await verifier.verify(retired_key.sign(jti="after-rotation")) + finally: + await client.aclose() + + +@respx.mock +async def test_a_kid_miss_re_reads_metadata_before_forcing_the_key_set_refresh( + signing_key_factory: Callable[[str], SigningKey], +) -> None: + """A kid the cache cannot satisfy makes the cached document suspect too. + + The refresh interval is left at its default and never elapses here, so the + rotation can only be followed because the miss re-read the document. Were + the location resolved from the cached document alone, the forced refetch + would go straight back to the withdrawn URI and the token would be rejected + until the next interval boundary. + """ + old_jwks_uri = "https://auth.example.com/jwks-v1.json" + new_jwks_uri = "https://auth.example.com/jwks-v2.json" + old_key = signing_key_factory("key-v1") + new_key = signing_key_factory("key-v2") + + rotated = False + metadata_calls = 0 + + def metadata_response(request: httpx.Request) -> httpx.Response: + nonlocal metadata_calls + metadata_calls += 1 return httpx.Response( 200, json={ "issuer": "https://auth.example.com", - "jwks_uri": jwks_uri, # Same URI both times - "token_endpoint": endpoint, # Different endpoint + "jwks_uri": new_jwks_uri if rotated else old_jwks_uri, }, ) respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( side_effect=metadata_response ) + respx.get(old_jwks_uri).mock(return_value=respx.MockResponse(200, json=old_key.jwks)) + new_route = respx.get(new_jwks_uri).mock( + return_value=respx.MockResponse(200, json=new_key.jwks) + ) + + client = await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=FetchSettings(ssrf_protection=False), + ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) - # Mock JWKS - jwks: Any = jwks_keypair["jwks"] - respx.get(jwks_uri).mock(return_value=respx.MockResponse(status_code=200, json=jwks)) + try: + assert (await verifier.verify(old_key.sign())).kid == "key-v1" + metadata_calls_before_rotation = metadata_calls + + rotated = True + # No interval is brought forward: the only thing that can reveal the + # rotation is the token itself, whose kid the cached key set lacks. + assert (await verifier.verify(new_key.sign())).kid == "key-v2" + assert metadata_calls > metadata_calls_before_rotation + assert new_route.call_count >= 1 + finally: + await client.aclose() + + +@respx.mock +async def test_a_failed_metadata_refresh_does_not_fail_verification( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], +) -> None: + """The metadata hop must not turn an AS outage into a verification outage. + + Token-level issuer identity is checked against the configured issuer, not + against the document, so a key set that can still satisfy the token is + enough. The refresh error is logged and verification continues. + """ + jwks_uri = "https://auth.example.com/jwks.json" + key = signing_key_factory("stable-key") + + metadata_broken = False + + def metadata_response(request: httpx.Request) -> httpx.Response: + if metadata_broken: + return httpx.Response(503) + return httpx.Response( + 200, + json={"issuer": "https://auth.example.com", "jwks_uri": jwks_uri}, + ) + + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + side_effect=metadata_response + ) + respx.get(jwks_uri).mock(return_value=respx.MockResponse(200, json=key.jwks)) - _no_ssrf = FetchSettings(ssrf_protection=False) client = await AuthplaneClient.create( issuer="https://auth.example.com", - fetch_settings=_no_ssrf, + fetch_settings=FetchSettings(ssrf_protection=False), ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) try: - # Capture original JWKS cache instance - original_jwks_cache = client.jwks_cache + assert (await verifier.verify(key.sign())).kid == "stable-key" - # Force metadata refresh - await client.metadata_cache.get(force_refresh=True) # pyright: ignore[reportOptionalMemberAccess] - await asyncio.sleep(0.1) + metadata_broken = True + expire_metadata_interval(client) + + assert (await verifier.verify(key.sign(jti="during-outage"))).kid == "stable-key" + finally: + await client.aclose() - # JWKS cache should NOT have been replaced (same instance) - assert client.jwks_cache is original_jwks_cache - assert client._jwks_uri == jwks_uri # pyright: ignore[reportPrivateUsage] +@respx.mock +async def test_a_rejected_metadata_refresh_displaces_neither_the_document_nor_the_key_source( + signing_key_factory: Callable[[str], SigningKey], + expire_metadata_interval: Callable[[AuthplaneClient], None], +) -> None: + """A metadata document that fails validation must decide nothing. + + RFC 8414 §3.3 issuer identity is the sharpest case: a refresh that answers + with another issuer's document, pointing ``jwks_uri`` at a key set that + issuer controls. Checking the document on the way *out* of the cache rather + than on the way in would let it be committed first and steer key retrieval + while it sat there — and a token signed by the substituted key set would + then verify against the configured issuer, which is forgery, not a stale + read. Rejecting before the commit leaves both the cached document and the + key source where they were. + """ + genuine_key = signing_key_factory("genuine-key") + attacker_key = signing_key_factory("attacker-key") + + hijacked = False + + def metadata_response(request: httpx.Request) -> httpx.Response: + if hijacked: + return httpx.Response( + 200, + json={ + "issuer": "https://evil.example.com", + "jwks_uri": "https://evil.example.com/jwks.json", + }, + ) + return httpx.Response( + 200, + json={ + "issuer": "https://auth.example.com", + "jwks_uri": "https://auth.example.com/jwks.json", + }, + ) + + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + side_effect=metadata_response + ) + genuine_route = respx.get("https://auth.example.com/jwks.json").mock( + return_value=respx.MockResponse(200, json=genuine_key.jwks) + ) + attacker_route = respx.get("https://evil.example.com/jwks.json").mock( + return_value=respx.MockResponse(200, json=attacker_key.jwks) + ) + + client = await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=FetchSettings(ssrf_protection=False), + ) + verifier = client.resource(resource="https://api.example.com", scopes=["read:data"]) + + try: + assert (await verifier.verify(genuine_key.sign())).kid == "genuine-key" + + hijacked = True + expire_metadata_interval(client) + + # The key source is untouched, so the genuine key still verifies. + assert (await verifier.verify(genuine_key.sign(jti="after-rejection"))).kid == "genuine-key" + assert not attacker_route.called + + # And a token signed by the substituted key set does not verify. The + # unknown kid drives a forced re-read of both documents, which is the + # path that would reach that key set had the rejected document been + # committed. + with pytest.raises(InvalidSignatureError): + await verifier.verify(attacker_key.sign()) + assert not attacker_route.called + + # The document still cached is the last one that passed validation. + metadata_cache = client.metadata_cache + assert metadata_cache is not None + assert await metadata_cache.get_jwks_uri() == "https://auth.example.com/jwks.json" + assert genuine_route.called finally: await client.aclose() diff --git a/tests/verifier/test_verifier_edge_cases.py b/tests/verifier/test_verifier_edge_cases.py index e4b4fc9..0119e43 100644 --- a/tests/verifier/test_verifier_edge_cases.py +++ b/tests/verifier/test_verifier_edge_cases.py @@ -6,7 +6,6 @@ - ``verify()`` surfaces unexpected runtime exceptions distinctly - tokens with a list ``aud`` are accepted (multi-audience support) - unexpected exception inside ``_verify_token_core`` is wrapped -- metadata change callbacks on AuthplaneClient """ from collections.abc import Callable @@ -39,60 +38,6 @@ async def test_scopes_property_returns_configured_scopes(mock_jwks: Any) -> None await client.aclose() -# --------------------------------------------------------------------------- -# Metadata change: new metadata drops jwks_uri -# --------------------------------------------------------------------------- - - -async def test_on_metadata_changed_logs_error_when_new_metadata_drops_jwks_uri( - client: AuthplaneClient, -) -> None: - """When refreshed AS metadata no longer contains a jwks_uri, the client - should log a warning and clear its jwks_uri.""" - assert client._jwks_uri is not None # pyright: ignore[reportPrivateUsage] - - old_metadata: dict[str, Any] = { - "issuer": "https://auth.example.com", - "jwks_uri": client._jwks_uri, # pyright: ignore[reportPrivateUsage] - } - # New metadata document without a jwks_uri field - new_metadata: dict[str, Any] = { - "issuer": "https://auth.example.com", - } - - await client._on_metadata_changed(old_metadata, new_metadata) # pyright: ignore[reportPrivateUsage] - - assert client._jwks_uri is None # pyright: ignore[reportPrivateUsage] - - -# --------------------------------------------------------------------------- -# Metadata change: introspection_endpoint changes -# --------------------------------------------------------------------------- - - -async def test_on_metadata_changed_logs_introspection_endpoint_change( - client: AuthplaneClient, -) -> None: - """When the introspection_endpoint changes in refreshed metadata the client - logs the change.""" - old_metadata: dict[str, Any] = { - "issuer": "https://auth.example.com", - "jwks_uri": client._jwks_uri, # pyright: ignore[reportPrivateUsage] - "introspection_endpoint": "https://auth.example.com/oauth/introspect/v1", - } - new_metadata: dict[str, Any] = { - "issuer": "https://auth.example.com", - "jwks_uri": client._jwks_uri, # pyright: ignore[reportPrivateUsage] - "introspection_endpoint": "https://auth.example.com/oauth/introspect/v2", - } - - # Must complete without error; logging is verified implicitly via coverage. - await client._on_metadata_changed(old_metadata, new_metadata) # pyright: ignore[reportPrivateUsage] - - # jwks_uri must be unchanged (the endpoint update does not affect JWKS). - assert client._jwks_uri is not None # pyright: ignore[reportPrivateUsage] - - # --------------------------------------------------------------------------- # verify() surfaces unexpected errors as VerifierRuntimeError # --------------------------------------------------------------------------- @@ -138,10 +83,7 @@ async def test_multi_audience_token_accepted( """A token whose aud claim is a multi-element list is accepted when resource is present.""" token = token_factory(aud=["https://api.example.com", "https://other.com"]) # type: ignore[arg-type] claims = await verifier.verify(token) - # Compare the full audience rather than testing membership: the exact - # tuple also proves the configured resource was matched as a whole value, - # not as a substring of a longer audience entry. - assert claims.audience == ("https://api.example.com", "https://other.com") + assert "https://api.example.com" in claims.audience # ---------------------------------------------------------------------------