Skip to content

feat(sdk): handle DPoP-Nonce challenges (DSPX-3397) - #993

Draft
dmihalcik-virtru wants to merge 1 commit into
dspx-3397-5-cli-dpop-keysfrom
dspx-3397-6-dpop-nonce
Draft

feat(sdk): handle DPoP-Nonce challenges (DSPX-3397)#993
dmihalcik-virtru wants to merge 1 commit into
dspx-3397-5-cli-dpop-keysfrom
dspx-3397-6-dpop-nonce

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Aug 10, 2026

Copy link
Copy Markdown
Member

Stack: 5 of 6 — base is #991. This is the headline PR: everything beneath it is prefactoring split out of draft #939.

What

RFC 9449 lets a server demand that DPoP proofs carry a server-issued nonce — an AS answers HTTP 400 + error=use_dpop_nonce (§8), an RS answers HTTP 401 + DPoP-Nonce + WWW-Authenticate: DPoP error="use_dpop_nonce" (§9). The SDK ignored both, so against a nonce-requiring Keycloak or KAS every DPoP request failed with no retry.

This adds an origin-keyed nonce cache and wires it through the five places that mint or forward a proof:

call site challenge shape
AccessToken.doPost (token endpoint) 400 + DPoP-Nonce
AccessToken.info (userinfo) 401 + DPoP-Nonce
access-fetch (legacy REST rewrap, KAS registry) 401 + DPoP-Nonce
authTokenDPoPInterceptor (Connect) Code.Unauthenticated
authProviderInterceptor (Connect) Code.Unauthenticated

Two shared helpers own the retry so those five stay one implementation: sendWithNonceRetry (fetch) and callWithNonceRetry (Connect). Retry is capped at one. A server that omits the nonce or repeats the one we just sent produces a single warning naming the call site, then the original error propagates.

Why the cache is threaded rather than global-only

Connect errors don't reliably surface response headers, so PlatformClient wraps the transport fetch to capture DPoP-Nonce off the raw response. That only works if the transport and the auth layer share one instance. Hence AuthProvider.nonceCache, threaded provider → interceptor → transport. defaultNonceCache is the shared fallback so custom/legacy providers keep working; pass a dedicated DPoPNonceCache for per-client isolation.

Decorators that wrap a provider must forward nonceCache. The CLI's logging wrapper now does — without it the two layers diverge silently and the retry never carries the nonce.

Drive-by fix

auth/providers.ts dropped dpopEnabled and signingKey when building the AccessToken in all three factories, so DPoP was unreachable through the provider API regardless of nonce support.

How to test

cd lib && npm test     # 412 mocha + 412 web-test-runner
cd cli && npm test     # 32

New suites: dpop-nonce, dpop-rewrap-nonce, dpop-rpc-nonce (mocha) and auth/dpop-nonce, access/access-fetch, interceptors (web).

tests/server.ts gains a strict RFC 9449 proof verifier — typ, alg, jwk shape, htm/htu, iat skew, jti replay, ath, cnf.jkt — and issues real nonce challenges from both the mock token endpoint and the KAS/policy RPC handlers. Strictness is deliberate: a proof-minting regression should fail locally rather than only at xtest time. It's a no-op for non-DPoP callers, so the existing Bearer tests are untouched.

The roundtrip CI job sets require_nonce: true and marks the Keycloak clients dpop.bound.access.tokens, so e2e exercises the challenge rather than plain proof-of-possession. xtest can't cover this: its nonce cases only run under the shared dpop-challenge input, which also swaps in a platform config the other SDKs aren't ready for.

Risk

Touches auth. The retry path is inert for non-DPoP providers and servers (they never emit a DPoP-Nonce), and the cache falls back to a shared default when a provider doesn't expose one, so legacy AuthProvider implementations are unaffected.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 72ef3d61-e086-4ccc-8cb4-d42c31120b04

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

X-Test Failure Report

opentdf-ctl
opentdf-sdk-lib

@dmihalcik-virtru
dmihalcik-virtru force-pushed the dspx-3397-5-cli-dpop-keys branch from e6accca to 435b290 Compare August 10, 2026 17:47
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

X-Test Failure Report

opentdf-ctl
opentdf-sdk-lib

RFC 9449 lets a server demand that DPoP proofs carry a server-issued nonce: an
authorization server answers with HTTP 400 + `error=use_dpop_nonce` (§8), a
resource server with HTTP 401 + `DPoP-Nonce` + `WWW-Authenticate: DPoP
error="use_dpop_nonce"` (§9). Both replies include the nonce to use. The SDK
ignored them, so against a nonce-requiring Keycloak or KAS every DPoP request
failed and no retry was attempted.

Adds an origin-keyed nonce cache (`src/auth/dpop-nonce.ts`) and wires it
through the five places that mint or forward a DPoP proof:

- `AccessToken.info` / `.doPost` (token + userinfo endpoints)
- `access-fetch` (legacy REST rewrap and KAS-registry list)
- `authTokenDPoPInterceptor` and `authProviderInterceptor` (Connect-RPC)

Each sends with the cached nonce, and on a challenge adopts the server's nonce
and retries exactly once. Two shared helpers -- `sendWithNonceRetry` (fetch) and
`callWithNonceRetry` (Connect) -- own that logic so the five call sites stay a
single implementation. Retries are capped at one; a server that omits the nonce
or repeats the one we just sent gets a single warning naming the call site, and
the original error propagates rather than looping.

Connect errors do not reliably surface response headers, so `PlatformClient`
wraps the transport's `fetch` to record `DPoP-Nonce` off the raw response.
That only works if the transport and the auth layer share one cache instance,
so the cache is exposed on `AuthProvider.nonceCache` and threaded from provider
to interceptor to transport. `defaultNonceCache` is the shared fallback for
custom providers; pass a dedicated `DPoPNonceCache` for per-client isolation.
Decorators that wrap a provider must forward `nonceCache` -- the CLI's logging
wrapper now does, and without it the two layers silently diverge and the retry
never carries the nonce.

Also fixes the three OIDC provider factories in `auth/providers.ts`, which
dropped `dpopEnabled` and `signingKey` when constructing the `AccessToken`. DPoP
was unreachable through the provider API regardless of nonce support.

Test coverage:
- `tests/server.ts` gains a strict RFC 9449 proof verifier (typ, alg, jwk
  shape, htm/htu, iat skew, jti replay, ath, cnf.jkt) and issues real nonce
  challenges from both the mock token endpoint and the KAS/policy RPC handlers.
  Strictness is the point: a regression in proof minting fails locally instead
  of only at xtest time.
- New suites cover the cache, the token-endpoint retry, the rewrap retry, and
  the RPC retry, in both mocha and web-test-runner.
- The roundtrip CI job sets `require_nonce: true` and marks the Keycloak
  clients `dpop.bound.access.tokens`, so the e2e path exercises the challenge
  rather than plain proof-of-possession.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru force-pushed the dspx-3397-5-cli-dpop-keys branch from 435b290 to e307560 Compare August 11, 2026 17:54
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