Skip to content

Commit f449b49

Browse files
authored
Merge pull request #402 from ForgeRock/oidc-force-renew-tokens
feat(oidc-client): implement force renew & revoke old tokens
2 parents 4d4be08 + bdbbbd2 commit f449b49

File tree

4 files changed

+304
-7
lines changed

4 files changed

+304
-7
lines changed

.changeset/every-bottles-sleep.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+
Implement force renew and revoke tokens that are replaced to tokens.get method
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
import { http, HttpResponse } from 'msw';
9+
import { setupServer } from 'msw/node';
10+
import { it, expect, describe, vi } from 'vitest';
11+
12+
import { oidc } from './client.store.js';
13+
14+
import type { OidcConfig } from './config.types.js';
15+
16+
vi.stubGlobal(
17+
'sessionStorage',
18+
(() => {
19+
let store: Record<string, string> = {};
20+
21+
return {
22+
getItem(key: string) {
23+
console.log('getItem', key);
24+
return store[key] || null;
25+
},
26+
setItem(key: string) {
27+
store[key] =
28+
'{"clientId":"123456789","serverConfig":{"baseUrl":"https://api.example.com"},"responseType":"code","redirectUri":"https://example.com/callback.html","scope":"openid profile","state":"NzUyNDUyMDAxOTMyNDUxNzI1NjkxNDc2MjEyMzUwMjQzMzQyMjE4OQ","verifier":"ODgyMzk2MjQ1MTkwMjQzMTkxNzcxNzcxNTUyMzgxMDcxNDgxOTcyMTIxMzQxNzYyMDkxNTMxMjcxMjI0MTU5MTY2MjI5MjA3MTk5MjM0MzcyMjQyMjI5"}';
29+
},
30+
removeItem(key: string) {
31+
delete store[key];
32+
},
33+
clear() {
34+
store = {};
35+
},
36+
};
37+
})(),
38+
);
39+
40+
const server = setupServer(
41+
// P1 Revoke
42+
http.post('*/as/authorize', async () => {
43+
console.log('authorize request received');
44+
return HttpResponse.json({
45+
authorizeResponse: {
46+
code: 123,
47+
state: 'NzUyNDUyMDAxOTMyNDUxNzI1NjkxNDc2MjEyMzUwMjQzMzQyMjE4OQ',
48+
},
49+
});
50+
}),
51+
http.post('*/as/token', async () =>
52+
HttpResponse.json({
53+
access_token: 'abcdefghijklmnop',
54+
id_token: '0987654321',
55+
}),
56+
),
57+
http.post('*/as/revoke', async () => HttpResponse.json(null, { status: 204 })),
58+
http.get('*/wellknown', async () =>
59+
HttpResponse.json({
60+
issuer: 'https://api.example.com/as/issuer',
61+
authorization_endpoint: 'https://api.example.com/as/authorize',
62+
token_endpoint: 'https://api.example.com/as/token',
63+
userinfo_endpoint: 'https://api.example.com/as/userinfo',
64+
introspection_endpoint: 'https://api.example.com/as/introspect',
65+
revocation_endpoint: 'https://api.example.com/as/revoke',
66+
response_types_supported: ['code', 'token', 'id_token', 'code id_token'],
67+
response_modes_supported: ['query', 'fragment', 'form_post', 'pi.flow'],
68+
}),
69+
),
70+
);
71+
72+
const storageKey = 'pic-oidcTokens';
73+
const storeObj: Record<string, string> = {};
74+
const customStorage = {
75+
get: async (key: string): Promise<string | null> => storeObj[key] || null,
76+
set: async (key: string, valueToSet: string) => {
77+
storeObj[key] = valueToSet;
78+
},
79+
remove: async (key: string) => {
80+
delete storeObj[key];
81+
},
82+
};
83+
const customStorageConfig = {
84+
type: 'custom' as const,
85+
name: 'oidcTokens',
86+
custom: customStorage,
87+
};
88+
89+
// Establish API mocking before all tests.
90+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
91+
92+
// Reset any request handlers and storage that we may add during the tests,
93+
// so they don't affect other tests.
94+
afterEach(() => {
95+
server.resetHandlers();
96+
customStorage.remove(storageKey);
97+
});
98+
99+
// Clean up after the tests are finished.
100+
afterAll(() => server.close());
101+
102+
// Only testing PingOne flow since iframe flow cannot be mocked in Vitest
103+
describe('PingOne token get method', async () => {
104+
const config: OidcConfig = {
105+
clientId: '123456789',
106+
redirectUri: 'https://example.com/callback.html',
107+
scope: 'openid profile',
108+
serverConfig: {
109+
wellknown: 'https://api.example.com/wellknown',
110+
},
111+
responseType: 'code',
112+
};
113+
114+
it('Get non-existent tokens', async () => {
115+
const oidcClient = await oidc({
116+
config,
117+
storage: customStorageConfig,
118+
});
119+
120+
if ('error' in oidcClient) {
121+
throw new Error('Error creating OIDC Client');
122+
}
123+
const tokens = await oidcClient.token.get();
124+
if (!('error' in tokens)) {
125+
expect.fail();
126+
}
127+
expect(tokens.error).toBe('No tokens found');
128+
});
129+
130+
it('Get tokens', async () => {
131+
customStorage.set(
132+
storageKey,
133+
JSON.stringify({
134+
accessToken: '1234567890',
135+
idToken: '0987654321',
136+
expiresAt: Date.now() + 10000,
137+
}),
138+
);
139+
140+
const oidcClient = await oidc({
141+
config,
142+
storage: customStorageConfig,
143+
});
144+
145+
if ('error' in oidcClient) {
146+
throw new Error('Error creating OIDC Client');
147+
}
148+
const tokens = await oidcClient.token.get();
149+
if ('error' in tokens) {
150+
expect.fail();
151+
}
152+
expect(tokens.accessToken).toBe('1234567890');
153+
});
154+
155+
it('Get expired tokens without background renewal', async () => {
156+
customStorage.set(
157+
storageKey,
158+
JSON.stringify({
159+
accessToken: '1234567890',
160+
idToken: '0987654321',
161+
expiresAt: Date.now() + 10000,
162+
}),
163+
);
164+
165+
const oidcClient = await oidc({
166+
config,
167+
storage: customStorageConfig,
168+
});
169+
170+
if ('error' in oidcClient) {
171+
throw new Error('Error creating OIDC Client');
172+
}
173+
const tokens = await oidcClient.token.get();
174+
if ('error' in tokens) {
175+
expect.fail();
176+
}
177+
expect(tokens.accessToken).toBe('1234567890');
178+
});
179+
180+
it('Get unexpired tokens with background renew true', async () => {
181+
const expiredTokens = {
182+
accessToken: '1234567890',
183+
idToken: '0987654321',
184+
expiresAt: 40000,
185+
expiryTimestamp: Date.now() + 40000,
186+
};
187+
customStorage.set(storageKey, JSON.stringify(expiredTokens));
188+
189+
const oidcClient = await oidc({
190+
config,
191+
storage: customStorageConfig,
192+
});
193+
194+
if ('error' in oidcClient) {
195+
throw new Error('Error creating OIDC Client');
196+
}
197+
const tokens = await oidcClient.token.get({ backgroundRenew: true });
198+
if ('error' in tokens) {
199+
console.log('tokens error', tokens);
200+
expect.fail();
201+
}
202+
expect(tokens.accessToken).toBe('1234567890');
203+
});
204+
205+
it('Renew tokens within threshold', async () => {
206+
const expiredTokens = {
207+
accessToken: '1234567890',
208+
idToken: '0987654321',
209+
expiresAt: 20000,
210+
expiryTimestamp: Date.now() + 20000,
211+
};
212+
customStorage.set(storageKey, JSON.stringify(expiredTokens));
213+
214+
const oidcClient = await oidc({
215+
config,
216+
storage: customStorageConfig,
217+
});
218+
219+
if ('error' in oidcClient) {
220+
throw new Error('Error creating OIDC Client');
221+
}
222+
const tokens = await oidcClient.token.get({ backgroundRenew: true });
223+
if ('error' in tokens) {
224+
console.log('tokens error', tokens);
225+
expect.fail();
226+
}
227+
expect(tokens.accessToken).toBe('abcdefghijklmnop');
228+
});
229+
230+
it('Get expired tokens', async () => {
231+
const expiredTokens = {
232+
accessToken: '1234567890',
233+
idToken: '0987654321',
234+
expiresAt: 1000,
235+
expiryTimestamp: Date.now() - 1000,
236+
};
237+
customStorage.set(storageKey, JSON.stringify(expiredTokens));
238+
239+
const oidcClient = await oidc({
240+
config,
241+
storage: customStorageConfig,
242+
});
243+
244+
if ('error' in oidcClient) {
245+
throw new Error('Error creating OIDC Client');
246+
}
247+
const tokens = await oidcClient.token.get({ backgroundRenew: true });
248+
if ('error' in tokens) {
249+
console.log('tokens error', tokens);
250+
expect.fail();
251+
}
252+
expect(tokens.accessToken).toBe('abcdefghijklmnop');
253+
});
254+
255+
it('Force renew tokens', async () => {
256+
const expiredTokens = {
257+
accessToken: '1234567890',
258+
idToken: '0987654321',
259+
expiresAt: 50000,
260+
expiryTimestamp: Date.now() + 50000,
261+
};
262+
customStorage.set(storageKey, JSON.stringify(expiredTokens));
263+
264+
const oidcClient = await oidc({
265+
config,
266+
storage: customStorageConfig,
267+
});
268+
269+
if ('error' in oidcClient) {
270+
throw new Error('Error creating OIDC Client');
271+
}
272+
const tokens = await oidcClient.token.get({ backgroundRenew: true, forceRenew: true });
273+
if ('error' in tokens) {
274+
console.log('tokens error', tokens);
275+
expect.fail();
276+
}
277+
expect(tokens.accessToken).toBe('abcdefghijklmnop');
278+
});
279+
});

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
220220
get: async (
221221
options?: GetTokensOptions,
222222
): Promise<OauthTokens | TokenExchangeErrorResponse | AuthorizationError | GenericError> => {
223-
const { backgroundRenew, authorizeOptions, storageOptions } = options || {};
223+
const { authorizeOptions, forceRenew, backgroundRenew, storageOptions } = options || {};
224224
const state = store.getState();
225225
const wellknown = wellknownSelector(wellknownUrl, state);
226226

@@ -242,13 +242,17 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
242242
};
243243
}
244244

