Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/lint-and-format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: Lint and Format

on:
pull_request:
branches: [main]

jobs:
check:
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: Run Tests

on:
pull_request:
branches: [main]

jobs:
test:
Expand Down
57 changes: 57 additions & 0 deletions AGENTS.md
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.
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"test:watch": "vitest",
"test": "vitest --watch=false"
},
"dependencies": {
"dpop": "^2.1.1"
},
"devDependencies": {
"fake-indexeddb": "^6.0.0",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-plugin-dts": "^3.8.0",
Expand Down
195 changes: 195 additions & 0 deletions packages/core/src/DPoP/DPoPStorage.test.ts
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 = () => {

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.

overwrite the open property so that when getKeyPair is called the DPoPStorage.openDb will fail.

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();
});
Comment thread
Copilot marked this conversation as resolved.
});
});
115 changes: 115 additions & 0 deletions packages/core/src/DPoP/DPoPStorage.ts
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);

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.

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