Skip to content

Commit 6273aad

Browse files
auth: add telemetry for why the user needed to reauth (aws#5243)
* util: Add in-memory cache utils interface Problem: There is a common use for giving a key and getting back a cached value. We have an existing implementation named KeyedCache, but it utilizes Promises, leaving it open to race conditions. Solution: Create a synchronous interface similar to KeyedCache. Signed-off-by: Nikolas Komonen <[email protected]> * auth: createToken accepts args related to reauthentication Problem: SsoAccessTokenProvider.createToken() is used for both authentication and reauthentication, but there isn't currently any way to identify the reason. This leaves a gap in our telemetry. Solution: Created an object CreateTokenArgs which will hold args to be used by createToken(). Now we can pass data to other parts of SsoAccessTokenProvider that may want this data. Most of the changes are adding in this new argument to createToken(). The notable change is in auth.ts#Auth.reauthenticat() where we now pass isReAuth in the createToken() call. Signed-off-by: Nikolas Komonen <[email protected]> * auth: add telemetry for why the user needed to reauth Problem: In the reauth metric (aws_loginWithBrowser + `isReAuth: true`) we didn't know why the user needed to reauth. It could have been due to regular expiration or some unexpected error. Solution: - When we invalidate an SSO connection, record the reason in ReAuthState. Then next time we reauth we can look at this state to see why. - The ReAuthState is in the SsoAccessToken provider since invalidation happens in this class itself and not in Auth. The SsoAccessTokenProvider.invalidate() is called to invalidate a connection and knows why the connection was invalidated. Signed-off-by: Nikolas Komonen <[email protected]> * refactor: remove hacky telemetry for reauth Now that we've changed how we pass the isReAuth and reAuthReason values to the final telemetry metric, we can get rid of the hack we used to pass these values in Auth.reauthenticate(). This commit is removing redundant code. SsoAccessTokenProvider.withBrowserLoginTelemetry() essentially has all the information it needs and doesn't need Auth.reauthenticate() to use `record()` to externally setup values. Signed-off-by: Nikolas Komonen <[email protected]> * run formatter Signed-off-by: Nikolas Komonen <[email protected]> * refactor: fix PR comments - Add some comments regarding why we call 'MapSync' with 'sync' in the name - Rename 'MemoryMap' to 'NestedMap' - rename some of the Map interface functions to be closer to the ES Map interface Signed-off-by: Nikolas Komonen <[email protected]> * refactor: merge new reauth state/telemetry tests with existing tests Signed-off-by: Nikolas Komonen <[email protected]> * rename: asKey() -> hash() Signed-off-by: Nikolas Komonen <[email protected]> --------- Signed-off-by: Nikolas Komonen <[email protected]>
1 parent 72b4222 commit 6273aad

File tree

9 files changed

+348
-93
lines changed

9 files changed

+348
-93
lines changed

packages/core/src/auth/auth.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,7 @@ export class Auth implements AuthService, ConnectionManager {
182182
const profile = this.store.getProfileOrThrow(id)
183183
if (profile.type === 'sso') {
184184
const provider = this.getSsoTokenProvider(id, profile)
185-
186-
// We cannot easily set isReAuth inside the createToken() call,
187-
// so we need to set it here.
188-
await telemetry.aws_loginWithBrowser.run(async (span) => {
189-
span.record({ isReAuth: true, credentialStartUrl: profile.startUrl })
190-
await this.authenticate(id, () => provider.createToken(), shouldInvalidate)
191-
})
185+
await this.authenticate(id, () => provider.createToken({ isReAuth: true }), shouldInvalidate)
192186

193187
return this.getSsoConnection(id, profile)
194188
} else {
@@ -386,7 +380,7 @@ export class Auth implements AuthService, ConnectionManager {
386380
throw new ToolkitError('Auth: Cannot force expire an IAM connection')
387381
}
388382
const provider = this.getSsoTokenProvider(conn.id, profile)
389-
await provider.invalidate()
383+
await provider.invalidate('devModeManualExpiration')
390384
// updates the state of the connection
391385
await this.refreshConnectionState(conn)
392386
}
@@ -530,7 +524,8 @@ export class Auth implements AuthService, ConnectionManager {
530524

531525
// XXX: never drop tokens in a dev environment, unless you are Amazon Q!
532526
if (getCodeCatalystDevEnvId() === undefined || isAmazonQ()) {
533-
await provider.invalidate()
527+
// TODO: Require invalidateConnection() to require a reason and then pass it in to the following instead
528+
await provider.invalidate('explicitInvalidation')
534529
}
535530
} else if (profile.type === 'iam') {
536531
globals.loginManager.store.invalidateCredentials(fromString(id))

packages/core/src/auth/sso/clients.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ export class SsoClient {
217217
private async handleError(error: unknown): Promise<never> {
218218
if (error instanceof SSOServiceException && isClientFault(error) && error.name !== 'ForbiddenException') {
219219
getLogger().warn(`credentials (sso): invalidating stored token: ${error.message}`)
220-
await this.provider.invalidate()
220+
await this.provider.invalidate(`ssoClient:${error.name}`)
221221
}
222222

223223
throw error

packages/core/src/auth/sso/ssoAccessTokenProvider.ts

Lines changed: 97 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
isNetworkError,
3030
} from '../../shared/errors'
3131
import { getLogger } from '../../shared/logger'
32-
import { AwsLoginWithBrowser, AwsRefreshCredentials, Metric, telemetry } from '../../shared/telemetry/telemetry'
32+
import { AwsLoginWithBrowser, AwsRefreshCredentials, telemetry } from '../../shared/telemetry/telemetry'
3333
import { indent, toBase64URL } from '../../shared/utilities/textUtilities'
3434
import { AuthSSOServer } from './server'
3535
import { CancellationError, sleep } from '../../shared/utilities/timeoutUtils'
@@ -41,6 +41,7 @@ import { isRemoteWorkspace, isWebWorkspace } from '../../shared/vscode/env'
4141
import { showInputBox } from '../../shared/ui/inputPrompter'
4242
import { DevSettings } from '../../shared/settings'
4343
import { onceChanged } from '../../shared/utilities/functionUtils'
44+
import { NestedMap } from '../../shared/utilities/map'
4445

4546
export const authenticationPath = 'sso/authenticated'
4647

@@ -67,16 +68,18 @@ export abstract class SsoAccessTokenProvider {
6768
public constructor(
6869
protected readonly profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
6970
protected readonly cache = getCache(),
70-
protected readonly oidc: OidcClient = OidcClient.create(profile.region)
71+
protected readonly oidc: OidcClient = OidcClient.create(profile.region),
72+
protected readonly reAuthState: ReAuthState = ReAuthState.instance
7173
) {}
7274

73-
public async invalidate(): Promise<void> {
75+
public async invalidate(reason: string): Promise<void> {
7476
getLogger().info(`SsoAccessTokenProvider: invalidate token and registration`)
7577
// Use allSettled() instead of all() to ensure all clear() calls are resolved.
7678
await Promise.allSettled([
7779
this.cache.token.clear(this.tokenCacheKey, 'SsoAccessTokenProvider.invalidate()'),
7880
this.cache.registration.clear(this.registrationCacheKey, 'SsoAccessTokenProvider.invalidate()'),
7981
])
82+
this.reAuthState.set(this.profile, { reAuthReason: `invalidate():${reason}` })
8083
}
8184

8285
public async getToken(): Promise<SsoToken | undefined> {
@@ -99,23 +102,30 @@ export abstract class SsoAccessTokenProvider {
99102

100103
return refreshed.token
101104
} else {
102-
await this.invalidate()
105+
await this.invalidate('allCacheExpired')
103106
}
104107
}
105108

106-
public async createToken(): Promise<SsoToken> {
107-
const access = await this.runFlow()
109+
public async createToken(args?: CreateTokenArgs): Promise<SsoToken> {
110+
const access = await this.runFlow(args)
108111
const identity = this.tokenCacheKey
109112
await this.cache.token.save(identity, access)
110113
await globals.globalState.setSsoSessionCreationDate(this.tokenCacheKey, new globals.clock.Date())
111114

112115
return { ...access.token, identity }
113116
}
114117

115-
private async runFlow() {
118+
private async runFlow(args?: CreateTokenArgs) {
116119
const registration = await this.getValidatedClientRegistration()
117120
try {
118-
return await this.authorize(registration)
121+
const result = await this.authorize(registration, args)
122+
123+
// Authentication in the browser is successfully done, so the reauth reason is now stale.
124+
// We don't clear the reason on failure since we want to keep reporting it as the reason until
125+
// reauth is a success.
126+
this.reAuthState.delete(this.profile, 'reauth successful')
127+
128+
return result
119129
} catch (err) {
120130
if (err instanceof SSOOIDCServiceException && isClientFault(err)) {
121131
await this.cache.registration.clear(
@@ -150,9 +160,10 @@ export abstract class SsoAccessTokenProvider {
150160
return refreshed
151161
} catch (err) {
152162
if (!isNetworkError(err)) {
163+
const reason = getTelemetryReason(err)
153164
telemetry.aws_refreshCredentials.emit({
154165
result: getTelemetryResult(err),
155-
reason: getTelemetryReason(err),
166+
reason,
156167
reasonDesc: getTelemetryReasonDesc(err),
157168
requestId: getRequestId(err),
158169
...metric,
@@ -163,6 +174,10 @@ export abstract class SsoAccessTokenProvider {
163174
this.tokenCacheKey,
164175
`client fault: SSOOIDCServiceException: ${err.message}`
165176
)
177+
// remember why refresh failed so next reauth flow will know why reauth is needed
178+
if (reason) {
179+
this.reAuthState.set(this.profile, { reAuthReason: `refresh:${reason}` })
180+
}
166181
}
167182
}
168183

@@ -185,33 +200,30 @@ export abstract class SsoAccessTokenProvider {
185200
/**
186201
* Wraps the given function with telemetry related to the browser login.
187202
*/
188-
protected withBrowserLoginTelemetry<T extends (...args: any[]) => any>(func: T): ReturnType<T> {
189-
const run = (span: Metric<AwsLoginWithBrowser>) => {
203+
protected withBrowserLoginTelemetry<T extends (...args: any[]) => any>(
204+
func: T,
205+
args?: CreateTokenArgs
206+
): ReturnType<T> {
207+
return telemetry.aws_loginWithBrowser.run((span) => {
190208
span.record({
191209
credentialStartUrl: this.profile.startUrl,
192210
source: SsoAccessTokenProvider._authSource,
211+
isReAuth: args?.isReAuth,
212+
reAuthReason: args?.isReAuth ? this.reAuthState.get(this.profile).reAuthReason : undefined,
193213
})
194214

195215
// Reset source in case there is a case where browser login was called but we forgot to set the source.
196216
// We don't want to attribute the wrong source.
197217
SsoAccessTokenProvider.authSource = 'unknown'
198218

199219
return func()
200-
}
201-
202-
// During certain flows, eg reauthentication, we are already running within a span (run())
203-
// so we don't need to create a new one.
204-
const span = telemetry.spans.find((s) => s.name === 'aws_loginWithBrowser')
205-
if (span !== undefined) {
206-
return run(span as unknown as Metric<AwsLoginWithBrowser>)
207-
}
208-
209-
return telemetry.aws_loginWithBrowser.run((span) => {
210-
return run(span)
211220
})
212221
}
213222

214-
protected abstract authorize(registration: ClientRegistration): Promise<{
223+
protected abstract authorize(
224+
registration: ClientRegistration,
225+
args?: CreateTokenArgs
226+
): Promise<{
215227
token: SsoToken
216228
registration: ClientRegistration
217229
region: string
@@ -230,6 +242,7 @@ export abstract class SsoAccessTokenProvider {
230242
profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
231243
cache = getCache(),
232244
oidc: OidcClient = OidcClient.create(profile.region),
245+
reAuthState?: ReAuthState,
233246
useDeviceFlow: () => boolean = () => {
234247
/**
235248
* Device code flow is neccessary when:
@@ -242,15 +255,24 @@ export abstract class SsoAccessTokenProvider {
242255
}
243256
) {
244257
if (DevSettings.instance.get('webAuth', false) && isWebWorkspace()) {
245-
return new WebAuthorization(profile, cache, oidc)
258+
return new WebAuthorization(profile, cache, oidc, reAuthState)
246259
}
247260
if (useDeviceFlow()) {
248-
return new DeviceFlowAuthorization(profile, cache, oidc)
261+
return new DeviceFlowAuthorization(profile, cache, oidc, reAuthState)
249262
}
250-
return new AuthFlowAuthorization(profile, cache, oidc)
263+
return new AuthFlowAuthorization(profile, cache, oidc, reAuthState)
251264
}
252265
}
253266

267+
/**
268+
* Supplementary arguments for the create token flow. This data can be used
269+
* for things like telemetry.
270+
*/
271+
export type CreateTokenArgs = {
272+
/** true if the create token flow is for reauthentication */
273+
isReAuth?: boolean
274+
}
275+
254276
const backoffDelayMs = 5000
255277
async function pollForTokenWithProgress<T extends { requestId?: string }>(
256278
fn: () => Promise<T>,
@@ -356,14 +378,6 @@ function getSessionDuration(id: string) {
356378
* - RefreshToken (optional)
357379
*/
358380
export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
359-
constructor(
360-
profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
361-
cache = getCache(),
362-
oidc: OidcClient = OidcClient.create(profile.region)
363-
) {
364-
super(profile, cache, oidc)
365-
}
366-
367381
override async registerClient(): Promise<ClientRegistration> {
368382
const companyName = getIdeProperties().company
369383
return this.oidc.registerClient(
@@ -377,7 +391,8 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
377391
}
378392

379393
override async authorize(
380-
registration: ClientRegistration
394+
registration: ClientRegistration,
395+
args?: CreateTokenArgs
381396
): Promise<{ token: SsoToken; registration: ClientRegistration; region: string; startUrl: string }> {
382397
// This will NOT throw on expired clientId/Secret, but WILL throw on invalid clientId/Secret
383398
const authorization = await this.oidc.startDeviceAuthorization({
@@ -403,7 +418,7 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
403418
)
404419
}
405420

406-
const token = this.withBrowserLoginTelemetry(() => openBrowserAndWaitUntilComplete())
421+
const token = this.withBrowserLoginTelemetry(() => openBrowserAndWaitUntilComplete(), args)
407422

408423
return this.formatToken(await token, registration)
409424
}
@@ -419,7 +434,7 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
419434

420435
// Clear cached if registration is expired
421436
if (cachedRegistration && isExpired(cachedRegistration)) {
422-
await this.invalidate()
437+
await this.invalidate('registrationExpired:DeviceCode')
423438
}
424439

425440
return loadOr(this.cache.registration, cacheKey, () => this.registerClient())
@@ -463,14 +478,6 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
463478
* 2. If there is a problem, server responds with `invalid_grant` error.
464479
*/
465480
class AuthFlowAuthorization extends SsoAccessTokenProvider {
466-
constructor(
467-
profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
468-
cache = getCache(),
469-
oidc: OidcClient
470-
) {
471-
super(profile, cache, oidc)
472-
}
473-
474481
override async registerClient(): Promise<ClientRegistration> {
475482
const companyName = getIdeProperties().company
476483
return this.oidc.registerClient(
@@ -489,7 +496,8 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
489496
}
490497

491498
override async authorize(
492-
registration: ClientRegistration
499+
registration: ClientRegistration,
500+
args?: CreateTokenArgs
493501
): Promise<{ token: SsoToken; registration: ClientRegistration; region: string; startUrl: string }> {
494502
const state = randomUUID()
495503
const authServer = AuthSSOServer.init(state)
@@ -531,7 +539,7 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
531539
telemetry.record({ requestId: res.requestId })
532540

533541
return res
534-
})
542+
}, args)
535543

536544
return this.formatToken(token, registration)
537545
} finally {
@@ -560,7 +568,7 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
560568

561569
// Clear cached if registration is expired or it uses a deprecate auth version (device code)
562570
if (cachedRegistration && (isExpired(cachedRegistration) || isDeprecatedAuth(cachedRegistration))) {
563-
await this.invalidate()
571+
await this.invalidate('registrationExpired:AuthFlow')
564572
}
565573

566574
return loadOr(this.cache.registration, cacheKey, () => this.registerClient())
@@ -575,14 +583,6 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
575583
class WebAuthorization extends SsoAccessTokenProvider {
576584
private redirectUri = 'http://127.0.0.1:54321/oauth/callback'
577585

578-
constructor(
579-
profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
580-
cache = getCache(),
581-
oidc: OidcClient
582-
) {
583-
super(profile, cache, oidc)
584-
}
585-
586586
override async registerClient(): Promise<ClientRegistration> {
587587
const companyName = getIdeProperties().company
588588
return this.oidc.registerClient(
@@ -601,7 +601,8 @@ class WebAuthorization extends SsoAccessTokenProvider {
601601
}
602602

603603
override async authorize(
604-
registration: ClientRegistration
604+
registration: ClientRegistration,
605+
args?: CreateTokenArgs
605606
): Promise<{ token: SsoToken; registration: ClientRegistration; region: string; startUrl: string }> {
606607
const state = randomUUID()
607608

@@ -641,7 +642,7 @@ class WebAuthorization extends SsoAccessTokenProvider {
641642
codeVerifier,
642643
code: inputBox,
643644
})
644-
})
645+
}, args)
645646

646647
return this.formatToken(token, registration)
647648
}
@@ -651,9 +652,46 @@ class WebAuthorization extends SsoAccessTokenProvider {
651652
const cachedRegistration = await this.cache.registration.load(cacheKey)
652653

653654
if (cachedRegistration && (isExpired(cachedRegistration) || cachedRegistration.flow !== 'web auth code')) {
654-
await this.invalidate()
655+
await this.invalidate('registrationExpired:WebAuth')
655656
}
656657

657658
return loadOr(this.cache.registration, cacheKey, () => this.registerClient())
658659
}
659660
}
661+
662+
/**
663+
* Remembers the reason an SSO session was put in to a "needs reauthentication" state.
664+
* The current use is for telemetry. When the user reauths, we want {@link AwsLoginWithBrowser}
665+
* to know why it needed to be reauthed.
666+
*
667+
* The flow is to use `set()` to remember why the user was put in to a reauth state,
668+
* then upon the next reauth use `get()`. Finally, use `clear()` if the reauth is
669+
* successful.
670+
*/
671+
export class ReAuthState extends NestedMap<ReAuthStateKey, ReAuthStateValue> {
672+
static #instance: ReAuthState
673+
static get instance() {
674+
return (this.#instance ??= new ReAuthState())
675+
}
676+
protected constructor() {
677+
super()
678+
}
679+
680+
protected override hash(profile: ReAuthStateKey): string {
681+
return profile.identifier ?? profile.startUrl
682+
}
683+
684+
protected override get name(): string {
685+
return ReAuthState.name
686+
}
687+
688+
override get default(): ReAuthStateValue {
689+
return { reAuthReason: undefined }
690+
}
691+
}
692+
693+
type ReAuthStateKey = Pick<SsoProfile, 'identifier' | 'startUrl'>
694+
type ReAuthStateValue = {
695+
// the latest reason for why the connection was moved in to a "needs reauth" state
696+
reAuthReason?: string
697+
}

0 commit comments

Comments
 (0)