-
Notifications
You must be signed in to change notification settings - Fork 9
feat: add DPoP core storage layer (ENG-4782) #201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mrudatsprint
merged 8 commits into
parent/dpop-in-the-javascript-sdk
from
miker/eng-4782/core-storage-layer
Jul 20, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
b95aba5
feat: add DPoP core storage layer (ENG-4782)
mrudatsprint 9f3e0bc
feat: fix file formatting.
mrudatsprint 7738bb4
fix: DPoPStorage openDb() error handling and test coverage
mrudatsprint d386a32
fix: fix copilot warnings.
mrudatsprint bd37ddf
fix: resolve DPoP transactions on tx.oncomplete, not req.onsuccess
mrudatsprint d91e5b7
feat: the workflow will run regardless of the branch being merged into.
mrudatsprint 952cd03
refactor: make DPoPStorage IndexedDB constants configurable via confi…
mrudatsprint b7e6feb
feature: delete contrived test to intercept a successful even and the…
mrudatsprint File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,6 @@ name: Lint and Format | |
|
|
||
| on: | ||
| pull_request: | ||
| branches: [main] | ||
|
|
||
| jobs: | ||
| check: | ||
|
|
||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,6 @@ name: Run Tests | |
|
|
||
| on: | ||
| pull_request: | ||
| branches: [main] | ||
|
|
||
| jobs: | ||
| test: | ||
|
|
||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| # AGENTS.md — fusionauth-javascript-sdk | ||
|
|
||
| ## Repo Layout | ||
|
|
||
| Yarn workspace monorepo: | ||
|
|
||
| - `packages/core` — `@fusionauth-sdk/core`, shared logic for React/Angular/Vue SDKs | ||
| - `packages/lexicon` — `@fusionauth-sdk/lexicon`, shared utility types (Path, GUID, etc.) | ||
| - `packages/sdk-react` — `@fusionauth/react-sdk` | ||
| - `packages/sdk-angular` — Angular SDK (`sdk-angular-workspace`) | ||
| - `packages/sdk-vue` — `@fusionauth/vue-sdk` | ||
|
|
||
| ## Package Manager | ||
|
|
||
| - **Yarn 1.22.x** via Corepack, not npm. `yarn` may not be on `PATH` directly — | ||
| use `corepack yarn <cmd>` if plain `yarn` isn't found. | ||
| - Root scripts: `yarn build:core`, `yarn build:sdk-react`, etc. Per-workspace: | ||
| `yarn workspace @fusionauth-sdk/core test`. | ||
|
|
||
| ## Node Version Constraint | ||
|
|
||
| - **Angular SDK requires Node ^22.22.3 / ^24.15.0 / >=26.0.0.** If the active | ||
| Node is v20.x, `yarn workspace sdk-angular-workspace test` (and the root | ||
| `yarn test` which runs it) will fail with an engine/CLI version error. | ||
| This is an environment limitation, not a code issue — don't try to "fix" it | ||
| by changing the Angular config. | ||
| - If `yarn install` complains about the Angular engine mismatch, use | ||
| `yarn install --ignore-engines`. | ||
|
|
||
| ## Testing | ||
|
|
||
| - Vitest, `environment: 'jsdom'` (see each package's `vite.config.ts`). | ||
| - `packages/core` tests needing `indexedDB` must polyfill it with | ||
| `fake-indexeddb`'s `IDBFactory` (jsdom has no native IndexedDB). | ||
| - Husky's `.husky/pre-commit` hook runs `yarn test` across **all** workspaces | ||
| plus `lint-staged`. In environments without Node 22+, this hook will fail | ||
| on the Angular workspace even when your actual changes are fine — verify | ||
| the specific package's tests pass, note that the Angular failure is | ||
| environmental, and use `git commit --no-verify` if needed (call this out | ||
| explicitly to the user rather than silently bypassing). | ||
|
|
||
| ## Lint / Format | ||
|
|
||
| - ESLint config: root `.eslintrc.json` (`@typescript-eslint`, warns on unused | ||
| vars, `prefer-const`). | ||
| - Prettier: root `.prettierrc` (single quotes, semi, trailing commas, 80 print | ||
| width). Run `npx prettier --check <paths>` / `--write` before committing. | ||
|
|
||
| ## Code Conventions | ||
|
|
||
| - Each module lives in its own folder with `X.ts`, `X.test.ts`, and an | ||
| `index.ts` barrel export (see `UrlHelper/`, `CookieHelpers/`, `DPoP/`). | ||
| - Constructors that take more than one argument use a config object (e.g. | ||
| `DPoPStorageConfig`, `UrlHelperConfig`) rather than positional parameters. | ||
| - Browser-only APIs (`localStorage`, `indexedDB`, etc.) should degrade | ||
| gracefully for non-browser/SSR consumers — see `RedirectHelper.ts`'s | ||
| try/catch fallback pattern. | ||
| - Doc comments (`/** ... */`) on all public `SDKConfig` fields and public | ||
| class methods, matching existing style. | ||
| - **IndexedDB `dbVersion`**: IndexedDB versions can only increase. Never pass | ||
| a `dbVersion` lower than one already persisted in a browser — doing so | ||
| produces a `VersionError` and the database will not open. Only increment | ||
| `dbVersion` when a structural schema change (e.g. new object store) is also | ||
| being made inside `onupgradeneeded`. |
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest'; | ||
| import { IDBFactory } from 'fake-indexeddb'; | ||
| import { generateKeyPair } from 'dpop'; | ||
|
|
||
| import { | ||
| DPoPStorage, | ||
| DEFAULT_DPOP_DB_NAME, | ||
| DEFAULT_DPOP_DB_VERSION, | ||
| DEFAULT_DPOP_STORE_NAME, | ||
| } from './DPoPStorage'; | ||
|
|
||
| // Provide a fresh in-process IndexedDB for each test so tests are isolated. | ||
| beforeEach(() => { | ||
| // @ts-ignore — jsdom does not implement indexedDB; fake-indexeddb fills the gap. | ||
| globalThis.indexedDB = new IDBFactory(); | ||
| }); | ||
|
|
||
| describe('DPoPStorage', () => { | ||
| it('returns undefined when no key pair has been stored', async () => { | ||
| const storage = new DPoPStorage({ clientId: 'client-a' }); | ||
| const result = await storage.getKeyPair(); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('persists a key pair and retrieves it', async () => { | ||
| const storage = new DPoPStorage({ clientId: 'client-a' }); | ||
| const keyPair = await generateKeyPair('ES256', { extractable: false }); | ||
|
|
||
| await storage.setKeyPair(keyPair); | ||
| const retrieved = await storage.getKeyPair(); | ||
|
|
||
| expect(retrieved).toBeDefined(); | ||
| // CryptoKey objects are deserialized from IndexedDB — reference equality | ||
| // will not hold. Assert structural identity instead. | ||
| expect(retrieved!.privateKey.type).toBe('private'); | ||
| expect(retrieved!.privateKey.algorithm).toStrictEqual( | ||
| keyPair.privateKey.algorithm, | ||
| ); | ||
| expect(retrieved!.publicKey.type).toBe('public'); | ||
| expect(retrieved!.publicKey.algorithm).toStrictEqual( | ||
| keyPair.publicKey.algorithm, | ||
| ); | ||
| }); | ||
|
|
||
| it('returns the same key pair on a subsequent call (same IDBFactory instance)', async () => { | ||
| const storage = new DPoPStorage({ clientId: 'client-a' }); | ||
| const keyPair = await generateKeyPair('ES256', { extractable: false }); | ||
| await storage.setKeyPair(keyPair); | ||
|
|
||
| const first = await storage.getKeyPair(); | ||
| const second = await storage.getKeyPair(); | ||
|
|
||
| expect(second!.privateKey.algorithm).toStrictEqual( | ||
| first!.privateKey.algorithm, | ||
| ); | ||
| expect(second!.publicKey.algorithm).toStrictEqual( | ||
| first!.publicKey.algorithm, | ||
| ); | ||
| }); | ||
|
|
||
| it('clearKeyPair removes the stored value', async () => { | ||
| const storage = new DPoPStorage({ clientId: 'client-a' }); | ||
| const keyPair = await generateKeyPair('ES256', { extractable: false }); | ||
| await storage.setKeyPair(keyPair); | ||
|
|
||
| await storage.clearKeyPair(); | ||
|
|
||
| const result = await storage.getKeyPair(); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('isolates key pairs by clientId — different clients do not share keys', async () => { | ||
| const storageA = new DPoPStorage({ clientId: 'client-a' }); | ||
| const storageB = new DPoPStorage({ clientId: 'client-b' }); | ||
|
|
||
| const keyPairA = await generateKeyPair('ES256', { extractable: false }); | ||
| await storageA.setKeyPair(keyPairA); | ||
|
|
||
| // client-b should see nothing | ||
| const resultB = await storageB.getKeyPair(); | ||
| expect(resultB).toBeUndefined(); | ||
|
|
||
| // client-a's data is still intact | ||
| const resultA = await storageA.getKeyPair(); | ||
| expect(resultA).toBeDefined(); | ||
| expect(resultA!.privateKey.algorithm).toStrictEqual( | ||
| keyPairA.privateKey.algorithm, | ||
| ); | ||
| }); | ||
|
|
||
| it('clearKeyPair for one clientId does not affect another', async () => { | ||
| const storageA = new DPoPStorage({ clientId: 'client-a' }); | ||
| const storageB = new DPoPStorage({ clientId: 'client-b' }); | ||
|
|
||
| const keyPairA = await generateKeyPair('ES256', { extractable: false }); | ||
| const keyPairB = await generateKeyPair('ES256', { extractable: false }); | ||
| await storageA.setKeyPair(keyPairA); | ||
| await storageB.setKeyPair(keyPairB); | ||
|
|
||
| await storageA.clearKeyPair(); | ||
|
|
||
| expect(await storageA.getKeyPair()).toBeUndefined(); | ||
| const resultB = await storageB.getKeyPair(); | ||
| expect(resultB).toBeDefined(); | ||
| expect(resultB!.privateKey.algorithm).toStrictEqual( | ||
| keyPairB.privateKey.algorithm, | ||
| ); | ||
| }); | ||
|
|
||
| describe('config defaults', () => { | ||
| it('uses DEFAULT_DPOP_DB_NAME when dbName is not provided', async () => { | ||
| const storage = new DPoPStorage({ clientId: 'client-a' }); | ||
| const keyPair = await generateKeyPair('ES256', { extractable: false }); | ||
| await storage.setKeyPair(keyPair); | ||
|
|
||
| // Verify the key landed in the default database by opening it directly. | ||
| const db = await new Promise<IDBDatabase>((resolve, reject) => { | ||
| const req = indexedDB.open( | ||
| DEFAULT_DPOP_DB_NAME, | ||
| DEFAULT_DPOP_DB_VERSION, | ||
| ); | ||
| req.onsuccess = () => resolve(req.result); | ||
| req.onerror = () => reject(req.error); | ||
| }); | ||
| const result = await new Promise((resolve, reject) => { | ||
| const tx = db.transaction(DEFAULT_DPOP_STORE_NAME, 'readonly'); | ||
| const req = tx.objectStore(DEFAULT_DPOP_STORE_NAME).get('client-a'); | ||
| tx.oncomplete = () => { | ||
| db.close(); | ||
| resolve(req.result); | ||
| }; | ||
| tx.onerror = () => { | ||
| db.close(); | ||
| reject(tx.error); | ||
| }; | ||
| }); | ||
| expect(result).toBeDefined(); | ||
| }); | ||
|
|
||
| it('uses custom dbName and storeName when provided', async () => { | ||
| const storage = new DPoPStorage({ | ||
| clientId: 'client-a', | ||
| dbName: 'my-custom-db', | ||
| storeName: 'my-custom-store', | ||
| }); | ||
| const keyPair = await generateKeyPair('ES256', { extractable: false }); | ||
| await storage.setKeyPair(keyPair); | ||
|
|
||
| // Verify the key landed in the custom database, not the default one. | ||
| const customDb = await new Promise<IDBDatabase>((resolve, reject) => { | ||
| const req = indexedDB.open('my-custom-db', DEFAULT_DPOP_DB_VERSION); | ||
| req.onupgradeneeded = e => { | ||
| (e.target as IDBOpenDBRequest).result.createObjectStore( | ||
| 'my-custom-store', | ||
| ); | ||
| }; | ||
| req.onsuccess = () => resolve(req.result); | ||
| req.onerror = () => reject(req.error); | ||
| }); | ||
| const result = await new Promise((resolve, reject) => { | ||
| const tx = customDb.transaction('my-custom-store', 'readonly'); | ||
| const req = tx.objectStore('my-custom-store').get('client-a'); | ||
| tx.oncomplete = () => { | ||
| customDb.close(); | ||
| resolve(req.result); | ||
| }; | ||
| tx.onerror = () => { | ||
| customDb.close(); | ||
| reject(tx.error); | ||
| }; | ||
| }); | ||
| expect(result).toBeDefined(); | ||
|
|
||
| // And nothing landed in the default database. | ||
| const defaultStorage = new DPoPStorage({ clientId: 'client-a' }); | ||
| expect(await defaultStorage.getKeyPair()).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('two instances with different dbNames but the same clientId do not share keys', async () => { | ||
| const storageA = new DPoPStorage({ | ||
| clientId: 'client-a', | ||
| dbName: 'db-one', | ||
| }); | ||
| const storageB = new DPoPStorage({ | ||
| clientId: 'client-a', | ||
| dbName: 'db-two', | ||
| }); | ||
|
|
||
| const keyPair = await generateKeyPair('ES256', { extractable: false }); | ||
| await storageA.setKeyPair(keyPair); | ||
|
|
||
| expect(await storageB.getKeyPair()).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('rejects with a VersionError when dbVersion is lower than an already-open database', async () => { | ||
| // Open the database at version 2 first. | ||
| await new Promise<void>((resolve, reject) => { | ||
| const req = indexedDB.open(DEFAULT_DPOP_DB_NAME, 2); | ||
| req.onupgradeneeded = e => { | ||
| const db = (e.target as IDBOpenDBRequest).result; | ||
| if (!db.objectStoreNames.contains(DEFAULT_DPOP_STORE_NAME)) { | ||
| db.createObjectStore(DEFAULT_DPOP_STORE_NAME); | ||
| } | ||
| }; | ||
| req.onsuccess = () => { | ||
| req.result.close(); | ||
| resolve(); | ||
| }; | ||
| req.onerror = () => reject(req.error); | ||
| }); | ||
|
|
||
| // Now attempt to open the same database at version 1 — must reject. | ||
| const storage = new DPoPStorage({ | ||
| clientId: 'client-a', | ||
| dbVersion: 1, // lower than the persisted version 2 | ||
| }); | ||
| await expect(storage.getKeyPair()).rejects.toBeDefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('openDb() error handling', () => { | ||
| it('rejects with a descriptive error when indexedDB is unavailable (SSR / non-browser)', async () => { | ||
| // @ts-ignore | ||
| delete globalThis.indexedDB; | ||
|
|
||
| const storage = new DPoPStorage({ clientId: 'client-a' }); | ||
| await expect(storage.getKeyPair()).rejects.toThrow( | ||
| 'indexedDB is not available in this environment', | ||
| ); | ||
| await expect( | ||
| storage.setKeyPair( | ||
| await generateKeyPair('ES256', { extractable: false }), | ||
| ), | ||
| ).rejects.toThrow('indexedDB is not available in this environment'); | ||
| await expect(storage.clearKeyPair()).rejects.toThrow( | ||
| 'indexedDB is not available in this environment', | ||
| ); | ||
| // globalThis.indexedDB is restored by the next beforeEach | ||
| }); | ||
|
|
||
| it('rejects when indexedDB.open() throws synchronously', async () => { | ||
| const originalOpen = globalThis.indexedDB.open.bind(globalThis.indexedDB); | ||
|
|
||
| try { | ||
| globalThis.indexedDB.open = () => { | ||
| throw new Error('blocked by security policy'); | ||
| }; | ||
|
|
||
| const storage = new DPoPStorage({ clientId: 'client-a' }); | ||
| await expect(storage.getKeyPair()).rejects.toThrow( | ||
| 'blocked by security policy', | ||
| ); | ||
| } finally { | ||
| globalThis.indexedDB.open = originalOpen; | ||
| } | ||
| }); | ||
|
Copilot marked this conversation as resolved.
|
||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.