Skip to content

feat: Implement DPoPManager - central coordinator (ENG-4784)#202

Open
mrudatsprint wants to merge 25 commits into
parent/dpop-in-the-javascript-sdkfrom
miker/eng-4784/central-coordinator
Open

feat: Implement DPoPManager - central coordinator (ENG-4784)#202
mrudatsprint wants to merge 25 commits into
parent/dpop-in-the-javascript-sdkfrom
miker/eng-4784/central-coordinator

Conversation

@mrudatsprint

@mrudatsprint mrudatsprint commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Issue:

Description:

Implement the DPoPManager - responsible for:

  • key pair creation
  • generating the thumbprint for the dpop_jkt parameter on /oauth2/authorize
  • generating the DPoP proof JWT

Note: The framework layers (React, Angular and Vue) will hold a reference to the DPoPManager to expose dpopFetch and generateProof.

- 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

@mrudatsprint mrudatsprint left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self-review

@@ -0,0 +1,427 @@
/**

@mrudatsprint mrudatsprint Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the API call to a resource server.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DPoPManager to coordinate keypair persistence, token storage integration, proof generation, and nonce caching/retry logic.
  • Added comprehensive Vitest unit tests for keypair lifecycle, proof claims, fetch header behavior, nonce retry, and clear().
  • 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.

Comment thread packages/core/src/DPoP/DPoPManager.ts
Comment thread packages/core/src/DPoP/DPoPManager.ts
Comment thread packages/core/src/DPoP/DPoPManager.ts
Comment thread packages/core/src/DPoP/DPoPManager.test.ts
Comment thread e2e/tests/dpop-smoke.test.ts Outdated
… 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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self, that's because there's only one clientId in the IndexedDB.

@mrudatsprint
mrudatsprint marked this pull request as ready for review July 20, 2026 19:42
Base automatically changed from miker/eng-4782/core-storage-layer to parent/dpop-in-the-javascript-sdk July 20, 2026 19:49
@mrudatsprint
mrudatsprint requested review from a team as code owners July 20, 2026 19:49
@mrudatsprint
mrudatsprint requested a review from wied03 July 20, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants