feat(oauth): DPoP binding enforcement on the JWT-bearer RS path (#118)#151
Merged
Conversation
PR #148 enforced the DPoP cnf.jkt binding resource-server-side only for opaque reference tokens (the introspection path). A DPoP-bound *JWT* was still honoured as a plain bearer token — the sender constraint was never checked. This closes that gap for the JwtBearer validation path, the JWT twin of ModgudIntrospectionHandler. Two composable hooks added in ModgudJwtBearerPostConfigure: - OnMessageReceived — a DPoP-bound token is presented under the `DPoP` auth scheme, which JwtBearer's `Bearer`-only extraction ignores. Lift the token out of the `DPoP` header so the JWT actually gets validated. Plain `Bearer` requests are untouched; the host's own handler is preserved. - OnTokenValidated — after validation, enforce the binding (RFC 9449 §7.1): a token carrying cnf.jkt MUST arrive under the DPoP scheme with a valid proof whose key thumbprint matches; a bound token presented as bearer, or the DPoP scheme against an unbound token, is rejected (401). Runs before UserInfo enrichment — no point enriching a request we're about to fail. The proof cryptography reuses the shared DpopResourceValidator / DpopProofValidator core (identical to the introspection path); only the JWT-specific glue is new. No change to the duplicated crypto files, so no keep-in-sync impact. Tests (Modgud.Tests.Unit): the pure decision core (EvaluateBinding) is pinned across every branch — unbound/bearer ok, unbound/DPoP rejected, bound/bearer rejected, bound/DPoP with valid/missing/wrong-url/wrong-ath/ wrong-key proof — plus ExtractDpopSchemeToken, TryGetBoundJkt, and the OnTokenValidated→Fail wiring exercised on a real TokenValidatedContext. Full DPoP unit suite green (73). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sixth DPoP slice for #118, and the RS-side twin of #148. #148 enforced the DPoP
cnf.jktbinding resource-server-side only for opaque reference tokens (the introspection path). A DPoP-bound JWT was still honoured as a plain bearer token — the sender constraint was never checked on the JwtBearer path. This closes that gap.What changed
Two composable hooks in
ModgudJwtBearerPostConfigure(both preserve any handler the host already set):OnMessageReceived— a DPoP-bound token is presented under theAuthorization: DPoP …scheme, which JwtBearer'sBearer-only extraction ignores.ExtractDpopSchemeTokenlifts the token out so the JWT actually gets validated. PlainBearerrequests are untouched.OnTokenValidated—EnforceBindingapplies RFC 9449 §7.1: a validated token carryingcnf.jktMUST arrive under the DPoP scheme with a valid proof whose key thumbprint matches (and whoseathbinds the token); a bound token presented as bearer, or the DPoP scheme against an unbound token, is failed → 401. Runs before UserInfo enrichment.The proof crypto reuses the shared
DpopResourceValidator/DpopProofValidatorcore — identical to the introspection path. Only the JWT-specific glue is new, so no change to the duplicated crypto files (no keep-in-sync impact).Tests
Modgud.Tests.Unit— the pure decision coreEvaluateBindingis pinned across every branch:Plus
ExtractDpopSchemeToken,TryGetBoundJkt(incl. non-jktcnflike mTLSx5t#S256, and malformedcnf), and theOnTokenValidated → Failwiring exercised on a realTokenValidatedContext. Full DPoP unit suite green (73).Consistent with #148, coverage is the exhaustively-tested pure core + the wiring translation; the
OnMessageReceived → ctx.Tokenlift is the standard JwtBearer custom-extraction pattern (same shape SignalR uses for itsaccess_tokenquery param).Remaining #118 DPoP slices
DPoP-Nonce · refresh-token binding for public clients. The
security-model.md"DPoP: Not supported" line gets updated in the final slice once the feature set is complete.🤖 Generated with Claude Code