Skip to content

Navigation Menu

Sign in
Sign up

test(auth): the OAuth2 destination-binding limb of ASVS 10.2.2, and why client_secret_jwt was refused (BACKLOG #1158) - #969

Merged
wshallwshall merged 3 commits into
main from
claude/oauth2-destbind-1158
Sep 8, 2026
Merged

test(auth): the OAuth2 destination-binding limb of ASVS 10.2.2, and why client_secret_jwt was refused (BACKLOG #1158) #969
wshallwshall merged 3 commits into
main from
claude/oauth2-destbind-1158

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

What this is

The OAuth2 client-credentials destination-binding limb of BACKLOG #1158 (ASVS 10.2.2), and only that limb. Not the issuer-to-endpoint coherence check, not RFC 9207, not the residual correction.

Outcome: CONCLUDED-AS-RESEARCH plus a small change. The gap is confirmed. The remedy the row implies -- a client_secret_jwt auth style -- was refused, because it does not close the gap, and the control that does close it already ships under another name. What landed is the instrument that pins the finding and the operator-facing text that makes the shipped control findable.

What was measured

The asymmetry reproduces, off the wire rather than off the source. Driving each provider against a recording opener: the SMART assertion carries aud equal to the pinned token endpoint plus exp and jti; both oauth2_auth_style values transmit the reusable client_secret and assert no audience and no lifetime.

All three of the row's anchors for this limb are stale, in the direction this repo expects -- the code moved, the quoted lines did not.

The row says At ebdfa44a6 that line is The real site
smart.py:160 a missing-client_id error smart.py:169 (self.audience = audience or token_url), consumed at :237
http_auth.py:184 a missing-client_secret error string http_auth.py:263-268
http_auth.py:240-242 inside access_token's cache write same as above

Row #1159 quotes smart.py:160 for a different claim, so one stale anchor is now doing two jobs.

Why client_secret_jwt was refused

It is the wrong parity. The SMART leg's property comes from the key being asymmetric -- the server holds only a public key. A symmetric assertion is verified by a server holding the same secret, so that server can mint one for any other audience. It would stop transmitting the secret without restoring the property the row's sentence is about, and landing it as the answer to that sentence is the compensating-control-on-a-false-premise shape the project rules name.

Its cost is also real. SignatureAlgorithm carries no HMAC member and no none, and transports/signing.py names that absence as why an RS256-to-HS256 confusion is inexpressible on the attacker-reachable verify_compact_jws path. Reaching client_secret_jwt means either widening that enum or standing up a second JWT minter beside the audited one.

And the correct control already ships, misnamed. with_smart_backend composes over a bare Rest() and puts a plain RFC 7523 section 2.2 private_key_jwt exchange on the wire -- form keys exactly grant_type, client_assertion_type, client_assertion, nothing FHIR- or SMART-specific. That is a discoverability defect, and it is what this PR fixes.

What would reverse the refusal: a named partner authorization server that requires client_secret_jwt, or a ruling that a second, non-audited JWT minter is acceptable beside signing.py.

What changed

  • messagefoundry/transports/http_auth.py -- docstring only, no executable code. with_oauth2_client_credentials now says what a shared secret does not buy and points at the asymmetric alternative.
  • docs/CONNECTIONS.md -- one paragraph in the SMART section: it is not only for SMART servers, and why to prefer it.
  • tests/test_oauth2_destination_binding.py -- new. Pins four of the five facts the finding rests on and points at the owner of the fifth.
  • docs/BACKLOG.md -- one amendment paragraph at the end of ## 1158., opening by naming the limb. No re-score and no closure -- the closing act is a scorecard re-score.

A sixth measurement, outside the limb

No per-connection auth mode reaches connections.toml at all. The loader hands the [settings] table to a keyword-only transport factory with a closed parameter list, so SMART, OAuth2-CC and Digest are each refused there -- loudly, which is the safe direction, but it means "reachable from both surfaces" is a bar none of the shipped modes clears. Pinned with a positive control.

Verification

Ran, in this worktree's .venv, Python 3.14:

  • ruff check . and ruff format --check messagefoundry tests -- clean (1036 files).
  • mypy messagefoundry (strict) -- clean, 268 source files. Also mypy on the new test module -- clean.
  • pytest on the new module plus test_http_auth.py, test_smart_backend.py, test_connections_file.py, test_compact_jws_verify.py, test_backlog_status_check.py, test_backlog_citation_check.py, test_doc_guards_lane.py, test_cp1252_console_safety.py -- 211 passed.
  • A wider slice, -k "auth or smart or oauth or rest or soap or fhir or connections or wiring or redact" -- 1898 passed, 59 skipped.
  • Doc-drift and ledger gates (test_asvs_file_surface_doc_drift, test_asvs_rescore_handoff, test_asvs_tally_lint, test_backlog_dependency_census, test_backlog_score_advisory, test_docs_cite_no_refused_config_keys, test_communications_inventory) -- 591 passed.
  • All local pre-commit hooks passed at commit time, including the ledger gate and the forbidden-content scan. Never --no-verify.

Red-first: six mutation arms, all red, each hash-checked so a mutant that failed to apply could not score as survived. The arms unbind the SMART audience, give the symmetric leg a destination claim, stop sending the secret in the Basic header, put a SMART-only field in the generic exchange, let the transport factory swallow unknown settings, and drop oauth2_auth_style from the composer.

Not run: the full bare pytest (including the web-console suite), and anything that only runs on a hosted runner.

Legs to read after this process exits

  • windows-service-smoke (NSSM) -- never visible to a Builder.
  • The backlog-hygiene required context. This PR cites BACKLOG #1158 and touches messagefoundry/, so the gate demands a same-PR docs/BACKLOG.md edit; it has one.
  • The full CI test matrix on both platforms, and the two mypy platform legs.

Collision with PR 942, exact files

PR 942 (packet F) touches messagefoundry/transports/http_auth.py and messagefoundry/transports/smart.py, plus tests/test_http_auth.py and tests/test_smart_backend.py. I read its diff line by line before editing.

The Manager's belief that the hunks would not conflict is confirmed. PR 942's hunks in both files are a bounded-read change: an import line near the top, and the body = resp.read() line inside _fetch_token. My only change to either file is the with_oauth2_client_credentials docstring at the bottom of http_auth.py (around line 498), roughly 220 lines below PR 942's lower hunk. I touch smart.py not at all. No overlapping hunk, and no shared test file -- my tests are a new module.

One forward-compatibility note: my fake response's read takes an optional amt, so it answers a bounded socket read and a bare one identically. It is correct whichever of the two PRs lands first.

Claim state, recorded rather than forced

claim.ps1 -Take 1158 was REFUSED. The row is held by .claude/worktrees/asvs-federation-brief-08716b (branch claude/asvs-federation-brief-08716b), whose note reads:

ROLE=builder. ASVS 10.2.2: advisory arm reporting an OIDC endpoint hosted apart from its issuer, on the oidc-auth-params seam.

That is a different limb on the same row and different files. The claim gate is keyed on the item number and cannot express a limb, so this is the gate working as designed rather than a real collision. I did not -Force it -- policy, and quiet is not dead. For the same reason the commit subject does not cite BACKLOG #1158: claim_check.py enforces on the subject line for code-touching diffs, and the body is permitted by design, which is where the citation is.

Three writers are amending ## 1158. (this branch, PR 943, and claude/oidc-coherence-1158). My amendment is one contiguous block that opens by naming its limb, so a rebase can tell them apart.

🤖 Generated with Claude Code

wshallwshall and others added 2 commits September 6, 2026 17:56
...nt_secret_jwt
The ASVS 10.2.2 row's destination-binding limb (BACKLOG #1158) is confirmed and
its implied remedy is declined, because that remedy does not close the gap.
Measured off the wire, not the source. Driving each provider against a recording
opener: the SMART assertion carries aud equal to the pinned token endpoint plus
exp and jti, while both oauth2_auth_style values transmit the reusable
client_secret and assert no audience and no lifetime. All three of the row's
anchors for this limb are stale -- the real sites are smart.py:169 and :237, and
http_auth.py:263-268.
client_secret_jwt is the wrong parity. The SMART leg's property comes from the
key being asymmetric; a symmetric assertion is verified by a server holding the
same secret, so that server can mint one for any other audience. It would stop
transmitting the secret without restoring the property. Its cost is also real:
SignatureAlgorithm carries no HMAC member, and transports/signing.py names that
absence as why an RS256-to-HS256 confusion is inexpressible on the
attacker-reachable verify_compact_jws path.
The correct control already ships, misnamed. with_smart_backend composes over a
bare Rest() and puts a plain RFC 7523 section 2.2 private_key_jwt exchange on the
wire, with nothing FHIR- or SMART-specific in it. That is a discoverability
defect, and it is what this change fixes: the composer docstring and
docs/CONNECTIONS.md now say so, and say what a shared secret does not buy.
tests/test_oauth2_destination_binding.py pins four of the five facts the finding
rests on and points at the owner of the fifth, since test_compact_jws_verify.py
already guards the enum. Six mutation arms, all red, each hash-checked so a
mutant that failed to apply could not score as survived. One more measurement
rides along: no per-connection auth mode
reaches connections.toml, because _build_spec calls a keyword-only transport
factory with a closed parameter list, so SMART, OAuth2-CC and Digest are each
refused there.
No re-score and no closure. The closing act is a scorecard re-score.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the unread Green and mergeable, but not marked read. Read it, then add: reviewed label Sep 7, 2026

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

@wshallwshall

This pull request is green and nobody has marked it read.

Every check that has reported is passing and the reviewed label is absent, so
a reviewer has read this is the only thing between it and a merge. Nothing else reports that, which is
why this comment exists (BACKLOG #1413).

To clear it, IN THIS ORDER:

  1. If the branch is behind main, update it FIRST: gh pr update-branch --repo MEFORORG/MessageFoundry 969.
    That push fires synchronize, and synchronize REMOVES the reviewed label. Labelling
    before updating throws the label away and costs a round trip.
  2. Read the diff.
  3. gh pr edit 969 --repo MEFORORG/MessageFoundry --add-label reviewed

DO NOT TRIAGE THIS FROM mergeStateStatus. It returns one value with precedence, so BEHIND,
DIRTY and UNSTABLE each mask the missing check -- the requirement is invisible until you act on
something else. This check never reads that field; see scripts/ci/check_unread_prs.py.

A seat can find every pull request in this state in one call: gh pr list --label unread.

Adding reviewed withdraws unread: the label event re-evaluates this pull request,
and so does the next completion of a watched workflow. Those are the two edges that clear it, so a
change neither of them reports -- converting to a draft, say -- can leave the label standing until one
of them next happens.

The reviewed label is a PROCESS gate. It records that a step happened. It does not
establish that an independent party looked, and nothing here should be read as saying it does.

# Conflicts:
#	docs/BACKLOG.md
wshallwshall added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit b0605f3 Sep 8, 2026
44 checks passed
wshallwshall deleted the claude/oauth2-destbind-1158 branch September 8, 2026 04:14
@github-actions github-actions Bot removed the unread Green and mergeable, but not marked read. Read it, then add: reviewed label Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /