Skip to content

Commit ccf3fc1

Browse files
authored
Merge pull request #359 from ForgeRock/oidc-logout
feat(oidc-client): implement OIDC logout
2 parents 2de357c + add5a17 commit ccf3fc1

8 files changed

Lines changed: 282 additions & 13 deletions

File tree

e2e/oidc-app/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@
88
<meta name="viewport" content="width=device-width, initial-scale=1" />
99
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
1010
<link rel="stylesheet" href="/src/styles.css" />
11+
12+
<style>
13+
#logout {
14+
display: none;
15+
}
16+
</style>
1117
</head>
1218
<body>
1319
<h1>Welcome</h1>
20+
<button id="login">Login</button>
21+
<button id="logout">Logout</button>
1422
<script type="module" src="/src/main.ts"></script>
1523
</body>
1624
</html>

e2e/oidc-app/src/main.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const pingOneConfig = {
1515
config: {
1616
clientId: '654b14e2-7cc5-4977-8104-c4113e43c537',
1717
redirectUri: 'http://localhost:8443/',
18-
scope: 'openid',
18+
scope: 'openid revoke',
1919
serverConfig: {
2020
wellknown:
2121
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
@@ -30,12 +30,8 @@ async function app() {
3030
const urlParams = new URLSearchParams(window.location.search);
3131
const code = urlParams.get('code');
3232
const state = urlParams.get('state');
33-
// get error and error_description if they exist
34-
const error = urlParams.get('error');
35-
// const errorDescription = urlParams.get('error_description');
3633

37-
// Handle background authorization flow
38-
if (!code && !error) {
34+
document.getElementById('login')?.addEventListener('click', async () => {
3935
const response = await oidcClient.authorize.background();
4036

4137
if ('error' in response) {
@@ -52,15 +48,30 @@ async function app() {
5248
} else if ('code' in response) {
5349
console.log('Authorization Code:', response.code);
5450
const tokenResponse = await oidcClient.token.exchange(response.code, response.state);
51+
5552
if ('error' in response) {
5653
console.error('Token Exchange Error:', tokenResponse);
5754
} else {
5855
console.log('Token Exchange Response:', tokenResponse);
56+
document.getElementById('logout')!.style.display = 'block';
57+
document.getElementById('login')!.style.display = 'none';
5958
}
6059
}
60+
});
61+
62+
document.getElementById('logout')?.addEventListener('click', async () => {
63+
const response = await oidcClient.user.logout();
64+
65+
if (response && 'error' in response) {
66+
console.error('Logout Error:', response);
67+
} else {
68+
console.log('Logout successful');
69+
document.getElementById('logout')!.style.display = 'none';
70+
document.getElementById('login')!.style.display = 'block';
71+
}
72+
});
6173

62-
// Handle the user redirecting after authentication
63-
} else if (code && state) {
74+
if (code && state) {
6475
const response = await oidcClient.token.exchange(code, state);
6576

6677
if ('error' in response) {

packages/oidc-client/src/lib/client.store.ts

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Micro } from 'effect';
1111
import { exitIsFail, exitIsSuccess } from 'effect/Micro';
1212

1313
import { authorizeµ } from './authorize.request.js';
14-
import { createClientStore } from './client.store.utils.js';
14+
import { createClientStore, createError } from './client.store.utils.js';
1515
import { createValuesµ, handleTokenResponseµ, validateValuesµ } from './exchange.utils.js';
1616
import { GenericError } from './error.types.js';
1717
import { oidcApi } from './oidc.api.js';
@@ -20,7 +20,7 @@ import { wellknownApi, wellknownSelector } from './wellknown.api.js';
2020
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
2121
import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types';
2222

23-
import type { OidcConfig } from './config.types.js';
23+
import type { OauthTokens, OidcConfig } from './config.types.js';
2424
import type { AuthorizeErrorResponse } from './authorize.request.types.js';
2525
import type { TokenExchangeErrorResponse } from './exchange.types.js';
2626

@@ -39,7 +39,7 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
3939
storage?: Partial<StorageConfig>;
4040
}) {
4141
const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom });
42-
const storageClient = createStorage({
42+
const storageClient = createStorage<OauthTokens>({
4343
type: 'localStorage',
4444
name: 'oidcTokens',
4545
...storage,
@@ -159,7 +159,12 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
159159
Micro.flatMap(({ data, error }) => handleTokenResponseµ(data, error)),
160160
Micro.flatMap((data) =>
161161
Micro.promise(async () => {
162-
await storageClient.set(data);
162+
await storageClient.set({
163+
accessToken: data.access_token,
164+
idToken: data.id_token,
165+
refreshToken: data.refresh_token,
166+
expiresAt: data.expires_in,
167+
});
163168
return data;
164169
}),
165170
),
@@ -180,5 +185,140 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
180185
}
181186
},
182187
},
188+
user: {
189+
info: async () => {
190+
const state = store.getState();
191+
const wellknown = wellknownSelector(wellknownUrl, state);
192+
193+
if (!wellknown?.userinfo_endpoint) {
194+
const err = {
195+
error: 'Wellknown missing userinfo endpoint',
196+
type: 'wellknown_error',
197+
} as AuthorizeErrorResponse;
198+
199+
log.error(err.error);
200+
201+
return err;
202+
}
203+
204+
const tokens = await storageClient.get();
205+
206+
if (!tokens || !('accessToken' in tokens)) {
207+
const err = {
208+
error: 'No access token found',
209+
type: 'auth_error',
210+
} as AuthorizeErrorResponse;
211+
212+
log.error(err.error);
213+
214+
return err;
215+
}
216+
217+
return await store.dispatch(
218+
oidcApi.endpoints.userInfo.initiate({
219+
accessToken: tokens.accessToken,
220+
endpoint: wellknown.userinfo_endpoint,
221+
}),
222+
);
223+
},
224+
logout: async () => {
225+
const state = store.getState();
226+
const wellknown = wellknownSelector(wellknownUrl, state);
227+
228+
if (!wellknown?.end_session_endpoint) {
229+
const err = {
230+
error: 'Wellknown missing end session endpoint',
231+
type: 'wellknown_error',
232+
} as AuthorizeErrorResponse;
233+
234+
log.error(err.error);
235+
236+
return err;
237+
}
238+
239+
const tokens = await storageClient.get();
240+
241+
if (!tokens) {
242+
return createError('no_tokens', log);
243+
}
244+
245+
if (!('accessToken' in tokens)) {
246+
return createError('no_access_token', log);
247+
}
248+
249+
if (!('idToken' in tokens)) {
250+
return createError('no_id_token', log);
251+
}
252+
253+
const logout = Micro.zip(
254+
Micro.tryPromise({
255+
try: () =>
256+
store.dispatch(
257+
oidcApi.endpoints.endSession.initiate({
258+
idToken: tokens.idToken,
259+
endpoint:
260+
wellknown.ping_end_idp_session_endpoint || wellknown.end_session_endpoint,
261+
}),
262+
),
263+
catch: () => {
264+
const err = {
265+
error: 'Logout request failed',
266+
message: 'network_error',
267+
} as GenericError;
268+
269+
log.error(err);
270+
271+
return err;
272+
},
273+
}),
274+
Micro.tryPromise({
275+
try: () =>
276+
store.dispatch(
277+
oidcApi.endpoints.revoke.initiate({
278+
accessToken: tokens.accessToken,
279+
clientId: config.clientId,
280+
endpoint: wellknown.revocation_endpoint,
281+
}),
282+
),
283+
catch: () => {
284+
const err = {
285+
error: 'Revoke request failed',
286+
message: 'network_error',
287+
} as GenericError;
288+
289+
log.error(err);
290+
291+
return err;
292+
},
293+
}),
294+
).pipe(
295+
Micro.flatMap(([sessionResponse, revokeResponse]) =>
296+
Micro.gen(function* () {
297+
const deleteResponse = yield* Micro.promise(storageClient.remove);
298+
return {
299+
sessionResponse: sessionResponse,
300+
revokeResponse: revokeResponse,
301+
deleteResponse,
302+
};
303+
}),
304+
),
305+
);
306+
307+
const result = await Micro.runPromiseExit(logout);
308+
309+
if (exitIsSuccess(result)) {
310+
await storageClient.remove();
311+
return result.value;
312+
} else if (exitIsFail(result)) {
313+
return result.cause.error;
314+
} else {
315+
return {
316+
error: 'Logout failure',
317+
message: result.cause.message,
318+
type: 'auth_error',
319+
} as GenericError;
320+
}
321+
},
322+
},
183323
};
184324
}

packages/oidc-client/src/lib/client.store.utils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { logger as loggerFn } from '@forgerock/sdk-logger';
1010
import { configureStore } from '@reduxjs/toolkit';
1111
import { oidcApi } from './oidc.api.js';
1212
import { wellknownApi } from './wellknown.api.js';
13+
import { GenericError } from './error.types.js';
1314

1415
export function createClientStore<ActionType extends ActionTypes>({
1516
requestMiddleware,
@@ -41,6 +42,43 @@ export function createClientStore<ActionType extends ActionTypes>({
4142
});
4243
}
4344

45+
export function createError(
46+
type: 'no_tokens' | 'no_access_token' | 'no_id_token',
47+
log: ReturnType<typeof loggerFn>,
48+
) {
49+
let error: GenericError;
50+
51+
if (type === 'no_tokens') {
52+
error = {
53+
error: 'No tokens found in storage',
54+
message: 'Required for ending session and revoking access token',
55+
type: 'state_error',
56+
} as const;
57+
} else if (type === 'no_access_token') {
58+
error = {
59+
error: 'No access token found',
60+
message: 'No access token found in storage; required for revoking access token',
61+
type: 'state_error',
62+
} as const;
63+
} else if (type === 'no_id_token') {
64+
error = {
65+
error: 'No ID token found',
66+
message: 'No ID token found in storage; required for ending session',
67+
type: 'state_error',
68+
} as const;
69+
} else {
70+
error = {
71+
error: 'Unknown error type',
72+
message: 'An unknown error occurred while creating the error object',
73+
type: 'unknown_error',
74+
} as const;
75+
}
76+
77+
log.error(error.error);
78+
79+
return error;
80+
}
81+
4482
type ClientStore = typeof createClientStore;
4583

4684
export type RootState = ReturnType<ReturnType<ClientStore>['getState']>;

packages/oidc-client/src/lib/config.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ export interface OidcConfig extends AsyncLegacyConfigOptions {
2020
export interface InternalDaVinciConfig extends OidcConfig {
2121
wellknownResponse: WellKnownResponse;
2222
}
23+
24+
export interface OauthTokens {
25+
accessToken: string;
26+
idToken: string;
27+
refreshToken?: string;
28+
expiresAt?: number;
29+
}

packages/oidc-client/src/lib/exchange.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { OidcConfig } from './config.types.js';
22

33
export interface TokenExchangeResponse {
44
access_token: string;
5-
id_token?: string;
5+
id_token: string;
66
refresh_token?: string;
77
expires_in?: number;
88
scope?: string;

0 commit comments

Comments
 (0)