feat: Framework SDKs support DPoP - #207
Open
mrudatsprint wants to merge 101 commits into
Open
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
…t (ENG-4786) - Add Pkce module (generateCodeVerifier, generateCodeChallenge) with RFC 7636 Appendix B test vector coverage; runs under @vitest-environment node - Extend RedirectHelper to persist code_verifier as a second colon-delimited segment alongside state; add public getCodeVerifier() getter; add test file - SDKCore: construct DPoPManager when config.useDpop is true; startLogin() is now async — DPoP branch calls getOrCreateKeyPair()/getThumbprint() and generates PKCE params then redirects to /oauth2/authorize directly; isLoggedIn delegates to DPoPManager.isLoggedIn in DPoP mode (not app.at_exp cookie) - SDKCore.test.ts: add DPoP-mode describe block with mocked DPoPManager and Pkce (jsdom lacks crypto.subtle); all existing cookie-mode tests unaffected - e2e/dpop-smoke.test.ts: replace local generatePkce() helper with shared Pkce module; add Tier 0 tests exercising SDKCore.startLogin() in DPoP mode end-to-end (no live FusionAuth required for Tier 0) - Export Pkce from packages/core/src/index.ts Note: yarn test:core cannot run in this sandbox environment due to a missing @rollup/rollup-linux-arm64-gnu native binary (arch mismatch); TypeScript compilation (tsc --noEmit) and ESLint/Prettier are clean.
…edirect assertion Without the explicit jsdom annotation, vitest inherits the 'node' environment from DPoPManager.test.ts when the full suite runs, causing 'document is not defined' and 'window is not defined' failures in all SDKCore tests. Also corrects the handlePreRedirect spy assertion: cookie-mode startLogin() passes one argument (state), not two — the codeVerifier arg is only added in DPoP mode.
SDKCore's constructor calls scheduleTokenExpiration() which calls
getAccessTokenExpirationMoment(). In a Node/Playwright process document
doesn't exist, so CookieHelpers catches the ReferenceError and logs
'Error accessing cookies...' to console.error. The tests still pass, but the
stderr noise is confusing.
Fix: extract a shared DPOP_CONFIG constant in the Tier 0 describe block that
includes a no-op cookieAdapter ({ at_exp: () => undefined }). This causes
getAccessTokenExpirationMoment() to take the adapter path and skip
document.cookie entirely, eliminating the noise.
Also fixes T0-1 where the await core.startLogin() call was accidentally
dropped during the previous config refactor.
…ormat RedirectHelper now stores nonce:codeVerifier:state (three colon-delimited segments) instead of the previous nonce:state (two segments). The Angular sdkcore/ directory is generated by 'yarn get-sdk-core' which copies packages/core/src/ verbatim — so in CI the Angular RedirectHelper picks up the updated parser automatically. The test was writing the old two-segment format 'abc123:/welcome-page', which the new parser splits as [nonce='abc123', codeVerifier='/welcome-page', state=''] — returning undefined for state instead of '/welcome-page'. Fix: write 'abc123::/welcome-page' (empty codeVerifier segment, matching cookie mode where no verifier is stored).
…value format
RedirectHelper now stores nonce:codeVerifier:state (three colon-delimited
segments) instead of nonce:state (two segments). Both sdk-vue and sdk-react
import SDKCore directly from @fusionauth-sdk/core (via the @fusionauth-sdk/*
tsconfig path alias), so their tests exercise the live, current
RedirectHelper — same root cause as the earlier Angular fix.
- packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts: was seeding
the old 2-segment format ('rAnd0mStR1ng:<state>'), causing the new state
getter to return undefined instead of the expected state value. Fixed to
'rAnd0mStR1ng::<state>' (empty codeVerifier segment).
- packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx:
had the same stale 2-segment seed, but wasn't caught by CI because the
assertion only checked toHaveBeenCalled() (no argument check). Fixed the
seed format and strengthened the assertion to toHaveBeenCalledWith(stateValue)
to restore real coverage of the callback argument.
… 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).
The merge of miker/eng-4801/refresh-token into this branch reintroduced the pre-fix version of refreshDpopToken(), silently dropping the two Copilot review fixes from PR #206: - Preserve the existing refresh token when FusionAuth's refresh response omits refresh_token (no rotation), instead of clearing it out. - Read the token response via response.clone().json() so the Response returned to callers still has an unconsumed body. Also updates the unit test's mockTokenResponse() to return a fresh Response per fetch() call (mockImplementation instead of mockResolvedValue), and restores the regression test for the no-rotation case.
DPoP mode has no hosted backend to proxy through, so startLogout() should never target /app/logout/ (a hosted-backend-only path that does not exist on a raw FusionAuth server). Add UrlHelper.getOAuth2LogoutUrl(), which builds the direct FusionAuth /oauth2/logout URL (client_id + post_logout_redirect_uri), and switch SDKCore.startDpopLogout() to use it. Cookie-mode getLogoutUrl()/startLogout() are unchanged. Update dpop-smoke.test.ts's logout assertion accordingly, and add unit coverage in UrlHelper.test.ts and SDKCore.test.ts.
Mirrors endpoints.test.ts for DPoP mode, driving a consuming quickstart application (useDpop: true) through its UI and validating the direct FusionAuth calls SDKCore makes instead of the hosted backend API: - Login: /oauth2/authorize (dpop_jkt, code_challenge/S256) followed by the direct /oauth2/token authorization_code exchange (DPoP header, tokens landing in localStorage instead of app.* cookies). - Refresh: waits for the configured auto-refresh window to fire a direct /oauth2/token refresh_token grant (DPoP header, new access token). - Logout: direct /oauth2/logout navigation and local DPoP state cleared. Register and fetching user info remain known gaps (not yet DPoP-aware in SDKCore) and are documented as such in the file header. Also: - Add playwright.dpop-endpoints.config.ts (mirrors the main config, scoped to this file via testMatch, keeps webServer/SERVER_COMMAND/PORT support). - Exclude both DPoP e2e files (dpop-smoke.test.ts, dpop-endpoints.test.ts) from the main playwright.config.ts via testIgnore, since they require different FusionAuth Application configs than the hosted-backend-mode quickstart used by endpoints.test.ts/cookies.test.ts. - Add a test:e2e:dpop-endpoints root script. - Update README's DPoP E2E tests section accordingly.
- Add UrlHelper.getUserInfoUrl(), building the direct FusionAuth /oauth2/userinfo URL, matching the getTokenUrl()/getOAuth2LogoutUrl() pattern. - fetchUserInfo() now branches to a new fetchDpopUserInfo() in DPoP mode: reads the stored access token from DPoPManager, throws a descriptive error if not logged in, and calls DPoPManager.fetch() against /oauth2/userinfo directly instead of the hosted backend's /app/me. DPoPManager.fetch() already handles the DPoP proof (with ath), Authorization/DPoP headers, and nonce-retry dance. - Cookie-mode fetchUserInfo()/getMeUrl() behavior is unchanged. - No changes needed to React/Vue/Angular SDKs -- all three call core.fetchUserInfo() generically and get this transparently. - Add unit tests for UrlHelper.getUserInfoUrl() and SDKCore.fetchUserInfo() DPoP mode (success, no-access-token error, non-OK error, cookie-mode unaffected). - dpop-endpoints.test.ts: add an e2e test asserting fetchUserInfo() hits /oauth2/userinfo directly (with a DPoP header) and that the fetched claims surface correctly in the app's UI. Update the file's "known gaps" header comment -- only Register remains. - README: update the DPoP E2E tests section accordingly.
… at mount useUserInfo()'s auto-fetch previously checked core.isLoggedIn synchronously, exactly once, inside a ref-guarded effect. This works in cookie mode (the post-redirect callback does a full page reload, so isLoggedIn is already true by the time the app re-mounts), but breaks DPoP mode: the code exchange happens asynchronously within the same already-mounted app, so isLoggedIn is still false the one time the guard checks it, and fetchUserInfo() is never called -- shouldAutoFetchUserInfo silently does nothing for the rest of the session. - useUserInfo() now accepts isLoggedIn as an explicit reactive parameter instead of reading core.isLoggedIn directly, so the auto-fetch effect re-evaluates when isLoggedIn transitions to true (still only fetching once, via the existing ref guard). - FusionAuthProvider passes its own isLoggedIn state through. - Add a regression test simulating the DPoP flow (isLoggedIn starts false, flips true once the post-redirect token exchange settles) and asserting userInfo is fetched once that happens. Vue's createFusionAuth() has the same synchronous-check pattern (and Angular's auto-refresh check does too), but neither exposes useDpop yet (ENG-4788/ENG-4789), so it's currently dormant there -- worth revisiting once DPoP wiring lands for those SDKs.
Moves the standalone 'User info is fetched directly from /oauth2/userinfo' test's assertions into 'Access token auto-refreshes...' (renamed to 'User info is fetched after login, and the access token auto-refreshes via a direct /oauth2/token refresh_token grant'), right after authenticate() and before the refresh-window wait. Both are automatic, no-user-interaction behaviors that fire post-login (shouldAutoFetchUserInfo and shouldAutoRefresh), so combining them saves one login/authenticate cycle. File now has 3 tests: Login, this merged one, and Logout.
Logs every request/response/requestfailed to/from FusionAuth (localhost:9011), plus browser console messages and page errors, to the Playwright terminal output. Should be reverted once the root cause of the userinfo test timeout is found.
…gation" This reverts commit 02ed28a.
Root-caused the userinfo test timeout: the browser's CORS preflight for /oauth2/userinfo fails with 'No Access-Control-Allow-Origin header is present', so the request never reaches FusionAuth at all. Confirmed via live diagnostics that /oauth2/authorize and /oauth2/token succeed cross-origin (handled independently of the System CORS filter), but /oauth2/userinfo does not -- this is a FusionAuth Application/System CORS configuration gap, not a bug in SDKCore/DPoPManager. Document the required CORS configuration (Settings -> System -> CORS: enable the filter, add the quickstart's origin, add DPoP and Authorization to Allowed headers) in the file's Prerequisites section.
… handlePostRedirect() calls SDKCore.handlePostRedirect() only cleared the pending `code` query param and persisted `code_verifier` *after* a successful /oauth2/token exchange. A second call arriving while the first was still in flight (e.g. React StrictMode's mount -> cleanup -> mount double-invoke of effects, or an un-memoized onRedirect prop retriggering the effect) would read the same still-pending code/verifier and re-POST to /oauth2/token, exchanging the same authorization code twice — visible as two 'exchange authorization code' debug entries in the FusionAuth event log. Fix: memoize handlePostRedirect()'s promise (single-flight, mirroring DPoPManager.getOrCreateKeyPair()'s keyPairPromise pattern) so repeated or concurrent calls on the same SDKCore instance always return the same in-flight/settled promise instead of re-entering handleDpopPostRedirect(). - packages/core/src/SDKCore/SDKCore.ts: add postRedirectPromise guard. - packages/core/src/SDKCore/SDKCore.test.ts: add regression tests (verified both fail without the fix). - e2e/tests/dpop-endpoints.test.ts: track every authorization_code exchange request during login and assert exactly one occurs. sdk-angular's vendored SDKCore.ts copy is gitignored and generated via `yarn get-sdk-core` (packages/sdk-angular/getSDKCore.js), so it picks up this fix automatically — verified locally, nothing to commit there. sdk-vue consumes @fusionauth-sdk/core directly and gets the fix for free too.
'refresh token grant — issues new DPoP-bound tokens' had two compounding bugs after being resurrected via a merge: 1. test.skip(!refreshToken, ...) referenced a variable that was never declared in this file (ReferenceError). Every other test in the file uses the !accessToken skip-guard convention -- switch to that. 2. The test requires core to still be logged in (asserts core.isLoggedIn and calls core.refreshToken()), but it ran *after* 'startLogout() clears DPoP state...', which already logs core out. Move it back to run right after the authorization code grant test and before startLogout(), matching its actual dependency.
Base automatically changed from
miker/eng-4801/refresh-token
to
parent/dpop-in-the-javascript-sdk
July 30, 2026 18:28
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds DPoP (RFC 9449) support across the framework SDKs (React, Angular, Vue) by surfacing DPoP helpers from @fusionauth-sdk/core, extending configuration to enable DPoP mode, and updating E2E coverage to validate direct FusionAuth /oauth2/* calls from a DPoP-enabled quickstart.
Changes:
- Expose DPoP APIs (
dpopFetch,generateProof,getAccessToken) in React/Vue contexts and Angular service, gated byuseDpop. - Extend
SDKCore/UrlHelperwith DPoP-mode endpoint helpers and DPoP-awarefetchUserInfo()/ logout behavior. - Replace the prior DPoP smoke test setup with a dedicated “DPoP endpoints” Playwright config + test suite and update docs/scripts accordingly.
Reviewed changes
Copilot reviewed 41 out of 42 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Updates lockfile to reflect dependency/version changes (incl. Vitest upgrades). |
| README.md | Updates DPoP E2E test documentation to focus on endpoint tests + new config. |
| playwright.dpop.config.ts | Removes obsolete DPoP smoke-test Playwright config. |
| playwright.dpop-endpoints.config.ts | Adds dedicated Playwright config for DPoP endpoint test suite. |
| playwright.config.ts | Ignores the DPoP endpoint test in the default E2E run. |
| packages/sdk-vue/web-types.json | Bumps Vue SDK version metadata to 1.4.0. |
| packages/sdk-vue/src/types.ts | Adds useDpop/dpopTokenStorage config and optional DPoP helpers to the Vue types. |
| packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts | Exposes DPoP helpers and makes post-redirect/login state updates reactive in DPoP mode. |
| packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts | Adds Vue tests covering DPoP helper exposure and post-redirect DPoP login settling. |
| packages/sdk-vue/README.md | Documents Vue DPoP mode config and the additional returned helpers. |
| packages/sdk-vue/package.json | Bumps Vue SDK version to 1.4.0. |
| packages/sdk-vue/CHANGES.md | Adds Vue 1.4.0 changelog entry for DPoP support. |
| packages/sdk-react/src/testing-tools/mocks/createContextMock.ts | Extends React context mock to include DPoP helpers. |
| packages/sdk-react/src/components/providers/hooks/useUserInfo.ts | Makes auto-fetch depend on reactive isLoggedIn state (for DPoP post-redirect settling). |
| packages/sdk-react/src/components/providers/hooks/useRedirecting.ts | Awaits core.handlePostRedirect() and provides a post-settlement hook. |
| packages/sdk-react/src/components/providers/hooks/useDpop.ts | Adds a hook to expose DPoP helpers only when DPoP mode is enabled. |
| packages/sdk-react/src/components/providers/hooks/index.ts | Re-exports the new useDpop hook. |
| packages/sdk-react/src/components/providers/FusionAuthProviderContext.ts | Adds optional DPoP helper fields to provider context. |
| packages/sdk-react/src/components/providers/FusionAuthProviderConfig.ts | Adds useDpop/dpopTokenStorage to React provider config. |
| packages/sdk-react/src/components/providers/FusionAuthProvider.tsx | Wires DPoP config into SDKCore and exposes DPoP helpers via context. |
| packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx | Adds React tests for DPoP helper exposure and post-redirect DPoP login settling. |
| packages/sdk-react/README.md | Documents React DPoP mode config and the additional returned helpers. |
| packages/sdk-react/package.json | Bumps React SDK version to 2.7.0 and updates Vitest to ^3.2.6. |
| packages/sdk-react/CHANGES.md | Adds React 2.7.0 changelog entry for DPoP support. |
| packages/sdk-angular/README.md | Documents Angular DPoP mode config and new FusionAuthService helpers. |
| packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/types.ts | Adds useDpop/dpopTokenStorage to Angular config type. |
| packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.ts | Adds DPoP helper methods and improves reactivity around post-redirect/login settling. |
| packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.spec.ts | Adds Angular tests for DPoP helper behavior and reactive login settling. |
| packages/sdk-angular/projects/fusionauth-angular-sdk/package.json | Bumps Angular SDK version to 2.1.0 and adds dpop dependency. |
| packages/sdk-angular/projects/fusionauth-angular-sdk/ng-package.json | Allows dpop as a non-peer dependency for packaging. |
| packages/sdk-angular/CHANGES.md | Adds Angular 2.1.0 changelog entry for DPoP support. |
| packages/lexicon/package.json | Updates Vitest dev dependency to ^3.2.6. |
| packages/core/src/UrlHelper/UrlHelper.ts | Adds DPoP-mode /oauth2/logout and /oauth2/userinfo URL helpers. |
| packages/core/src/UrlHelper/UrlHelper.test.ts | Adds unit tests for new DPoP-mode URL helpers. |
| packages/core/src/SDKCore/SDKCore.ts | Adds DPoP fetch/proof APIs, DPoP-aware userinfo fetch, and direct /oauth2/logout wiring. |
| packages/core/src/SDKCore/SDKCore.test.ts | Adds/extends tests for DPoP logout, dpopFetch/generateProof, and DPoP-mode userinfo. |
| packages/core/src/SDKContext/SDKContext.ts | Adds optional DPoP helpers to the core SDK context interface. |
| packages/core/package.json | Updates Vitest dev dependency to ^3.2.6. |
| package.json | Adds a dedicated test:e2e:dpop-endpoints script for the new Playwright config. |
| e2e/tests/dpop-smoke.test.ts | Removes obsolete DPoP smoke test suite. |
| e2e/tests/dpop-endpoints.test.ts | Adds new DPoP endpoint-focused E2E suite against a DPoP-enabled quickstart. |
| e2e/pages/common.page.ts | Updates quickstart page-object navigation waits to better handle redirects/load completion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
mrudatsprint
marked this pull request as ready for review
August 1, 2026 17:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issues:
Description:
Update each Framework SDK to support DPoP. This includes: