-
Notifications
You must be signed in to change notification settings - Fork 9
feat: Framework SDKs support DPoP #207
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
Merged
mrudatsprint
merged 101 commits into
parent/dpop-in-the-javascript-sdk
from
mike/eng-4787/dpop-sdks
Aug 3, 2026
Merged
Changes from all commits
Commits
Show all changes
101 commits
Select commit
Hold shift + click to select a range
b95aba5
feat: add DPoP core storage layer (ENG-4782)
mrudatsprint 9f3e0bc
feat: fix file formatting.
mrudatsprint 7738bb4
fix: DPoPStorage openDb() error handling and test coverage
mrudatsprint d386a32
fix: fix copilot warnings.
mrudatsprint bd37ddf
fix: resolve DPoP transactions on tx.oncomplete, not req.onsuccess
mrudatsprint d91e5b7
feat: the workflow will run regardless of the branch being merged into.
mrudatsprint 7eeb367
feat: implement DPoPManager central coordinator (ENG-4784)
mrudatsprint 06a557e
feat: re-generate lock file.
mrudatsprint 00a0acb
feat: re-generate lock file.
mrudatsprint 14b9b4a
feat: update lock file.
mrudatsprint e9a8048
Merge branches 'miker/eng-4784/central-coordinator' and 'miker/eng-47…
mrudatsprint ec6372e
test: add DPoP smoke tests against real FusionAuth instance (pre-SDKC…
mrudatsprint 324d970
feat: fix format and lint errors.
mrudatsprint 952cd03
refactor: make DPoPStorage IndexedDB constants configurable via confi…
mrudatsprint b7e6feb
feature: delete contrived test to intercept a successful even and the…
mrudatsprint 2c27865
Merge branch 'miker/eng-4782/core-storage-layer' into miker/eng-4784/…
mrudatsprint b5601e2
fix: update DPoPStorage constructor calls to use config object after …
mrudatsprint 252322a
feature: update lock file
mrudatsprint eb01796
feat: implement SDKCore.startLogin() for DPoP authorization code gran…
mrudatsprint ce41545
fix: add @vitest-environment jsdom to SDKCore.test.ts; fix handlePreR…
mrudatsprint f29df57
fix: suppress cookie console.error noise in Tier 0 e2e tests
mrudatsprint 7babfae
feat: update lock file.
mrudatsprint bfc666a
fix: update Angular onRedirect test to use 3-segment redirect-value f…
mrudatsprint 27b9ab6
fix: update Vue and React onRedirect tests to use 3-segment redirect-…
mrudatsprint 14c1d0c
fix: merge Request and init headers in DPoPManager.fetch() instead of…
mrudatsprint b1567a6
feat: fix angular and vue tests.
mrudatsprint f7cefc8
fix: clone Request before retry in fetch() to avoid double body consu…
mrudatsprint ddb5509
fix: normalize htu and htm in generateProof() per RFC 9449 (PR #202 C…
mrudatsprint 185357c
fix: remove dead captured header variables and misleading comment in …
mrudatsprint 098bbfb
feat: update approvers.
mrudatsprint 13db934
feat: fix merge conflict.
mrudatsprint 2b6e6de
feat: fix failing tests.
mrudatsprint 920081d
test: add deterministic nonce-retry smoke test (T2-4)
mrudatsprint b711f2d
fix: revert startLogin() to void, address Copilot PR review comment (…
mrudatsprint 8cfc607
docs: fix stale/ambiguous state-reconstruction description in Redirec…
mrudatsprint a03dd2e
fix: preserve state from legacy 2-segment redirect values (Copilot PR…
mrudatsprint 50b852f
feat: update comments.
mrudatsprint 8f8f5bc
feat: SDKCore: implement handlePostRedirect() authorization code exch…
mrudatsprint d5f4569
feat: remove references to ENG- linear issues.
mrudatsprint 5f517f0
feat: remove redundant comments.
mrudatsprint 1f4de60
feat: merge and remove verbose comments.
mrudatsprint aa7bbdc
feat: merge in the parent branch.
mrudatsprint 9acf888
feat: remove file not needed until adding end to end tests.
mrudatsprint 24b7337
feat: remove lengthy comment.
mrudatsprint e5342e3
feat: remote unnecessary comments.
mrudatsprint ac12953
feat: merge in the parent branch.
mrudatsprint 81f46f4
feat: remove verbose comment.
mrudatsprint ab79bf8
feat: copilot review warnings.
mrudatsprint fd4353e
feat: failing smoke test.
mrudatsprint d138ca3
feat: minimize verbose comments.
mrudatsprint 0232d82
feat: clean up comments.
mrudatsprint 1a58211
feat: minimize verbose comments.
mrudatsprint 44cd888
feat: SDKCore - implement startLogout() and getAccessToken() for DPoP…
mrudatsprint 595c4e4
feat: rebuild the lock file.
mrudatsprint ff619a3
feat: remove verbose comments.
mrudatsprint 16859d9
feat: remove verbose comments.
mrudatsprint 711c709
feat: SDKCore - implement refreshToken() for DPoP mode (ENG-4801)
mrudatsprint e3e174a
feat: refresh token grant
mrudatsprint 78cf20d
feat: remove verbose comments.
mrudatsprint 5bef47a
chore: upgrade vitest to v3.2.6 for core, lexicon, and sdk-react
mrudatsprint 20d66a8
feat: add DPoP to the React SDK, sync the version of vitest being use…
mrudatsprint 4d8985d
Merge branch 'parent/dpop-in-the-javascript-sdk' into miker/eng-4800/…
mrudatsprint 4a990cf
feat: copilot recommendation .
mrudatsprint c5e1f35
feat: cleanup comments.
mrudatsprint 31cb1ad
feat: reduce commenting.
mrudatsprint 46984e9
Merge branch 'miker/eng-4800/code-grant-finish' into miker/eng-4802/s…
mrudatsprint 7861863
feat: insure logout url is called.
mrudatsprint ed1f063
feat: use hosted backend mode versus cookie mode in comments.
mrudatsprint e716fee
feat: remove verbose comments.
mrudatsprint fe643bf
Merge branch 'parent/dpop-in-the-javascript-sdk' into miker/eng-4802/…
mrudatsprint 6455306
Merge branch 'miker/eng-4802/start-logout' into miker/eng-4801/refres…
mrudatsprint 210dbf8
feat: update from the last merge.
mrudatsprint 617826e
feat: remove comment verbosity.
mrudatsprint f8c2e59
feat: remove comment verbosity.
mrudatsprint 4298efe
Merge branch 'miker/eng-4801/refresh-token' into mike/eng-4787/dpop-sdks
mrudatsprint a19cf50
feat: reduce verbose commenting.
mrudatsprint 6797302
fix: address Copilot review comments on refreshDpopToken() (PR #206)
mrudatsprint f90d545
feat: preserve the existing refresh token, if needed.
mrudatsprint 3960681
fix: reapply refreshDpopToken() Copilot fixes lost in the eng-4801 merge
mrudatsprint b5e1de4
fix: DPoP mode startLogout() targets /oauth2/logout directly
mrudatsprint d14345a
test: add DPoP endpoint e2e tests (dpop-endpoints.test.ts)
mrudatsprint c8ada31
feat: SDKCore.fetchUserInfo() is DPoP aware (ENG-4931)
mrudatsprint 12d466c
feat: cleanup
mrudatsprint e0cca73
feat: test user info endpoint.
mrudatsprint 4b3a38d
fix: React SDK auto-fetches userInfo after async DPoP login, not just…
mrudatsprint d4263c7
test: merge userinfo check into the auto-refresh test
mrudatsprint 02ed28a
debug: temporary diagnostics for the /oauth2/userinfo investigation
mrudatsprint f9c6ffe
Revert "debug: temporary diagnostics for the /oauth2/userinfo investi…
mrudatsprint 0d90540
docs: document the FusionAuth CORS prerequisite for /oauth2/userinfo
mrudatsprint 0258a16
feat: remove verbose comments.
mrudatsprint af93031
fix: prevent duplicate DPoP authorization code exchange on concurrent…
mrudatsprint cef6017
feat: cleanup duplicate code grant exchanges being tracket.
mrudatsprint bea8a4b
Merge branch 'parent/dpop-in-the-javascript-sdk' into miker/eng-4801/…
mrudatsprint 37c3c35
fix: dpop-smoke.test.ts refresh token test — undefined var + wrong order
mrudatsprint 3417fc2
feat: parent merge
mrudatsprint 2924951
feat: The Angular Framework has is using the DPoP functionality in th…
mrudatsprint 0b98e9c
feat: The Vue Framework using DPoP.
mrudatsprint 52188c8
feat: the VUE framework using DPoP.
mrudatsprint 1cce4e5
feat: remove verbose comments.
mrudatsprint 9c97883
feat: remove verbose comments.
mrudatsprint f0ca26c
feat: remove test duplication.
mrudatsprint File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.