From 83320a40e397021262712558fa1b5a57c44a9be7 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 10 Aug 2026 12:29:27 -0400 Subject: [PATCH] feat(sdk): handle DPoP-Nonce challenges (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../workflows/roundtrip/config-demo-idp.sh | 6 +- .../workflows/roundtrip/encrypt-decrypt.sh | 3 + .../workflows/roundtrip/keycloak_data.yaml | 4 +- .github/workflows/roundtrip/opentdf.yaml | 10 + cli/src/cli.ts | 6 + lib/src/access.ts | 40 ++- lib/src/access/access-fetch.ts | 127 ++++--- lib/src/access/access-rpc.ts | 18 +- lib/src/auth/auth.ts | 11 + lib/src/auth/dpop-nonce.ts | 280 +++++++++++++++ lib/src/auth/interceptors.ts | 104 ++++-- .../auth/oidc-clientcredentials-provider.ts | 10 + lib/src/auth/oidc-externaljwt-provider.ts | 10 + lib/src/auth/oidc-refreshtoken-provider.ts | 10 + lib/src/auth/oidc.ts | 163 ++++++--- lib/src/auth/providers.ts | 6 + lib/src/index.ts | 1 + lib/src/platform.ts | 39 ++- lib/tests/mocha/dpop-nonce.spec.ts | 138 ++++++++ lib/tests/mocha/dpop-rewrap-nonce.spec.ts | 95 +++++ lib/tests/mocha/dpop-rpc-nonce.spec.ts | 62 ++++ lib/tests/server.ts | 331 +++++++++++++++++- lib/tests/web/access/access-fetch.test.ts | 100 ++++++ lib/tests/web/auth/auth.test.ts | 104 ++++++ lib/tests/web/auth/dpop-nonce.test.ts | 207 +++++++++++ lib/tests/web/interceptors.test.ts | 107 +++++- 26 files changed, 1840 insertions(+), 152 deletions(-) create mode 100644 lib/src/auth/dpop-nonce.ts create mode 100644 lib/tests/mocha/dpop-nonce.spec.ts create mode 100644 lib/tests/mocha/dpop-rewrap-nonce.spec.ts create mode 100644 lib/tests/mocha/dpop-rpc-nonce.spec.ts create mode 100644 lib/tests/web/auth/dpop-nonce.test.ts diff --git a/.github/workflows/roundtrip/config-demo-idp.sh b/.github/workflows/roundtrip/config-demo-idp.sh index e9cf9383e..9c278b624 100755 --- a/.github/workflows/roundtrip/config-demo-idp.sh +++ b/.github/workflows/roundtrip/config-demo-idp.sh @@ -30,7 +30,8 @@ kcadm.sh create clients -r opentdf \ -s serviceAccountsEnabled=false \ -s publicClient=true \ -s protocol=openid-connect \ - -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' + -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' \ + -s 'attributes={"dpop.bound.access.tokens":"true"}' kcadm.sh create clients -r opentdf \ -s clientId=testclient \ @@ -38,7 +39,8 @@ kcadm.sh create clients -r opentdf \ -s enabled=true \ -s standardFlowEnabled=true \ -s serviceAccountsEnabled=true \ - -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' + -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' \ + -s 'attributes={"dpop.bound.access.tokens":"true"}' kcadm.sh create users -r opentdf -s username=user1 -s enabled=true -s firstName=Alice -s lastName=User kcadm.sh set-password -r opentdf --username user1 --new-password testuser123 diff --git a/.github/workflows/roundtrip/encrypt-decrypt.sh b/.github/workflows/roundtrip/encrypt-decrypt.sh index a57dc2bf8..e88106015 100755 --- a/.github/workflows/roundtrip/encrypt-decrypt.sh +++ b/.github/workflows/roundtrip/encrypt-decrypt.sh @@ -16,6 +16,7 @@ _tdf3_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample.txt.tdf \ encrypt "${plain}" \ --containerType tdf3 \ @@ -28,6 +29,7 @@ _tdf3_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample_out.txt \ --containerType tdf3 \ decrypt sample.txt.tdf @@ -50,6 +52,7 @@ _tdf3_inspect_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample-with-attrs.txt.tdf \ --attributes 'https://attr.io/attr/a/value/1,https://attr.io/attr/x/value/2' \ encrypt "${plain}" \ diff --git a/.github/workflows/roundtrip/keycloak_data.yaml b/.github/workflows/roundtrip/keycloak_data.yaml index 201a2b654..da410a4b8 100644 --- a/.github/workflows/roundtrip/keycloak_data.yaml +++ b/.github/workflows/roundtrip/keycloak_data.yaml @@ -42,9 +42,11 @@ realms: serviceAccountsEnabled: true clientAuthenticatorType: client-secret secret: secret + attributes: + dpop.bound.access.tokens: "true" protocolMappers: - *customAudMapper - sa_realm_roles: + sa_realm_roles: - opentdf-standard - client: clientID: tdf-entity-resolution diff --git a/.github/workflows/roundtrip/opentdf.yaml b/.github/workflows/roundtrip/opentdf.yaml index 0b402d2f6..abd6deb2e 100644 --- a/.github/workflows/roundtrip/opentdf.yaml +++ b/.github/workflows/roundtrip/opentdf.yaml @@ -54,6 +54,16 @@ server: public_client_id: 'opentdf-public' audience: 'http://localhost:65432' issuer: http://localhost:65432/auth/realms/opentdf + dpop: + # Make KAS answer the first DPoP-proofed request with 401 + DPoP-Nonce so + # the roundtrip actually walks the server-issued nonce retry, not just + # plain proof-of-possession. xtest can't cover this: its nonce cases only + # run when the shared `dpop-challenge` input is on, which also swaps in a + # platform config other SDKs aren't ready for. Left off, this PR's headline + # feature would ship with no CI coverage at all. + # Only `enforce` would reject bearer tokens outright; that stays off, so + # the non-DPoP paths in this job are unaffected. + require_nonce: true policy: ## Dot notation is used to access nested claims (i.e. realm_access.roles) # Claim that represents the user (i.e. email) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 0749f4c8b..8c1a6089a 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -96,6 +96,12 @@ async function processAuth( const requestLog: AuthProviders.HttpRequest[] = []; return { requestLog, + // Forward the wrapped provider's per-client DPoP-Nonce cache. Without this, + // the auth interceptor/transport fall back to the shared default cache while + // `withCreds` (delegated below) mints proofs from the wrapped provider's own + // cache — the two diverge and the DPoP-Nonce challenge retry never carries + // the server nonce (RFC 9449 §9). + nonceCache: actual.nonceCache, updateClientPublicKey: async (signingKey: KeyPair) => { actual.updateClientPublicKey(signingKey); log('DEBUG', `updateClientPublicKey: [${signingKey?.publicKey}]`); diff --git a/lib/src/access.ts b/lib/src/access.ts index 9de960bc5..41657063a 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -1,11 +1,7 @@ import { Code, ConnectError } from '@connectrpc/connect'; import { type AuthConfig, resolveAuthConfig } from './auth/interceptors.js'; import { RewrapResponse } from './platform/kas/kas_pb.js'; -import { - extractRpcErrorMessage, - getPlatformUrlFromKasEndpoint, - validateSecureUrl, -} from './utils.js'; +import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js'; import { base64 } from './encodings/index.js'; import { KEY_ALGORITHMS, @@ -52,13 +48,16 @@ export async function fetchWrappedKey( fulfillableObligationFQNs: string[] ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); - const { interceptors, authProvider } = resolveAuthConfig(auth); + const { authProvider } = resolveAuthConfig(auth); + // Pass the original AuthConfig (not just its interceptors) so the RPC layer can + // recover the provider's per-client DPoP nonce cache and keep the transport's + // nonce capture and the interceptor's retry on the same instance (RFC 9449 §9). const rpcCall = () => fetchWrappedKeysRpc( platformUrl, signedRequestToken, - { interceptors }, + auth, rewrapAdditionalContextHeader(fulfillableObligationFQNs) ); @@ -70,9 +69,9 @@ export async function fetchWrappedKey( // Try the modern Connect-RPC rewrap first, falling back to the legacy REST // rewrap only for non-auth failures (older, non-Connect platforms). A - // definitive KAS auth/validation answer (401/403/400) surfaces as-is via - // tryRpcThenLegacy rather than being masked by the legacy 404 on - // Connect-only platforms. + // definitive KAS auth/validation answer (401/403/400 — incl. a post-nonce- + // challenge 401, RFC 9449 §9) surfaces as-is via tryRpcThenLegacy rather than + // being masked by the legacy 404 on Connect-only platforms. // We intentionally omit the rewrap additional context from legacy requests: // platforms new enough to know about obligations handle RPC successfully. return await tryRpcThenLegacy( @@ -207,9 +206,11 @@ export async function fetchKeyAccessServers( platformUrl: string, auth: AuthConfig ): Promise { - const { interceptors, authProvider } = resolveAuthConfig(auth); + const { authProvider } = resolveAuthConfig(auth); - const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, { interceptors }); + // Pass the original AuthConfig so the RPC layer shares the provider's per-client + // DPoP nonce cache with the transport (see fetchWrappedKey). + const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, auth); if (!authProvider) { return await rpcCall(); @@ -248,7 +249,7 @@ export async function fetchKasPubKey( } catch (e) { // Base key is optional; fall back to the RPC/legacy public-key path. Log a // one-line summary via errBrief (never the raw error object, which for Connect - // errors can carry response metadata). + // errors can carry response metadata including DPoP nonces). console.log(`base key fetch failed, falling back to RPC/legacy public key: ${errBrief(e)}`); } @@ -327,10 +328,15 @@ async function tryRpcThenLegacy( /** * A log-safe one-line summary of an error: its message (and Connect code), never - * the whole error object — Connect errors can carry response headers and - * metadata that should not be dumped to the console. + * the whole error object — Connect errors can carry response headers/metadata + * (including DPoP nonces) that should not be dumped to logs on the auth path. */ function errBrief(e: unknown): string { - const message = extractRpcErrorMessage(e); - return e instanceof ConnectError ? `${Code[e.code]}: ${message}` : message; + if (e instanceof ConnectError) { + return `${Code[e.code]}: ${e.message}`; + } + if (e instanceof Error) { + return e.message; + } + return String(e); } diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index f25627e29..10f2dd68e 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -1,5 +1,6 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js'; -import { type AuthProvider } from '../auth/auth.js'; +import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; +import { defaultNonceCache, sendWithNonceRetry, toOrigin } from '../auth/dpop-nonce.js'; import { ConfigurationError, InvalidFileError, @@ -10,6 +11,53 @@ import { } from '../errors.js'; import { validateSecureUrl } from '../utils.js'; +/** fetch() options shared by the authenticated legacy requests. */ +type FetchInit = Omit; + +/** + * Signs `httpReq` via the AuthProvider, sends it, and handles a single + * DPoP-Nonce challenge (RFC 9449 §9): if a resource server rejects the request + * with a fresh `DPoP-Nonce` header, cache the nonce and retry once so + * `withCreds` can mint a proof carrying it. Non-DPoP providers and servers + * never emit a `DPoP-Nonce`, so they take the single-request path unchanged. + * + * The caller keeps ownership of status-code handling; this only owns transport + * and the nonce retry. + */ +async function fetchWithCredsAndNonceRetry( + authProvider: AuthProvider, + httpReq: HttpRequest, + init: FetchInit, + networkErrorMessage: string +): Promise { + const send = async (): Promise => { + const req = await authProvider.withCreds(httpReq); + try { + return await fetch(req.url, { + ...init, + method: req.method, + headers: req.headers, + body: req.body as BodyInit, + }); + } catch (e) { + throw new NetworkError(`${networkErrorMessage} [${req.url}]`, e); + } + }; + + // Non-absolute URLs have no origin; nonce caching is origin-keyed, so those pass through. + const origin = toOrigin(httpReq.url); + if (!origin) { + return send(); + } + + // Use the provider's per-client cache so the retry proof carries the nonce + // withCreds reads back (falls back to the shared default for custom providers). + // `send` re-signs through withCreds, which reads the refreshed nonce from that + // cache itself, so the nonce argument is unused here. + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + return sendWithNonceRetry(nonceCache, origin, 'legacy fetch', () => send()); +} + export type RewrapRequest = { signedRequestToken: string; }; @@ -33,53 +81,43 @@ export async function fetchWrappedKey( requestBody: RewrapRequest, authProvider: AuthProvider ): Promise { - const req = await authProvider.withCreds({ - url, - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const response = await fetchWithCredsAndNonceRetry( + authProvider, + { + url, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), }, - body: JSON.stringify(requestBody), - }); - - let response: Response; - - try { - response = await fetch(req.url, { - method: req.method, + { mode: 'cors', // no-cors, *cors, same-origin cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached credentials: 'same-origin', // include, *same-origin, omit - headers: req.headers, redirect: 'follow', // manual, *follow, error referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url - body: req.body as BodyInit, - }); - } catch (e) { - throw new NetworkError(`unable to fetch wrapped key from [${url}]`, e); - } + }, + 'unable to fetch wrapped key from' + ); if (!response.ok) { switch (response.status) { case 400: throw new InvalidFileError( - `400 for [${req.url}]: rewrap bad request [${await response.text()}]` + `400 for [${url}]: rewrap bad request [${await response.text()}]` ); case 401: - throw new UnauthenticatedError(`401 for [${req.url}]; rewrap auth failure`); + throw new UnauthenticatedError(`401 for [${url}]; rewrap auth failure`); case 403: - throw new PermissionDeniedError( - `403 for [${req.url}]; rewrap permission denied: forbidden` - ); + throw new PermissionDeniedError(`403 for [${url}]; rewrap permission denied: forbidden`); default: if (response.status >= 500) { throw new ServiceError( - `${response.status} for [${req.url}]: rewrap failure due to service error [${await response.text()}]` + `${response.status} for [${url}]: rewrap failure due to service error [${await response.text()}]` ); } - throw new NetworkError( - `${req.method} ${req.url} => ${response.status} ${response.statusText}` - ); + throw new NetworkError(`POST ${url} => ${response.status} ${response.statusText}`); } } @@ -93,32 +131,29 @@ export async function fetchKeyAccessServers( let nextOffset = 0; const allServers = []; do { - const req = await authProvider.withCreds({ - url: `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`, - method: 'GET', - headers: { - 'Content-Type': 'application/json', + const requestUrl = `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`; + const response = await fetchWithCredsAndNonceRetry( + authProvider, + { + url: requestUrl, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, }, - }); - let response: Response; - try { - response = await fetch(req.url, { - method: req.method, - headers: req.headers, - body: req.body as BodyInit, + { mode: 'cors', cache: 'no-cache', credentials: 'same-origin', redirect: 'follow', referrerPolicy: 'no-referrer', - }); - } catch (e) { - throw new NetworkError(`unable to fetch kas list from [${req.url}]`, e); - } + }, + 'unable to fetch kas list from' + ); // if we get an error from the kas registry, throw an error if (!response.ok) { throw new ServiceError( - `unable to fetch kas list from [${req.url}], status: ${response.status}` + `unable to fetch kas list from [${requestUrl}], status: ${response.status}` ); } const { keyAccessServers = [], pagination = {} } = await response.json(); diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index b99b76da6..0910833a5 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -7,6 +7,7 @@ import { } from '../access.js'; import { type AuthConfig, resolveInterceptors } from '../auth/interceptors.js'; +import { isAuthProvider } from '../auth/auth.js'; import { ConfigurationError, InvalidFileError, @@ -40,7 +41,14 @@ export async function fetchWrappedKey( rewrapAdditionalContextHeader?: string ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); - const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl }); + // Share the provider's per-client nonce cache so the transport's nonce capture + // and the auth interceptor's retry read the same instance (RFC 9449 §9). + const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined; + const platform = new PlatformClient({ + interceptors: resolveInterceptors(auth), + platformUrl, + nonceCache, + }); const options: CallOptions = {}; if (rewrapAdditionalContextHeader) { options.headers = { @@ -127,7 +135,13 @@ export async function fetchKeyAccessServers( ): Promise { let nextOffset = 0; const allServers = []; - const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl }); + // Share the provider's per-client nonce cache (see fetchWrappedKey above). + const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined; + const platform = new PlatformClient({ + interceptors: resolveInterceptors(auth), + platformUrl, + nonceCache, + }); do { let response: ListKeyAccessServersResponse; diff --git a/lib/src/auth/auth.ts b/lib/src/auth/auth.ts index 7405b3a40..838e9acfb 100644 --- a/lib/src/auth/auth.ts +++ b/lib/src/auth/auth.ts @@ -4,6 +4,7 @@ import { type PrivateKey, } from '../../tdf3/src/crypto/declarations.js'; import { signJwt, type JwtHeader, type JwtPayload } from '../../tdf3/src/crypto/jwt.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; export type HttpMethod = | 'GET' @@ -110,6 +111,16 @@ export type AuthProvider = { * @param httpReq - Required. An http request pre-populated with the data public key. */ withCreds(httpReq: HttpRequest): Promise; + + /** + * DPoP-Nonce cache (RFC 9449 §8), keyed by origin. Optional: consumers fall + * back to the shared `defaultNonceCache` when it is absent, so custom/legacy + * providers keep working. SDK providers expose the cache their own proofs read + * and write (the shared default unless a dedicated cache was injected for + * per-client isolation), so the auth interceptor and the transport read the + * same instance. Decorators that wrap a provider should forward this. + */ + nonceCache?: DPoPNonceCache; }; export function isAuthProvider(a?: unknown): a is AuthProvider { diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts new file mode 100644 index 000000000..41d2f2815 --- /dev/null +++ b/lib/src/auth/dpop-nonce.ts @@ -0,0 +1,280 @@ +/** + * DPoP-Nonce cache manager per RFC 9449 §8. + * Caches server-issued nonces by origin for use in subsequent DPoP proofs. + */ + +import { Code, ConnectError } from '@connectrpc/connect'; + +export class DPoPNonceCache { + private readonly cache = new Map(); + + /** + * Get cached nonce for an origin. + */ + get(origin: string): string | undefined { + return this.cache.get(origin); + } + + /** + * Store a nonce for an origin. + * Overwrites any existing nonce for that origin. + */ + set(origin: string, nonce: string): void { + this.cache.set(origin, nonce); + } + + /** + * Clear all cached nonces. Useful for test teardown. + */ + clearAll(): void { + this.cache.clear(); + } + + /** + * Extract DPoP-Nonce from response headers (case-insensitive). + */ + static extractNonce(headers?: Headers): string | undefined { + return typeof headers?.get === 'function' ? headers.get('dpop-nonce') || undefined : undefined; + } +} + +/** + * A `DPoP-Nonce` header source: a raw `Response`'s headers or a Connect error's + * metadata (both are `Headers`, whose `get` is case-insensitive). + */ +type NonceHeaders = Headers | undefined; + +/** The origin of an absolute URL, or `undefined` when it is relative/unparseable. */ +export function toOrigin(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +/** + * Adopt `challenge` as this origin's nonce when it is present and differs from + * the one we just sent (`sentNonce`), recording it in `cache`. Returns the fresh + * nonce, or `undefined` when the caller should NOT retry (no nonce, or it matches + * what we already used). RFC 9449 §9. + */ +function adoptIfFresh( + cache: DPoPNonceCache, + origin: string, + challenge: string | undefined, + sentNonce: string | undefined +): string | undefined { + if (challenge && challenge !== sentNonce) { + cache.set(origin, challenge); + return challenge; + } + return undefined; +} + +/** + * Given a response's headers, return a *fresh* challenge nonce that differs from + * the one we just sent (`sentNonce`), recording it in `cache`. Returns + * `undefined` when there is no nonce or it matches what we already used — i.e. + * when the caller should NOT retry. RFC 9449 §9. + */ +export function adoptChallengeNonce( + cache: DPoPNonceCache, + origin: string, + headers: NonceHeaders, + sentNonce: string | undefined +): string | undefined { + return adoptIfFresh(cache, origin, DPoPNonceCache.extractNonce(headers), sentNonce); +} + +/** + * Connect-error variant of {@link adoptChallengeNonce}. The transport `fetch` + * wrapper usually records the nonce off the raw 401, but Connect errors don't + * reliably surface response headers, so we also consult the cache and the error + * metadata. Returns a fresh nonce to retry with, or `undefined`. + */ +export function adoptChallengeNonceFromConnectError( + cache: DPoPNonceCache, + origin: string, + metadata: NonceHeaders, + sentNonce: string | undefined +): string | undefined { + const metadataNonce = DPoPNonceCache.extractNonce(metadata); + const cachedNonce = cache.get(origin); + // Prefer metadata when it carries a nonce different from the one sent. The + // cache can still contain that stale sent nonce when a custom Connect + // transport exposes response metadata but does not capture raw headers. + const challenge = metadataNonce && metadataNonce !== sentNonce ? metadataNonce : cachedNonce; + return adoptIfFresh(cache, origin, challenge, sentNonce); +} + +/** + * Warm the cache from a response's `DPoP-Nonce` header (RFC 9449 §8). No-op when + * the response carries no nonce. + */ +export function warmNonceFromResponse( + cache: DPoPNonceCache, + origin: string, + headers: NonceHeaders +): void { + adoptIfFresh(cache, origin, DPoPNonceCache.extractNonce(headers), undefined); +} + +/** Why no fresh nonce could be adopted from a genuine DPoP-Nonce challenge. */ +function nonceRetryGiveUpReason( + challenge: string | undefined, + sentNonce: string | undefined +): string { + if (!challenge) { + return 'server omitted the DPoP-Nonce'; + } + if (challenge === sentNonce) { + return 'server repeated the already-used DPoP-Nonce'; + } + return 'DPoP-Nonce could not be adopted'; +} + +/** + * Emit ONE concise warning when a genuine DPoP-Nonce challenge was detected but + * no fresh nonce could be adopted (the server omitted it, or repeated the one we + * already sent), so the retry is skipped and the original error propagates. Call + * only after confirming a challenge was present — otherwise an ordinary 401 would + * spam a misleading warning. RFC 9449 §9. + */ +export function warnNonceRetryGiveUp( + context: string, + origin: string, + challenge: string | undefined, + sentNonce: string | undefined +): void { + const reason = nonceRetryGiveUpReason(challenge, sentNonce); + console.warn(`DPoP nonce retry skipped (${context}, ${origin}): ${reason}`); +} + +/** + * Run `send`, and on a DPoP-Nonce challenge run it once more with the nonce the + * server handed back (RFC 9449 §8/§9). The cached nonce for `origin` is passed + * to `send` so it can mint a proof carrying it — callers that re-sign through an + * `AuthProvider` (which reads the cache itself) may ignore the argument, since + * the fresh nonce is written to `cache` before the retry. + * + * Whichever response we end on warms the cache. Non-DPoP servers never emit a + * `DPoP-Nonce`, so they take the single-request path unchanged. + * + * @param context short label naming the call site, used in the give-up warning + */ +export async function sendWithNonceRetry( + cache: DPoPNonceCache, + origin: string, + context: string, + send: (nonce: string | undefined) => Promise +): Promise { + const sentNonce = cache.get(origin); + let response = await send(sentNonce); + + if (!response.ok) { + const challenge = DPoPNonceCache.extractNonce(response.headers); + const freshNonce = adoptChallengeNonce(cache, origin, response.headers, sentNonce); + if (freshNonce) { + response = await send(freshNonce); + } else if (challenge) { + // A DPoP-Nonce was offered but is stale/unusable, so the retry is skipped; + // note it (only when a nonce was actually present — never on a plain 401). + warnNonceRetryGiveUp(context, origin, challenge, sentNonce); + } + } + + warmNonceFromResponse(cache, origin, response.headers); + return response; +} + +/** The part of a Connect response this module needs: its response headers. */ +type HeaderBearing = { header: Headers }; + +/** + * Connect-RPC counterpart of {@link sendWithNonceRetry}. A DPoP resource server + * rejects a proof minted without (or with a stale) nonce by returning + * `Unauthenticated` with a fresh `DPoP-Nonce`; re-run `call` once with that nonce + * (RFC 9449 §9). Any other error propagates untouched. + * + * Connect errors don't reliably surface response headers, so the nonce is read + * from the cache — the transport's fetch wrapper records it off the raw 401 (see + * {@link captureNonce}) — with the error metadata as a fallback. + * + * @param context short label naming the call site, used in the give-up warning + */ +export async function callWithNonceRetry( + cache: DPoPNonceCache, + origin: string, + context: string, + call: (nonce: string | undefined) => Promise +): Promise { + const sentNonce = cache.get(origin); + try { + const response = await call(sentNonce); + warmNonceFromResponse(cache, origin, response.header); + return response; + } catch (err) { + if (err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = adoptChallengeNonceFromConnectError( + cache, + origin, + err.metadata, + sentNonce + ); + if (serverNonce) { + const retryResponse = await call(serverNonce); + warmNonceFromResponse(cache, origin, retryResponse.header); + return retryResponse; + } + // A nonce challenge we can't act on (server omitted/repeated the nonce): + // surface why the retry was skipped before the original error propagates. + warnNonceRetryGiveUp( + context, + origin, + cache.get(origin) ?? DPoPNonceCache.extractNonce(err.metadata), + sentNonce + ); + } + throw err; + } +} + +/** + * Shared, process-wide nonce cache — the default for every DPoP path (the + * `AccessToken` cache, the auth interceptor, the Connect transport, and the + * legacy fetch retry) unless a dedicated cache is injected. Keeping one default + * instance means those layers stay consistent even when a provider is wrapped by + * a decorator that doesn't forward `nonceCache`. For per-client isolation, pass a + * dedicated {@link DPoPNonceCache} to the `AccessToken` constructor, + * `PlatformClientOptions.nonceCache`, or `DPoPInterceptorOptions.nonceCache`. + */ +export const defaultNonceCache = new DPoPNonceCache(); + +/** + * Record a `DPoP-Nonce` response header into `cache`, keyed by the request's origin. + * + * This works directly off the raw `Response`, so it captures the nonce even when + * a transport (e.g. Connect-RPC) does not surface response headers on its error + * type. Some resource servers (KAS) reject a proof minted without a nonce with a + * raw HTTP 401 carrying `DPoP-Nonce` + `WWW-Authenticate: DPoP error="use_dpop_nonce"` + * (RFC 9449 §9); capturing here lets the auth layer mint a nonce-bearing proof on + * retry. + */ +export function captureNonce(cache: DPoPNonceCache, requestUrl: string, headers?: Headers): void { + const nonce = DPoPNonceCache.extractNonce(headers); + if (!nonce) { + return; + } + const origin = toOrigin(requestUrl); + if (origin) { + cache.set(origin, nonce); + } else { + // The cache is origin-keyed, so a relative request URL can't be stored — and + // since this is the only place a Connect-RPC nonce challenge is captured, that + // silently disables the retry. Surface it rather than dropping it quietly. + console.warn( + `DPoP-Nonce present but request URL is not absolute (${requestUrl}); cannot cache nonce, retry disabled.` + ); + } +} diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c0a0f7971..9fe8cf7ae 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -5,6 +5,7 @@ import * as DefaultCryptoService from '../../tdf3/src/crypto/index.js'; import DPoP from './dpop.js'; import { type AuthProvider } from './auth.js'; import { base64 } from '../encodings/index.js'; +import { callWithNonceRetry, DPoPNonceCache, defaultNonceCache, toOrigin } from './dpop-nonce.js'; /** * A function that returns a valid access token string. @@ -22,6 +23,12 @@ export type DPoPInterceptorOptions = { dpopKeys?: KeyPair | Promise; /** CryptoService for signing. Defaults to DefaultCryptoService. */ cryptoService?: CryptoService; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8). Defaults to the shared + * {@link defaultNonceCache}; pass the same instance to `PlatformClient` for + * strict per-client isolation on the interceptor-only path. + */ + nonceCache?: DPoPNonceCache; }; /** @@ -78,6 +85,7 @@ export function authTokenInterceptor(tokenProvider: TokenProvider): Interceptor */ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPInterceptor { const cryptoService = options.cryptoService ?? DefaultCryptoService; + const nonceCache = options.nonceCache ?? defaultNonceCache; const dpopKeysPromise: Promise = options.dpopKeys ? Promise.resolve(options.dpopKeys) : cryptoService.generateSigningKeyPair(); @@ -86,19 +94,22 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI const [token, keys] = await Promise.all([options.tokenProvider(), dpopKeysPromise]); const url = new URL(req.url); - const httpUri = `${url.origin}${url.pathname}`; - - // Generate DPoP proof JWT for this request - const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST'); + const origin = url.origin; + const httpUri = `${origin}${url.pathname}`; // Export public key PEM for X-VirtruPubKey header const publicKeyPem = await cryptoService.exportPublicKeyPem(keys.publicKey); - req.header.set('Authorization', `Bearer ${token}`); - req.header.set('DPoP', dpopProof); + req.header.set('Authorization', `DPoP ${token}`); + // TODO: rename to X-OpenTDF-PubKey (coordinate with platform Keycloak mapper; see oidc.ts doPost) req.header.set('X-VirtruPubKey', base64.encode(publicKeyPem)); - return next(req); + // Mint the proof around whichever nonce applies — the cached one normally, + // the server's on a DPoP-Nonce challenge retry. + return callWithNonceRetry(nonceCache, origin, 'rpc interceptor', async (nonce) => { + req.header.set('DPoP', await DPoP(keys, cryptoService, httpUri, 'POST', nonce, token)); + return next(req); + }); }; // Attach dpopKeys to the interceptor function @@ -121,38 +132,61 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI */ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor { return (next) => async (req) => { - const url = new URL(req.url); - const pathOnly = url.pathname; - // Signs only the path of the url in the request - let token; - try { - token = await authProvider.withCreds({ - url: pathOnly, - method: 'POST', - // Start with any headers Connect already has - headers: { - ...Object.fromEntries(req.header.entries()), - 'Content-Type': 'application/json', - }, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { - throw new Error( - 'PlatformClient: DPoP key binding is not complete. ' + - 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + - '`await client.ready` before constructing PlatformClient. ' + - `Original error: ${msg}` - ); + // Pass the full request URL to withCreds. DPoP-enabled providers need the + // absolute URL to compute the proof's `htu` claim and the origin for the + // nonce cache; `new URL()` on a bare path throws "Invalid URL". Non-DPoP + // providers ignore the URL (they only add a Bearer header), so this stays + // backwards-compatible with legacy AuthProviders. + + // Re-sign the request via withCreds and apply the resulting headers. Called + // once normally, and again on a DPoP-Nonce challenge so the provider mints a + // fresh proof carrying the server-issued nonce (read from nonceCache). + const sign = async (): Promise => { + let token; + try { + token = await authProvider.withCreds({ + url: req.url, + method: 'POST', + // Start with any headers Connect already has + headers: { + ...Object.fromEntries(req.header.entries()), + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { + throw new Error( + 'PlatformClient: DPoP key binding is not complete. ' + + 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + + '`await client.ready` before constructing PlatformClient. ' + + `Original error: ${msg}` + ); + } + throw err; } - throw err; + + Object.entries(token.headers).forEach(([key, value]) => { + req.header.set(key, value); + }); + }; + + // Non-absolute URLs have no origin; nonce caching is origin-keyed, so those pass through. + const origin = toOrigin(req.url); + if (!origin) { + await sign(); + return next(req); } - Object.entries(token.headers).forEach(([key, value]) => { - req.header.set(key, value); + // Share the provider's per-client cache so the nonce withCreds embeds and the + // one we read back on a 401 are the same instance (falls back to the shared + // default for custom providers that don't expose one). `sign` re-reads the + // nonce from that cache itself, so the nonce argument is unused here. + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + return callWithNonceRetry(nonceCache, origin, 'auth interceptor', async () => { + await sign(); + return next(req); }); - - return await next(req); }; } diff --git a/lib/src/auth/oidc-clientcredentials-provider.ts b/lib/src/auth/oidc-clientcredentials-provider.ts index 8d3629bae..d14a57b2b 100644 --- a/lib/src/auth/oidc-clientcredentials-provider.ts +++ b/lib/src/auth/oidc-clientcredentials-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ClientSecretCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -14,6 +15,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -29,6 +32,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -41,4 +46,9 @@ export class OIDCClientCredentialsProvider implements AuthProvider { async withCreds(httpReq: HttpRequest): Promise { return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-externaljwt-provider.ts b/lib/src/auth/oidc-externaljwt-provider.ts index 2a7266882..0a706e888 100644 --- a/lib/src/auth/oidc-externaljwt-provider.ts +++ b/lib/src/auth/oidc-externaljwt-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ExternalJwtCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -15,6 +16,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -30,6 +33,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -50,4 +55,9 @@ export class OIDCExternalJwtProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-refreshtoken-provider.ts b/lib/src/auth/oidc-refreshtoken-provider.ts index 9f7bac2d9..42391ebb9 100644 --- a/lib/src/auth/oidc-refreshtoken-provider.ts +++ b/lib/src/auth/oidc-refreshtoken-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type RefreshTokenCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -29,6 +30,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -44,6 +47,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -64,4 +69,9 @@ export class OIDCRefreshTokenProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index b842890e6..20a504574 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,6 +5,7 @@ import { base64 } from '../encodings/index.js'; import { ConfigurationError, TdfError } from '../errors.js'; import { rstrip } from '../utils.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; +import { defaultNonceCache, DPoPNonceCache, sendWithNonceRetry } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -19,7 +20,12 @@ export type CommonCredentials = { /** Whether or not DPoP is enabled. */ dpopEnabled?: boolean; - /** the client's public key, base64 encoded. Will be bound to the OIDC token. Deprecated. If not set in the constructor, */ + /** + * The client's DPoP/signing key pair, bound to the issued OIDC token (as + * `cnf.jkt`) when DPoP is enabled. May be supplied here or bound later via + * `updateClientPublicKey`, which forces a token refresh so the new key takes + * effect. + */ signingKey?: KeyPair; }; @@ -109,7 +115,21 @@ export class AccessToken { cryptoService: CryptoService; - constructor(cfg: OIDCCredentials, cryptoService: CryptoService, request?: typeof fetch) { + /** + * DPoP-Nonce cache (RFC 9449 §8). Defaults to the shared {@link defaultNonceCache} + * so the interceptor, transport, and `withCreds` stay consistent even when a + * provider is wrapped by a decorator that doesn't forward `nonceCache`. Pass a + * dedicated {@link DPoPNonceCache} to the constructor for per-client isolation; + * it is exposed on providers via `nonceCache` so the auth layer reads the same instance. + */ + readonly nonceCache: DPoPNonceCache; + + constructor( + cfg: OIDCCredentials, + cryptoService: CryptoService, + request?: typeof fetch, + nonceCache: DPoPNonceCache = defaultNonceCache + ) { if (!cfg.clientId) { throw new ConfigurationError( 'A Keycloak client identifier is currently required for all auth mechanisms' @@ -137,6 +157,28 @@ export class AccessToken { this.userInfoEndpoint = cfg.oidcUserInfoEndpoint || `${this.baseUrl}/protocol/openid-connect/userinfo`; this.signingKey = cfg.signingKey; + this.nonceCache = nonceCache; + } + + /** + * Returns the configured DPoP signing key, throwing if DPoP is enabled but no + * key has been bound yet. Call only from DPoP-enabled paths. + * + * Validation is intentionally at request time rather than construction so the + * deferred-binding flow keeps working: a client may construct with DPoP + * enabled and bind the key later via + * {@link refreshTokenClaimsWithClientPubkeyIfNeeded} (e.g. `opentdf.ts` + * `ready`), which happens before the first request. All request paths + * (`info`, `doPost`, `withCreds`) fail here consistently rather than one + * silently downgrading to a Bearer token. + */ + private requireSigningKey(): KeyPair { + if (!this.signingKey) { + throw new ConfigurationError( + 'Client public key was not set via `updateClientPublicKey` or passed in via constructor; required when DPoP is enabled' + ); + } + return this.signingKey; } /** @@ -145,21 +187,33 @@ export class AccessToken { * @returns */ async info(accessToken: string): Promise { + const origin = new URL(this.userInfoEndpoint).origin; const headers = { ...this.extraHeaders, - Authorization: `Bearer ${accessToken}`, } as Record; - if (this.config.dpopEnabled && this.signingKey) { - headers.DPoP = await dpopFn( - this.signingKey, - this.cryptoService, - this.userInfoEndpoint, - 'POST' - ); - } - const response = await (this.request || fetch)(this.userInfoEndpoint, { - headers, - }); + // Resolve the DPoP signing key up front (throws if DPoP is enabled but no + // key has been bound); undefined when DPoP is disabled. No silent Bearer + // downgrade — a misconfigured DPoP client fails consistently with doPost/withCreds. + const signingKey = this.config.dpopEnabled ? this.requireSigningKey() : undefined; + headers.Authorization = signingKey ? `DPoP ${accessToken}` : `Bearer ${accessToken}`; + const get = () => (this.request || fetch)(this.userInfoEndpoint, { headers }); + + // On a DPoP-Nonce challenge, re-mint the proof with the server-supplied nonce + // and retry once (RFC 9449 §9). Non-DPoP requests take the plain path. + const response = signingKey + ? await sendWithNonceRetry(this.nonceCache, origin, 'userinfo', async (nonce) => { + headers.DPoP = await dpopFn( + signingKey, + this.cryptoService, + this.userInfoEndpoint, + 'GET', + nonce, + accessToken + ); + return get(); + }) + : await get(); + if (!response.ok) { console.error(await response.text()); throw new TdfError( @@ -171,26 +225,39 @@ export class AccessToken { } async doPost(url: string, o: Record) { + const origin = new URL(url).origin; const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }; - // add DPoP headers if configured - if (this.config.dpopEnabled) { - if (!this.signingKey) { - throw new ConfigurationError('No signature configured'); - } + // add DPoP headers if configured. Resolve the signing key up front (throws + // if DPoP is enabled but no key has been bound); undefined when disabled. + const signingKey = this.config.dpopEnabled ? this.requireSigningKey() : undefined; + if (signingKey) { // Export opaque public key to PEM format for header - const publicKeyPem = await this.cryptoService.exportPublicKeyPem(this.signingKey.publicKey); + const publicKeyPem = await this.cryptoService.exportPublicKeyPem(signingKey.publicKey); // TODO: Rename to X-OpenTDF-PubKey; requires coordinated change with // platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`). headers['X-VirtruPubKey'] = base64.encode(publicKeyPem); - headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST'); } - return (this.request || fetch)(url, { - method: 'POST', - headers, - body: qstringify(o), + + const post = () => + (this.request || fetch)(url, { + method: 'POST', + headers, + body: qstringify(o), + }); + + if (!signingKey) { + return post(); + } + + // Handle DPoP-Nonce challenge. RFC 9449 §8: authorization servers return + // HTTP 400 with error=use_dpop_nonce; §9: resource servers return 401. + // Either way the retry re-mints the proof around the server-supplied nonce. + return sendWithNonceRetry(this.nonceCache, origin, 'token endpoint', async (nonce) => { + headers.DPoP = await dpopFn(signingKey, this.cryptoService, url, 'POST', nonce); + return post(); }); } @@ -279,11 +346,13 @@ export class AccessToken { } /** - * A TDF client MUST call this method whenever the client wants to use a new - * ephemeral key set. This updates the keys used to: - * or wishes to set the keypair after creating the object. + * A TDF client MUST call this method whenever it wants to bind a new ephemeral + * signing key (e.g. when setting the keypair after constructing the object). * - * Calling this function will trigger a forcible token refresh using the cached refresh token, and contact the auth server. + * It records the new signing key and, when DPoP is enabled, invalidates the + * cached token so the next `get()` obtains a token bound to the new key. It is + * a no-op when the key is unchanged and a token is already cached; it does not + * itself contact the auth server. */ async refreshTokenClaimsWithClientPubkeyIfNeeded(signingKey: KeyPair): Promise { // If we already have a token, and the pubkey is unchanged, @@ -292,10 +361,15 @@ export class AccessToken { if (this.data?.access_token && signingKey === this.signingKey) { return; } - delete this.data; - delete this.cachedExpiry; - delete this.inFlight; this.signingKey = signingKey; + // A DPoP-bound token (cnf.jkt) is tied to a specific key, so rotating the + // signing key invalidates any cached token. Non-DPoP tokens are key- + // independent and can stay cached across a key change. + if (this.config.dpopEnabled) { + delete this.data; + delete this.cachedExpiry; + delete this.inFlight; + } } /** @@ -326,23 +400,28 @@ export class AccessToken { } async withCreds(httpReq: HttpRequest): Promise { - if (this.config.dpopEnabled && !this.signingKey) { - throw new ConfigurationError( - 'Client public key was not set via `updateClientPublicKey` or passed in via constructor; required when DPoP is enabled' - ); - } + // Resolve the DPoP signing key up front (throws if DPoP is enabled but no + // key has been bound); undefined when DPoP is disabled. + const signingKey = this.config.dpopEnabled ? this.requireSigningKey() : undefined; const accessToken = await this.get(); - if (this.config.dpopEnabled && this.signingKey) { + if (signingKey) { + const url = new URL(httpReq.url); + const origin = url.origin; + // RFC 9449 §4.2: the `htu` claim is the request URI without query and + // fragment. Resource servers (and the mock) recompute and compare it, so + // a proof carrying the query string is rejected. + const htu = `${origin}${url.pathname}`; + const cachedNonce = this.nonceCache.get(origin); const dpopToken = await dpopFn( - this.signingKey, + signingKey, this.cryptoService, - httpReq.url, + htu, httpReq.method, - /* nonce */ undefined, + cachedNonce, accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? - return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + return withHeaders(httpReq, { Authorization: `DPoP ${accessToken}`, DPoP: dpopToken }); } return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}` }); } diff --git a/lib/src/auth/providers.ts b/lib/src/auth/providers.ts index d5893f609..b36d3134a 100644 --- a/lib/src/auth/providers.ts +++ b/lib/src/auth/providers.ts @@ -42,6 +42,8 @@ export const clientSecretAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -74,6 +76,8 @@ export const externalAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -104,6 +108,8 @@ export const refreshAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); diff --git a/lib/src/index.ts b/lib/src/index.ts index d0cf60edf..df4fc4f45 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,6 +10,7 @@ export { type Interceptor, type TokenProvider, } from './auth/interceptors.js'; +export { DPoPNonceCache, sendWithNonceRetry } from './auth/dpop-nonce.js'; export { clientCredentialsTokenProvider, refreshTokenProvider, diff --git a/lib/src/platform.ts b/lib/src/platform.ts index fdff544eb..01caddd4a 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -5,6 +5,25 @@ export * as platformConnect from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-web'; import type { AuthProvider } from '../tdf3/index.js'; import { authProviderInterceptor } from './auth/interceptors.js'; +import { captureNonce, DPoPNonceCache, defaultNonceCache } from './auth/dpop-nonce.js'; + +/** + * Build a `fetch` wrapper that records any `DPoP-Nonce` response header into + * `nonceCache` before handing the response back to the Connect transport. The + * Connect error type does not reliably surface response headers, so capturing at + * the transport layer is what lets the DPoP auth interceptors mint a + * nonce-bearing proof and retry a rewrap challenged per RFC 9449 §9. `nonceCache` + * must be the same instance the auth interceptor reads. + */ +function makeNonceCapturingFetch(nonceCache: DPoPNonceCache): typeof globalThis.fetch { + return async (input, init) => { + const response = await fetch(input, init); + const requestUrl = + typeof input === 'string' || input instanceof URL ? input.toString() : input.url; + captureNonce(nonceCache, requestUrl, response.headers); + return response; + }; +} import { Client, createClient, Interceptor } from '@connectrpc/connect'; import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js'; @@ -54,6 +73,13 @@ export interface PlatformClientOptions { interceptors?: Interceptor[]; /** Base URL of the platform API. */ platformUrl: string; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8) for the transport's nonce capture. + * When an `authProvider` is supplied its own `nonceCache` is used; otherwise + * pass the same instance given to `authTokenDPoPInterceptor` for the + * interceptor-only path. Defaults to the shared {@link defaultNonceCache}. + */ + nonceCache?: DPoPNonceCache; } /** @@ -86,19 +112,28 @@ export class PlatformClient { readonly v2: PlatformServicesV2; constructor(options: PlatformClientOptions) { + // The deprecated `authProvider` option is still supported: both the auth interceptor and + // the transport's nonce cache derive from it. Read it once here rather than at each use. + const { authProvider } = options; // NOSONAR - deliberate back-compat read of a deprecated option + const interceptors: Interceptor[] = []; - if (options.authProvider) { - interceptors.push(authProviderInterceptor(options.authProvider)); + if (authProvider) { + interceptors.push(authProviderInterceptor(authProvider)); } if (options.interceptors?.length) { interceptors.push(...options.interceptors); } + // Capture nonces into the same cache the auth interceptor reads: the auth + // provider's own cache when present, else the caller-supplied/default one. + const nonceCache = authProvider?.nonceCache ?? options.nonceCache ?? defaultNonceCache; + const transport = createConnectTransport({ baseUrl: options.platformUrl, interceptors, + fetch: makeNonceCapturingFetch(nonceCache), }); this.v1 = { diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts new file mode 100644 index 000000000..833380c3e --- /dev/null +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -0,0 +1,138 @@ +import { expect } from 'chai'; +import { AccessToken } from '../../src/auth/oidc.js'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { defaultNonceCache } from '../../src/auth/dpop-nonce.js'; +import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by server.ts /protocol/openid-connect/token endpoint +const SERVER_NONCE = 'dpop-test-nonce-abc'; + +describe('DPoP nonce challenge — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + // AccessToken/providers default to the shared defaultNonceCache; clear it + // between tests so a cached nonce doesn't leak across cases. + afterEach(() => { + defaultNonceCache.clearAll(); + }); + + it('transparently retries with server-issued nonce and returns 200', async () => { + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + // No fetch override: uses global fetch (Node 18+) against the real server + ); + + // doPost sends the initial request (no nonce), gets 401 + DPoP-Nonce, + // then automatically retries with the nonce and receives 200. + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + const body = (await response.json()) as { access_token: string }; + expect(body.access_token).to.equal('test-dpop-token'); + + // Cache must be populated with the server's nonce after the round-trip + expect(accessToken.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('uses cached nonce on the first request after a prior successful challenge', async () => { + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + ); + + // Pre-seed this client's cache as if a prior request already populated it + accessToken.nonceCache.set(SERVER_ORIGIN, SERVER_NONCE); + + // With the correct nonce already cached, the first request should succeed directly (no retry). + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + }); + + it('initial token fetch via clientSecretAuthProvider sends DPoP proof when configured with a signing key', async () => { + // Mirrors the CLI path: when --dpop is set, the provider is constructed + // with dpopEnabled + signingKey so the very first POST /token carries a + // DPoP header (RFC 9449 §5) and survives the nonce challenge. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const token = await provider.oidcAuth.get(false); + expect(token).to.equal('test-dpop-token'); + expect(provider.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('omits DPoP header when no signing key is configured, even after updateClientPublicKey binds one for body signing', async () => { + // Mirrors the legacy/non-DPoP CLI path: no --dpop flag, but TDF3Client. + // createSessionKeys still calls updateClientPublicKey to bind a key used + // for TDF body signing. The token POST must NOT include a DPoP header, + // otherwise Keycloak issues a DPoP-bound token that the platform's + // Connect-RPC interceptors then present as plain Bearer → 401. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + // No dpopEnabled / signingKey — non-DPoP flow. + }); + await provider.updateClientPublicKey(keyPair); + + // Capture the actual outgoing token POST rather than trusting a config flag: + // a stubbed request lets us assert the real header shape without a server. + let sentHeaders: Record | undefined; + provider.oidcAuth.request = async (_input, init) => { + sentHeaders = init?.headers as Record; + return new Response(JSON.stringify({ access_token: 'non-dpop-token' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const token = await provider.oidcAuth.get(false); + expect(token).to.equal('non-dpop-token'); + // The real regression guard: no DPoP proof (nor pubkey header) on the token POST. + expect(sentHeaders).to.not.have.property('DPoP'); + expect(sentHeaders).to.not.have.property('X-VirtruPubKey'); + // And the exposed AccessToken config must remain non-DPoP after the bind. + expect(provider.oidcAuth.config.dpopEnabled).to.not.equal(true); + }); +}); diff --git a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts new file mode 100644 index 000000000..03399316d --- /dev/null +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -0,0 +1,95 @@ +import { assert, expect } from 'chai'; + +import { getMocks } from '../mocks/index.js'; +import { Client } from '../../tdf3/src/index.js'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { defaultNonceCache } from '../../src/auth/dpop-nonce.js'; +import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; +import type { Scope } from '../../tdf3/src/client/builders.js'; + +const Mocks = getMocks(); + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce the mock server's resource-server (rewrap) gate demands; see +// DPOP_RS_NONCE in tests/server.ts. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * End-to-end regression for the Connect-RPC DPoP-Nonce challenge retry on the + * KAS *rewrap* path (RFC 9449 §9) — the exact xtest scenario + * (`test_dpop_server_issued_nonce_retry`) that failed only when js was the + * decrypt SDK against a `require_nonce` KAS. + * + * Drives a full encrypt → decrypt roundtrip through a DPoP auth provider so the + * rewrap carries `Authorization: DPoP ` and trips the mock server's RS + * gate. The first proof lacks the RS nonce, the server challenges with a 401 + + * `DPoP-Nonce`, and `authProviderInterceptor` must cache the nonce, re-sign, and + * retry once for the rewrap (and therefore the decrypt) to succeed. + */ +describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let dpopKeyPair: KeyPair; + + before(async () => { + dpopKeyPair = await generateSigningKeyPair(); + }); + + // Providers default to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.clearAll(); + }); + + it('decrypt survives the rewrap nonce challenge and returns the plaintext', async () => { + const expectedVal = 'rewrap nonce roundtrip'; + + // A DPoP-enabled provider makes every authenticated request (token + rewrap) + // present `Authorization: DPoP` and a proof, which is what activates the RS + // gate on the mock KAS rewrap endpoint. + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: dpopKeyPair, + }); + + const client = new Client.Client({ + kasEndpoint: SERVER_ORIGIN, + platformUrl: SERVER_ORIGIN, + allowedKases: [SERVER_ORIGIN], + dpopKeys: Mocks.entityKeyPair(), + clientId: 'test-client', + authProvider, + }); + + const scope: Scope = { dissem: ['user@domain.com'], attributes: [] }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + offline: true, + scope, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const decryptStream = await client.decrypt({ + source: { type: 'stream', location: encryptedStream.stream }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + + // A successful decrypt proves the rewrap survived the challenge; the cached + // RS nonce proves a challenge actually happened and the retry adopted it. + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/mocha/dpop-rpc-nonce.spec.ts b/lib/tests/mocha/dpop-rpc-nonce.spec.ts new file mode 100644 index 000000000..d42ac7767 --- /dev/null +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { defaultNonceCache } from '../../src/auth/dpop-nonce.js'; +import { PlatformClient } from '../../src/platform.js'; +import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by the mock server's resource-server (RPC) endpoints. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * End-to-end regression for the Connect-RPC DPoP-Nonce challenge retry. + * + * Drives a real PlatformClient (Connect transport) through a DPoP auth provider + * against the mock server so `ListKeyAccessServers` issues an RS nonce challenge + * and the `authProviderInterceptor` must catch the ConnectError, cache the nonce, + * re-sign, and retry once. Before that interceptor fix the first call rejected + * with a Code.Unauthenticated ConnectError — exactly the bug that reached xtest + * (`test_dpop_server_issued_nonce_retry`). + */ +describe('DPoP RS nonce retry over Connect-RPC — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + // Providers default to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.clearAll(); + }); + + it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const platform = new PlatformClient({ authProvider, platformUrl: SERVER_ORIGIN }); + + // No RS nonce is cached for this origin yet: the first proof carries the + // wrong (or no) nonce, the server challenges with the RS nonce, and the + // interceptor must retry once for this call to resolve. + const response = await platform.v1.keyAccessServerRegistry.listKeyAccessServers({}); + + expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); + expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); + + // The consumed challenge leaves the RS nonce cached for the origin, proving + // a challenge happened and the retry adopted it. + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index b18308db2..1fafaf1f6 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,5 +1,5 @@ import * as jose from 'jose'; -import { createServer, IncomingMessage, RequestListener } from 'node:http'; +import { createServer, IncomingMessage, RequestListener, ServerResponse } from 'node:http'; import { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'; import { base64 } from '../src/encodings/index.js'; @@ -64,6 +64,284 @@ const KAS_RSA_PRIVATE_KEY = DefaultCryptoService.importPrivateKey!(Mocks.kasPriv usage: 'encrypt', }); +// ============================================================================= +// DPoP proof verification (RFC 9449 + RFC 7518 §3.4) for the mock server. +// Strict on purpose: this is what real Keycloak / panva-jose do, so when a +// regression in our SDK's proof minting (e.g. DER-encoded ECDSA) lands, the +// integration tests fail locally instead of only at xtest time. +// ============================================================================= + +const DPOP_TOKEN_NONCE = 'dpop-test-nonce-abc'; +const DPOP_RS_NONCE = 'dpop-test-rs-nonce-xyz'; +const DPOP_IAT_SKEW_SECONDS = 60; + +// access_token → JWK SHA-256 thumbprint of the key it was bound to. +// Populated by the token endpoint when minting a DPoP-bound token; consulted +// by the KAS rewrap handler to enforce RFC 9449 §6.1 jkt binding. +const dpopBoundJkts = new Map(); + +// Seen jti values per minted-by-this-server lifetime to detect replay. +// Real servers would TTL-evict; this is a test mock, full clear on shutdown is fine. +const seenJtis = new Set(); + +type DPoPCheckOpts = { + htm: string; + htu: string; + requireAth?: { accessToken: string }; + requireBoundJkt?: string; + requireNonce?: string; +}; + +type DPoPCheckResult = + | { ok: true; jkt: string; jti: string; payload: jose.JWTPayload } + | { + ok: false; + status: number; + error: string; + error_description: string; + // If set, the server must include this DPoP-Nonce header so the client retries. + challengeNonce?: string; + }; + +/** Strict-mode parse and verify a DPoP proof per RFC 9449 + RFC 7518 §3.4. */ +async function verifyDpopProof( + rawProof: string | undefined, + opts: DPoPCheckOpts +): Promise { + if (!rawProof) { + return { + ok: false, + status: 400, + error: 'invalid_request', + error_description: 'DPoP header required', + }; + } + + let protectedHeader: jose.ProtectedHeaderParameters; + try { + protectedHeader = jose.decodeProtectedHeader(rawProof); + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `cannot decode DPoP header: ${(err as Error).message}`, + }; + } + if (protectedHeader.typ !== 'dpop+jwt') { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `typ must be "dpop+jwt", got ${String(protectedHeader.typ)}`, + }; + } + const alg = protectedHeader.alg; + if (!alg || alg === 'none' || alg.startsWith('HS') || !/^(ES|RS|PS|EdDSA)/.test(alg)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `alg "${String(alg)}" is not an allowed asymmetric JWS alg`, + }; + } + const jwk = protectedHeader.jwk; + if (!jwk || typeof jwk !== 'object') { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jwk header parameter missing', + }; + } + for (const forbidden of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'k']) { + if (forbidden in (jwk as Record)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `jwk must not contain private parameter "${forbidden}"`, + }; + } + } + + let key: jose.CryptoKey | Uint8Array; + try { + key = (await jose.importJWK(jwk as jose.JWK, alg)) as jose.CryptoKey; + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `cannot import jwk: ${(err as Error).message}`, + }; + } + + let payload: jose.JWTPayload; + try { + ({ payload } = await jose.jwtVerify(rawProof, key)); + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `signature verification failed: ${(err as Error).message}`, + }; + } + + if (payload.htm !== opts.htm) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `htm mismatch: expected ${opts.htm}, got ${String(payload.htm)}`, + }; + } + if (payload.htu !== opts.htu) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `htu mismatch: expected ${opts.htu}, got ${String(payload.htu)}`, + }; + } + const now = Math.floor(Date.now() / 1000); + if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_IAT_SKEW_SECONDS) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `iat out of window (${String(payload.iat)} vs server now ${now})`, + }; + } + if (typeof payload.jti !== 'string' || payload.jti.length === 0) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jti claim required', + }; + } + if (seenJtis.has(payload.jti)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jti replay detected', + }; + } + + if (opts.requireNonce && payload.nonce !== opts.requireNonce) { + return { + ok: false, + status: 0, // caller decides 400 (AS) vs 401 (RS) + error: 'use_dpop_nonce', + error_description: 'DPoP nonce required', + challengeNonce: opts.requireNonce, + }; + } + + if (opts.requireAth) { + const expected = await athClaim(opts.requireAth.accessToken); + if (payload.ath !== expected) { + return { + ok: false, + status: 401, + error: 'invalid_dpop_proof', + error_description: `ath mismatch: expected ${expected}, got ${String(payload.ath)}`, + }; + } + } + + const jkt = await jose.calculateJwkThumbprint(jwk as jose.JWK); + if (opts.requireBoundJkt && opts.requireBoundJkt !== jkt) { + return { + ok: false, + status: 401, + error: 'invalid_token', + error_description: 'access token cnf.jkt does not match DPoP proof jkt', + }; + } + + seenJtis.add(payload.jti); + return { ok: true, jkt, jti: payload.jti, payload }; +} + +/** RFC 9449 §6.1: ath = base64url-nopad(SHA-256(ASCII(access_token))). */ +async function athClaim(accessToken: string): Promise { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)); + return base64UrlNoPad(new Uint8Array(hash)); +} + +function base64UrlNoPad(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +/** Build the htu (target URI sans query and fragment) for a server request. */ +function requestHtu(req: IncomingMessage): string { + // The test server listens on http://localhost:3000; URL fields beyond + // pathname (query, fragment) MUST be stripped per RFC 9449 §4.2. + const url = new URL(req.url ?? '/', 'http://localhost:3000'); + return `${url.origin}${url.pathname}`; +} + +/** + * RFC 9449 resource-server DPoP gate for Connect-RPC endpoints. A no-op + * (returns true) unless the request carries `Authorization: DPoP `, so + * Bearer / unauthenticated callers pass through unchanged and the many non-DPoP + * tests keep working. + * + * On a proof failure it writes a Connect-correct response and returns false; the + * caller MUST `return` immediately. The status is always 401 so connect-web maps + * it to Code.Unauthenticated (HTTP 400 would map to Code.Internal, which the + * SDK's nonce-retry interceptor does not act on). The nonce travels in the + * `DPoP-Nonce` response header (surfaced to the client via ConnectError.metadata), + * and the JSON body uses the Connect `{code, message}` envelope. + * + * The proof's `htm` is always 'POST': both SDK interceptors hard-code POST when + * minting the proof regardless of the verb the Connect transport uses, so we must + * NOT derive htm from req.method here. + */ +async function enforceRsDpop(req: IncomingMessage, res: ServerResponse): Promise { + const authHeader = (req.headers['authorization'] as string | undefined) ?? ''; + const scheme = 'DPoP'; + if (!authHeader.startsWith(scheme)) return true; // non-DPoP request → unchanged behavior + + // HTTP optional whitespace is limited to SP / HTAB. Parse it directly instead + // of using a backtracking expression over the user-controlled header value. + let tokenStart = scheme.length; + if (authHeader[tokenStart] !== ' ' && authHeader[tokenStart] !== '\t') return true; + while (authHeader[tokenStart] === ' ' || authHeader[tokenStart] === '\t') tokenStart += 1; + + const accessToken = authHeader.slice(tokenStart); + if (!accessToken) return true; + + const proofCheck = await verifyDpopProof(req.headers['dpop'] as string | undefined, { + htm: 'POST', + htu: requestHtu(req), + requireAth: { accessToken }, + requireBoundJkt: dpopBoundJkts.get(accessToken), + requireNonce: DPOP_RS_NONCE, + }); + if (proofCheck.ok) return true; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (proofCheck.challengeNonce) { + headers['DPoP-Nonce'] = proofCheck.challengeNonce; + headers['WWW-Authenticate'] = `DPoP error="${proofCheck.error}"`; + } + res.writeHead(401, headers); + res.end( + JSON.stringify({ + code: 'unauthenticated', + message: proofCheck.error_description || proofCheck.error, + }) + ); + return false; +} + function range(start: number, end: number): Uint8Array { const result = []; for (let i = start; i <= end; i++) { @@ -113,9 +391,11 @@ const kas: RequestListener = async (req, res) => { 'roundtrip-test-response', 'connect-protocol-version', 'connect-streaming-protocol-version', + 'x-virtrupubkey', ].join(', ') ); res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Expose-Headers', 'DPoP-Nonce'); // GET should be allowed for everything except rewrap, POST only for rewrap but IDC res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST'); try { @@ -242,6 +522,13 @@ const kas: RequestListener = async (req, res) => { res.end(JSON.stringify({ code: 'unauthenticated', message: 'unauthenticated' })); return; } + + // Strict RFC 9449 DPoP resource-server check. Only triggers when the + // request actually carries `Authorization: DPoP `; non-DPoP + // (Bearer or unauthenticated) callers pass through unchanged so the + // many non-DPoP rewrap tests keep working. + if (!(await enforceRsDpop(req, res))) return; + const body = await getBody(req); const bodyText = new TextDecoder().decode(body); const { signedRequestToken } = JSON.parse(bodyText); @@ -523,9 +810,12 @@ const kas: RequestListener = async (req, res) => { res.end(fullRange); } } else if (url.pathname === '/policy.attributes.AttributesService/GetAttributeValuesByFqns') { + // DPoP callers are authenticated by the RS gate; Bearer callers fall + // through to the legacy `Bearer dummy-auth-token` check below. + if (!(await enforceRsDpop(req, res))) return; res.setHeader('Content-Type', 'application/json'); const token = req.headers['authorization'] as string; - if (!token || !token.startsWith('Bearer dummy-auth-token')) { + if (!token || !(token.startsWith('Bearer dummy-auth-token') || token.startsWith('DPoP '))) { res.statusCode = 401; res.end(JSON.stringify({ code: 'unauthenticated', message: 'unauthenticated' })); return; @@ -573,6 +863,7 @@ const kas: RequestListener = async (req, res) => { } else if ( url.pathname === '/policy.kasregistry.KeyAccessServerRegistryService/ListKeyAccessServers' ) { + if (!(await enforceRsDpop(req, res))) return; res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end( @@ -611,8 +902,11 @@ const kas: RequestListener = async (req, res) => { ); return; } else if (url.pathname === '/policy.attributes.AttributesService/ListAttributes') { + // DPoP callers are authenticated by the RS gate; Bearer callers fall + // through to the legacy `Bearer dummy-auth-token` check below. + if (!(await enforceRsDpop(req, res))) return; const token = req.headers['authorization'] as string; - if (!token || !token.startsWith('Bearer dummy-auth-token')) { + if (!token || !(token.startsWith('Bearer dummy-auth-token') || token.startsWith('DPoP '))) { res.statusCode = 401; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'error' })); @@ -622,6 +916,37 @@ const kas: RequestListener = async (req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'ok' })); return; + } else if (url.pathname === '/protocol/openid-connect/token') { + // Mock Keycloak token endpoint with strict RFC 9449 DPoP verification. + // First request gets a nonce challenge (400 + use_dpop_nonce + DPoP-Nonce header + // per RFC 9449 §8 — note: AS uses 400, RS uses 401). The retry must include + // a proof whose `nonce` claim matches. + const dpopHeader = req.headers['dpop'] as string | undefined; + const htu = requestHtu(req); + const check = await verifyDpopProof(dpopHeader, { + htm: 'POST', + htu, + requireNonce: DPOP_TOKEN_NONCE, + }); + if (!check.ok) { + const status = + check.error === 'use_dpop_nonce' ? 400 : check.status > 0 ? check.status : 400; + const headers: Record = { 'Content-Type': 'application/json' }; + if (check.challengeNonce) headers['DPoP-Nonce'] = check.challengeNonce; + res.writeHead(status, headers); + res.end(JSON.stringify({ error: check.error, error_description: check.error_description })); + return; + } + + // Mint an opaque access token; bind it to the DPoP proof's JWK thumbprint + // so the rewrap handler (RS-side, below) can enforce cnf.jkt binding. + const accessToken = 'test-dpop-token'; + dpopBoundJkts.set(accessToken, check.jkt); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 })); + return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); res.statusCode = 404; diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index abdba1c86..f91bcf035 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -16,6 +16,7 @@ import { UnauthenticatedError, } from '../../../src/errors.js'; import { OriginAllowList } from '../../../src/access.js'; +import { DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; import type { AuthProvider } from '../../../src/index.js'; // ------------------------------------------------------------- @@ -230,6 +231,105 @@ describe('access-fetch.js', () => { }); }); + describe('DPoP-Nonce challenge retry (RFC 9449 §9)', () => { + const platformUrl = 'https://platform.example.com'; + const origin = 'https://platform.example.com'; + const challengeNonce = 'server-issued-nonce-123'; + + // A response carrying real Headers so DPoPNonceCache.extractNonce works. + // @ts-expect-error test helper, loose body typing + const responseWithNonce = (body, ok, status, nonce?: string) => + Promise.resolve({ + ok, + status, + statusText: ok ? 'OK' : 'Unauthorized', + headers: new Headers(nonce ? { 'DPoP-Nonce': nonce } : {}), + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + } as Response); + + // withCreds that signs each request with whatever nonce is currently cached + // for the origin, recording it so the test can confirm the retry saw the + // server challenge. + const noncesSeen: (string | undefined)[] = []; + // The provider owns its per-client cache; the retry path reads it back. + const nonceCache = new DPoPNonceCache(); + const dpopAuthProvider: AuthProvider = { + nonceCache, + withCreds: sinon.stub().callsFake(async (req) => { + noncesSeen.push(nonceCache.get(origin)); + return { ...req, headers: { ...req.headers, Authorization: 'DPoP test-token' } }; + }), + } as unknown as AuthProvider; + + beforeEach(() => { + noncesSeen.length = 0; + nonceCache.clearAll(); + // @ts-expect-error stub + dpopAuthProvider.withCreds.resetHistory(); + }); + + afterEach(() => { + nonceCache.clearAll(); + }); + + it('retries once with the server nonce and succeeds', async () => { + fetchStub + .onCall(0) + .returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + fetchStub + .onCall(1) + .returns( + responseWithNonce( + { keyAccessServers: [{ uri: 'https://kas1.example.com' }], pagination: {} }, + true, + 200 + ) + ); + + const result = await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + + expect(fetchStub.calledTwice).to.be.true; + // First proof had no nonce; the retry proof was minted after caching it. + expect(noncesSeen).to.deep.equal([undefined, challengeNonce]); + expect(result.origins).to.include('https://kas1.example.com'); + }); + + it('does not retry when the 401 carries no DPoP-Nonce', async () => { + fetchStub.returns(responseWithNonce('nope', false, 401)); + + let caught: unknown; + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + caught = e; + } + // The real 401 must surface unchanged (not masked): a ServiceError that + // names the KAS-list request and its status. + expect(caught).to.be.instanceOf(ServiceError); + expect((caught as ServiceError).message).to.include('unable to fetch kas list'); + expect((caught as ServiceError).message).to.include('status: 401'); + expect(fetchStub.calledOnce).to.be.true; + }); + + it('does not retry again when the same nonce is returned twice', async () => { + // Server keeps rejecting with the same nonce: retry once, then give up. + fetchStub.returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + + let caught: unknown; + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(ServiceError); + expect((caught as ServiceError).message).to.include('status: 401'); + expect(fetchStub.calledTwice).to.be.true; + }); + }); + describe('fetchKasPubKey', () => { const kasEndpoint = 'https://kas.example.com'; // FIX: Provide a real, valid base64-encoded key. The `...` is not valid. diff --git a/lib/tests/web/auth/auth.test.ts b/lib/tests/web/auth/auth.test.ts index 85168d5d1..4ccc0c659 100644 --- a/lib/tests/web/auth/auth.test.ts +++ b/lib/tests/web/auth/auth.test.ts @@ -87,6 +87,28 @@ describe('AccessToken', () => { expect(e.message).to.match(/Unauthorized/); } }); + it('throws when DPoP is enabled but signingKey is missing (no silent Bearer downgrade)', async () => { + const mf = mockFetch({ access_token: 'fdfsdffsdf' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/yeet', + clientId: 'yoo', + refreshToken: 'ignored', + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + try { + await accessToken.info('fakeToken'); + assert.fail('Expected ConfigurationError'); + } catch (e) { + expect(e.message).to.match(/required when DPoP is enabled/); + } + // Must fail before contacting userinfo, not silently fall back to Bearer. + expect(mf.called, 'must not send a userinfo request when misconfigured').to.be.false; + }); }); describe('exchanging refresh token for token with TDF claims', () => { @@ -427,5 +449,87 @@ describe('AccessToken', () => { expect(e.message).to.match(/required when DPoP is enabled/); } }); + + it('token exchange (doPost via get) throws when DPoP is enabled but signingKey is missing', async () => { + const mf = mockFetch({ access_token: 'test_token' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + try { + await accessToken.get(); + assert.fail('Expected ConfigurationError'); + } catch (e) { + expect(e.message).to.match(/required when DPoP is enabled/); + } + // Same consistent failure as info()/withCreds — never POST to the token endpoint. + expect(mf.called, 'must not POST to the token endpoint when misconfigured').to.be.false; + }); + + it('deferred key binding: withCreds succeeds after refreshTokenClaimsWithClientPubkeyIfNeeded', async () => { + // The legitimate deferred-binding flow (mirrors opentdf.ts `ready`): + // construct DPoP-enabled with NO key, bind the key later, then request. + const mf = mockFetch({ access_token: 'test_token' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + const signingKey = await generateTestSigningKey(); + await accessToken.refreshTokenClaimsWithClientPubkeyIfNeeded(signingKey); + const result = await accessToken.withCreds({ + url: 'https://kas.invalid/v2/rewrap', + method: 'POST', + headers: {}, + }); + expect(result.headers).to.have.property('Authorization', 'DPoP test_token'); + expect(result.headers).to.have.property('DPoP'); + }); + + it('strips query and fragment from the DPoP proof htu (RFC 9449 §4.2)', async () => { + const signingKey = await generateTestSigningKey(); + const mf = mockFetch({ access_token: 'test_token' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + signingKey, + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + const result = await accessToken.withCreds({ + url: 'https://platform.invalid/key-access-servers?pagination.offset=0', + method: 'GET', + headers: {}, + }); + + const decodeJwtPayload = (jwt: string): Record => { + let b64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4 !== 0) { + b64 += '='; + } + return JSON.parse(atob(b64)); + }; + const payload = decodeJwtPayload(result.headers.DPoP); + expect(payload.htu).to.equal('https://platform.invalid/key-access-servers'); + expect(payload.htm).to.equal('GET'); + }); }); }); diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts new file mode 100644 index 000000000..8598f999a --- /dev/null +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -0,0 +1,207 @@ +import { expect } from '@esm-bundle/chai'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { stub } from 'sinon'; +import { AccessToken } from '../../../src/auth/oidc.js'; +import { defaultNonceCache, DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; +import { authTokenDPoPInterceptor } from '../../../src/auth/interceptors.js'; +import { DefaultCryptoService, generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; + +/** Decode JWT payload without verification (base64url → JSON). */ +function decodeJwtPayload(jwt: string): Record { + const b64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '='); + return JSON.parse(atob(padded)); +} + +// ── AccessToken.doPost nonce retry ────────────────────────────────────────── + +describe('AccessToken.doPost DPoP-Nonce retry', () => { + const ORIGIN = 'http://localhost:3000'; + const TOKEN_URL = `${ORIGIN}/protocol/openid-connect/token`; + const NONCE = 'server-nonce-xyz'; + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + // AccessToken defaults to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.clearAll(); + }); + + function makeAccessToken(fetchStub: typeof fetch) { + return new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService, + fetchStub + ); + } + + it('retries with nonce when server responds 401 with DPoP-Nonce header', async () => { + const fetchStub = stub(); + // First call: 401 challenge with DPoP-Nonce header + fetchStub.onFirstCall().resolves({ + status: 401, + ok: false, + headers: new Headers({ 'DPoP-Nonce': NONCE }), + } as Response); + // Second call: 200 success + fetchStub.onSecondCall().resolves({ + status: 200, + ok: true, + headers: new Headers(), + json: stub().resolves({ access_token: 'test-token' }), + } as unknown as Response); + + const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); + + expect(fetchStub.callCount).to.equal(2); + expect(result.status).to.equal(200); + expect(accessToken.nonceCache.get(ORIGIN)).to.equal(NONCE); + + // Second request's DPoP proof must include the nonce + const secondInit = fetchStub.secondCall.args[1] as RequestInit; + const secondHeaders = secondInit.headers as Record; + const retryPayload = decodeJwtPayload(secondHeaders['DPoP']); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + const fetchStub = stub().resolves({ + status: 401, + ok: false, + headers: new Headers({ 'DPoP-Nonce': NONCE }), + } as Response); + + const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + // Pre-seed this client's cache with the same nonce the server will return + accessToken.nonceCache.set(ORIGIN, NONCE); + const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); + + // No retry — same nonce means we'd loop; return the 401 to the caller + expect(fetchStub.callCount).to.equal(1); + expect(result.status).to.equal(401); + }); +}); + +// ── authTokenDPoPInterceptor nonce retry ──────────────────────────────────── + +describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { + const ORIGIN = 'http://localhost:3000'; + const REQUEST_URL = `${ORIGIN}/kas.AccessService/Rewrap`; + const NONCE = 'interceptor-nonce-abc'; + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + function makeInterceptor(nonceCache: DPoPNonceCache) { + return authTokenDPoPInterceptor({ + tokenProvider: async () => 'dummy-access-token', + dpopKeys: Promise.resolve(keyPair), + nonceCache, + }); + } + + function makeMockReq() { + return { header: new Headers(), url: REQUEST_URL } as Parameters< + ReturnType> + >[0]; + } + + it('retries with nonce when interceptor catches a code-16 error with dpop-nonce metadata', async () => { + const mockNext = stub(); + // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata + mockNext + .onFirstCall() + .callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) + ); + // Second call: success + mockNext.onSecondCall().resolves({ header: { get: () => null } }); + + const nonceCache = new DPoPNonceCache(); + const interceptor = makeInterceptor(nonceCache); + await interceptor(mockNext as Parameters[0])(makeMockReq()); + + expect(mockNext.callCount).to.equal(2); + expect(nonceCache.get(ORIGIN)).to.equal(NONCE); + + // Retry request must have nonce in its DPoP proof + const retryReq = mockNext.secondCall.firstArg as { header: Headers }; + const retryDpopJwt = retryReq.header.get('DPoP')!; + const retryPayload = decodeJwtPayload(retryDpopJwt); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + const nonceCache = new DPoPNonceCache(); + nonceCache.set(ORIGIN, NONCE); + + const mockNext = stub().callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) + ); + + const interceptor = makeInterceptor(nonceCache); + try { + await interceptor(mockNext as Parameters[0])(makeMockReq()); + expect.fail('should have thrown'); + } catch { + // Expected: interceptor re-throws when nonce unchanged + } + + expect(mockNext.callCount).to.equal(1); + }); + + it('uses a rotated metadata nonce when the cache still contains the sent nonce', async () => { + const sentNonce = 'stale-nonce'; + const rotatedNonce = 'rotated-nonce'; + const nonceCache = new DPoPNonceCache(); + nonceCache.set(ORIGIN, sentNonce); + + const mockNext = stub(); + mockNext + .onFirstCall() + .rejects( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': rotatedNonce }) + ) + ); + mockNext.onSecondCall().resolves({ header: new Headers() }); + + const interceptor = makeInterceptor(nonceCache); + await interceptor(mockNext as Parameters[0])(makeMockReq()); + + expect(mockNext.callCount).to.equal(2); + expect(nonceCache.get(ORIGIN)).to.equal(rotatedNonce); + const retryReq = mockNext.secondCall.firstArg as { header: Headers }; + expect(decodeJwtPayload(retryReq.header.get('DPoP')!).nonce).to.equal(rotatedNonce); + }); +}); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 25ba358cb..f8fcb8a08 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -1,5 +1,5 @@ import { expect } from '@esm-bundle/chai'; -import { type Interceptor } from '@connectrpc/connect'; +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; import type { AuthProvider } from '../../src/auth/auth.js'; import { HttpRequest, withHeaders } from '../../src/auth/auth.js'; import { @@ -10,6 +10,7 @@ import { resolveAuthConfig, isInterceptorConfig, } from '../../src/auth/interceptors.js'; +import { DPoPNonceCache } from '../../src/auth/dpop-nonce.js'; // --- helpers --- @@ -61,7 +62,7 @@ describe('authTokenDPoPInterceptor', () => { const headers = await captureHeaders(interceptor); - expect(headers.get('Authorization')).to.equal('Bearer dpop-token'); + expect(headers.get('Authorization')).to.equal('DPoP dpop-token'); expect(headers.get('DPoP')).to.be.a('string'); expect(headers.get('DPoP')!.split('.')).to.have.length(3); // JWT format expect(headers.get('X-VirtruPubKey')).to.be.a('string'); @@ -142,6 +143,108 @@ describe('authProviderInterceptor', () => { expect(headers.get('X-Custom')).to.equal('custom-value'); }); + it('passes the full request URL to withCreds (not just the path)', async () => { + // Regression: a DPoP-enabled provider computes the proof `htu` and nonce + // origin via `new URL(req.url)`, which throws on a bare path. The + // interceptor must hand withCreds the absolute URL. + let seenUrl: string | undefined; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (req: HttpRequest) => { + seenUrl = req.url; + // Mimic a DPoP provider that parses the URL; a bare path throws here. + new URL(req.url); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + await captureHeaders(interceptor, 'https://platform.example.com/policy.attributes/Get'); + + expect(seenUrl).to.equal('https://platform.example.com/policy.attributes/Get'); + }); + + it('retries once with the server-issued DPoP-Nonce on an Unauthenticated challenge', async () => { + const origin = 'https://platform.example.com'; + const url = `${origin}/policy.kasregistry/ListKeyAccessServers`; + const nonceCache = new DPoPNonceCache(); + + // Provider records the nonce it sees so we can assert the retry carried it. + const seenNonces: (string | undefined)[] = []; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + nonceCache, + withCreds: async (req: HttpRequest) => { + seenNonces.push(nonceCache.get(new URL(req.url).origin)); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + let attempts = 0; + const mockNext = async () => { + attempts++; + if (attempts === 1) { + // First attempt: server issues a nonce challenge. + throw new ConnectError('unauthenticated', Code.Unauthenticated, { + 'dpop-nonce': 'server-nonce-xyz', + }); + } + return { header: new Headers(), message: {} } as Awaited>>; + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + const mockReq = { header: new Headers(), url } as Parameters>[0]; + await interceptor(mockNext)(mockReq); + + expect(attempts).to.equal(2); + expect(seenNonces).to.deep.equal([undefined, 'server-nonce-xyz']); + expect(nonceCache.get(origin)).to.equal('server-nonce-xyz'); + }); + + it('gives up and rethrows the original error when the challenge carries no new nonce', async () => { + const origin = 'https://platform.example.com'; + const url = `${origin}/policy.kasregistry/ListKeyAccessServers`; + const nonceCache = new DPoPNonceCache(); + + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + nonceCache, + withCreds: async (req: HttpRequest) => withHeaders(req, { Authorization: 'DPoP token' }), + }; + + // Unauthenticated, but the server supplied no DPoP-Nonce and the cache is + // empty, so there is nothing to retry with: the original error must + // propagate unchanged rather than be swallowed or retried in a loop. + const thrown = new ConnectError('unauthenticated', Code.Unauthenticated); + let attempts = 0; + const mockNext = async () => { + attempts++; + throw thrown; + }; + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }; + + let caught: unknown; + try { + const interceptor = authProviderInterceptor(mockAuthProvider); + const mockReq = { header: new Headers(), url } as Parameters>[0]; + await interceptor(mockNext)(mockReq); + } catch (e) { + caught = e; + } finally { + console.warn = originalWarn; + } + + expect(caught).to.equal(thrown); // same error instance, not masked + expect(attempts).to.equal(1); // no retry, no loop + expect(warnings).to.have.length(1); + expect(warnings[0]).to.include('nonce retry skipped'); + }); + it('wraps updateClientPublicKey errors with helpful message', async () => { const failingProvider: AuthProvider = { updateClientPublicKey: async () => {},