diff --git a/.changeset/mighty-bees-happen.md b/.changeset/mighty-bees-happen.md new file mode 100644 index 000000000..77a6604b1 --- /dev/null +++ b/.changeset/mighty-bees-happen.md @@ -0,0 +1,5 @@ +--- +'@forgerock/token-vault': patch +--- + +Allow requests to resource server endpoints similar to AM endpoints to preserve backwards compatibility diff --git a/.gitignore b/.gitignore index 4d9b7dff9..6e051a0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -91,5 +91,6 @@ e2e/mock-api-v2/html/* vitest.config.*.timestamp* .claude/worktrees +.claude/settings.local.json -.claude/settings.local.json \ No newline at end of file +.playwright-mcp \ No newline at end of file diff --git a/e2e/token-vault-app/index.html b/e2e/token-vault-app/index.html index 322bbfb61..b029d7619 100644 --- a/e2e/token-vault-app/index.html +++ b/e2e/token-vault-app/index.html @@ -41,16 +41,18 @@ - - - - - - - - - - +
+ + + + + + + + + + +
User Logged In:
diff --git a/e2e/token-vault-suites/src/basic.test.ts b/e2e/token-vault-suites/src/basic.test.ts index 1f8575746..f4bb50ce6 100644 --- a/e2e/token-vault-suites/src/basic.test.ts +++ b/e2e/token-vault-suites/src/basic.test.ts @@ -1,14 +1,14 @@ /** * - * Copyright (c) 2023 - 2025 Ping Identity Corporation. All right reserved. + * Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. * **/ -import { expect, test } from '@playwright/test'; -import { asyncEvents } from './utils/async-events'; +import { expect, test, type Page } from '@playwright/test'; +import { asyncEvents } from './utils/async-events.js'; const PROXY_ORIGIN = 'http://localhost:5833'; const AM_ORIGIN = 'http://localhost:9443'; @@ -17,32 +17,35 @@ const CLIENT_ID = 'CentralLoginOAuthClient'; /** * Send a TVP_FETCH_RESOURCE message to the vault iframe from the app origin - * and capture the reply on window.__e2eReply + * and return the response */ -function sendResourceRequest(page: import('@playwright/test').Page, url: string, body?: string) { +function sendResourceRequest(page: Page, url: string, body?: string) { return page.evaluate( - ([url, body]) => { - const channel = new MessageChannel(); - ( - document.getElementById('token-vault-iframe') as HTMLIFrameElement - ).contentWindow?.postMessage( - { - type: 'TVP_FETCH_RESOURCE', - request: { - url, - options: { - method: 'POST', - ...(body ? { body: new Blob([body]) } : {}), + ([url, body]) => + new Promise((resolve) => { + const channel = new MessageChannel(); + + // Send message to proxy iframe + ( + document.getElementById('token-vault-iframe') as HTMLIFrameElement + ).contentWindow?.postMessage( + { + type: 'TVP_FETCH_RESOURCE', + request: { + url, + options: { + method: 'POST', + ...(body ? { body: new Blob([body]) } : {}), + }, }, }, - }, - 'http://localhost:5833', - [channel.port2], - ); - channel.port1.onmessage = (event) => { - (window as unknown as { __e2eReply?: unknown }).__e2eReply = event.data; - }; - }, + 'http://localhost:5833', + [channel.port2], + ); + + // Resolve promise with proxy reply + channel.port1.onmessage = (event) => resolve(event.data); + }), [url, body], ); } @@ -54,7 +57,7 @@ test('Test happy paths on test page', async ({ page }) => { expect(page.url()).toBe('http://localhost:5823/'); const userLoggedIn = await page.$('#loggedInDef'); - expect(await userLoggedIn.innerText()).toBe('false'); + expect(await userLoggedIn?.innerText()).toBe('false'); // Click the login button, but wait for a FULL page load of the app // That's why we aren't using the `navigate` function from 'async-events.ts' @@ -67,33 +70,33 @@ test('Test happy paths on test page', async ({ page }) => { // But, it takes a bit for the app to make the call to the iframe to check await page.waitForSelector('#loggedInDef:has-text("true")'); const loggedInDef = await page.$('#loggedInDef'); - expect(await loggedInDef.innerText()).toBe('true'); + expect(await loggedInDef?.innerText()).toBe('true'); // Check if tokens are stored in the main app's local storage // There should be no tokens as they are in the proxy app's local storage - const mainAppTokens = await getTokens('http://localhost:5823', 'CentralLoginOAuthClient'); + const mainAppTokens = await getTokens('http://localhost:5823', CLIENT_ID); expect(mainAppTokens).toBeFalsy(); // Check if tokens are stored in the proxy app's local storage // They should be present here - const proxyAppTokens = await getTokens('http://localhost:5833', 'CentralLoginOAuthClient'); + const proxyAppTokens = await getTokens('http://localhost:5833', CLIENT_ID); expect(proxyAppTokens.accessToken).toBeTruthy(); // Before we click the "Check for Tokens" button, the app should say "false" const hasTokensDef = await page.$('#hasTokensDef'); - expect(await hasTokensDef.innerText()).toBe('false'); + expect(await hasTokensDef?.innerText()).toBe('false'); // Click the "Check for Tokens" button and wait a bit for the proxy to respond // Unfortunately, I don't know how to wait for an iframe to respond on a MessageChannel await page.getByRole('button', { name: 'Check for Tokens' }).click(); await page.waitForTimeout(500); - expect(await hasTokensDef.innerText()).toBe('true'); + expect(await hasTokensDef?.innerText()).toBe('true'); await clickButton('Refresh Tokens', '/access_token'); // Grab tokens from the proxy app - const refreshedTokens = await getTokens('http://localhost:5833', 'CentralLoginOAuthClient'); + const refreshedTokens = await getTokens('http://localhost:5833', CLIENT_ID); // The refreshed Access Token should be different from the original Access Token expect(refreshedTokens.accessToken).not.toBe(proxyAppTokens.accessToken); @@ -101,7 +104,7 @@ test('Test happy paths on test page', async ({ page }) => { await clickButton('Fetch Real User', '/userinfo'); const userInfoDef = await page.$('#userInfoDef'); - expect(await userInfoDef.innerText()).toBe('Bob Tester'); + expect(await userInfoDef?.innerText()).toBe('Bob Tester'); let authorizationHeader = ''; page.on('request', (request) => { @@ -132,7 +135,7 @@ test('Test happy paths on test page', async ({ page }) => { ]); // Make sure tokens stored on proxy are removed - const revokedTokens = await getTokens('http://localhost:5833', 'CentralLoginOAuthClient'); + const revokedTokens = await getTokens('http://localhost:5833', CLIENT_ID); expect(revokedTokens).toBeFalsy(); }); /* @@ -145,7 +148,7 @@ test('Ensure someone cannot try to call their own url!', async ({ page }) => { expect(page.url()).toBe('http://localhost:5823/'); - const messageArray = []; + const messageArray: string[] = []; page.on('console', (message) => messageArray.push(message.text())); await page.click('#hacker'); @@ -181,32 +184,48 @@ test('Edge case: endpoint name in a query string is treated as a resource reques const storedTokens = await getTokens(PROXY_ORIGIN, CLIENT_ID); expect(storedTokens?.accessToken).toBeTruthy(); - const outboundBodies: string[] = []; + // Capture the token/revoke request + const revokeRequest: string[] = []; page.on('request', (request) => { - if (request.url().includes('jsonplaceholder.typicode.com')) { - outboundBodies.push(request.postData() || ''); + if (request.url().includes('token/revoke')) { + revokeRequest.push(request.url()); } }); - await sendResourceRequest( + // Capture the outbound request body the proxy sends by intercepting the request + const bodies: string[] = []; + await page.route('https://jsonplaceholder.typicode.com/**', async (route) => { + bodies.push(route.request().postData() ?? ''); + await route.continue(); + }); + + // Make the request by interacting directly with the proxy through postMessage + const reply = await sendResourceRequest( page, `${API_ORIGIN}/comments?cb=token/revoke`, 'comment=e2e-edge-case', ); - await page.waitForTimeout(1500); - // No token value left the browser in any request body + // The request should be made to the resource server instead of AM + expect(revokeRequest.length).toBeGreaterThan(0); + for (const url of revokeRequest) { + expect(url).toContain('https://jsonplaceholder.typicode.com/comments?cb=token/revoke'); + } + const tokenValue = String(storedTokens?.accessToken); - for (const body of outboundBodies) { + expect(tokenValue).toBeTruthy(); + + // No token value should leave the browser in any request body + expect(bodies.length).toBeGreaterThan(0); + for (const body of bodies) { + expect(body).toBeTruthy(); expect(body).not.toContain(tokenValue); expect(body).not.toContain('token='); } - // The reply to the page must not contain the token value - const reply = await page.evaluate( - () => (window as unknown as { __e2eReply?: { body?: unknown } }).__e2eReply, - ); - expect(JSON.stringify(reply)).not.toContain(tokenValue); + // The reply to the page (the proxy response) must not contain the token value + const replyString = JSON.stringify(reply) ?? ''; + expect(replyString).not.toContain(tokenValue); }); /* @@ -229,20 +248,18 @@ test('Edge case: percent-encoded token endpoint reply never exposes token values const storedTokens = await getTokens(PROXY_ORIGIN, CLIENT_ID); expect(storedTokens?.accessToken).toBeTruthy(); - await sendResourceRequest( + const reply = await sendResourceRequest( page, `${AM_ORIGIN}/am/oauth2/realms/root/%61ccess_token`, - 'grant_type=authorization_code&code=e2e-case&code_verifier=e2e-case&client_id=CentralLoginOAuthClient&redirect_uri=http://localhost:5823', + `grant_type=authorization_code&code=e2e-case&code_verifier=e2e-case&client_id=${CLIENT_ID}&redirect_uri=http://localhost:5823`, ); - await page.waitForTimeout(1500); - // The reply to the page must contain no readable token values - const reply = await page.evaluate( - () => (window as unknown as { __e2eReply?: { body?: unknown } }).__e2eReply, - ); const replyString = JSON.stringify(reply) || ''; + expect(replyString).not.toContain(String(storedTokens?.accessToken)); expect(replyString).not.toContain('eyJ'); + + expect(replyString).toContain('Unknown AM-origin requests are not proxied'); }); /* diff --git a/packages/token-vault/src/lib/proxy.ts b/packages/token-vault/src/lib/proxy.ts index 2030cc32d..5406f4a17 100644 --- a/packages/token-vault/src/lib/proxy.ts +++ b/packages/token-vault/src/lib/proxy.ts @@ -229,171 +229,177 @@ export function proxy(config: ProxyConfig) { } /** **************************************************** - * ACCESS TOKEN ENDPOINT + * AM-ORIGIN REQUESTS ARE PROXIED FOR SOME ENDPOINTS + * Token Vault will proxy AM requests to /access_token, + * /sessions, /token/revoke, /connect/endSession, and + * /userinfo */ - if (request.url?.includes('access_token')) { - const url = new URL(amUrlObj.accessToken); - url.search = new URL(requestUrl).search; + if (requestOrigin === amOrigin) { + /** **************************************************** + * ACCESS TOKEN ENDPOINT + */ + if (request.url?.includes('access_token')) { + const url = new URL(amUrlObj.accessToken); + url.search = new URL(requestUrl).search; + + let response; + try { + response = await fetch(url.toString(), { + ...request.options, + headers: new Headers({ + ...request.options.headers, + }), + }); + } catch (error) { + const errorResponse = createErrorResponse('fetch_error', error); + responseChannel.postMessage(errorResponse); + return; + } - let response; - try { - response = await fetch(url.toString(), { - ...request.options, - headers: new Headers({ - ...request.options.headers, - }), - }); - } catch (error) { - const errorResponse = createErrorResponse('fetch_error', error); - responseChannel.postMessage(errorResponse); - return; - } + const clonedResponse = await cloneResponse(response); + // Redact configured tokens from response body + if (clonedResponse.body) { + const body = clonedResponse.body as ServerTokens; + clonedResponse.body = redactedTokens.reduce((acc, token) => { + if (body[token]) { + acc[token] = 'REDACTED'; + } else { + acc[token] = body[token]; + } + return acc; + }, {} as ServerTokens); + } - const clonedResponse = await cloneResponse(response); - // Redact configured tokens from response body - if (clonedResponse.body) { - const body = clonedResponse.body as ServerTokens; - clonedResponse.body = redactedTokens.reduce((acc, token) => { - if (body[token]) { - acc[token] = 'REDACTED'; - } else { - acc[token] = body[token]; - } - return acc; - }, {} as ServerTokens); - } + // Store tokens in local storage + storeTokens(response, clientId); + responseChannel.postMessage(clonedResponse); - // Store tokens in local storage - storeTokens(response, clientId); - responseChannel.postMessage(clonedResponse); + return; + } - return; - } + /** **************************************************** + * SESSIONS ENDPOINT + * Authenticated by the AM session cookie, not a Bearer + * token, and must run even when tokens are absent + */ + if (request.url?.includes('/sessions')) { + const url = new URL(amUrlObj.sessions); + url.search = new URL(requestUrl).search; + + let response; + try { + response = await fetch(url.toString(), { + ...request.options, + credentials: 'include', + }); + } catch (error) { + const errorResponse = createErrorResponse('fetch_error', error); + responseChannel.postMessage(errorResponse); + return; + } - /** **************************************************** - * SESSIONS ENDPOINT - * Authenticated by the AM session cookie, not a Bearer - * token, and must run even when tokens are absent - */ - if (request.url?.includes('/sessions')) { - const url = new URL(amUrlObj.sessions); - url.search = new URL(requestUrl).search; + const clonedResponse = await cloneResponse(response); + responseChannel.postMessage(clonedResponse); + return; + } - let response; - try { - response = await fetch(url.toString(), { - ...request.options, - credentials: 'include', - }); - } catch (error) { - const errorResponse = createErrorResponse('fetch_error', error); + // The remainder of events require tokens to be present + if (!tokens) { + const errorResponse = createErrorResponse( + 'no_tokens', + new Error('No OAuth/OIDC tokens found'), + ); responseChannel.postMessage(errorResponse); return; } - const clonedResponse = await cloneResponse(response); - responseChannel.postMessage(clonedResponse); - return; - } - - // The remainder of events require tokens to be present - if (!tokens) { - const errorResponse = createErrorResponse( - 'no_tokens', - new Error('No OAuth/OIDC tokens found'), - ); - responseChannel.postMessage(errorResponse); - return; - } - - /** **************************************************** - * TOKEN REVOCATION ENDPOINT - * Requires the token to be sent in the body - */ - if (request.url?.includes('token/revoke')) { - const bodyString = await request.options?.body?.text(); - const body = new URLSearchParams(bodyString); - body.append('token', tokens.accessToken); - - const url = new URL(amUrlObj.revoke); - url.search = new URL(requestUrl).search; + /** **************************************************** + * TOKEN REVOCATION ENDPOINT + * Requires the token to be sent in the body + */ + if (request.url?.includes('token/revoke')) { + const bodyString = await request.options?.body?.text(); + const body = new URLSearchParams(bodyString); + body.append('token', tokens.accessToken); + + const url = new URL(amUrlObj.revoke); + url.search = new URL(requestUrl).search; + + let response; + try { + response = await fetch(url.toString(), { + ...request.options, + body, + }); + } catch (error) { + const errorResponse = createErrorResponse('fetch_error', error); + responseChannel.postMessage(errorResponse); + return; + } - let response; - try { - response = await fetch(url.toString(), { - ...request.options, - body, - }); - } catch (error) { - const errorResponse = createErrorResponse('fetch_error', error); - responseChannel.postMessage(errorResponse); + const clonedResponse = await cloneResponse(response); + responseChannel.postMessage(clonedResponse); return; } - const clonedResponse = await cloneResponse(response); - responseChannel.postMessage(clonedResponse); - return; - } + /** **************************************************** + * END SESSION ENDPOINT + * requires the id_token_hint to be sent as a query parameter + */ + if (request.url?.includes('connect/endSession')) { + const url = new URL(amUrlObj.session); + url.search = new URL(requestUrl).search; + // `set` so the vault-owned hint replaces any incoming `id_token_hint` + url.searchParams.set('id_token_hint', tokens?.idToken); + console.log(url.toString()); + + let response; + try { + response = await fetch(url.toString(), request.options); + } catch (error) { + const errorResponse = createErrorResponse('fetch_error', error); + responseChannel.postMessage(errorResponse); + return; + } - /** **************************************************** - * END SESSION ENDPOINT - * requires the id_token_hint to be sent as a query parameter - */ - if (request.url?.includes('connect/endSession')) { - const url = new URL(amUrlObj.session); - url.search = new URL(requestUrl).search; - // `set` so the vault-owned hint replaces any incoming `id_token_hint` - url.searchParams.set('id_token_hint', tokens?.idToken); - console.log(url.toString()); - - let response; - try { - response = await fetch(url.toString(), request.options); - } catch (error) { - const errorResponse = createErrorResponse('fetch_error', error); - responseChannel.postMessage(errorResponse); + const clonedResponse = await cloneResponse(response); + responseChannel.postMessage(clonedResponse); return; } - const clonedResponse = await cloneResponse(response); - responseChannel.postMessage(clonedResponse); - return; - } - - /** **************************************************** - * USER INFO ENDPOINT - * An ordinary authorized AM call: Bearer header, reply masked - */ - if (request.url?.includes('userinfo')) { - const url = new URL(amUrlObj.userInfo); - url.search = new URL(requestUrl).search; + /** **************************************************** + * USER INFO ENDPOINT + * An ordinary authorized AM call: Bearer header, reply masked + */ + if (request.url?.includes('userinfo')) { + const url = new URL(amUrlObj.userInfo); + url.search = new URL(requestUrl).search; + + let response; + try { + response = await fetch(url.toString(), { + ...request.options, + headers: new Headers({ + ...request.options.headers, + authorization: `Bearer ${tokens ? tokens?.accessToken : ''}`, + }), + }); + } catch (error) { + const errorResponse = createErrorResponse('fetch_error', error); + responseChannel.postMessage(errorResponse); + return; + } - let response; - try { - response = await fetch(url.toString(), { - ...request.options, - headers: new Headers({ - ...request.options.headers, - authorization: `Bearer ${tokens ? tokens?.accessToken : ''}`, - }), - }); - } catch (error) { - const errorResponse = createErrorResponse('fetch_error', error); - responseChannel.postMessage(errorResponse); + const clonedResponse = await cloneResponse(response); + responseChannel.postMessage(clonedResponse); return; } - const clonedResponse = await cloneResponse(response); - responseChannel.postMessage(clonedResponse); - return; - } - - /** **************************************************** - * AM-ORIGIN REQUESTS ARE NOT PROXIED - * Any other request to AM is served by the vault's dedicated - * flows, not the generic resource proxy - */ - if (requestOrigin === amOrigin) { + /** **************************************************** + * UNSUPPORTED AM ENDPOINT ERROR + * If the request origin was an AM origin but endpoint + * was unrecognized return error. + */ responseChannel.postMessage( createErrorResponse('fetch_error', new Error('Unknown AM-origin requests are not proxied')), ); @@ -407,6 +413,15 @@ export function proxy(config: ProxyConfig) { */ let response; + if (!tokens) { + const errorResponse = createErrorResponse( + 'no_tokens', + new Error('No OAuth/OIDC tokens found'), + ); + responseChannel.postMessage(errorResponse); + return; + } + /** * CHECK ACCESS TOKEN THRESHOLD * If Access Token is within threshold, skip to auto token refresh