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