packages/core/src/auth/sso/ssoAccessTokenProvider.ts (548 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode'
import globals from '../../shared/extensionGlobals'
import { AuthorizationPendingException, SSOOIDCServiceException, SlowDownException } from '@aws-sdk/client-sso-oidc'
import { SsoToken, ClientRegistration, isExpired, SsoProfile, openSsoPortalLink, isDeprecatedAuth } from './model'
import { getCache } from './cache'
import { hasProps, hasStringProps, RequiredProps, selectFrom } from '../../shared/utilities/tsUtils'
import { OidcClient } from './clients'
import { DiskCacheError, loadOr } from '../../shared/utilities/cacheUtils'
import {
ToolkitError,
getErrorMsg,
getRequestId,
getTelemetryReason,
getTelemetryReasonDesc,
getTelemetryResult,
isClientFault,
isNetworkError,
} from '../../shared/errors'
import { getLogger } from '../../shared/logger/logger'
import { AwsLoginWithBrowser, AwsRefreshCredentials, telemetry } from '../../shared/telemetry/telemetry'
import { indent, toBase64URL } from '../../shared/utilities/textUtilities'
import { AuthSSOServer } from './server'
import { CancellationError, sleep } from '../../shared/utilities/timeoutUtils'
import { oidcClientName, isAmazonQ } from '../../shared/extensionUtilities'
import { randomBytes, createHash } from 'crypto'
import { localize } from '../../shared/utilities/vsCodeUtils'
import { randomUUID } from '../../shared/crypto'
import { getExtRuntimeContext } from '../../shared/vscode/env'
import { showInputBox } from '../../shared/ui/inputPrompter'
import { AmazonQPromptSettings, DevSettings, PromptSettings, ToolkitPromptSettings } from '../../shared/settings'
import { onceChanged } from '../../shared/utilities/functionUtils'
import { NestedMap } from '../../shared/utilities/map'
import { asStringifiedStack } from '../../shared/telemetry/spans'
import { showViewLogsMessage } from '../../shared/utilities/messages'
import _ from 'lodash'
import { builderIdStartUrl } from './constants'
export const authenticationPath = 'sso/authenticated'
const clientRegistrationType = 'public'
const deviceGrantType = 'urn:ietf:params:oauth:grant-type:device_code'
const authorizationGrantType = 'authorization_code'
const refreshGrantType = 'refresh_token'
/**
* See {@link DeviceFlowAuthorization} or {@link AuthFlowAuthorization} for protocol overview.
*/
export abstract class SsoAccessTokenProvider {
/**
* Source to pass to aws_loginWithBrowser metric. Due to the complexity of how auth can be called,
* there is no other easy way to pass this in without signficant refactors.
*/
private static _authSource: string = 'unknown'
private static logIfChanged = onceChanged((s: string) => getLogger().info(s))
private readonly className = 'SsoAccessTokenProvider'
public static set authSource(val: string) {
SsoAccessTokenProvider._authSource = val
}
public constructor(
protected readonly profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
protected readonly cache = getCache(),
protected readonly oidc: OidcClient = OidcClient.create(profile.region),
protected readonly reAuthState: ReAuthState = ReAuthState.instance
) {}
public async invalidate(reason: string): Promise<void> {
getLogger().info(`SsoAccessTokenProvider: invalidate token and registration`)
// always emit telemetry when the cache is deleted.
// most of the time cache is deleted on cache expiration, this is infrequent and expected.
// Any premature scenarios, that are not explicit deletion by the user, are likely bugs.
await telemetry.auth_modifyConnection.run(
async (span) => {
span.record({
source: asStringifiedStack(telemetry.getFunctionStack()),
action: 'deleteSsoCache',
credentialStartUrl: this.profile.startUrl,
sessionDuration: this.getSessionDuration(),
})
// Use allSettled() instead of all() to ensure all clear() calls are resolved.
await Promise.allSettled([
this.cache.token.clear(this.tokenCacheKey, 'SsoAccessTokenProvider.invalidate()'),
this.cache.registration.clear(this.registrationCacheKey, 'SsoAccessTokenProvider.invalidate()'),
])
},
{ emit: true, functionId: { name: 'invalidate', class: this.className } }
)
this.reAuthState.set(this.profile, { reAuthReason: `invalidate():${reason}` })
}
public async getToken(): Promise<SsoToken | undefined> {
const data = await this.cache.token.load(this.tokenCacheKey)
SsoAccessTokenProvider.logIfChanged(
indent(
`current client registration id=${data?.registration?.clientId}
expires at ${data?.registration?.expiresAt}
key = ${this.tokenCacheKey}`,
4,
true
)
)
if (!data || !isExpired(data.token)) {
return data?.token
}
if (data.registration && !isExpired(data.registration) && hasProps(data.token, 'refreshToken')) {
const refreshed = await this.refreshToken(data.token, data.registration)
return refreshed.token
} else {
await this.invalidate('allCacheExpired')
}
}
public async createToken(args?: CreateTokenArgs): Promise<SsoToken> {
const access = await this.runFlow(args)
const identity = this.tokenCacheKey
await this.cache.token.save(identity, access)
await globals.globalState.setSsoSessionCreationDate(this.tokenCacheKey, new globals.clock.Date())
return { ...access.token, identity }
}
private async runFlow(args?: CreateTokenArgs) {
const registration = await this.getValidatedClientRegistration()
args = {
...args,
registrationClientId: registration.clientId,
registrationExpiresAt: registration.expiresAt.toISOString(),
}
try {
const result = await this.authorize(registration, args)
// Authentication in the browser is successfully done, so the reauth reason is now stale.
// We don't clear the reason on failure since we want to keep reporting it as the reason until
// reauth is a success.
this.reAuthState.delete(this.profile, 'reauth successful')
return result
} catch (err) {
if (err instanceof SSOOIDCServiceException && isClientFault(err)) {
await this.cache.registration.clear(
this.registrationCacheKey,
`client fault: SSOOIDCServiceException: ${err.message}`
)
}
throw err
}
}
private async refreshToken(token: RequiredProps<SsoToken, 'refreshToken'>, registration: ClientRegistration) {
const metric = {
sessionDuration: getSessionDuration(this.tokenCacheKey),
credentialType: 'bearerToken',
credentialSourceId: this.profile.startUrl === builderIdStartUrl ? 'awsId' : 'iamIdentityCenter',
credentialStartUrl: this.profile.startUrl,
awsRegion: this.profile.region,
ssoRegistrationExpiresAt: registration.expiresAt.toISOString(),
ssoRegistrationClientId: registration.clientId,
}
try {
const clientInfo = selectFrom(registration, 'clientId', 'clientSecret')
const response = await this.oidc.createToken({ ...clientInfo, ...token, grantType: refreshGrantType })
const refreshed = this.formatToken(response, registration)
await this.cache.token.save(this.tokenCacheKey, refreshed)
telemetry.aws_refreshCredentials.emit({
result: 'Succeeded',
requestId: response.requestId,
...metric,
} as AwsRefreshCredentials)
return refreshed
} catch (err) {
if (err instanceof DiskCacheError) {
/**
* Background:
* - During token refresh the cache sometimes fails due to a file system error.
* - When these errors ocurr it will cause the token refresh process to fail, and the users SSO
* connection to become invalid.
* - Because these cache errors do not indicate the SSO session is actually stale,
* we want to catch these errors and not invalidate the users SSO connection since a
* subsequent attempt to refresh may succeed.
* - To give the user a chance to resolve their filesystem related issue, we want to point them
* to the logs where the error was logged. Hopefully they can use this information to fix the issue,
* or at least hint for them to provide the logs in a bug report.
*/
void DiskCacheErrorMessage.instance.showMessageThrottled(err)
} else if (!isNetworkError(err)) {
const reason = getTelemetryReason(err)
telemetry.aws_refreshCredentials.emit({
result: getTelemetryResult(err),
reason,
reasonDesc: getTelemetryReasonDesc(err),
requestId: getRequestId(err),
...metric,
} as AwsRefreshCredentials)
if (err instanceof SSOOIDCServiceException && isClientFault(err)) {
await this.cache.token.clear(
this.tokenCacheKey,
`client fault: SSOOIDCServiceException: ${err.message}`
)
// remember why refresh failed so next reauth flow will know why reauth is needed
if (reason) {
this.reAuthState.set(this.profile, { reAuthReason: `refresh:${reason}` })
}
}
}
throw err
}
}
getSessionDuration() {
return getSessionDuration(this.tokenCacheKey)
}
protected formatToken(token: SsoToken, registration: ClientRegistration) {
return { token, registration, region: this.profile.region, startUrl: this.profile.startUrl }
}
protected get tokenCacheKey() {
return this.profile.identifier ?? this.profile.startUrl
}
protected get registrationCacheKey() {
return { startUrl: this.profile.startUrl, region: this.profile.region, scopes: this.profile.scopes }
}
/**
* Wraps the given function with telemetry related to the browser login.
*/
protected withBrowserLoginTelemetry<T extends (...args: any[]) => any>(
func: T,
args?: CreateTokenArgs
): ReturnType<T> {
return telemetry.aws_loginWithBrowser.run((span) => {
span.record({
credentialStartUrl: this.profile.startUrl,
source: SsoAccessTokenProvider._authSource,
isReAuth: args?.isReAuth,
reAuthReason: args?.isReAuth ? this.reAuthState.get(this.profile).reAuthReason : undefined,
awsRegion: this.profile.region,
ssoRegistrationExpiresAt: args?.registrationExpiresAt,
ssoRegistrationClientId: args?.registrationClientId,
sessionDuration: getSessionDuration(this.tokenCacheKey),
})
// Reset source in case there is a case where browser login was called but we forgot to set the source.
// We don't want to attribute the wrong source.
SsoAccessTokenProvider.authSource = 'unknown'
return func()
})
}
protected abstract authorize(
registration: ClientRegistration,
args?: CreateTokenArgs
): Promise<{
token: SsoToken
registration: ClientRegistration
region: string
startUrl: string
}>
/**
* If the registration already exists locally, it
* will be validated before being returned. Otherwise, a client registration is
* created and returned.
*/
protected abstract getValidatedClientRegistration(): Promise<ClientRegistration>
protected abstract registerClient(): Promise<ClientRegistration>
public static create(
profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
cache = getCache(),
oidc: OidcClient = OidcClient.create(profile.region),
reAuthState?: ReAuthState,
useDeviceFlow: () => boolean = () => {
/**
* Device code flow is neccessary when:
* 1. We are in a workspace connected through ssh (codecatalyst, etc)
* 2. We are connected to a remote backend through the web browser (code server, openshift dev spaces)
*
* Since we are unable to serve the final authorization page
*/
return getExtRuntimeContext().extensionHost === 'remote'
}
) {
if (DevSettings.instance.get('webAuth', false) && getExtRuntimeContext().extensionHost === 'webworker') {
return new WebAuthorization(profile, cache, oidc, reAuthState)
}
if (useDeviceFlow()) {
return new DeviceFlowAuthorization(profile, cache, oidc, reAuthState)
}
return new AuthFlowAuthorization(profile, cache, oidc, reAuthState)
}
/**
* Returns a client registration for the current profile if it exists, otherwise
* undefined.
*/
public async getClientRegistration() {
return await this.cache.registration.load(this.registrationCacheKey)
}
}
/**
* Supplementary arguments for the create token flow. This data can be used
* for things like telemetry.
*/
export type CreateTokenArgs = {
/** true if the create token flow is for reauthentication */
isReAuth?: boolean
/** registration info for telemetry */
registrationClientId?: string
registrationExpiresAt?: string
}
const backoffDelayMs = 5000
async function pollForTokenWithProgress<T extends { requestId?: string }>(
fn: () => Promise<T>,
authorization: Awaited<ReturnType<OidcClient['startDeviceAuthorization']>>,
interval = authorization.interval ?? backoffDelayMs
) {
async function poll(token: vscode.CancellationToken) {
while (
authorization.expiresAt.getTime() - globals.clock.Date.now() > interval &&
!token.isCancellationRequested
) {
try {
const res = await fn()
telemetry.record({
requestId: res.requestId,
})
return res
} catch (err) {
if (!hasStringProps(err, 'name')) {
throw err
}
if (err instanceof SlowDownException) {
interval += backoffDelayMs
} else if (!(err instanceof AuthorizationPendingException)) {
throw err
}
}
await sleep(interval)
}
// TODO: verify that this emits telemetry
throw new ToolkitError('Timed-out waiting for browser login flow to complete', {
code: 'TimedOut',
})
}
return vscode.window.withProgress(
{
title: localize(
'AWS.auth.loginWithBrowser.messageDetail',
'Confirm code "{0}" in the login page opened in your web browser.',
authorization.userCode
),
cancellable: true,
location: vscode.ProgressLocation.Notification,
},
(_, token) =>
Promise.race([
poll(token),
new Promise<never>((_, reject) =>
token.onCancellationRequested(() => reject(new CancellationError('user')))
),
])
)
}
/**
* Gets SSO session creation timestamp for the given session `id`.
*
* @param id Session id
*/
function getSessionDuration(id: string) {
const creationDate = globals.globalState.getSsoSessionCreationDate(id)
return creationDate !== undefined ? globals.clock.Date.now() - creationDate : undefined
}
/**
* SSO "device code" flow (RFC: https://tools.ietf.org/html/rfc8628)
* 1. Get a client id (SSO-OIDC identifier, formatted per RFC6749).
* - Toolkit code: {@link SsoAccessTokenProvider.registerClient}
* - Calls {@link OidcClient.registerClient}
* - RETURNS:
* - ClientSecret
* - ClientId
* - ClientSecretExpiresAt
* - Client registration is valid for potentially months and creates state
* server-side, so the client SHOULD cache them to disk.
* 2. Start device authorization.
* - Toolkit code: {@link SsoAccessTokenProvider.authorize}
* - Calls {@link OidcClient.startDeviceAuthorization}
* - RETURNS (RFC: https://tools.ietf.org/html/rfc8628#section-3.2):
* - DeviceCode : Device verification code
* - UserCode : User verification code
* - VerificationUri : User verification URI on the authorization server
* - VerificationUriComplete: User verification URI including the `user_code`
* - ExpiresIn : Lifetime (seconds) of `device_code` and `user_code`
* - Interval : Minimum time (seconds) the client SHOULD wait between polling intervals.
* 3. Poll for the access token.
* - Toolkit code: {@link SsoAccessTokenProvider.authorize}
* - Calls {@link pollForTokenWithProgress}
* - RETURNS:
* - AccessToken
* - ExpiresIn
* - RefreshToken (optional)
* 4. (Repeat) Tokens SHOULD be refreshed if expired and a refresh token is available.
* - Toolkit code: {@link SsoAccessTokenProvider.refreshToken}
* - Calls {@link OidcClient.createToken}
* - RETURNS:
* - AccessToken
* - ExpiresIn
* - RefreshToken (optional)
*/
export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
override async registerClient(): Promise<ClientRegistration> {
return this.oidc.registerClient(
{
clientName: oidcClientName(),
clientType: clientRegistrationType,
scopes: this.profile.scopes,
},
this.profile.startUrl
)
}
override async authorize(
registration: ClientRegistration,
args?: CreateTokenArgs
): Promise<{ token: SsoToken; registration: ClientRegistration; region: string; startUrl: string }> {
// This will NOT throw on expired clientId/Secret, but WILL throw on invalid clientId/Secret
const authorization = await this.oidc.startDeviceAuthorization({
startUrl: this.profile.startUrl,
clientId: registration.clientId,
clientSecret: registration.clientSecret,
})
const openBrowserAndWaitUntilComplete = async () => {
if (!(await openSsoPortalLink(this.profile.startUrl, authorization))) {
throw new CancellationError('user')
}
return await pollForTokenWithProgress(
() =>
this.oidc.createToken({
clientId: registration.clientId,
clientSecret: registration.clientSecret,
deviceCode: authorization.deviceCode,
grantType: deviceGrantType,
}),
authorization
)
}
const token = this.withBrowserLoginTelemetry(() => openBrowserAndWaitUntilComplete(), args)
return this.formatToken(await token, registration)
}
/**
* If the registration already exists locally, it
* will be validated before being returned. Otherwise, a client registration is
* created and returned.
*/
override async getValidatedClientRegistration(): Promise<ClientRegistration> {
return telemetry.function_call.run(
async () => {
const cacheKey = this.registrationCacheKey
const cachedRegistration = await this.cache.registration.load(cacheKey)
// Clear cached if registration is expired
if (cachedRegistration && isExpired(cachedRegistration)) {
await this.invalidate('registrationExpired:DeviceCode')
}
return loadOr(this.cache.registration, cacheKey, () => this.registerClient())
},
{ emit: false, functionId: { name: 'getValidatedClientRegistration', class: 'DeviceFlowAuthorization' } }
)
}
}
/**
* SSO "authorization code" + PKCE flow (https://oauth.net/2/grant-types/authorization-code/)
* 1. `grant_type = authorization_code`
* 2. Clients exchange an authorization code for an access token.
* 1. After the user returns to the client via the redirect URL, the application will get the authorization code from the URL and use it to request an access token.
* 3. PKCE https://oauth.net/2/pkce/
* 1. PKCE-enhanced Authorization Code Flow prevents CSRF and authorization code injection attacks, by introducing a *secret* created by the client, that can be verified by the authorization server.
* 2. PKCE does not add any new responses, so clients can always use the PKCE extension even if an authorization server does not support it.
* 4. LIFECYCLE
* 1. CLIENT CREATES AN APP (ONE-TIME)
* 1. Client creates an "app": server returns a `client_id` for use in all future sessions. (expires in 90 days)
* 2. PKCE SEQUENCE:
* 1. Client app generates a random secret (`code_verifier`) per authorization request.
* 2. Client: AUTHORIZATION REQUEST: `registerClient()`: client sends SHA256 hash (`code_challenge_method`) of the secret (`code_challenge`) in the authorization request.
* 1. PARAMETERS:
* 1. `response_type=code`: indicates that your client expects to receive an authorization code.
* 2. `client_id`
* 3. `redirect_uri`: Server will navigate the user to this URL, after appending `?code=…`. Typically `http://127.0.0.1/…` but may be remote (`https://vscode.dev/…`) or custom URI scheme (`vscode://…`).
* 4. `state=1234zyx`: CSRF token. Random string generated by your (client) application, which you’ll verify later.
* 5. `code_challenge`: See above.
* 6. `code_challenge_method=S256`: either "plain" or "S256".
* 3. Server: website redirects the user to `<redirect_uri>?code=…&state=…`.
* 1. Client verifies the `state` (CSRF token).
* 2. Client gets the `code`. Can later exchange it for a "token set" (access token, refresh token and id token).
* 4. Client: ACCESS TOKEN REQUEST ("AUTHORIZATION CODE EXCHANGE"): `createToken()`: client exchanges the authorization code for an access token and sends the un-hashed secret (`code_verifier`), which the server can hash and compare to the original hash, to verify the createToken() request came from the actual client.
* 1. PARAMETERS:
* 1. `grant_type=authorization_code`
* 2. `client_id`
* 3. `redirect_uri`: See above.
* 4. `code`: Authorization code obtained from the redirect.
* 5. `client_secret` (optional): The application’s registered client secret if it was issued a secret.
* 6. `code_verifier`: See above.
* 5. Server: transforms the provided `code_verifier` using the same hash method (`code_challenge_method`), then compares it to the stored `code_challenge` string.
* 1. If the verifier matches the expected value, server issues an access token.
* 2. If there is a problem, server responds with `invalid_grant` error.
*/
class AuthFlowAuthorization extends SsoAccessTokenProvider {
override async registerClient(): Promise<ClientRegistration> {
return this.oidc.registerClient(
{
// All AWS extensions (Q, Toolkit) for a given IDE use the same client name.
clientName: oidcClientName(),
clientType: clientRegistrationType,
scopes: this.profile.scopes,
grantTypes: [authorizationGrantType, refreshGrantType],
redirectUris: ['http://127.0.0.1/oauth/callback'],
issuerUrl: this.profile.startUrl,
},
this.profile.startUrl,
'auth code'
)
}
override async authorize(
registration: ClientRegistration,
args?: CreateTokenArgs
): Promise<{ token: SsoToken; registration: ClientRegistration; region: string; startUrl: string }> {
const state = randomUUID()
const authServer = AuthSSOServer.init(state)
try {
await authServer.start()
const token = await this.withBrowserLoginTelemetry(async () => {
const redirectUri = authServer.redirectUri
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest().toString('base64url')
const location = await this.oidc.authorize({
responseType: 'code',
clientId: registration.clientId,
redirectUri: redirectUri,
scopes: this.profile.scopes ?? [],
state,
codeChallenge,
codeChallengeMethod: 'S256',
})
await vscode.env.openExternal(vscode.Uri.parse(location))
const authorizationCode = await authServer.waitForAuthorization()
if (authorizationCode.isErr()) {
throw authorizationCode.err()
}
const res = await this.oidc.createToken({
clientId: registration.clientId,
clientSecret: registration.clientSecret,
grantType: authorizationGrantType,
redirectUri,
codeVerifier,
code: authorizationCode.unwrap(),
})
telemetry.record({ requestId: res.requestId })
return res
}, args)
return this.formatToken(token, registration)
} finally {
// Temporary delay to make sure the auth ui was displayed to the user before closing
// inspired by https://github.com/microsoft/vscode/blob/a49c81edea6647684eee87d204e50feed9c455f6/extensions/github-authentication/src/flows.ts#L262
setTimeout(() => {
authServer.close().catch((e) => {
getLogger().error(
'AuthFlowAuthorization: AuthSSOServer.close() failed: %s: %s',
(e as Error).name,
(e as Error).message
)
})
}, 5000)
}
}
/**
* If the registration already exists locally, it
* will be validated before being returned. Otherwise, a client registration is
* created and returned.
*/
override async getValidatedClientRegistration(): Promise<ClientRegistration> {
return telemetry.function_call.run(
async () => {
const cacheKey = this.registrationCacheKey
const cachedRegistration = await this.cache.registration.load(cacheKey)
// Clear cached if registration is expired or it uses a deprecate auth version (device code)
if (cachedRegistration && (isExpired(cachedRegistration) || isDeprecatedAuth(cachedRegistration))) {
await this.invalidate('registrationExpired:AuthFlow')
}
return loadOr(this.cache.registration, cacheKey, () => this.registerClient())
},
{ emit: false, functionId: { name: 'getValidatedClientRegistration', class: 'AuthFlowAuthorization' } }
)
}
}
/**
* Alternative to {@link AuthFlowAuthorization} for demo/testing purposes.
*
* Allows user to enter the code manually after completing the authorization flow.
*/
class WebAuthorization extends SsoAccessTokenProvider {
private redirectUri = 'http://127.0.0.1:54321/oauth/callback'
override async registerClient(): Promise<ClientRegistration> {
return this.oidc.registerClient(
{
// All AWS extensions (Q, Toolkit) for a given IDE use the same client name.
clientName: oidcClientName(),
clientType: clientRegistrationType,
scopes: this.profile.scopes,
grantTypes: [authorizationGrantType, refreshGrantType],
redirectUris: [this.redirectUri],
issuerUrl: this.profile.startUrl,
},
this.profile.startUrl,
'web auth code'
)
}
override async authorize(
registration: ClientRegistration,
args?: CreateTokenArgs
): Promise<{ token: SsoToken; registration: ClientRegistration; region: string; startUrl: string }> {
const state = randomUUID()
const token = await this.withBrowserLoginTelemetry(async () => {
const codeVerifier = toBase64URL(randomBytes(32).toString('base64'))
const codeChallenge = toBase64URL(createHash('sha256').update(codeVerifier).digest().toString('base64'))
const location = await this.oidc.authorize({
responseType: 'code',
clientId: registration.clientId,
// we aren't running on localhost so we can't see the what ports are free
redirectUri: this.redirectUri,
scopes: this.profile.scopes ?? [],
state,
codeChallenge,
codeChallengeMethod: 'S256',
})
await vscode.env.openExternal(vscode.Uri.parse(location))
const inputBox = await showInputBox({
title: 'Authorization Input',
placeholder: 'Input the authorization code',
validateInput: (val: string) => {
if (val.length === 0) {
return 'At least one character is required'
}
return undefined
},
})
return this.oidc.createToken({
clientId: registration.clientId,
clientSecret: registration.clientSecret,
grantType: authorizationGrantType,
redirectUri: this.redirectUri,
codeVerifier,
code: inputBox,
})
}, args)
return this.formatToken(token, registration)
}
override async getValidatedClientRegistration(): Promise<ClientRegistration> {
return telemetry.function_call.run(
async () => {
const cacheKey = this.registrationCacheKey
const cachedRegistration = await this.cache.registration.load(cacheKey)
if (
cachedRegistration &&
(isExpired(cachedRegistration) || cachedRegistration.flow !== 'web auth code')
) {
await this.invalidate('registrationExpired:WebAuth')
}
return loadOr(this.cache.registration, cacheKey, () => this.registerClient())
},
{ emit: false, functionId: { name: 'getValidatedClientRegistration', class: 'WebAuthorization' } }
)
}
}
/**
* Remembers the reason an SSO session was put in to a "needs reauthentication" state.
* The current use is for telemetry. When the user reauths, we want {@link AwsLoginWithBrowser}
* to know why it needed to be reauthed.
*
* The flow is to use `set()` to remember why the user was put in to a reauth state,
* then upon the next reauth use `get()`. Finally, use `clear()` if the reauth is
* successful.
*/
export class ReAuthState extends NestedMap<ReAuthStateKey, ReAuthStateValue> {
static #instance: ReAuthState
static get instance() {
return (this.#instance ??= new ReAuthState())
}
protected constructor() {
super()
}
protected override hash(profile: ReAuthStateKey): string {
return profile.identifier ?? profile.startUrl
}
protected override get name(): string {
return ReAuthState.name
}
override get default(): ReAuthStateValue {
return { reAuthReason: undefined }
}
}
type ReAuthStateKey = Pick<SsoProfile, 'identifier' | 'startUrl'>
type ReAuthStateValue = {
// the latest reason for why the connection was moved in to a "needs reauth" state
reAuthReason?: string
}
/**
* Singleton class that manages showing the user a message during {@link DiskCacheError} errors.
*
* Background:
* - We need this {@link DiskCacheErrorMessage} specifically as a singleton since we want to ensure
* that only 1 instance of this message appears at a time. The current implementation creates a new
* {@link SsoAccessTokenProvider} instance each time a token is requested, and this can happen multiple
* times in rapid succession.
*/
class DiskCacheErrorMessage {
static #instance: DiskCacheErrorMessage
static get instance() {
return (this.#instance ??= new DiskCacheErrorMessage())
}
/**
* Show a `"don't show again"`-able message which tells the user about a file system related error
* with the sso cache.
*
* This message is throttled so we do not spam the user every time something requests a token.
*/
public showMessageThrottled(error: Error) {
return this._showMessageThrottled(error)
}
private _showMessageThrottled = _.throttle(async (error: Error) => this._showMessage(error), 60_000, {
leading: true,
})
private async _showMessage(error: Error) {
const dontShow = 'Never warn again'
const promptSettings: PromptSettings = isAmazonQ()
? AmazonQPromptSettings.instance
: ToolkitPromptSettings.instance
// We know 'ssoCacheError' is in all extension prompt settings
if (promptSettings.isPromptEnabled('ssoCacheError')) {
const result = await showMessage()
if (result === dontShow) {
await promptSettings.disablePrompt('ssoCacheError')
}
}
function showMessage() {
return showViewLogsMessage(
`Features using SSO will not work due to:\n"${getErrorMsg(error, true)}"`,
'error',
[dontShow]
)
}
}
}