Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f5b1143
feat: add DPoP core storage layer (ENG-4782) (#201)
mrudatsprint Jul 20, 2026
2ef92aa
feat: Implement DPoPManager - central coordinator (ENG-4784) (#202)
mrudatsprint Jul 22, 2026
1da8ebe
feat: The Authorization Code Grant can be started (#203)
mrudatsprint Jul 24, 2026
9792d12
feat: In the Authorization Code Grant flow, exchange the code for tok…
mrudatsprint Jul 28, 2026
3bacdd6
feat: The OIDC logout flow is supported (#205)
mrudatsprint Jul 29, 2026
86b6458
feat: The Refresh Token Grant flow is supported (#206)
mrudatsprint Jul 30, 2026
b395b86
feat: Framework SDKs support DPoP (#207)
mrudatsprint Aug 3, 2026
41be775
fix: validate token_type/expires_in and enforce state CSRF check on D…
mrudatsprint Aug 4, 2026
cb838a8
fix: resolve relative htu, consolidate nonce-challenge detection, duc…
mrudatsprint Aug 4, 2026
a8e7173
fix: validate PORT/SERVER_COMMAND env vars in dpop-endpoints Playwrig…
mrudatsprint Aug 4, 2026
af6da16
fix: use await instead of .then(), document zone/tick rationale in An…
mrudatsprint Aug 4, 2026
09d44f5
fix: replace NgZone.run()+ApplicationRef.tick() with a Signal in Angu…
mrudatsprint Aug 4, 2026
ea8cd6d
feat: remove verbose comments.
mrudatsprint Aug 4, 2026
38c1b15
feat: remove verbose comments.
mrudatsprint Aug 4, 2026
9e1553c
fix: handleDpopPostRedirect() returns its failure Error instead of th…
mrudatsprint Aug 4, 2026
4665c78
feat: add RedirectHelper.clearCodeFromUrl() (PR #211 review, batch 2 …
mrudatsprint Aug 4, 2026
9f0b4e0
feat: add UrlHelper.getOAuth2RegisterUrl() without dpop_jkt (PR #211 …
mrudatsprint Aug 4, 2026
976137e
feat: add UrlHelper.getOAuth2RegisterUrl() matching getAuthorizeUrl()…
mrudatsprint Aug 4, 2026
467f4b6
Merge branch 'parent/dpop-in-the-javascript-sdk' of github.com:Fusion…
mrudatsprint Aug 4, 2026
8e52bb8
feat: move DPoP flow methods onto DPoPManager (PR #211 review, batch …
mrudatsprint Aug 4, 2026
1becf41
refactor: SDKCore delegates DPoP flows to DPoPManager (PR #211 review…
mrudatsprint Aug 4, 2026
e2f9b07
fix: React onRedirect fires after isLoggedIn sync (PR #211 review, ba…
mrudatsprint Aug 4, 2026
24a0996
fix: React refreshToken() re-syncs isLoggedIn (PR #211 review, batch …
mrudatsprint Aug 4, 2026
2e29001
fix: React forwards onLoginFailure to SDKCore (PR #211 review, batch …
mrudatsprint Aug 4, 2026
3fe4385
fix: Angular onRedirect fires after isLoggedInSignal sync (PR #211 re…
mrudatsprint Aug 4, 2026
d9d9a44
fix: Angular + Vue refreshToken() re-syncs isLoggedIn (PR #211 review…
mrudatsprint Aug 4, 2026
4591ad6
fix: Angular + Vue refreshToken() re-syncs isLoggedIn (PR #211 review…
mrudatsprint Aug 4, 2026
63bb040
Merge branch 'parent/dpop-in-the-javascript-sdk' of github.com:Fusion…
mrudatsprint Aug 4, 2026
d3a4b25
fix: Angular + Vue expose onLoginFailure in FusionAuthConfig (PR #211…
mrudatsprint Aug 5, 2026
33949e9
fix: DPoPManager.fetch() builds a single Request via new Request()/cl…
mrudatsprint Aug 5, 2026
f7dfb0b
fix: DPoP redirects use an SDK-generated transactionState, not the ca…
mrudatsprint Aug 5, 2026
5e5190f
fix: update React/Vue DPoP redirect tests for SDK-generated transacti…
mrudatsprint Aug 5, 2026
f2728b3
fix: test:sdk-react/vue/angular scripts regenerate stale core artifac…
mrudatsprint Aug 5, 2026
ef9a228
fix: test:sdk-react/vue/angular scripts regenerate stale core artifac…
mrudatsprint Aug 5, 2026
d15f9eb
Merge branch 'parent/dpop-in-the-javascript-sdk' of github.com:Fusion…
mrudatsprint Aug 5, 2026
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
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# This is a managed file. Manual changes will be overwritten.
# https://github.com/FusionAuth/fusionauth-public-repos/

.github/ @fusionauth/owners @fusionauth/platform
.github/ @fusionauth/owners @fusionauth/platform @fusionauth/sdk-owners
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`.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ The e2e tests are structured to use the Page Object Model (POM) design pattern.
Example: In common.page.ts, methods for navigation and authentication are defined.
Tests import these page objects to perform actions, ensuring that if the UI changes, only the page object needs updating, not all the tests.

### DPoP E2E tests

`e2e/tests/dpop-endpoints.test.ts` — mirrors `endpoints.test.ts`, but drives a consuming quickstart application configured with `useDpop: true` through its UI. Since DPoP mode has no hosted backend mode (`SDKCore` talks directly to FusionAuth), this validates the *direct* calls to `/oauth2/authorize`, `/oauth2/token` (both the authorization code exchange and the refresh token grant), `/oauth2/userinfo`, and `/oauth2/logout`:
```
SERVER_COMMAND="your-dpop-quickstart-start-command" PORT=your-port-number npx playwright test e2e/tests/dpop-endpoints.test.ts --config playwright.dpop-endpoints.config.ts
```
This must be run on its own — it cannot be combined with `endpoints.test.ts` / `cookies.test.ts` in the same invocation, since those require a hosted backend mode quickstart instance instead.

## Architecture

We use a monorepo because our SDKs share core functionality, which is contained in the @fusionauth-sdk/core package. This private module is bundled into the distributed SDK packages, allowing us to maintain core logic in a single place.
Expand Down
13 changes: 10 additions & 3 deletions e2e/pages/common.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@ export class quickstartPage {
await this.locators.passwordInput.clear();
await this.locators.passwordInput.fill('password');
await this.locators.submitBtn.click();
// Wait for the full OAuth callback chain to complete (form POST → /app/callback
// code exchange → redirect back to the app). Without this, webkit doesn't finish
// committing the session cookies before the test body reads them.
await expect(this.locators.logOutBtn).toBeVisible();
await this.page.waitForLoadState('load');
}

async navToRegister() {
Expand All @@ -52,7 +50,16 @@ export class quickstartPage {
}

async logOut() {
const logoutNavigationPromise = this.page.waitForURL(
url => /\/(oauth2|app)\/logout/.test(url.pathname),
{ timeout: 10_000 },
);

await this.locators.logOutBtn.click();
await logoutNavigationPromise;

await expect(this.locators.logInBtn.nth(0)).toBeVisible();
// See the comment in authenticate() above.
await this.page.waitForLoadState('load');
}
}
197 changes: 197 additions & 0 deletions e2e/tests/dpop-endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* DPoP Endpoint Tests
*
* Mirrors `endpoints.test.ts`, but for a consuming quickstart application
* configured with `useDpop: true`. Since DPoP mode has no hosted backend to
* proxy through (`SDKCore` talks directly to FusionAuth), these tests
* validate the *direct* calls to FusionAuth's `/oauth2/authorize`,
* `/oauth2/token`, `/oauth2/userinfo`, and `/oauth2/logout` endpoints, and
* check for tokens in `localStorage` instead of `app.*` HttpOnly cookies.
*
* Run with:
* SERVER_COMMAND="your-dpop-quickstart-start-command" PORT=your-port-number \
* npx playwright test e2e/tests/dpop-endpoints.test.ts \
* --config playwright.dpop-endpoints.config.ts
*
* Prerequisites:
* - A consuming quickstart application (e.g. fusionauth-quickstart-javascript-react-web)
* configured with `useDpop: true`, `shouldAutoRefresh: true`, and
* `shouldAutoFetchUserInfo: true`
* - short access token (JWT) lifetime configured — e.g. 30-60 seconds —
* so the auto-refresh test below doesn't need a long wall-clock wait.
* Set `autoRefreshSecondsBeforeExpiry` so the refresh fires comfortably
* before expiry (e.g. 20s before a 30s token lifetime).
* - CORS must be configured in FusionAuth (Settings -> System -> CORS)
* to allow the quickstart's origin (e.g. http://localhost:3000) to call
* `/oauth2/userinfo` directly: enable the filter, add the origin to
* Allowed origins, and add `DPoP` and `Authorization` to Allowed
* headers. Without this, the userinfo request's CORS preflight fails
* with "No 'Access-Control-Allow-Origin' header is present"
*/

import { Page, test, BrowserContext, expect } from '@playwright/test';
import { quickstartPage } from '../pages/common.page';

interface DPoPTokens {
accessToken: string;
refreshToken?: string;
expiresAt: number;
tokenType: string;
}

async function readDpopTokens(page: Page): Promise<DPoPTokens | null> {
const evaluateTokens = () =>
page.evaluate(() => {
const key = Object.keys(localStorage).find(k =>
k.startsWith('fusionauth-sdk:tokens:'),
);
return key ? localStorage.getItem(key) : null;
});

let raw: string | null;
try {
raw = await evaluateTokens();
} catch (error) {
if (
error instanceof Error &&
error.message.includes('Execution context was destroyed')
) {
await page.waitForLoadState('load');
raw = await evaluateTokens();
} else {
throw error;
}
}
return raw ? JSON.parse(raw) : null;
}

test.describe('DPoP Endpoint Tests', () => {
test.describe.configure({ mode: 'serial' });

let page: Page;
let quickstart: quickstartPage;
let browserContext: BrowserContext;

test.beforeAll(async ({ browser }) => {
browserContext = await browser.newContext();
page = await browserContext.newPage();
quickstart = new quickstartPage(page);
});

test.afterAll(async () => {
await page?.close();
await browserContext?.close();
});

test.beforeEach(async () => {
await page.goto('/');
});

test('Login redirects directly to /oauth2/authorize with dpop_jkt and code_challenge, then exchanges the code at /oauth2/token', async () => {
await quickstart.navToLogIn();

const authorizeUrl = new URL(page.url());
expect(authorizeUrl.pathname).toBe('/oauth2/authorize');
expect(authorizeUrl.searchParams.get('response_type')).toBe('code');
expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256');

const dpopJkt = authorizeUrl.searchParams.get('dpop_jkt');
const codeChallenge = authorizeUrl.searchParams.get('code_challenge');
expect(dpopJkt).toBeTruthy();
expect(codeChallenge).toBeTruthy();

const tokenExchangeResponsePromise = page.waitForResponse(
response =>
response.url().includes('/oauth2/token') &&
response.request().method() === 'POST',
);

await quickstart.authenticate();

const tokenExchangeResponse = await tokenExchangeResponsePromise;
const tokenExchangeRequest = tokenExchangeResponse.request();
expect(new URL(tokenExchangeRequest.url()).pathname).toBe('/oauth2/token');
expect(tokenExchangeRequest.headers()['dpop']).toBeTruthy();

const body = new URLSearchParams(tokenExchangeRequest.postData() ?? '');
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code_verifier')).toBeTruthy();

const tokens = await readDpopTokens(page);
expect(tokens).not.toBeNull();
expect(tokens!.tokenType).toBe('DPoP');
expect(tokens!.accessToken).toBeTruthy();

const cookies = await browserContext.cookies();
['app.at', 'app.idt', 'app.rt', 'app.at_exp'].forEach(name => {
expect(cookies.find(cookie => cookie.name === name)).toBeUndefined();
});

await quickstart.logOut();
});

test('User info is fetched after login, and the access token auto-refreshes via a direct /oauth2/token refresh_token grant', async () => {
// The refresh window depends on the FusionAuth Application's configured
// access token lifetime and the quickstart's autoRefreshSecondsBeforeExpiry.
test.setTimeout(90_000);

await quickstart.navToLogIn();

const userInfoResponsePromise = page.waitForResponse(response =>
response.url().includes('/oauth2/userinfo'),
);

await quickstart.authenticate();

const userInfoResponse = await userInfoResponsePromise;
const userInfoRequest = userInfoResponse.request();
expect(new URL(userInfoRequest.url()).pathname).toBe('/oauth2/userinfo');
expect(userInfoRequest.headers()['dpop']).toBeTruthy();
expect(userInfoResponse.ok()).toBe(true);

await expect(page.getByText('richard@example.com')).toBeVisible();

const initialTokens = await readDpopTokens(page);
expect(initialTokens).not.toBeNull();

const refreshResponse = await page.waitForResponse(
response =>
response.url().includes('/oauth2/token') &&
(response.request().postData() ?? '').includes(
'grant_type=refresh_token',
),
{ timeout: 60_000 },
);

const refreshRequest = refreshResponse.request();
expect(refreshRequest.headers()['dpop']).toBeTruthy();
const body = new URLSearchParams(refreshRequest.postData() ?? '');
expect(body.get('refresh_token')).toBeTruthy();

const refreshedTokens = await readDpopTokens(page);
expect(refreshedTokens).not.toBeNull();
expect(refreshedTokens!.accessToken).not.toBe(initialTokens!.accessToken);

await quickstart.logOut();
});

test('Logout redirects directly to /oauth2/logout and clears local DPoP state', async () => {
await quickstart.navToLogIn();
await quickstart.authenticate();

expect(await readDpopTokens(page)).not.toBeNull();

const logoutRequestPromise = page.waitForRequest(request =>
request.url().includes('/oauth2/logout'),
);

await quickstart.logOut();

const logoutRequest = await logoutRequestPromise;
const logoutUrl = new URL(logoutRequest.url());
expect(logoutUrl.pathname).toBe('/oauth2/logout');
expect(logoutUrl.searchParams.get('client_id')).toBeTruthy();

expect(await readDpopTokens(page)).toBeNull();
});
});
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@
"test:core": "yarn workspace @fusionauth-sdk/core test",
"test:lexicon": "yarn workspace @fusionauth-sdk/lexicon test",
"test:sdk-angular": "yarn workspace sdk-angular-workspace test",
"test:sdk-react": "yarn workspace @fusionauth/react-sdk test",
"test:sdk-vue": "yarn workspace @fusionauth/vue-sdk test",
"test:sdk-react": "yarn build:core && yarn workspace @fusionauth/react-sdk test",
"test:sdk-vue": "yarn build:core && yarn workspace @fusionauth/vue-sdk test",
"test:e2e": "yarn playwright test",
"test:e2e:dpop-endpoints": "yarn playwright test --config playwright.dpop-endpoints.config.ts",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"lint:check": "eslint . --ext .ts,.tsx --max-warnings 0",
"format:fix": "prettier --write .",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
"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",
"vitest": "^1.4.0"
"vitest": "^3.2.6"
}
}
Loading
Loading