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
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
104 changes: 104 additions & 0 deletions packages/core/src/DPoP/DPoPStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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,
);
});
});
92 changes: 92 additions & 0 deletions packages/core/src/DPoP/DPoPStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { 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);
req.onsuccess = () => {
db.close();
resolve(req.result as KeyPair | undefined);
};
req.onerror = () => {
db.close();
reject(req.error);
};
});
}

/**
* 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');
const req = tx.objectStore(STORE_NAME).put(keyPair, this.clientId);
req.onsuccess = () => {
db.close();
resolve();
};
req.onerror = () => {
db.close();
reject(req.error);
};
});
}

/**
* 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');
const req = tx.objectStore(STORE_NAME).delete(this.clientId);
req.onsuccess = () => {
db.close();
resolve();
};
req.onerror = () => {
db.close();
reject(req.error);
};
});
}

private openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);

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);
});
}
Comment on lines +77 to +91
}
Loading
Loading