Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spotty-tires-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/oidc-client': minor
---

Migrate /authorize to RTK Query and improve result types
2 changes: 1 addition & 1 deletion e2e/oidc-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
"@forgerock/sdk-types": "workspace:*"
},
"nx": {
"tags": ["scope:app"]
"tags": ["scope:e2e"]
}
}
7 changes: 4 additions & 3 deletions e2e/oidc-app/src/utils/oidc-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
*/
import { oidc } from '@forgerock/oidc-client';
import type {
AuthorizeErrorResponse,
AuthorizationError,
GenericError,
GetAuthorizationUrlOptions,
OauthTokens,
TokenExchangeErrorResponse,
} from '@forgerock/oidc-client/types';
import { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types';

let tokenIndex = 0;

Expand All @@ -23,7 +24,7 @@ function displayError(error) {
}

function displayTokenResponse(
response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizeErrorResponse,
response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizationError,
) {
const appEl = document.getElementById('app');
if ('error' in response) {
Expand Down
59 changes: 21 additions & 38 deletions e2e/oidc-suites/src/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test.describe('PingAM login and get token tests', () => {
await navigate('/ping-am/');
expect(page.url()).toBe('http://localhost:8443/ping-am/');

await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/');
await clickButton('Login (Background)', '/authorize');

await page.getByLabel('User Name').fill(pingAmUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword);
Expand All @@ -38,7 +38,7 @@ test.describe('PingAM login and get token tests', () => {
await navigate('/ping-am/');
expect(page.url()).toBe('http://localhost:8443/ping-am/');

await clickButton('Login (Redirect)', 'https://openam-sdks.forgeblocks.com/');
await clickButton('Login (Redirect)', '/authorize');

await page.getByLabel('User Name').fill(pingAmUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword);
Expand All @@ -57,19 +57,11 @@ test.describe('PingAM login and get token tests', () => {

await page.getByRole('button', { name: 'Login (Background)' }).click();

await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`);
await expect(page.locator('.error')).toContainText('Error calling authorization URL');
await expect(page.locator('.error')).toContainText(`"type": "auth_error"`);
});

test('redirect login with invalid client id fails', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('/ping-am/?clientid=bad-id');
expect(page.url()).toBe('http://localhost:8443/ping-am/?clientid=bad-id');

await clickButton('Login (Redirect)', 'https://openam-sdks.forgeblocks.com/');

await expect(page.getByText('invalid_client')).toBeVisible();
await expect(page.locator('.error')).toContainText(`CONFIGURATION_ERROR`);
await expect(page.locator('.error')).toContainText(
'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.',
);
await expect(page.locator('.error')).toContainText(`"type": "network_error"`);
});
});

Expand All @@ -79,7 +71,7 @@ test.describe('PingOne login and get token tests', () => {
await navigate('/ping-one/');
expect(page.url()).toBe('http://localhost:8443/ping-one/');

await clickButton('Login (Background)', 'https://apps.pingone.ca/');
await clickButton('Login (Background)', '/authorize');

await page.getByLabel('Username').fill(pingOneUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword);
Expand All @@ -97,7 +89,7 @@ test.describe('PingOne login and get token tests', () => {
await navigate('/ping-one/');
expect(page.url()).toBe('http://localhost:8443/ping-one/');

await clickButton('Login (Redirect)', 'https://apps.pingone.ca/');
await clickButton('Login (Redirect)', '/authorize');

await page.getByLabel('Username').fill(pingOneUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword);
Expand All @@ -117,24 +109,11 @@ test.describe('PingOne login and get token tests', () => {

await page.getByRole('button', { name: 'Login (Background)' }).click();

await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`);
await expect(page.locator('.error')).toContainText('Failed to fetch');
await expect(page.locator('.error')).toContainText(`"type": "auth_error"`);
});

test('redirect login with invalid client id fails', async ({ page }) => {
const { navigate, clickButton } = asyncEvents(page);
await navigate('/ping-one/?clientid=bad-id');
expect(page.url()).toBe('http://localhost:8443/ping-one/?clientid=bad-id');

await clickButton('Login (Redirect)', 'https://apps.pingone.ca/');

await expect(page.getByText('Error')).toBeVisible();
await expect(
page
.getByText('The request could not be completed. The requested resource was not found.')
.first(),
).toBeVisible();
await expect(page.locator('.error')).toContainText(`CONFIGURATION_ERROR`);
await expect(page.locator('.error')).toContainText(
'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.',
);
await expect(page.locator('.error')).toContainText(`"type": "network_error"`);
});

test('login with pi.flow response mode', async ({ page }) => {
Expand All @@ -151,7 +130,7 @@ test.describe('PingOne login and get token tests', () => {
}
});

await clickButton('Login (Background)', 'https://apps.pingone.ca/');
await clickButton('Login (Background)', '/authorize');

await page.getByLabel('Username').fill(pingOneUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingOnePassword);
Expand Down Expand Up @@ -182,6 +161,10 @@ test('oidc client fails to initialize with bad wellknown', async ({ page }) => {
await navigate('/ping-am/?wellknown=bad-wellknown');
expect(page.url()).toBe('http://localhost:8443/ping-am/?wellknown=bad-wellknown');

await expect(page.locator('.error')).toContainText(`"error": "Error fetching wellknown config"`);
await expect(page.locator('.error')).toContainText(`"type": "network_error"`);
await page.getByRole('button', { name: 'Login (Background)' }).click();

await expect(page.locator('.error')).toContainText(
'Authorization endpoint not found in wellknown configuration',
);
await expect(page.locator('.error')).toContainText('wellknown_error');
});
198 changes: 141 additions & 57 deletions packages/oidc-client/src/lib/authorize.request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@ import { CustomLogger } from '@forgerock/sdk-logger';
import { Micro } from 'effect';

import {
authorizeFetchµ,
createAuthorizeUrlµ,
authorizeIframeµ,
buildAuthorizeOptionsµ,
createAuthorizeErrorµ,
} from './authorize.request.utils.js';

import type { GetAuthorizationUrlOptions, WellKnownResponse } from '@forgerock/sdk-types';

import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js';
import type { createClientStore } from './client.store.utils.js';
import type { OidcConfig } from './config.types.js';
import type {
AuthorizeErrorResponse,
AuthorizeSuccessResponse,
} from './authorize.request.types.js';
import { oidcApi } from './oidc.api.js';

/**
* @function authorizeµ
Expand All @@ -29,67 +27,153 @@ import type {
* @param {OidcConfig} config - The OIDC client configuration.
* @param {CustomLogger} log - The logger instance for logging debug information.
* @param {GetAuthorizationUrlOptions} options - Optional parameters for the authorization request.
* @returns {Micro.Micro<AuthorizeSuccessResponse, AuthorizeErrorResponse, never>} - A micro effect that resolves to the authorization response.
* @returns {Micro.Micro<AuthorizationSuccess, AuthorizationError, never>} - A micro effect that resolves to the authorization response.
*/
export function authorizeµ(
wellknown: WellKnownResponse,
config: OidcConfig,
log: CustomLogger,
store: ReturnType<typeof createClientStore>,
options?: GetAuthorizationUrlOptions,
) {
return buildAuthorizeOptionsµ(wellknown, config, options).pipe(
Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)),
Micro.tap((url) => log.debug('Authorize URL created', url)),
Micro.tapError((url) => Micro.sync(() => log.error('Error creating authorize URL', url))),
Micro.flatMap(([url, config, options]) => {
if (options.responseMode === 'pi.flow') {
/**
* If we support the pi.flow field, this means we are using a PingOne server.
* PingOne servers do not support redirection through iframes because they
* set iframe's to DENY.
*
* We do not use RTK Query for this because we don't want caching, or store
* updates, and want the request to be made similar to the iframe method below.
*
* This returns a Micro that resolves to the parsed response JSON.
*/
return authorizeFetchµ(url).pipe(
Micro.flatMap(
(response): Micro.Micro<AuthorizeSuccessResponse, AuthorizeErrorResponse, never> => {
if ('code' in response) {
log.debug('Received code in response', response);
return Micro.succeed(response);
}
log.error('Error in authorize response', response);
// For redirection, we need to remove `pi.flow` from the options
const redirectOptions = options;
delete redirectOptions.responseMode;
return createAuthorizeErrorµ(response, wellknown, config, options);
},
),
);
} else {
/**
* If the response mode is not pi.flow, then we are likely using a traditional
* redirect based server supporting iframes. An example would be PingAM.
*
* This returns a Micro that's either the success URL parameters or error URL
* parameters.
*/
return authorizeIframeµ(url, config).pipe(
Micro.flatMap(
(response): Micro.Micro<AuthorizeSuccessResponse, AuthorizeErrorResponse, never> => {
if ('code' in response && 'state' in response) {
log.debug('Received authorization code', response);
return Micro.succeed(response as unknown as AuthorizeSuccessResponse);
}
log.error('Error in authorize response', response);
const errorResponse = response as unknown as AuthorizeErrorResponse;
return createAuthorizeErrorµ(errorResponse, wellknown, config, options);
},
),
);
}
}),
Micro.flatMap(
([url, options]): Micro.Micro<AuthorizationSuccess, AuthorizationError, never> => {
if (options.responseMode === 'pi.flow') {
/**
* If we support the pi.flow field, this means we are using a PingOne server.
* PingOne servers do not support redirection through iframes because they
* set iframe's to DENY.
*
* We do not use RTK Query for this because we don't want caching, or store
* updates, and want the request to be made similar to the iframe method below.
*
* This returns a Micro that resolves to the parsed response JSON.
*/
return Micro.promise(() =>
store.dispatch(oidcApi.endpoints.authorizeFetch.initiate({ url })),
).pipe(
Micro.flatMap(
({ error, data }): Micro.Micro<AuthorizationSuccess, AuthorizationError, never> => {
if (error) {
// Check for serialized error
if (!('status' in error)) {
// This is a network or fetch error, so return it as-is
return Micro.fail({
error: error.code || 'Unknown_Error',
error_description:
error.message || 'An unknown error occurred during authorization',
type: 'unknown_error',
});
}

// If there is no data, this is an unknown error
if (!('data' in error)) {
return Micro.fail({
error: 'Unknown_Error',
error_description: 'An unknown error occurred during authorization',
type: 'unknown_error',
});
}

const errorDetails = error.data as AuthorizationError;

// If the error is a configuration issue, return it as-is
if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
return Micro.fail(errorDetails);
}

// If the error is not a configuration issue, we build a new Authorize URL
// For redirection, we need to remove `pi.flow` from the options
const redirectOptions = options;
delete redirectOptions.responseMode;

// Create an error with a new Authorize URL
return createAuthorizeErrorµ(errorDetails, wellknown, options);
}

log.debug('Received success response', data);

if (data.authorizeResponse) {
// Authorization was successful
return Micro.succeed(data.authorizeResponse);
} else {
// This should never be reached, but just in case
return Micro.fail({
error: 'Unknown_Error',
error_description: 'Response schema was not recognized',
type: 'unknown_error',
});
}
},
),
);
} else {
/**
* If the response mode is not pi.flow, then we are likely using a traditional
* redirect based server supporting iframes. An example would be PingAM.
*
* This returns a Micro that's either the success URL parameters or error URL
* parameters.
*/
return Micro.promise(() =>
store.dispatch(oidcApi.endpoints.authorizeIframe.initiate({ url })),
).pipe(
Micro.flatMap(
({ error, data }): Micro.Micro<AuthorizationSuccess, AuthorizationError, never> => {
if (error) {
// Check for serialized error
if (!('status' in error)) {
// This is a network or fetch error, so return it as-is
return Micro.fail({
error: error.code || 'Unknown_Error',
error_description:
error.message || 'An unknown error occurred during authorization',
type: 'unknown_error',
});
}

// If there is no data, this is an unknown error
if (!('data' in error)) {
return Micro.fail({
error: 'Unknown_Error',
error_description: 'An unknown error occurred during authorization',
type: 'unknown_error',
});
}

const errorDetails = error.data as AuthorizationError;

// If the error is a configuration issue, return it as-is
if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
return Micro.fail(errorDetails);
}

// This is an expected error, so combine error with a new Authorize URL
return createAuthorizeErrorµ(errorDetails, wellknown, options);
}

log.debug('Received success response', data);

if (data) {
// Authorization was successful
return Micro.succeed(data);
} else {
// This should never be reached, but just in case
return Micro.fail({
error: 'Unknown_Error',
error_description: 'Redirect parameters was not recognized',
type: 'unknown_error',
});
}
},
),
);
}
},
),
);
}
Loading
Loading