Skip to content

feat(oauth): DPoP resource-server validation in the client library (#118)#148

Merged
windischb merged 1 commit into
developfrom
feat/dpop-rs-validation
Jul 19, 2026
Merged

feat(oauth): DPoP resource-server validation in the client library (#118)#148
windischb merged 1 commit into
developfrom
feat/dpop-rs-validation

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Part of #118. Third DPoP (RFC 9449) slice, on top of issuance (#147).

A resource server using Modgud.Client.AspNetCore's reference-token
(introspection) mode
now validates the DPoP binding end to end.

Changes

  • Crypto core duplicated into the client library (Dpop/JwkThumbprint,
    DpopProofValidator, DpopValidationResult) — verbatim, BCL-only, each
    with a keep-in-sync banner. Keeps this published NuGet dependency-light
    instead of referencing server internals (the agreed decision).
  • DpopResourceValidator — validates the request's DPoP proof
    against the HTTP method + URL + access-token hash (ath) and confirms
    the proof key's RFC 7638 thumbprint equals the token's cnf.jkt
    (RFC 9449 §7.2). Pure and stateless.
  • ModgudIntrospectionHandler now accepts the Authorization: DPoP
    scheme, surfaces cnf.jkt from the introspection response, and enforces
    the binding:
    • DPoP-bound token (cnf.jkt present) → must be presented with the
      DPoP scheme and a matching, valid proof;
    • bound token presented as plain Bearer → rejected;
    • DPoP scheme against an unbound token → rejected.

Notable finding

OpenIddict's stock introspection already echoes cnf for a DPoP-bound
reference token (pinned by an integration test), so no AS-side echo handler
is needed — the RS reads it straight from the standard RFC 7662 response.

Tests

7 client-lib unit tests (cnf surfacing; proof accept / no-proof / wrong-url
/ ath-mismatch / key-mismatch) + an AS integration test pinning the
introspection cnf echo. Full DPoP suite green (50 unit + 5 integration).

Follow-ups (this is 3 of ~4)

  • JWT-bearer-path RS validation (AddModgudClient / OnMessageReceived +
    OnTokenValidated) — reference-token path lands here; JWT path is next.
  • Discovery (dpop_signing_alg_values_supported) + nonce + refresh
    binding + per-client RequireDpop.
  • (Optional) opt-in RS-side jti replay cache.

🤖 Generated with Claude Code

)

Third DPoP slice (RFC 9449): a resource server using
Modgud.Client.AspNetCore's reference-token (introspection) mode now
validates the DPoP binding end to end. Builds on issuance (#147).

- The DPoP crypto core (JwkThumbprint + DpopProofValidator +
  DpopValidationResult) is duplicated verbatim into the client library
  (BCL-only), keeping this published NuGet dependency-light rather than
  referencing server internals. Each copy carries a keep-in-sync banner.
- DpopResourceValidator: validates the request's DPoP proof against the
  method + URL + access-token hash (ath) and confirms the proof key's
  thumbprint equals the token's cnf.jkt (RFC 9449 §7.2). Pure/stateless.
- ModgudIntrospectionHandler now accepts the `Authorization: DPoP` scheme,
  surfaces cnf.jkt out of the introspection response, and enforces the
  binding: a DPoP-bound token MUST be presented with the DPoP scheme + a
  matching proof; a bound token used as a plain bearer token is rejected,
  and a DPoP request against an unbound token is rejected.

Verified that OpenIddict's stock introspection already echoes `cnf` for a
DPoP-bound reference token (integration test), so no AS-side handler is
needed — the RS reads it straight from the standard response.

Tests: 7 client-lib unit tests (cnf surfacing + proof accept / no-proof /
wrong-url / ath-mismatch / key-mismatch) and an AS integration test
pinning the introspection cnf echo. Full DPoP suite green (50 unit + 5
integration).

Follow-ups: JWT-bearer-path RS validation (AddModgudClient); discovery
(dpop_signing_alg_values_supported) + nonce + refresh binding + per-client
RequireDpop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@windischb
windischb merged commit 5eb8117 into develop Jul 19, 2026
8 checks passed
@windischb
windischb deleted the feat/dpop-rs-validation branch July 19, 2026 04:38
windischb added a commit that referenced this pull request Jul 19, 2026
#151)

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>
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