245-
// If we have tokens, and they are NOT expired, return them
246-
if (tokens && !isExpiryWithinThreshold(oauthThreshold, tokens.expiryTimestamp)) {
245+
// If forceRenew is false, we have tokens, and they are NOT expired, return them
246+
if (
247+
!forceRenew &&
248+
tokens &&
249+
!isExpiryWithinThreshold(oauthThreshold, tokens.expiryTimestamp)
250+
) {
247251
return tokens;
248252
}
249253

250-
// If backgroundRenew is false, return token, regardless of expiration, or the "no tokens found" error
251-
if (!backgroundRenew) {
254+
// If backgroundRenew and forceRenew is false return token, regardless of expiration, or the "no tokens found" error
255+
if (!backgroundRenew && !forceRenew) {
252256
return (
253257
tokens || {
254258
error: 'No tokens found',
@@ -257,7 +261,7 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
257261
);
258262
}
259263

260-
// If we're here, backgroundRenew is true and we have no OR expired tokens, so renewal is needed
264+
// If we're here, backgroundRenew is true and we have no tokens, expired tokens or forceRenew is true
261265
const attemptAuthorizeGetTokensµ = authorizeµ(
262266
wellknown,
263267
config,
@@ -277,6 +281,14 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
277281
});
278282
}),
279283
Micro.tap(async (tokens) => {
284+
await store.dispatch(
285+
oidcApi.endpoints.revoke.initiate({
286+
accessToken: tokens.accessToken,
287+
clientId: config.clientId,
288+
endpoint: wellknown.revocation_endpoint,
289+
}),
290+
);
291+
await storageClient.remove();
280292
await storageClient.set(tokens);
281293
}),
282294
);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import type { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-ty
22
import type { StorageConfig } from '@forgerock/storage';
33

44
export interface GetTokensOptions {
5-
backgroundRenew?: boolean;
65
authorizeOptions?: GetAuthorizationUrlOptions;
6+
backgroundRenew?: boolean;
7+
forceRenew?: boolean;
78
storageOptions?: Partial<StorageConfig>;
89
}
910

0 commit comments

Comments
 (0)