Skip to content

Commit cb5cc21

Browse files
fix(auth): client registration expiry not checked when creating token #3877
Problem: In the scenario a client registration was expired and SsoAccessTokenProvider.createToken() was called, the expired client registration would be used to create the new token. This scenario was not expected to occurr since it was assumed that getToken() would be called before createToken() and getToken() would do the work of invalidating the expired client registration so that createToken() would not need to check if the client registration is expired. Solution: Since we cannot guarantee createToken() is always run before getToken(), in createToken() we will verify that the client registration is not expired, and if it is we will create a new registration, then continue with the same process as before. Signed-off-by: nkomonen <[email protected]> * add changelog item Signed-off-by: nkomonen <[email protected]> --------- Signed-off-by: nkomonen <[email protected]>
1 parent 1ce011c commit cb5cc21

File tree

3 files changed

+75
-7
lines changed

3 files changed

+75
-7
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "BuilderID/IdentityCenter: Fix 'Invalid Client' error in the browser when re-authenticating"
4+
}

src/auth/sso/ssoAccessTokenProvider.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,15 @@ export class SsoAccessTokenProvider {
108108
}
109109

110110
private async runFlow() {
111-
const cacheKey = this.registrationCacheKey
112-
const registration = await loadOr(this.cache.registration, cacheKey, () => this.registerClient())
113-
111+
const registration = await this.getValidatedClientRegistration()
114112
try {
115113
return await this.authorize(registration)
116114
} catch (err) {
117115
if (err instanceof SSOOIDCServiceException && isClientFault(err)) {
118-
await this.cache.registration.clear(cacheKey, `client fault: SSOOIDCServiceException: ${err.message}`)
116+
await this.cache.registration.clear(
117+
this.registrationCacheKey,
118+
`client fault: SSOOIDCServiceException: ${err.message}`
119+
)
119120
}
120121

121122
throw err
@@ -166,6 +167,7 @@ export class SsoAccessTokenProvider {
166167
}
167168

168169
private async authorize(registration: ClientRegistration) {
170+
// This will NOT throw on expired clientId/Secret, but WILL throw on invalid clientId/Secret
169171
const authorization = await this.oidc.startDeviceAuthorization({
170172
startUrl: this.profile.startUrl,
171173
clientId: registration.clientId,
@@ -187,6 +189,23 @@ export class SsoAccessTokenProvider {
187189
return this.formatToken(token, registration)
188190
}
189191

192+
/**
193+
* If the registration already exists locally, it
194+
* will be validated before being returned. Otherwise, a client registration is
195+
* created and returned.
196+
*/
197+
private async getValidatedClientRegistration(): Promise<ClientRegistration> {
198+
const cacheKey = this.registrationCacheKey
199+
const cachedRegistration = await this.cache.registration.load(cacheKey)
200+
201+
// Clear cached if registration is expired
202+
if (cachedRegistration && isExpired(cachedRegistration)) {
203+
await this.invalidate()
204+
}
205+
206+
return loadOr(this.cache.registration, cacheKey, () => this.registerClient())
207+
}
208+
190209
private async registerClient(): Promise<ClientRegistration> {
191210
const companyName = getIdeProperties().company
192211
return this.oidc.registerClient({

src/test/credentials/sso/ssoAccessTokenProvider.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { SsoAccessTokenProvider } from '../../../auth/sso/ssoAccessTokenProvider
1010
import { installFakeClock } from '../../testUtil'
1111
import { getCache } from '../../../auth/sso/cache'
1212

13-
import { instance, mock, when, anything, reset } from '../../utilities/mockito'
13+
import { instance, mock, when, anything, reset, deepEqual } from '../../utilities/mockito'
1414
import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../../shared/filesystemUtilities'
1515
import { ClientRegistration, SsoToken, proceedToBrowser } from '../../../auth/sso/model'
1616
import { OidcClient } from '../../../auth/sso/clients'
@@ -199,13 +199,15 @@ describe('SsoAccessTokenProvider', function () {
199199
getOpenExternalStub().resolves(userClicked)
200200
}
201201

202-
function setupFlow() {
202+
function setupFlow(opts?: { skipAuthorization: boolean }) {
203203
const token = createToken(hourInMs)
204204
const registration = createRegistration(hourInMs)
205205
const authorization = createAuthorization(hourInMs)
206206

207207
when(oidcClient.registerClient(anything())).thenResolve(registration)
208-
when(oidcClient.startDeviceAuthorization(anything())).thenResolve(authorization)
208+
if (!opts?.skipAuthorization) {
209+
when(oidcClient.startDeviceAuthorization(anything())).thenResolve(authorization)
210+
}
209211
when(oidcClient.createToken(anything())).thenResolve(token)
210212

211213
return { token, registration, authorization }
@@ -251,6 +253,49 @@ describe('SsoAccessTokenProvider', function () {
251253
await assert.rejects(resp, ToolkitError)
252254
})
253255

256+
/**
257+
* Saves an expired client registration to the cache.
258+
*/
259+
async function saveExpiredRegistrationToCache(): Promise<{
260+
key: { region: string; scopes: string[] }
261+
registration: ClientRegistration
262+
}> {
263+
const key = { region, scopes: [] }
264+
const registration = {
265+
clientId: 'myExpiredClientId',
266+
clientSecret: 'myExpiredClientSecret',
267+
expiresAt: new clock.Date(clock.Date.now() - 1), // expired date
268+
}
269+
await cache.registration.save(key, registration)
270+
return { key, registration }
271+
}
272+
273+
it('registers a new client registration if the existing client registration is expired', async function () {
274+
const { token, registration: validRegistration, authorization } = setupFlow({ skipAuthorization: true })
275+
stubOpen()
276+
277+
const { key: registrationKey, registration: expiredRegistration } = await saveExpiredRegistrationToCache()
278+
// If we do not invalidate the expired registration, startDeviceAuthorization()
279+
// will be given expired registration values. The following ensures we only
280+
// return a value when startDeviceAuthorization() is given valid registration values.
281+
when(
282+
oidcClient.startDeviceAuthorization(
283+
deepEqual({
284+
startUrl: startUrl,
285+
clientId: validRegistration.clientId,
286+
clientSecret: validRegistration.clientSecret,
287+
})
288+
)
289+
).thenResolve(authorization)
290+
291+
// sanity check we have expired registration in cache
292+
assert.deepStrictEqual(await cache.registration.load(registrationKey), expiredRegistration)
293+
const result = await sut.createToken()
294+
// a valid registration should be cached since the previous was expired
295+
assert.deepStrictEqual(await cache.registration.load(registrationKey), validRegistration)
296+
assert.deepStrictEqual(result, { ...token, identity: startUrl }) // verify final result
297+
})
298+
254299
describe('Exceptions', function () {
255300
it('removes the client registration cache on client faults', async function () {
256301
const exception = new UnauthorizedClientException({ message: '', $metadata: {} })

0 commit comments

Comments
 (0)