-
Notifications
You must be signed in to change notification settings - Fork 1
test(auth): the OAuth2 destination-binding limb of ASVS 10.2.2, and why client_secret_jwt was refused (BACKLOG #1158) - #969
Conversation
...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>
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:
- If the branch is behind
main, update it FIRST:gh pr update-branch --repo MEFORORG/MessageFoundry 969.
That push firessynchronize, andsynchronizeREMOVES thereviewedlabel. Labelling
before updating throws the label away and costs a round trip. - Read the diff.
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
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_jwtauth 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
audequal to the pinned token endpoint plusexpandjti; bothoauth2_auth_stylevalues transmit the reusableclient_secretand 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.
ebdfa44a6that line issmart.py:160client_iderrorsmart.py:169(self.audience = audience or token_url), consumed at:237http_auth.py:184client_secreterror stringhttp_auth.py:263-268http_auth.py:240-242access_token's cache writeRow #1159 quotes
smart.py:160for a different claim, so one stale anchor is now doing two jobs.Why
client_secret_jwtwas refusedIt 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.
SignatureAlgorithmcarries no HMAC member and nonone, andtransports/signing.pynames that absence as why an RS256-to-HS256 confusion is inexpressible on the attacker-reachableverify_compact_jwspath. Reachingclient_secret_jwtmeans either widening that enum or standing up a second JWT minter beside the audited one.And the correct control already ships, misnamed.
with_smart_backendcomposes over a bareRest()and puts a plain RFC 7523 section 2.2private_key_jwtexchange on the wire -- form keys exactlygrant_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 besidesigning.py.What changed
messagefoundry/transports/http_auth.py-- docstring only, no executable code.with_oauth2_client_credentialsnow 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.tomlat 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 .andruff format --check messagefoundry tests-- clean (1036 files).mypy messagefoundry(strict) -- clean, 268 source files. Alsomypyon the new test module -- clean.pyteston the new module plustest_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.-k "auth or smart or oauth or rest or soap or fhir or connections or wiring or redact"-- 1898 passed, 59 skipped.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.--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_stylefrom 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.backlog-hygienerequired context. This PR citesBACKLOG #1158and touchesmessagefoundry/, so the gate demands a same-PRdocs/BACKLOG.mdedit; it has one.mypyplatform legs.Collision with PR 942, exact files
PR 942 (packet F) touches
messagefoundry/transports/http_auth.pyandmessagefoundry/transports/smart.py, plustests/test_http_auth.pyandtests/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 thewith_oauth2_client_credentialsdocstring at the bottom ofhttp_auth.py(around line 498), roughly 220 lines below PR 942's lower hunk. I touchsmart.pynot at all. No overlapping hunk, and no shared test file -- my tests are a new module.One forward-compatibility note: my fake response's
readtakes an optionalamt, 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 1158was REFUSED. The row is held by.claude/worktrees/asvs-federation-brief-08716b(branchclaude/asvs-federation-brief-08716b), whose note reads: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
-Forceit -- policy, and quiet is not dead. For the same reason the commit subject does not citeBACKLOG #1158:claim_check.pyenforces 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, andclaude/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