Skip to content

Commit 4d4be08

Browse files
authored
Merge pull request #378 from ForgeRock/oidc-authorize-to-rtk-query
refactor(oidc-client): move authorize requests into rtk query
2 parents fc9227f + 4d0ee71 commit 4d4be08

File tree

13 files changed

+410
-243
lines changed

13 files changed

+410
-243
lines changed

.changeset/spotty-tires-admire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/oidc-client': minor
3+
---
4+
5+
Migrate /authorize to RTK Query and improve result types

e2e/oidc-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
"@forgerock/sdk-types": "workspace:*"
1515
},
1616
"nx": {
17-
"tags": ["scope:app"]
17+
"tags": ["scope:e2e"]
1818
}
1919
}

e2e/oidc-app/src/utils/oidc-app.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
*/
99
import { oidc } from '@forgerock/oidc-client';
1010
import type {
11-
AuthorizeErrorResponse,
11+
AuthorizationError,
12+
GenericError,
13+
GetAuthorizationUrlOptions,
1214
OauthTokens,
1315
TokenExchangeErrorResponse,
1416
} from '@forgerock/oidc-client/types';
15-
import { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types';
1617

1718
let tokenIndex = 0;
1819

@@ -23,7 +24,7 @@ function displayError(error) {
2324
}
2425

2526
function displayTokenResponse(
26-
response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizeErrorResponse,
27+
response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizationError,
2728
) {
2829
const appEl = document.getElementById('app');
2930
if ('error' in response) {

e2e/oidc-suites/src/login.spec.ts

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ test.describe('PingAM login and get token tests', () => {
2121
await navigate('/ping-am/');
2222
expect(page.url()).toBe('http://localhost:8443/ping-am/');
2323

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

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

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

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

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

60-
await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`);
61-
await expect(page.locator('.error')).toContainText('Error calling authorization URL');
62-
await expect(page.locator('.error')).toContainText(`"type": "auth_error"`);
63-
});
64-
65-
test('redirect login with invalid client id fails', async ({ page }) => {
66-
const { navigate, clickButton } = asyncEvents(page);
67-
await navigate('/ping-am/?clientid=bad-id');
68-
expect(page.url()).toBe('http://localhost:8443/ping-am/?clientid=bad-id');
69-
70-
await clickButton('Login (Redirect)', 'https://openam-sdks.forgeblocks.com/');
71-
72-
await expect(page.getByText('invalid_client')).toBeVisible();
60+
await expect(page.locator('.error')).toContainText(`CONFIGURATION_ERROR`);
61+
await expect(page.locator('.error')).toContainText(
62+
'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.',
63+
);
64+
await expect(page.locator('.error')).toContainText(`"type": "network_error"`);
7365
});
7466
});
7567

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

82-
await clickButton('Login (Background)', 'https://apps.pingone.ca/');
74+
await clickButton('Login (Background)', '/authorize');
8375

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

100-
await clickButton('Login (Redirect)', 'https://apps.pingone.ca/');
92+
await clickButton('Login (Redirect)', '/authorize');
10193

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

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

120-
await expect(page.locator('.error')).toContainText(`"error": "Authorization Network Failure"`);
121-
await expect(page.locator('.error')).toContainText('Failed to fetch');
122-
await expect(page.locator('.error')).toContainText(`"type": "auth_error"`);
123-
});
124-
125-
test('redirect login with invalid client id fails', async ({ page }) => {
126-
const { navigate, clickButton } = asyncEvents(page);
127-
await navigate('/ping-one/?clientid=bad-id');
128-
expect(page.url()).toBe('http://localhost:8443/ping-one/?clientid=bad-id');
129-
130-
await clickButton('Login (Redirect)', 'https://apps.pingone.ca/');
131-
132-
await expect(page.getByText('Error')).toBeVisible();
133-
await expect(
134-
page
135-
.getByText('The request could not be completed. The requested resource was not found.')
136-
.first(),
137-
).toBeVisible();
112+
await expect(page.locator('.error')).toContainText(`CONFIGURATION_ERROR`);
113+
await expect(page.locator('.error')).toContainText(
114+
'Configuration error. Please check your OAuth configuration, like clientId or allowed redirect URLs.',
115+
);
116+
await expect(page.locator('.error')).toContainText(`"type": "network_error"`);
138117
});
139118

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

154-
await clickButton('Login (Background)', 'https://apps.pingone.ca/');
133+
await clickButton('Login (Background)', '/authorize');
155134

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

185-
await expect(page.locator('.error')).toContainText(`"error": "Error fetching wellknown config"`);
186-
await expect(page.locator('.error')).toContainText(`"type": "network_error"`);
164+
await page.getByRole('button', { name: 'Login (Background)' }).click();
165+
166+
await expect(page.locator('.error')).toContainText(
167+
'Authorization endpoint not found in wellknown configuration',
168+
);
169+
await expect(page.locator('.error')).toContainText('wellknown_error');
187170
});

packages/oidc-client/src/lib/authorize.request.ts

Lines changed: 141 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,17 @@ import { CustomLogger } from '@forgerock/sdk-logger';
88
import { Micro } from 'effect';
99

1010
import {
11-
authorizeFetchµ,
1211
createAuthorizeUrlµ,
13-
authorizeIframeµ,
1412
buildAuthorizeOptionsµ,
1513
createAuthorizeErrorµ,
1614
} from './authorize.request.utils.js';
1715

1816
import type { GetAuthorizationUrlOptions, WellKnownResponse } from '@forgerock/sdk-types';
17+
18+
import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js';
19+
import type { createClientStore } from './client.store.utils.js';
1920
import type { OidcConfig } from './config.types.js';
20-
import type {
21-
AuthorizeErrorResponse,
22-
AuthorizeSuccessResponse,
23-
} from './authorize.request.types.js';
21+
import { oidcApi } from './oidc.api.js';
2422

2523
/**
2624
* @function authorizeµ
@@ -29,67 +27,153 @@ import type {
2927
* @param {OidcConfig} config - The OIDC client configuration.
3028
* @param {CustomLogger} log - The logger instance for logging debug information.
3129
* @param {GetAuthorizationUrlOptions} options - Optional parameters for the authorization request.
32-
* @returns {Micro.Micro<AuthorizeSuccessResponse, AuthorizeErrorResponse, never>} - A micro effect that resolves to the authorization response.
30+
* @returns {Micro.Micro<AuthorizationSuccess, AuthorizationError, never>} - A micro effect that resolves to the authorization response.
3331
*/
3432
export function authorizeµ(
3533
wellknown: WellKnownResponse,
3634
config: OidcConfig,
3735
log: CustomLogger,
36+
store: ReturnType<typeof createClientStore>,
3837
options?: GetAuthorizationUrlOptions,
3938
) {
4039
return buildAuthorizeOptionsµ(wellknown, config, options).pipe(
4140
Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)),
4241
Micro.tap((url) => log.debug('Authorize URL created', url)),
4342
Micro.tapError((url) => Micro.sync(() => log.error('Error creating authorize URL', url))),
44-
Micro.flatMap(([url, config, options]) => {
45-
if (options.responseMode === 'pi.flow') {
46-
/**
47-
* If we support the pi.flow field, this means we are using a PingOne server.
48-
* PingOne servers do not support redirection through iframes because they
49-
* set iframe's to DENY.
50-
*
51-
* We do not use RTK Query for this because we don't want caching, or store
52-
* updates, and want the request to be made similar to the iframe method below.
53-
*
54-
* This returns a Micro that resolves to the parsed response JSON.
55-
*/
56-
return authorizeFetchµ(url).pipe(
57-
Micro.flatMap(
58-
(response): Micro.Micro<AuthorizeSuccessResponse, AuthorizeErrorResponse, never> => {
59-
if ('code' in response) {
60-
log.debug('Received code in response', response);
61-
return Micro.succeed(response);
62-
}
63-
log.error('Error in authorize response', response);
64-
// For redirection, we need to remove `pi.flow` from the options
65-
const redirectOptions = options;
66-
delete redirectOptions.responseMode;
67-
return createAuthorizeErrorµ(response, wellknown, config, options);
68-
},
69-
),
70-
);
71-
} else {
72-
/**
73-
* If the response mode is not pi.flow, then we are likely using a traditional
74-
* redirect based server supporting iframes. An example would be PingAM.
75-
*
76-
* This returns a Micro that's either the success URL parameters or error URL
77-
* parameters.
78-
*/
79-
return authorizeIframeµ(url, config).pipe(
80-
Micro.flatMap(
81-
(response): Micro.Micro<AuthorizeSuccessResponse, AuthorizeErrorResponse, never> => {
82-
if ('code' in response && 'state' in response) {
83-
log.debug('Received authorization code', response);
84-
return Micro.succeed(response as unknown as AuthorizeSuccessResponse);
85-
}
86-
log.error('Error in authorize response', response);
87-
const errorResponse = response as unknown as AuthorizeErrorResponse;
88-
return createAuthorizeErrorµ(errorResponse, wellknown, config, options);
89-
},
90-
),
91-
);
92-
}
93-
}),
43+
Micro.flatMap(
44+
([url, options]): Micro.Micro<AuthorizationSuccess, AuthorizationError, never> => {
45+
if (options.responseMode === 'pi.flow') {
46+
/**
47+
* If we support the pi.flow field, this means we are using a PingOne server.
48+
* PingOne servers do not support redirection through iframes because they
49+
* set iframe's to DENY.
50+
*
51+
* We do not use RTK Query for this because we don't want caching, or store
52+
* updates, and want the request to be made similar to the iframe method below.
53+
*
54+
* This returns a Micro that resolves to the parsed response JSON.
55+
*/
56+
return Micro.promise(() =>
57+
store.dispatch(oidcApi.endpoints.authorizeFetch.initiate({ url })),
58+
).pipe(
59+
Micro.flatMap(
60+
({ error, data }): Micro.Micro<AuthorizationSuccess, AuthorizationError, never> => {
61+
if (error) {
62+
// Check for serialized error
63+
if (!('status' in error)) {
64+
// This is a network or fetch error, so return it as-is
65+
return Micro.fail({
66+
error: error.code || 'Unknown_Error',
67+
error_description:
68+
error.message || 'An unknown error occurred during authorization',
69+
type: 'unknown_error',
70+
});
71+
}
72+
73+
// If there is no data, this is an unknown error
74+
if (!('data' in error)) {
75+
return Micro.fail({
76+
error: 'Unknown_Error',
77+
error_description: 'An unknown error occurred during authorization',
78+
type: 'unknown_error',
79+
});
80+
}
81+
82+
const errorDetails = error.data as AuthorizationError;
83+
84+
// If the error is a configuration issue, return it as-is
85+
if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
86+
return Micro.fail(errorDetails);
87+
}
88+
89+
// If the error is not a configuration issue, we build a new Authorize URL
90+
// For redirection, we need to remove `pi.flow` from the options
91+
const redirectOptions = options;
92+
delete redirectOptions.responseMode;
93+
94+
// Create an error with a new Authorize URL
95+
return createAuthorizeErrorµ(errorDetails, wellknown, options);
96+
}
97+
98+
log.debug('Received success response', data);
99+
100+
if (data.authorizeResponse) {
101+
// Authorization was successful
102+
return Micro.succeed(data.authorizeResponse);
103+
} else {
104+
// This should never be reached, but just in case
105+
return Micro.fail({
106+
error: 'Unknown_Error',
107+
error_description: 'Response schema was not recognized',
108+
type: 'unknown_error',
109+
});
110+
}
111+
},
112+
),
113+
);
114+
} else {
115+
/**
116+
* If the response mode is not pi.flow, then we are likely using a traditional
117+
* redirect based server supporting iframes. An example would be PingAM.
118+
*
119+
* This returns a Micro that's either the success URL parameters or error URL
120+
* parameters.
121+
*/
122+
return Micro.promise(() =>
123+
store.dispatch(oidcApi.endpoints.authorizeIframe.initiate({ url })),
124+
).pipe(
125+
Micro.flatMap(
126+
({ error, data }): Micro.Micro<AuthorizationSuccess, AuthorizationError, never> => {
127+
if (error) {
128+
// Check for serialized error
129+
if (!('status' in error)) {
130+
// This is a network or fetch error, so return it as-is
131+
return Micro.fail({
132+
error: error.code || 'Unknown_Error',
133+
error_description:
134+
error.message || 'An unknown error occurred during authorization',
135+
type: 'unknown_error',
136+
});
137+
}
138+
139+
// If there is no data, this is an unknown error
140+
if (!('data' in error)) {
141+
return Micro.fail({
142+
error: 'Unknown_Error',
143+
error_description: 'An unknown error occurred during authorization',
144+
type: 'unknown_error',
145+
});
146+
}
147+
148+
const errorDetails = error.data as AuthorizationError;
149+
150+
// If the error is a configuration issue, return it as-is
151+
if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
152+
return Micro.fail(errorDetails);
153+
}
154+
155+
// This is an expected error, so combine error with a new Authorize URL
156+
return createAuthorizeErrorµ(errorDetails, wellknown, options);
157+
}
158+
159+
log.debug('Received success response', data);
160+
161+
if (data) {
162+
// Authorization was successful
163+
return Micro.succeed(data);
164+
} else {
165+
// This should never be reached, but just in case
166+
return Micro.fail({
167+
error: 'Unknown_Error',
168+
error_description: 'Redirect parameters was not recognized',
169+
type: 'unknown_error',
170+
});
171+
}
172+
},
173+
),
174+
);
175+
}
176+
},
177+
),
94178
);
95179
}

0 commit comments

Comments
 (0)