-
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
base: parent/dpop-in-the-javascript-sdk
Are you sure you want to change the base?
Changes from all commits
b95aba5
9f3e0bc
7738bb4
d386a32
bd37ddf
d91e5b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,6 @@ name: Run Tests | |
|
|
||
| on: | ||
| pull_request: | ||
| branches: [main] | ||
|
|
||
| jobs: | ||
| test: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # 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/`). | ||
| - 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest'; | ||
| import { IDBFactory } from 'fake-indexeddb'; | ||
| import { generateKeyPair } from 'dpop'; | ||
|
|
||
| import { DPoPStorage } 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('client-a'); | ||
| const result = await storage.getKeyPair(); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('persists a key pair and retrieves it', async () => { | ||
| const storage = new DPoPStorage('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('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('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('client-a'); | ||
| const storageB = new DPoPStorage('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('client-a'); | ||
| const storageB = new DPoPStorage('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('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('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('client-a'); | ||
| await expect(storage.getKeyPair()).rejects.toThrow( | ||
| 'blocked by security policy', | ||
| ); | ||
| } finally { | ||
| globalThis.indexedDB.open = originalOpen; | ||
| } | ||
| }); | ||
|
|
||
| it('rejects when a readwrite transaction is aborted', async () => { | ||
| const storage = new DPoPStorage('client-a'); | ||
| const keyPair = await generateKeyPair('ES256', { extractable: false }); | ||
|
|
||
| // Store a key pair so we have a committed baseline. | ||
| await storage.setKeyPair(keyPair); | ||
|
|
||
| // Intercept the next open() call to wrap db.transaction() so we can | ||
| // abort the transaction synchronously inside the request's onsuccess — | ||
| // after the request fires but before tx.oncomplete, simulating a real | ||
| // abort scenario (e.g. quota exceeded). | ||
| const originalOpen = globalThis.indexedDB.open.bind(globalThis.indexedDB); | ||
| globalThis.indexedDB.open = (...args: Parameters<IDBFactory['open']>) => { | ||
| const openReq = originalOpen(...args); | ||
| openReq.addEventListener('success', () => { | ||
| const db = openReq.result as IDBDatabase; | ||
| const originalTx = db.transaction.bind(db); | ||
| db.transaction = ( | ||
| ...txArgs: Parameters<IDBDatabase['transaction']> | ||
| ) => { | ||
| const tx = originalTx(...txArgs); | ||
| // Wrap each request made on this transaction to abort synchronously | ||
| // inside onsuccess — this fires after the request succeeds but | ||
| // before tx.oncomplete, which is the exact scenario we want to test. | ||
| const originalGet = tx.objectStore.bind(tx); | ||
| const store = originalGet(txArgs[0] as string); | ||
| const originalDelete = store.delete.bind(store); | ||
| store.delete = ( | ||
| ...deleteArgs: Parameters<IDBObjectStore['delete']> | ||
| ) => { | ||
| const delReq = originalDelete(...deleteArgs); | ||
| delReq.addEventListener('success', () => { | ||
| tx.abort(); | ||
| }); | ||
| return delReq; | ||
| }; | ||
| return tx; | ||
| }; | ||
| }); | ||
| return openReq; | ||
| }; | ||
|
|
||
| try { | ||
| await expect(storage.clearKeyPair()).rejects.toThrow(); | ||
| } finally { | ||
| globalThis.indexedDB.open = originalOpen; | ||
| } | ||
|
|
||
| // The abort happened before oncomplete — the key pair should still be present. | ||
| const result = await storage.getKeyPair(); | ||
| expect(result).toBeDefined(); | ||
| }); | ||
|
Copilot marked this conversation as resolved.
|
||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import type { KeyPair } from 'dpop'; | ||
|
|
||
| const DB_NAME = 'fusionauth-sdk:dpop'; | ||
| const DB_VERSION = 1; | ||
| const STORE_NAME = 'keypair'; | ||
|
|
||
| /** | ||
| * IndexedDB abstraction for persisting the ES256 `CryptoKeyPair` across | ||
| * browser sessions. Keys are namespaced by `clientId` to isolate multiple | ||
| * FusionAuth applications hosted on the same origin. | ||
| */ | ||
| export class DPoPStorage { | ||
| private readonly clientId: string; | ||
|
|
||
| constructor(clientId: string) { | ||
| this.clientId = clientId; | ||
| } | ||
|
|
||
| /** | ||
| * Retrieves the persisted `CryptoKeyPair` for this `clientId`, or | ||
| * `undefined` if none exists. | ||
| */ | ||
| async getKeyPair(): Promise<KeyPair | undefined> { | ||
| const db = await this.openDb(); | ||
| return new Promise((resolve, reject) => { | ||
| const tx = db.transaction(STORE_NAME, 'readonly'); | ||
| const req = tx.objectStore(STORE_NAME).get(this.clientId); | ||
| tx.oncomplete = () => { | ||
| db.close(); | ||
| resolve(req.result as KeyPair | undefined); | ||
| }; | ||
| tx.onerror = () => { | ||
| db.close(); | ||
| reject(tx.error); | ||
| }; | ||
| tx.onabort = () => { | ||
| db.close(); | ||
| reject(tx.error ?? new Error('IndexedDB transaction aborted')); | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Persists a `CryptoKeyPair` to IndexedDB under this `clientId`. | ||
| */ | ||
| async setKeyPair(keyPair: KeyPair): Promise<void> { | ||
| 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); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. |
||
| tx.oncomplete = () => { | ||
| db.close(); | ||
| resolve(); | ||
| }; | ||
| tx.onerror = () => { | ||
| db.close(); | ||
| reject(tx.error); | ||
| }; | ||
| tx.onabort = () => { | ||
| db.close(); | ||
| reject(tx.error ?? new Error('IndexedDB transaction aborted')); | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Removes the stored key pair for this `clientId`. Called on logout. | ||
| */ | ||
| async clearKeyPair(): Promise<void> { | ||
| const db = await this.openDb(); | ||
| return new Promise((resolve, reject) => { | ||
| const tx = db.transaction(STORE_NAME, 'readwrite'); | ||
| tx.objectStore(STORE_NAME).delete(this.clientId); | ||
| tx.oncomplete = () => { | ||
| db.close(); | ||
| resolve(); | ||
| }; | ||
| tx.onerror = () => { | ||
| db.close(); | ||
| reject(tx.error); | ||
| }; | ||
| tx.onabort = () => { | ||
| db.close(); | ||
| reject(tx.error ?? new Error('IndexedDB transaction aborted')); | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| private openDb(): Promise<IDBDatabase> { | ||
| return new Promise((resolve, reject) => { | ||
| // Graceful fallback for non-browser/SSR environments. | ||
| if (typeof indexedDB === 'undefined') { | ||
| reject(new Error('indexedDB is not available in this environment')); | ||
| return; | ||
| } | ||
| let req: IDBOpenDBRequest; | ||
| try { | ||
| req = indexedDB.open(DB_NAME, DB_VERSION); | ||
| } catch (err) { | ||
| reject(err); | ||
| return; | ||
| } | ||
|
|
||
| req.onupgradeneeded = event => { | ||
| const db = (event.target as IDBOpenDBRequest).result; | ||
| if (!db.objectStoreNames.contains(STORE_NAME)) { | ||
| db.createObjectStore(STORE_NAME); | ||
| } | ||
| }; | ||
|
|
||
| req.onsuccess = () => resolve(req.result); | ||
| req.onerror = () => reject(req.error); | ||
| }); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
overwrite the open property so that when getKeyPair is called the DPoPStorage.openDb will fail.