-
Notifications
You must be signed in to change notification settings - Fork 9
feat: additional end to end tests #208
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
Changes from all commits
b95aba5
9f3e0bc
7738bb4
d386a32
bd37ddf
d91e5b7
7eeb367
06a557e
00a0acb
14b9b4a
e9a8048
ec6372e
324d970
952cd03
b7e6feb
2c27865
b5601e2
252322a
eb01796
ce41545
f29df57
7babfae
bfc666a
27b9ab6
14c1d0c
b1567a6
f7cefc8
ddb5509
185357c
098bbfb
13db934
2b6e6de
920081d
b711f2d
8cfc607
a03dd2e
50b852f
8f8f5bc
d5f4569
5f517f0
1f4de60
aa7bbdc
9acf888
24b7337
e5342e3
ac12953
81f46f4
ab79bf8
fd4353e
d138ca3
0232d82
1a58211
44cd888
595c4e4
ff619a3
16859d9
711c709
e3e174a
78cf20d
5bef47a
20d66a8
4d8985d
4a990cf
c5e1f35
31cb1ad
46984e9
7861863
ed1f063
e716fee
fe643bf
6455306
210dbf8
617826e
f8c2e59
4298efe
a19cf50
6797302
f90d545
3960681
b5e1de4
d14345a
c8ada31
12d466c
e0cca73
4b3a38d
d4263c7
02ed28a
f9c6ffe
0d90540
0258a16
af93031
cef6017
bea8a4b
37c3c35
3417fc2
2924951
0b98e9c
52188c8
1cce4e5
9c97883
f0ca26c
89f588d
4893e00
0aa919d
dba3da8
06425d8
744627d
26c41f1
af86a09
7e2924f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,8 +10,7 @@ | |
| * | ||
| * 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 | ||
| * yarn test:e2e:dpop-endpoints | ||
| * | ||
| * Prerequisites: | ||
| * - A consuming quickstart application (e.g. fusionauth-quickstart-javascript-react-web) | ||
|
|
@@ -27,9 +26,14 @@ | |
| * 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 { createHash } from 'node:crypto'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
|
|
||
| import { Page, Route, test, BrowserContext, expect } from '@playwright/test'; | ||
| import { quickstartPage } from '../pages/common.page'; | ||
|
|
||
| interface DPoPTokens { | ||
|
|
@@ -39,6 +43,17 @@ interface DPoPTokens { | |
| tokenType: string; | ||
| } | ||
|
|
||
| interface DpopSdkConfig { | ||
| clientId: string; | ||
| serverUrl: string; | ||
| redirectUri: string; | ||
| } | ||
|
|
||
| const CORE_BUNDLE_PATH = path.resolve( | ||
| __dirname, | ||
| '../../packages/core/dist/index.js', | ||
| ); | ||
|
|
||
| async function readDpopTokens(page: Page): Promise<DPoPTokens | null> { | ||
| const evaluateTokens = () => | ||
| page.evaluate(() => { | ||
|
|
@@ -65,6 +80,97 @@ async function readDpopTokens(page: Page): Promise<DPoPTokens | null> { | |
| return raw ? JSON.parse(raw) : null; | ||
| } | ||
|
|
||
| function decodeJwtPayload(jwt: string): Record<string, unknown> { | ||
| const payload = jwt.split('.')[1]; | ||
| return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); | ||
| } | ||
|
|
||
| /** `ath` claim value per RFC 9449: `base64url(SHA-256(accessToken))`. */ | ||
| function computeAth(accessToken: string): string { | ||
| return createHash('sha256').update(accessToken).digest('base64url'); | ||
| } | ||
|
|
||
| /** | ||
| * Reads `client_id`, `redirect_uri`, and the FusionAuth origin off the | ||
| * `/oauth2/authorize` URL. Call this immediately after | ||
| * `quickstart.navToLogIn()` (before `authenticate()`), while `page.url()` | ||
| * still points at the authorize redirect. | ||
| */ | ||
| function captureSdkConfig(page: Page): DpopSdkConfig { | ||
| const authorizeUrl = new URL(page.url()); | ||
| const clientId = authorizeUrl.searchParams.get('client_id'); | ||
| const redirectUri = authorizeUrl.searchParams.get('redirect_uri'); | ||
| if (!clientId || !redirectUri) { | ||
| throw new Error( | ||
| 'Expected client_id and redirect_uri on the /oauth2/authorize URL. ' + | ||
| 'Call captureSdkConfig() right after quickstart.navToLogIn().', | ||
| ); | ||
| } | ||
| return { clientId, redirectUri, serverUrl: authorizeUrl.origin }; | ||
| } | ||
|
|
||
| /** | ||
| * Injects a second, independent `SDKCore` instance into the page. | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added three |
||
| * This is needed because `dpopFetch()` and `getAccessToken()` are only | ||
| * available on the SDKCore instance, and none of these tests drive them | ||
| * through UI interaction. | ||
| */ | ||
| async function injectDpopSdkCore( | ||
| page: Page, | ||
| config: DpopSdkConfig, | ||
| ): Promise<void> { | ||
| let bundleSource: string; | ||
| try { | ||
| bundleSource = fs.readFileSync(CORE_BUNDLE_PATH, 'utf-8'); | ||
| } catch { | ||
| throw new Error( | ||
| `Could not read ${CORE_BUNDLE_PATH}. Build @fusionauth-sdk/core first ` + | ||
| '(e.g. `yarn build:core`).', | ||
| ); | ||
| } | ||
|
|
||
| const exportMatch = bundleSource.match(/(\S+)\s+as\s+SDKCore/); | ||
| if (!exportMatch) { | ||
| throw new Error( | ||
| `Could not locate the SDKCore export in ${CORE_BUNDLE_PATH}.`, | ||
| ); | ||
| } | ||
| const localName = exportMatch[1]; | ||
|
|
||
| const script = `${bundleSource} | ||
| window.__e2eSdkCore = new ${localName}({ | ||
| clientId: ${JSON.stringify(config.clientId)}, | ||
| serverUrl: ${JSON.stringify(config.serverUrl)}, | ||
| redirectUri: ${JSON.stringify(config.redirectUri)}, | ||
| useDpop: true, | ||
| dpopTokenStorage: 'localStorage', | ||
| onTokenExpiration: () => {}, | ||
| });`; | ||
|
|
||
| await page.addScriptTag({ content: script, type: 'module' }); | ||
| await page.waitForFunction(() => (window as any).__e2eSdkCore !== undefined); | ||
| } | ||
|
|
||
| /** | ||
| * Answers a CORS preflight `OPTIONS` request directly and returns `true`, | ||
| * or returns `false` for any other method so the caller can run its real | ||
| * request logic. | ||
| */ | ||
| function handleCorsPreflight(route: Route): boolean { | ||
| if (route.request().method() !== 'OPTIONS') { | ||
| return false; | ||
| } | ||
| route.fulfill({ | ||
| status: 204, | ||
| headers: { | ||
| 'access-control-allow-origin': '*', | ||
| 'access-control-allow-methods': 'GET, POST, OPTIONS', | ||
| 'access-control-allow-headers': 'Authorization, DPoP', | ||
| }, | ||
| }); | ||
| return true; | ||
| } | ||
|
|
||
| test.describe('DPoP Endpoint Tests', () => { | ||
| test.describe.configure({ mode: 'serial' }); | ||
|
|
||
|
|
@@ -194,4 +300,151 @@ test.describe('DPoP Endpoint Tests', () => { | |
|
|
||
| expect(await readDpopTokens(page)).toBeNull(); | ||
| }); | ||
|
|
||
| test('dpopFetch() sends Authorization: DPoP and DPoP proof headers with a correct ath claim', async () => { | ||
| await quickstart.navToLogIn(); | ||
| const sdkConfig = captureSdkConfig(page); | ||
| await quickstart.authenticate(); | ||
|
|
||
| await injectDpopSdkCore(page, sdkConfig); | ||
|
|
||
| const accessToken = await page.evaluate( | ||
| () => (window as any).__e2eSdkCore.getAccessToken() as string | null, | ||
| ); | ||
| expect(accessToken).toBeTruthy(); | ||
|
|
||
| let capturedAuthHeader: string | undefined; | ||
| let capturedDpopHeader: string | undefined; | ||
|
|
||
| await page.route('https://api.example.com/data', route => { | ||
| if (handleCorsPreflight(route)) return; | ||
|
|
||
| const headers = route.request().headers(); | ||
| capturedAuthHeader = headers['authorization']; | ||
| capturedDpopHeader = headers['dpop']; | ||
| route.fulfill({ | ||
| status: 200, | ||
| contentType: 'application/json', | ||
| body: JSON.stringify({ ok: true }), | ||
| }); | ||
| }); | ||
|
|
||
| const result = await page.evaluate(async () => { | ||
| const response = await (window as any).__e2eSdkCore.dpopFetch( | ||
| 'https://api.example.com/data', | ||
| { method: 'GET' }, | ||
| ); | ||
| return { status: response.status, ok: response.ok }; | ||
| }); | ||
|
|
||
| expect(result.ok).toBe(true); | ||
| expect(capturedAuthHeader).toBe(`DPoP ${accessToken}`); | ||
| expect(capturedDpopHeader).toBeTruthy(); | ||
| expect(capturedDpopHeader!.split('.').length).toBe(3); | ||
|
|
||
| const proofPayload = decodeJwtPayload(capturedDpopHeader!); | ||
| expect(proofPayload.ath).toBe(computeAth(accessToken!)); | ||
| expect(proofPayload.htm).toBe('GET'); | ||
| expect(new URL(proofPayload.htu as string).pathname).toBe('/data'); | ||
|
|
||
| const accessTokenAfterFetch = await page.evaluate( | ||
| () => (window as any).__e2eSdkCore.getAccessToken() as string | null, | ||
| ); | ||
| expect(accessTokenAfterFetch).toBe(accessToken); | ||
|
|
||
| await quickstart.logOut(); | ||
| }); | ||
|
|
||
| test('dpopFetch() retries exactly once with the server nonce after a 401 use_dpop_nonce challenge', async () => { | ||
| await quickstart.navToLogIn(); | ||
| const sdkConfig = captureSdkConfig(page); | ||
| await quickstart.authenticate(); | ||
|
|
||
| await injectDpopSdkCore(page, sdkConfig); | ||
|
|
||
| const serverNonce = 'e2e-test-nonce-abc123'; | ||
| let requestCount = 0; | ||
| let retryDpopHeader: string | undefined; | ||
|
|
||
| await page.route('https://api.example.com/nonce-protected', route => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note, the re-try logic is in dropFetch() |
||
| if (handleCorsPreflight(route)) return; | ||
|
|
||
| requestCount += 1; | ||
| if (requestCount === 1) { | ||
| route.fulfill({ | ||
| status: 401, | ||
| headers: { | ||
| 'access-control-allow-origin': '*', | ||
| 'access-control-expose-headers': 'WWW-Authenticate, DPoP-Nonce', | ||
| 'www-authenticate': 'DPoP error="use_dpop_nonce"', | ||
| 'dpop-nonce': serverNonce, | ||
| }, | ||
| body: '', | ||
| }); | ||
| return; | ||
| } | ||
| retryDpopHeader = route.request().headers()['dpop']; | ||
| route.fulfill({ | ||
| status: 200, | ||
| contentType: 'application/json', | ||
| body: '{}', | ||
| }); | ||
| }); | ||
|
|
||
| const result = await page.evaluate(async () => { | ||
| const response = await (window as any).__e2eSdkCore.dpopFetch( | ||
| 'https://api.example.com/nonce-protected', | ||
| { method: 'GET' }, | ||
| ); | ||
| return { status: response.status }; | ||
| }); | ||
|
|
||
| expect(requestCount).toBe(2); | ||
| expect(result.status).toBe(200); | ||
| expect(retryDpopHeader).toBeTruthy(); | ||
| expect(decodeJwtPayload(retryDpopHeader!).nonce).toBe(serverNonce); | ||
|
|
||
| await quickstart.logOut(); | ||
| }); | ||
|
|
||
| test('dpopFetch() does not retry a second time when the retry also returns a 401 use_dpop_nonce', async () => { | ||
| await quickstart.navToLogIn(); | ||
| const sdkConfig = captureSdkConfig(page); | ||
| await quickstart.authenticate(); | ||
|
|
||
| await injectDpopSdkCore(page, sdkConfig); | ||
|
|
||
| let requestCount = 0; | ||
|
|
||
| await page.route('https://api.example.com/always-nonce', route => { | ||
| if (handleCorsPreflight(route)) return; | ||
|
|
||
| requestCount += 1; | ||
| route.fulfill({ | ||
| status: 401, | ||
| headers: { | ||
| 'access-control-allow-origin': '*', | ||
| 'access-control-expose-headers': 'WWW-Authenticate, DPoP-Nonce', | ||
| 'www-authenticate': 'DPoP error="use_dpop_nonce"', | ||
| 'dpop-nonce': `e2e-test-nonce-${requestCount}`, | ||
| }, | ||
| body: '', | ||
| }); | ||
| }); | ||
|
|
||
| const result = await page.evaluate(async () => { | ||
| const response = await (window as any).__e2eSdkCore.dpopFetch( | ||
| 'https://api.example.com/always-nonce', | ||
| { method: 'GET' }, | ||
| ); | ||
| return { status: response.status }; | ||
| }); | ||
|
|
||
| // Exactly the initial request plus one retry - no further retries even | ||
| // though the retry itself also returned 401 use_dpop_nonce. | ||
| expect(requestCount).toBe(2); | ||
| expect(result.status).toBe(401); | ||
|
|
||
| await quickstart.logOut(); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.