feat: Implement DPoPManager - central coordinator (ENG-4784)#202
feat: Implement DPoPManager - central coordinator (ENG-4784)#202mrudatsprint wants to merge 25 commits into
Conversation
- Add `dpop` v2.1.1 runtime dependency and `fake-indexeddb` devDependency to packages/core/package.json - SDKConfig: add optional `useDpop` and `dpopTokenStorage` fields - UrlHelper: add `getAuthorizeUrl(state?, dpopJkt?, codeChallenge?)` targeting FusionAuth /oauth2/authorize directly; update UrlHelperTypes to include response_type, code_challenge, code_challenge_method, dpop_jkt - DPoPStorage: IndexedDB abstraction for ES256 CryptoKeyPair persistence (db: fusionauth-sdk:dpop, store: keypair, keyed by clientId) - DPoPTokenStore: localStorage/memory token storage for DPoP-bound tokens (key: fusionauth-sdk:tokens:<clientId>); includes getAccessToken() and isExpired getter - packages/core/src/DPoP/index.ts re-exports both classes - 54 tests passing (21 DPoPTokenStore, 6 DPoPStorage, 16 UrlHelper, 7 SDKCore, 4 CookieHelpers)
- Remove 'as any' cast in catch block — reject() accepts unknown directly - Add tests for indexedDB unavailable (SSR/non-browser): all three public methods (getKeyPair, setKeyPair, clearKeyPair) reject with a descriptive error - Add test for indexedDB.open() throwing synchronously (e.g. security policy block)
All three DPoPStorage methods (getKeyPair, setKeyPair, clearKeyPair) now resolve on tx.oncomplete and reject on tx.onerror / tx.onabort. Previously, resolving on req.onsuccess meant the caller was told 'success' before the transaction had fully committed — a transaction abort occurring after the request succeeded (e.g. quota exceeded) would go undetected. Applies the same fix consistently to all three methods, including getKeyPair (readonly, lower risk, but now consistent) and setKeyPair (readwrite, same durability concern as clearKeyPair). Adds a test that aborts a clearKeyPair transaction synchronously inside the request onsuccess handler and verifies the promise rejects and the key pair is still present in IndexedDB.
…84/central-coordinator' of github.com:FusionAuth/fusionauth-javascript-sdk into miker/eng-4784/central-coordinator
…g object - Export DEFAULT_DPOP_DB_NAME, DEFAULT_DPOP_DB_VERSION, DEFAULT_DPOP_STORE_NAME as named constants (no hardcoded magic strings anywhere in the codebase) - Add DPoPStorageConfig interface with clientId (required) and optional dbName, dbVersion, storeName fields — each defaults to the exported constant - Refactor DPoPStorage constructor from positional (clientId: string) to config object, matching the UrlHelperConfig convention in this monorepo - openDb() and all three public methods now reference instance fields (this.dbName, this.dbVersion, this.storeName) instead of module constants - Add tests: defaults apply when no config overrides provided; custom dbName and storeName land data in the right database; two instances with different dbNames but the same clientId do not share keys; dbVersion downgrade produces a clean rejection (VersionError) - Update AGENTS.md: note the config-object constructor convention and the IndexedDB dbVersion must-only-increase constraint
…central-coordinator
…ENG-4782 refactor
mrudatsprint
left a comment
There was a problem hiding this comment.
self-review
| @@ -0,0 +1,427 @@ | |||
| /** | |||
There was a problem hiding this comment.
I created these end to end tests to validate the core storage layer as well as the DPoPManager who is the central coordinator of all DPoP operations.
The smoke tests in this file will most likely be DELETED when the grants are implemented and end to end tests are created for each grant.
Here's the FusionAuth Event Log for the logging in the smoke tests:
Authorization Code Grant
- PKCE was validated along with the DPoP proof JWT provided with the cnf.jkt claim containing the DPoP public key thumbprint
OAuth2 exchange authorization code debug log for [dpop] with clientId [baf3d520-40d7-4000-9b62-e6a7d0091102].
7/16/2026 11:03:50 PM Z DPoP proof provided with thumbprint: SyUm2I6EC6a_HuXry6Li_E_56mFBFes7F6elIUC2M3s
7/16/2026 11:03:50 PM Z Validate the provided authorization code [zA1WGnrZqI4YeU82O43LdjAC8CJ-wQ9DPtcYcrz5BTE].
7/16/2026 11:03:50 PM Z Validate PKCE code_challenge [RCEQYSlY6Q_5kE230EBfP4G1eklVn8GsU7ywgVN8fdQ] provided during the authorization request with the provided code_verifier [B4YAYq-PQQWA-EY9RvwAtjEjsVrvREi6Y4-W0PLAK1Q]. Calculated code_challenge [RCEQYSlY6Q_5kE230EBfP4G1eklVn8GsU7ywgVN8fdQ].
7/16/2026 11:03:50 PM Z Scopes requested [openid offline_access email profile]
7/16/2026 11:03:50 PM Z Ensure the provided request parameters match those provided the authorization request.
7/16/2026 11:03:50 PM Z User is registered for application with Id [baf3d520-40d7-4000-9b62-e6a7d0091102] the [roles] and [applicationId] claims will be added.
7/16/2026 11:03:50 PM Z The authorization code has been successfully exchanged for an access token.
Access token received with the public key thumbprint
{
"applicationId": "baf3d520-40d7-4000-9b62-e6a7d0091102",
"aud": "baf3d520-40d7-4000-9b62-e6a7d0091102",
"auth_time": 1784243029,
"authenticationType": "PASSWORD",
"cnf": {
"jkt": "SyUm2I6EC6a_HuXry6Li_E_56mFBFes7F6elIUC2M3s"
},
"exp": 1784243090,
"gty": [
"authorization_code"
],
"iat": 1784243030,
"iss": "acme.com",
"jti": "d9948867-b49d-4b83-bf74-908ed5052ad6",
"roles": [],
"scope": "openid offline_access email profile",
"sid": "a71587c0-972f-4cb2-b50a-312e6be912da",
"sub": "6eda57ca-1030-4701-acda-ee1d57e62b4e",
"tid": "4446b114-aec7-427a-ba4a-6805efd3b9cb",
"tty": "at"
}
Refresh Token Grant
Access token received with the public key thumbprint
{
"applicationId": "baf3d520-40d7-4000-9b62-e6a7d0091102",
"aud": "baf3d520-40d7-4000-9b62-e6a7d0091102",
"auth_time": 1784243029,
"authenticationType": "REFRESH_TOKEN",
"cnf": {
"jkt": "SyUm2I6EC6a_HuXry6Li_E_56mFBFes7F6elIUC2M3s"
},
"exp": 1784243090,
"iat": 1784243030,
"iss": "acme.com",
"jti": "cea3146f-ef20-4e96-8ef0-2c7f953d9308",
"roles": [],
"scope": "openid offline_access email profile",
"sid": "a71587c0-972f-4cb2-b50a-312e6be912da",
"sub": "6eda57ca-1030-4701-acda-ee1d57e62b4e",
"tid": "4446b114-aec7-427a-ba4a-6805efd3b9cb",
"tty": "at"
}
| } | ||
| headers.set('DPoP', proof); | ||
|
|
||
| return globalThis.fetch(input, { ...init, headers }); |
There was a problem hiding this comment.
This is the API call to a resource server.
There was a problem hiding this comment.
Pull request overview
Implements a new DPoPManager in @fusionauth-sdk/core as the central coordinator for DPoP key management, thumbprint generation (for dpop_jkt on /oauth2/authorize), proof JWT generation, and a DPoP-aware fetch wrapper with nonce retry handling. Adds unit coverage for the manager plus a standalone Playwright smoke test configuration and script to validate the flow end-to-end against a local FusionAuth instance (pre-SDKCore wiring).
Changes:
- Added
DPoPManagerto coordinate keypair persistence, token storage integration, proof generation, and nonce caching/retry logic. - Added comprehensive Vitest unit tests for keypair lifecycle, proof claims,
fetchheader behavior, nonce retry, andclear(). - Added standalone Playwright config and a DPoP smoke test that drives FusionAuth hosted login and token flows without requiring the quickstart app.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| playwright.dpop.config.ts | Adds a dedicated Playwright config for running the new DPoP smoke test independently of quickstart. |
| packages/core/src/DPoP/index.ts | Re-exports DPoPManager from the DPoP module barrel. |
| packages/core/src/DPoP/DPoPManager.ts | Introduces the DPoP coordinator class, including proof generation and a nonce-retrying fetch. |
| packages/core/src/DPoP/DPoPManager.test.ts | Adds unit tests validating DPoPManager behavior end-to-end in Node + fake IndexedDB. |
| e2e/tests/dpop-smoke.test.ts | Adds a Playwright-driven smoke test exercising authorization, token exchange/refresh, and resource calls using DPoP. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… dropping one (PR #202 review) _resolveHeaders() previously returned early with init.headers whenever it was present, silently discarding any headers already set on a Request object passed as input. This contradicted the _doFetch documentation's promise to never drop caller headers. _resolveHeaders() now returns a merged Headers object: init.headers is the base, and Request.headers are layered on top, winning on any conflicting header name.
…mption (PR #202 Copilot review) fetch() previously passed the same input reference to both the initial attempt and the nonce-triggered retry. If input was a Request with a body, the first attempt consumed it, and the retry would throw a 'body already used' error instead of succeeding. - Clone the Request twice up front (before either is read from) so each attempt gets an independent, unconsumed body. Request.clone() safely tees any internal streaming body per spec, so this also covers a Request built with a ReadableStream body. - A raw ReadableStream passed via init.body (not wrapped in a Request) cannot be cloned this way. On retry, this now throws a clear, actionable error instead of letting native fetch throw an opaque one.
…opilot review) generateProof() documented htu as being 'without query/fragment' but passed it through unmodified. fetch() supplies Request.url, which can include a query string, so proofs generated via dpopFetch() could carry an htu that includes query parameters — a subtle interop bug with strict DPoP verifiers. htm was also not normalised to uppercase, which most DPoP verifiers require. Both are now normalised inside generateProof() itself, so this is correct regardless of whether callers go through fetch() or call generateProof() directly with arbitrary casing/query strings.
…dpop-smoke.test.ts (PR #202 Copilot review) T2-1 declared capturedAuthHeader/capturedDpopHeader that were never assigned and only suppressed via void, alongside a comment claiming Playwright route interception captures DPoPManager.fetch()'s headers — no such interception exists since fetch() runs in the Node test process, not the browser page. Removed the dead variables and replaced the comment with an accurate explanation of how correctness is actually validated (end-to-end via FusionAuth's server-side verification, plus T2-2's direct proof decoding).
| * operation. Subsequent calls return the memoised result immediately. | ||
| */ | ||
| async getOrCreateKeyPair(): Promise<KeyPair> { | ||
| if (!this.keyPairPromise) { |
There was a problem hiding this comment.
Starting a second review.
The reason keyPairPromise is considered memoised is the result of the Promise has already been cached.
| const serverNonce = response.headers.get('DPoP-Nonce')!; | ||
| this.nonces.set(origin, serverNonce); | ||
|
|
||
| // Single retry — return the result regardless of status. |
There was a problem hiding this comment.
Why retry only once? The RFC states The client will typically retry the request with the new nonce value supplied upon receiving a use_dpop_nonce error with an accompanying nonce value.
| @@ -0,0 +1,610 @@ | |||
| // @vitest-environment node | |||
There was a problem hiding this comment.
It took me some time to understand this is a special comment that vitest recognizes.
| const seed = makeManager(); | ||
| const original = await seed.getOrCreateKeyPair(); | ||
|
|
||
| // A second manager sharing the same IndexedDB and clientId should load the |
There was a problem hiding this comment.
Note to self, that's because there's only one clientId in the IndexedDB.
Issue:
Description:
Implement the DPoPManager - responsible for:
dpop_jktparameter on/oauth2/authorizeNote: The framework layers (React, Angular and Vue) will hold a reference to the DPoPManager to expose dpopFetch and generateProof.