Skip to content
Merged
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
64 changes: 64 additions & 0 deletions AGENTS.md
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`.
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
258 changes: 258 additions & 0 deletions packages/core/src/DPoP/DPoPStorage.test.ts
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 = () => {
Comment thread
mrudatsprint marked this conversation as resolved.
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;
}
});
Comment thread
Copilot marked this conversation as resolved.
});
});
Loading
Loading