diff --git a/.changeset/chubby-chairs-dream.md b/.changeset/chubby-chairs-dream.md new file mode 100644 index 000000000..5d99e20cb --- /dev/null +++ b/.changeset/chubby-chairs-dream.md @@ -0,0 +1,5 @@ +--- +'@forgerock/oidc-client': minor +--- + +Implement token `revoke` method diff --git a/e2e/oidc-app/src/ping-am/index.html b/e2e/oidc-app/src/ping-am/index.html index 9ae63641e..f1021f29e 100644 --- a/e2e/oidc-app/src/ping-am/index.html +++ b/e2e/oidc-app/src/ping-am/index.html @@ -4,8 +4,7 @@ E2E Test | Ping Identity JavaScript SDK @@ -19,7 +18,8 @@

OIDC App | PingAM Login

- + + Start Over diff --git a/e2e/oidc-app/src/ping-one/index.html b/e2e/oidc-app/src/ping-one/index.html index bdcc56f70..f1a50104e 100644 --- a/e2e/oidc-app/src/ping-one/index.html +++ b/e2e/oidc-app/src/ping-one/index.html @@ -4,8 +4,7 @@ E2E Test | Ping Identity JavaScript SDK @@ -19,7 +18,8 @@

OIDC App | P1 Login

- + + Start Over diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts index a03e00729..eaa099bff 100644 --- a/e2e/oidc-app/src/utils/oidc-app.ts +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -27,13 +27,13 @@ function displayTokenResponse( response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizationError, ) { const appEl = document.getElementById('app'); - if ('error' in response) { + if ('error' in response || !('accessToken' in response)) { console.error('Token Error:', response); displayError(response); } else { console.log('Token Response:', response); document.getElementById('logout').style.display = 'block'; - document.getElementById('userinfo').style.display = 'block'; + document.getElementById('user-info-btn').style.display = 'block'; document.getElementById('login-background').style.display = 'none'; document.getElementById('login-redirect').style.display = 'none'; @@ -108,7 +108,7 @@ export async function oidcApp({ config, urlParams }) { displayTokenResponse(response); }); - document.getElementById('userinfo').addEventListener('click', async () => { + document.getElementById('user-info-btn').addEventListener('click', async () => { const userInfo = await oidcClient.user.info(); if ('error' in userInfo) { @@ -124,16 +124,30 @@ export async function oidcApp({ config, urlParams }) { } }); + document.getElementById('revoke').addEventListener('click', async () => { + const response = await oidcClient.token.revoke(); + + if ('error' in response) { + console.error('Token Revocation Error:', response); + displayError(response); + } else { + const appEl = document.getElementById('app'); + const userInfoEl = document.createElement('div'); + userInfoEl.innerHTML = `

Token successfully revoked

`; + appEl.appendChild(userInfoEl); + } + }); + document.getElementById('logout').addEventListener('click', async () => { const response = await oidcClient.user.logout(); - if (response && 'error' in response) { + if ('error' in response) { console.error('Logout Error:', response); displayError(response); } else { console.log('Logout successful'); document.getElementById('logout').style.display = 'none'; - document.getElementById('userinfo').style.display = 'none'; + document.getElementById('user-info-btn').style.display = 'none'; document.getElementById('login-background').style.display = 'block'; document.getElementById('login-redirect').style.display = 'block'; window.location.assign(window.location.origin + window.location.pathname); diff --git a/e2e/oidc-suites/src/login.spec.ts b/e2e/oidc-suites/src/login.spec.ts index 26c212e1e..1443be4c3 100644 --- a/e2e/oidc-suites/src/login.spec.ts +++ b/e2e/oidc-suites/src/login.spec.ts @@ -30,7 +30,6 @@ test.describe('PingAM login and get token tests', () => { expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); await expect(page.locator('#accessToken-0')).not.toBeEmpty(); - await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); }); test('redirect login with valid credentials', async ({ page }) => { @@ -47,7 +46,6 @@ test.describe('PingAM login and get token tests', () => { expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); await expect(page.locator('#accessToken-0')).not.toBeEmpty(); - await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); }); test('background login with invalid client id fails', async ({ page }) => { @@ -81,7 +79,6 @@ test.describe('PingOne login and get token tests', () => { expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); await expect(page.locator('#accessToken-0')).not.toBeEmpty(); - await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); }); test('redirect login with valid credentials', async ({ page }) => { @@ -99,7 +96,6 @@ test.describe('PingOne login and get token tests', () => { expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); await expect(page.locator('#accessToken-0')).not.toBeEmpty(); - await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); }); test('login with invalid client id fails', async ({ page }) => { @@ -140,7 +136,6 @@ test.describe('PingOne login and get token tests', () => { expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); await expect(page.locator('#accessToken-0')).not.toBeEmpty(); - await expect(page.locator('#accessToken-0')).not.toHaveText('undefined'); }); }); diff --git a/e2e/oidc-suites/src/token.spec.ts b/e2e/oidc-suites/src/token.spec.ts index bfd5d7f5d..7758ff223 100644 --- a/e2e/oidc-suites/src/token.spec.ts +++ b/e2e/oidc-suites/src/token.spec.ts @@ -15,17 +15,6 @@ import { } from './utils/demo-users.js'; import { asyncEvents } from './utils/async-events.js'; -test('get tokens without logging in should error', async ({ page }) => { - const { navigate } = asyncEvents(page); - await navigate('/ping-am/'); - expect(page.url()).toBe('http://localhost:8443/ping-am/'); - - await page.getByRole('button', { name: 'Get Tokens' }).click(); - - await expect(page.locator('.error')).toContainText(`"error": "No tokens found"`); - await expect(page.locator('.error')).toContainText(`"type": "state_error"`); -}); - test.describe('PingAM tokens', () => { test('login and get tokens', async ({ page }) => { const { navigate, clickButton } = asyncEvents(page); @@ -44,7 +33,6 @@ test.describe('PingAM tokens', () => { await page.getByRole('button', { name: 'Get Tokens' }).click(); await expect(page.locator('#accessToken-1')).not.toBeEmpty(); - await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); const accessToken0 = await page.locator('#accessToken-0').textContent(); const accessToken1 = await page.locator('#accessToken-1').textContent(); @@ -70,13 +58,35 @@ test.describe('PingAM tokens', () => { await page.getByRole('button', { name: 'Renew Tokens' }).click(); await expect(page.locator('#accessToken-1')).not.toBeEmpty(); - await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); const accessToken0 = await page.locator('#accessToken-0').textContent(); const accessToken1 = await page.locator('#accessToken-1').textContent(); await expect(accessToken0).not.toBe(accessToken1); }); + test('login and revoke tokens', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await page.getByRole('button', { name: 'Next' }).click(); + + await page.waitForURL('http://localhost:8443/ping-am/**', { waitUntil: 'networkidle' }); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + + await page.getByRole('button', { name: 'Revoke Token' }).click(); + await expect(page.getByText('Token successfully revoked')).toBeVisible(); + const token = await page.evaluate(() => localStorage.getItem('pic-oidcTokens')); + await expect(token).toBeFalsy(); + }); + test('renew tokens without logging in should error', async ({ page }) => { const { navigate } = asyncEvents(page); await navigate('/ping-am/'); @@ -110,7 +120,6 @@ test.describe('PingOne tokens', () => { await page.getByRole('button', { name: 'Get Tokens' }).click(); await expect(page.locator('#accessToken-1')).not.toBeEmpty(); - await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); const accessToken0 = await page.locator('#accessToken-0').textContent(); const accessToken1 = await page.locator('#accessToken-1').textContent(); @@ -136,13 +145,35 @@ test.describe('PingOne tokens', () => { await page.getByRole('button', { name: 'Renew Tokens' }).click(); await expect(page.locator('#accessToken-1')).not.toBeEmpty(); - await expect(page.locator('#accessToken-1')).not.toHaveText('undefined'); const accessToken0 = await page.locator('#accessToken-0').textContent(); const accessToken1 = await page.locator('#accessToken-1').textContent(); await expect(accessToken0).not.toBe(accessToken1); }); + test('login and revoke tokens', async ({ page }) => { + const { navigate, clickButton } = asyncEvents(page); + await navigate('/ping-one/'); + expect(page.url()).toBe('http://localhost:8443/ping-one/'); + + await clickButton('Login (Background)', 'https://apps.pingone.ca/'); + + await page.getByLabel('Username').fill(pingOneUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); + await page.getByRole('button', { name: 'Sign On' }).click(); + + await page.waitForURL('http://localhost:8443/ping-one/**', { waitUntil: 'networkidle' }); + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + + await page.getByRole('button', { name: 'Revoke Token' }).click(); + await expect(page.getByText('Token successfully revoked')).toBeVisible(); + const token = await page.evaluate(() => localStorage.getItem('pic-oidcTokens')); + await expect(token).toBeFalsy(); + }); + test('renew tokens without logging in should error', async ({ page }) => { const { navigate } = asyncEvents(page); await navigate('/ping-one/'); @@ -155,3 +186,25 @@ test.describe('PingOne tokens', () => { await expect(page.locator('.error')).toContainText('User authentication is required'); }); }); + +test('get tokens without logging in should error', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await page.getByRole('button', { name: 'Get Tokens' }).click(); + + await expect(page.locator('.error')).toContainText(`"error": "No tokens found"`); + await expect(page.locator('.error')).toContainText(`"type": "state_error"`); +}); + +test('revoke tokens should error with missing token', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await page.getByRole('button', { name: 'Revoke Token' }).click(); + + await expect(page.locator('.error')).toContainText(`"error": "No access token found"`); + await expect(page.locator('.error')).toContainText(`"type": "state_error"`); +}); diff --git a/e2e/oidc-suites/src/user.spec.ts b/e2e/oidc-suites/src/user.spec.ts index 26978290a..5012b49f4 100644 --- a/e2e/oidc-suites/src/user.spec.ts +++ b/e2e/oidc-suites/src/user.spec.ts @@ -27,11 +27,11 @@ test.describe('User tests', () => { await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); await page.getByRole('button', { name: 'Next' }).click(); - await page.waitForURL('http://localhost:8443/ping-am/**'); + await page.waitForURL('http://localhost:8443/ping-am/**', { waitUntil: 'networkidle' }); expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); - await page.getByRole('button', { name: 'User Info' }).click(); + await clickButton('User Info', 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/userinfo'); await expect(page.locator('#userInfo')).not.toBeEmpty(); await expect(page.getByText('Sdk User')).toBeVisible(); await expect(page.getByText('sdkuser@example.com')).toBeVisible(); @@ -48,13 +48,28 @@ test.describe('User tests', () => { await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword); await page.getByRole('button', { name: 'Sign On' }).click(); - await page.waitForURL('http://localhost:8443/ping-one/**'); + await page.waitForURL('http://localhost:8443/ping-one/**', { waitUntil: 'networkidle' }); expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); - await page.getByRole('button', { name: 'User Info' }).click(); + await clickButton( + 'User Info', + 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/userinfo', + ); await expect(page.locator('#userInfo')).not.toBeEmpty(); await expect(page.getByText('demouser')).toBeVisible(); await expect(page.getByText('demouser@user.com')).toBeVisible(); }); + + test('get user info should error with missing token', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + expect(page.url()).toBe('http://localhost:8443/ping-am/'); + + await page.getByRole('button', { name: 'User Info' }).click(); + + await expect(page.locator('#userInfo')).not.toBeVisible(); + await expect(page.locator('.error')).toContainText(`"error": "No access token found"`); + await expect(page.locator('.error')).toContainText(`"type": "auth_error"`); + }); }); diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index d7b50f209..675815fb4 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -19,10 +19,17 @@ import { wellknownApi, wellknownSelector } from './wellknown.api.js'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; import type { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; -import type { GetTokensOptions, LogoutResult } from './client.types.js'; +import type { + GetTokensOptions, + LogoutErrorResult, + LogoutSuccessResult, + RevokeErrorResult, + RevokeSuccessResult, + UserInfoResponse, +} from './client.types.js'; import type { OauthTokens, OidcConfig } from './config.types.js'; import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js'; -import type { TokenExchangeErrorResponse, TokenExchangeResponse } from './exchange.types.js'; +import type { TokenExchangeErrorResponse } from './exchange.types.js'; import { isExpiryWithinThreshold } from './token.utils.js'; import { logoutµ } from './logout.request.js'; @@ -155,7 +162,7 @@ export async function oidc({ }, }, /** - * An object containing methods for token exchange + * An object containing methods for token management */ token: { /** @@ -295,6 +302,106 @@ export async function oidc({ }; } }, + /** + * @method revoke + * @description Revokes an access token using the revocation endpoint from the wellknown configuration. + * It requires an access token stored in the configured storage. + * @returns {Promise} - Returns a promise that resolves to the revoke response or an error response. + */ + revoke: async (): Promise => { + const state = store.getState(); + const wellknown = wellknownSelector(wellknownUrl, state); + + if (!wellknown?.revocation_endpoint) { + return { + error: 'Wellknown missing revocation endpoint', + type: 'wellknown_error', + }; + } + + const tokens = await storageClient.get(); + + if (!tokens || !('accessToken' in tokens)) { + return { + error: 'No access token found', + type: 'state_error', + }; + } + + const revokeµ = Micro.promise(() => + store.dispatch( + oidcApi.endpoints.revoke.initiate({ + accessToken: tokens.accessToken, + clientId: config.clientId, + endpoint: wellknown.revocation_endpoint, + }), + ), + ).pipe( + Micro.map(({ error }) => { + if (error) { + let message = 'An error occurred while revoking the token'; + let status: number | string = 'unknown'; + if ('message' in error && error.message) { + message = error.message; + } + if ('status' in error) { + status = error.status; + } + return { + error: 'Token revocation failure', + message, + type: 'auth_error', + status, + } as GenericError; + } + + return null; + }), + // Delete local token and return combined results + Micro.flatMap((revokeResponse) => + Micro.promise(() => storageClient.remove()).pipe( + Micro.flatMap((deleteRes) => { + const deleteResponse = typeof deleteRes === 'undefined' ? null : deleteRes; + + const isInnerRequestError = + (revokeResponse && 'error' in revokeResponse) || + (deleteResponse && + typeof deleteResponse === 'object' && + 'error' in deleteResponse); + + if (isInnerRequestError) { + const result: RevokeErrorResult = { + error: 'Inner request error', + revokeResponse, + deleteResponse, + }; + return Micro.fail(result); + } else { + const result: RevokeSuccessResult = { + revokeResponse: null, + deleteResponse: null, + }; + return Micro.succeed(result); + } + }), + ), + ), + ); + + const result = await Micro.runPromiseExit(revokeµ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + return { + error: 'Token revocation failure', + message: result.cause.message, + type: 'auth_error', + }; + } + }, }, /** @@ -305,9 +412,9 @@ export async function oidc({ * @method info * @description Retrieves user information using the userinfo endpoint from the wellknown configuration. * It requires an access token stored in the configured storage. - * @returns {Promise} - Returns a promise that resolves to user information or an error response. + * @returns {Promise} - Returns a promise that resolves to user information or an error response. */ - info: async (): Promise => { + info: async (): Promise => { const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -375,9 +482,9 @@ export async function oidc({ * @method logout * @description Logs out the user by revoking tokens and clearing the storage. * It uses the end session endpoint from the wellknown configuration. - * @returns {Promise} - Returns a promise that resolves to the logout response or an error. + * @returns {Promise} - Returns a promise that resolves to the logout response or an error. */ - logout: async (): Promise => { + logout: async (): Promise => { const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -388,6 +495,13 @@ export async function oidc({ }; } + if (!wellknown?.revocation_endpoint) { + return { + error: 'Wellknown missing revocation endpoint', + type: 'wellknown_error', + }; + } + const tokens = await storageClient.get(); if (!tokens) { diff --git a/packages/oidc-client/src/lib/client.types.ts b/packages/oidc-client/src/lib/client.types.ts index a7c3f2a31..995279438 100644 --- a/packages/oidc-client/src/lib/client.types.ts +++ b/packages/oidc-client/src/lib/client.types.ts @@ -7,11 +7,24 @@ export interface GetTokensOptions { storageOptions?: Partial; } -export type LogoutResult = Promise< - | GenericError - | { - sessionResponse: GenericError | null; - revokeResponse: GenericError | null; - deleteResponse: GenericError | null; - } ->; +export type RevokeSuccessResult = { + revokeResponse: GenericError | null; + deleteResponse: GenericError | null; +}; + +export type RevokeErrorResult = RevokeSuccessResult & { + error: string; +}; + +export type LogoutSuccessResult = RevokeSuccessResult & { + sessionResponse: GenericError | null; +}; + +export type LogoutErrorResult = LogoutSuccessResult & { + error: string; +}; + +export type UserInfoResponse = { + sub: string; + [key: string]: unknown; +}; diff --git a/packages/oidc-client/src/lib/logout.request.test.ts b/packages/oidc-client/src/lib/logout.request.test.ts index 5f8388e3c..39beb1765 100644 --- a/packages/oidc-client/src/lib/logout.request.test.ts +++ b/packages/oidc-client/src/lib/logout.request.test.ts @@ -6,6 +6,7 @@ */ import { it, expect, describe } from '@effect/vitest'; import { Micro } from 'effect'; +import { deepStrictEqual } from 'node:assert'; import { setupServer } from 'msw/node'; import { logoutµ } from './logout.request.js'; import { OauthTokens, OidcConfig } from './config.types.js'; @@ -127,28 +128,34 @@ describe('Ping AM', () => { const end_session_endpoint = 'https://example.com/am/oauth2/fake-realm/connect/endSession'; const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; - const result = yield* logoutµ({ - tokens, - config, - wellknown: { - ...partialWellknown, - end_session_endpoint, - revocation_endpoint, - }, - store, - storageClient, - }); - - expect(result).toStrictEqual({ - sessionResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, - revokeResponse: null, - deleteResponse: null, - }); + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + end_session_endpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + revokeResponse: null, + deleteResponse: null, + }), + ); }), ); @@ -157,28 +164,34 @@ describe('Ping AM', () => { const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; const revocation_endpoint = 'https://example.com/am/oauth2/fake-realm/token/revoke'; - const result = yield* logoutµ({ - tokens, - config, - wellknown: { - ...partialWellknown, - end_session_endpoint, - revocation_endpoint, - }, - store, - storageClient, - }); - - expect(result).toStrictEqual({ - sessionResponse: null, - revokeResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, - deleteResponse: null, - }); + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + end_session_endpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: null, + revokeResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + deleteResponse: null, + }), + ); }), ); }); @@ -217,29 +230,35 @@ describe('PingOne', () => { const ping_end_idp_session_endpoint = 'https://example.com/as/badIdpSignoff'; const revocation_endpoint = 'https://example.com/as/revoke'; - const result = yield* logoutµ({ - tokens, - config, - wellknown: { - ...partialWellknown, - ping_end_idp_session_endpoint, - end_session_endpoint: fakeEndSessionEndpoint, - revocation_endpoint, - }, - store, - storageClient, - }); - - expect(result).toStrictEqual({ - sessionResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, - revokeResponse: null, - deleteResponse: null, - }); + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + ping_end_idp_session_endpoint, + end_session_endpoint: fakeEndSessionEndpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + revokeResponse: null, + deleteResponse: null, + }), + ); }), ); @@ -248,29 +267,35 @@ describe('PingOne', () => { const ping_end_idp_session_endpoint = 'https://example.com/as/idpSignoff'; const revocation_endpoint = 'https://example.com/as/badRevoke'; - const result = yield* logoutµ({ - tokens, - config, - wellknown: { - ...partialWellknown, - ping_end_idp_session_endpoint, - end_session_endpoint: fakeEndSessionEndpoint, - revocation_endpoint, - }, - store, - storageClient, - }); - - expect(result).toStrictEqual({ - sessionResponse: null, - revokeResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, - deleteResponse: null, - }); + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + ping_end_idp_session_endpoint, + end_session_endpoint: fakeEndSessionEndpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: null, + revokeResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + deleteResponse: null, + }), + ); }), ); }); diff --git a/packages/oidc-client/src/lib/logout.request.ts b/packages/oidc-client/src/lib/logout.request.ts index 7c80ad8e3..04480fb9a 100644 --- a/packages/oidc-client/src/lib/logout.request.ts +++ b/packages/oidc-client/src/lib/logout.request.ts @@ -10,6 +10,7 @@ import { createClientStore, createLogoutError } from './client.store.utils.js'; import { OauthTokens, OidcConfig } from './config.types.js'; import { WellKnownResponse } from '@forgerock/sdk-types'; import { createStorage } from '@forgerock/storage'; +import { LogoutErrorResult, LogoutSuccessResult } from './client.types.js'; export function logoutµ({ tokens, @@ -48,15 +49,33 @@ export function logoutµ({ ).pipe( // Delete local token and return combined results Micro.flatMap(([sessionResponse, revokeResponse]) => - Micro.promise(async () => { - const deleteRes = await storageClient.remove(); - const deleteResponse = typeof deleteRes === 'undefined' ? null : deleteRes; - return { - sessionResponse, - revokeResponse, - deleteResponse, - }; - }), + Micro.promise(() => storageClient.remove()).pipe( + Micro.flatMap((deleteRes) => { + const deleteResponse = typeof deleteRes === 'undefined' ? null : deleteRes; + + const isInnerRequestError = + (sessionResponse && 'error' in sessionResponse) || + (revokeResponse && 'error' in revokeResponse) || + (deleteResponse && typeof deleteResponse === 'object' && 'error' in deleteResponse); + + if (isInnerRequestError) { + const result: LogoutErrorResult = { + error: 'Inner request error', + sessionResponse, + revokeResponse, + deleteResponse, + }; + return Micro.fail(result); + } else { + const result: LogoutSuccessResult = { + sessionResponse: null, + revokeResponse: null, + deleteResponse: null, + }; + return Micro.succeed(result); + } + }), + ), ), ); } diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index 7da87da26..8cb0b1c45 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -12,6 +12,7 @@ import { import type { TokenExchangeResponse } from './exchange.types.js'; import { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js'; import { iFrameManager } from '@forgerock/iframe-manager'; +import { UserInfoResponse } from './client.types.js'; interface Extras { requestMiddleware: RequestMiddleware[]; @@ -336,7 +337,7 @@ export const oidcApi = createApi({ return response as { data: object }; }, }), - userInfo: builder.mutation({ + userInfo: builder.mutation({ queryFn: async ({ accessToken, endpoint }, api, _, baseQuery) => { const { requestMiddleware, logger } = api.extra as Extras; @@ -375,7 +376,7 @@ export const oidcApi = createApi({ logger.debug('OIDC userInfo API response', response); - return response as { data: TokenExchangeResponse }; + return response as { data: UserInfoResponse }; }, }), }),