feat: add DPoP core storage layer (ENG-4782)#201
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)
There was a problem hiding this comment.
Pull request overview
This PR introduces the core building blocks needed to support DPoP mode in @fusionauth-sdk/core, adding a persistent keypair store (IndexedDB), a token store (localStorage/memory), and an authorize URL helper for DPoP + PKCE flows.
Changes:
- Added DPoP keypair persistence via IndexedDB (
DPoPStorage) and corresponding tests. - Added a DPoP token storage abstraction (
DPoPTokenStore) with localStorage/memory backends and tests. - Extended
UrlHelperto generate a direct/oauth2/authorizeURL for DPoP/PKCE, plus type/test updates and public exports.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/UrlHelper/UrlHelperTypes.ts | Expands query param typing to include DPoP + PKCE authorize parameters. |
| packages/core/src/UrlHelper/UrlHelper.ts | Adds getAuthorizeUrl for direct FusionAuth /oauth2/authorize URL generation. |
| packages/core/src/UrlHelper/UrlHelper.test.ts | Adds coverage for getAuthorizeUrl query string construction. |
| packages/core/src/SDKConfig/SDKConfig.ts | Introduces DPoP opt-in configuration (useDpop, dpopTokenStorage). |
| packages/core/src/index.ts | Exposes the new DPoP module from the package entrypoint. |
| packages/core/src/DPoP/index.ts | Barrel exports for DPoP storage/token store APIs. |
| packages/core/src/DPoP/DPoPTokenStore.ts | Implements token persistence abstraction for DPoP mode. |
| packages/core/src/DPoP/DPoPTokenStore.test.ts | Adds tests for localStorage and memory token persistence behavior. |
| packages/core/src/DPoP/DPoPStorage.ts | Implements IndexedDB-backed persistence for DPoP keypairs. |
| packages/core/src/DPoP/DPoPStorage.test.ts | Adds IndexedDB persistence tests using fake-indexeddb. |
| packages/core/package.json | Adds dpop runtime dependency and fake-indexeddb dev dependency. |
| AGENTS.md | Adds repo agent guidance (workspace layout, testing notes, and conventions). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- 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)
| /** | ||
| * Token storage location in DPoP mode. Only meaningful when `useDpop: true`. | ||
| * Defaults to `'localStorage'`. | ||
| */ | ||
| dpopTokenStorage?: 'localStorage' | 'memory'; |
There was a problem hiding this comment.
Should memory be the default instead of localStorage?
There was a problem hiding this comment.
My gut feel is localStorage.
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.
mrudatsprint
left a comment
There was a problem hiding this comment.
self-review completed
| const originalOpen = globalThis.indexedDB.open.bind(globalThis.indexedDB); | ||
|
|
||
| try { | ||
| globalThis.indexedDB.open = () => { |
There was a problem hiding this comment.
overwrite the open property so that when getKeyPair is called the DPoPStorage.openDb will fail.
| const db = await this.openDb(); | ||
| return new Promise((resolve, reject) => { | ||
| const tx = db.transaction(STORE_NAME, 'readwrite'); | ||
| tx.objectStore(STORE_NAME).put(keyPair, this.clientId); |
There was a problem hiding this comment.
The keyPair is namespaced by clientId to isolate multiple FusionAuth applications. This means multiple applications using DPoP running in for example two separate browser tabs will run successfully.
When using the Hosted Backend API this browser configuration doesn't work as the cookies are stored on the lowest level domain and displayed on all sub domains. Meaning, the next applications cookies overwrite the previous applications cookies.
| /** | ||
| * Token storage location in DPoP mode. Only meaningful when `useDpop: true`. | ||
| * Defaults to `'localStorage'`. | ||
| */ | ||
| dpopTokenStorage?: 'localStorage' | 'memory'; |
There was a problem hiding this comment.
My gut feel is localStorage.
Issue:
Description:
Implement the storage for the Cryptographic Key Pair (DPoPStorage) and the storage for the tokens (DPoPTokenStore)