Skip to content

feat(oauth): DPoP binding enforcement on the JWT-bearer RS path (#118)#151

Merged
windischb merged 1 commit into
developfrom
feat/dpop-jwt-bearer-binding
Jul 19, 2026
Merged

feat(oauth): DPoP binding enforcement on the JWT-bearer RS path (#118)#151
windischb merged 1 commit into
developfrom
feat/dpop-jwt-bearer-binding

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Sixth DPoP slice for #118, and the RS-side twin of #148. #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 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 the Authorization: DPoP … scheme, which JwtBearer's Bearer-only extraction ignores. ExtractDpopSchemeToken lifts the token out so the JWT actually gets validated. Plain Bearer requests are untouched.
  • OnTokenValidatedEnforceBinding applies RFC 9449 §7.1: a validated token carrying cnf.jkt MUST arrive under the DPoP scheme with a valid proof whose key thumbprint matches (and whose ath binds 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 / DpopProofValidator core — 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 core EvaluateBinding is pinned across every branch:

token scheme proof → result
unbound Bearer Ok
unbound DPoP rejected (false assertion)
bound Bearer rejected (must use DPoP)
bound DPoP valid+matching Ok
bound DPoP missing / wrong-url / wrong-ath / wrong-key rejected

Plus ExtractDpopSchemeToken, TryGetBoundJkt (incl. non-jkt cnf like mTLS x5t#S256, and malformed cnf), and the OnTokenValidated → Fail wiring exercised on a real TokenValidatedContext. Full DPoP unit suite green (73).

Consistent with #148, coverage is the exhaustively-tested pure core + the wiring translation; the OnMessageReceived → ctx.Token lift is the standard JwtBearer custom-extraction pattern (same shape SignalR uses for its access_token query 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

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>
@windischb
windischb merged commit 8e1a41e into develop Jul 19, 2026
8 checks passed
@windischb
windischb deleted the feat/dpop-jwt-bearer-binding branch July 19, 2026 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant