packages/core/src/login/webview/vue/backend.ts (199 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 { VueWebview } from '../../../webviews/main' import { Region } from '../../../shared/regions/endpoints' import { getTelemetryReasonDesc, ToolkitError } from '../../../shared/errors' import { CancellationError } from '../../../shared/utilities/timeoutUtils' import { trustedDomainCancellation } from '../../../auth/sso/model' import { handleWebviewError } from '../../../webviews/server' import { InvalidGrantException } from '@aws-sdk/client-sso-oidc' import { AwsConnection, Connection, hasScopes, scopesCodeCatalyst, scopesCodeWhispererChat, scopesSsoAccountAccess, SsoConnection, TelemetryMetadata, } from '../../../auth/connection' import { Auth } from '../../../auth/auth' import { StaticProfile, StaticProfileKeyErrorMessage } from '../../../auth/credentials/types' import { telemetry } from '../../../shared/telemetry/telemetry' import { AuthAddConnection } from '../../../shared/telemetry/telemetry' import { AuthSources } from '../util' import { AuthEnabledFeatures, AuthError, AuthFlowState, AuthUiClick, userCancelled } from './types' import { DevSettings } from '../../../shared/settings' import { AuthSSOServer } from '../../../auth/sso/server' import { getLogger } from '../../../shared/logger/logger' import { isValidUrl } from '../../../shared/utilities/uriUtils' import { RegionProfile } from '../../../codewhisperer/models/model' import { ProfileSwitchIntent } from '../../../codewhisperer/region/regionProfileManager' export abstract class CommonAuthWebview extends VueWebview { private readonly className = 'CommonAuthWebview' private metricMetadata: TelemetryMetadata = {} // authSource should be set by whatever triggers the auth page flow. // It will be reported in telemetry. static #authSource: string = AuthSources.vscodeComponent public static get authSource() { return CommonAuthWebview.#authSource } public static set authSource(source: string) { CommonAuthWebview.#authSource = source } public get authSource() { return CommonAuthWebview.#authSource } public set authSource(source: string) { CommonAuthWebview.#authSource = source } public getRegions(): Region[] { return globals.regionProvider.getRegions().reverse() } /** * Called when the UI load process is completed, regardless of success or failure * * @param errorMessage IF an error is caught on the frontend, this is the message. It will result in a failure metric. * Otherwise we assume success. */ public setUiReady(state: 'login' | 'reauth' | 'selectProfile', errorMessage?: string) { if (errorMessage) { this.setLoadFailure(state, errorMessage) } else { this.setDidLoad(state) } } /** * This wraps the execution of the given setupFunc() and handles common errors from the SSO setup process. * * @param methodName A value that will help identify which high level function called this method. * @param setupFunc The function which will be executed in a try/catch so that we can handle common errors. * @param postMetrics Whether to emit telemetry. * @returns */ async ssoSetup(methodName: string, setupFunc: () => Promise<any>, postMetrics: boolean = true) { const runSetup = async () => { try { await setupFunc() return } catch (e) { getLogger().error('ssoSetup encountered an error: %s', e) if (e instanceof ToolkitError && e.code === 'NotOnboarded') { /** * Connection is fine, they just skipped onboarding so not an actual error. * * The error comes from user cancelling prompt by {@link CodeCatalystAuthenticationProvider.promptOnboarding()} */ return } if ( CancellationError.isUserCancelled(e) || (e instanceof ToolkitError && (CancellationError.isUserCancelled(e.cause) || e.cancelled === true)) ) { return { id: userCancelled, text: 'Setup cancelled.' } } if (e instanceof ToolkitError && e.cause instanceof InvalidGrantException) { return { id: 'invalidGrantException', text: 'Permissions for this service may not be enabled by your SSO Admin, or the selected region may not be supported.', } } if ( e instanceof ToolkitError && (e.code === trustedDomainCancellation || e.cause?.name === trustedDomainCancellation) ) { return { id: 'trustedDomainCancellation', text: `Must 'Open' or 'Configure Trusted Domains', unless you cancelled.`, } } const invalidRequestException = 'InvalidRequestException' if ( (e instanceof Error && e.name === invalidRequestException) || (e instanceof ToolkitError && e.cause?.name === invalidRequestException) ) { return { id: 'badStartUrl', text: `Connection failed. Please verify your start URL.` } } // If SSO setup fails we want to be able to show the user an error in the UI, due to this we cannot // throw an error here. So instead this will additionally show an error message that provides more // detailed information. handleWebviewError(e, this.id, methodName) return { id: 'defaultFailure', text: 'Failed to setup.' } } } // Add context to our telemetry by adding the methodName argument to the function stack const result = await telemetry.function_call.run( async () => { return runSetup() }, { emit: false, functionId: { name: methodName, class: this.className } } ) if (postMetrics) { this.storeMetricMetadata(this.getResultForMetrics(result)) this.emitAuthMetric() } this.authSource = AuthSources.vscodeComponent return result } /** Allows the frontend to subscribe to events emitted by the backend regarding the ACTIVE auth connection changing in some way. */ abstract onActiveConnectionModified: vscode.EventEmitter<void> abstract startBuilderIdSetup(app: string): Promise<AuthError | undefined> abstract startEnterpriseSetup(startUrl: string, region: string, app: string): Promise<AuthError | undefined> async getAuthenticatedCredentialsError(data: StaticProfile): Promise<StaticProfileKeyErrorMessage | undefined> { return Auth.instance.authenticateData(data) } abstract startIamCredentialSetup( profileName: string, accessKey: string, secretKey: string ): Promise<AuthError | undefined> async showResourceExplorer(): Promise<void> { await vscode.commands.executeCommand('aws.explorer.focus') } abstract fetchConnections(): Promise<AwsConnection[] | undefined> async errorNotification(e: AuthError) { void vscode.window.showInformationMessage(`${e.text}`) } abstract quitLoginScreen(): Promise<void> /** * NOTE: If we eventually need to be able to specify the connection to reauth, it should * be an arg in this function */ abstract reauthenticateConnection(): Promise<void> abstract getReauthError(): Promise<AuthError | undefined> abstract getActiveConnection(): Promise<Connection | undefined> /** Refreshes the current state of the auth flow, determining what you see in the UI */ abstract refreshAuthState(): Promise<void> /** Use {@link refreshAuthState} first to ensure this returns the latest state */ abstract getAuthState(): Promise<AuthFlowState> abstract signout(): Promise<void> /** List current connections known by the extension for the purpose of preventing duplicates. */ abstract listSsoConnections(): Promise<SsoConnection[]> abstract listRegionProfiles(): Promise<RegionProfile[] | string> abstract selectRegionProfile(profile: RegionProfile, source: ProfileSwitchIntent): Promise<void> /** * Emit stored metric metadata. Does not reset the stored metric metadata, because it * may be used for additional emits (e.g. user cancels multiple times, user cancels then logs in) */ emitAuthMetric() { // We shouldn't report startUrl or region if we aren't reporting IdC if (this.metricMetadata.credentialSourceId !== 'iamIdentityCenter') { delete this.metricMetadata.awsRegion delete this.metricMetadata.credentialStartUrl } telemetry.auth_addConnection.emit({ ...this.metricMetadata, source: this.authSource, } as AuthAddConnection) } /** * Incrementally store auth metric data during vue, backend sign in logic, * and cancellation flows. */ storeMetricMetadata(data: TelemetryMetadata) { this.metricMetadata = { ...this.metricMetadata, ...data } } /** * Reset metadata stored by the auth form. */ resetStoredMetricMetadata() { this.metricMetadata = {} } /** * Determines the status of the metric to report. */ getResultForMetrics(error?: AuthError) { const metadata: Partial<TelemetryMetadata> = {} if (error) { if (error.id === userCancelled) { metadata.result = 'Cancelled' } else { metadata.result = 'Failed' metadata.reason = error.id metadata.reasonDesc = getTelemetryReasonDesc(error.text) } } else { metadata.result = 'Succeeded' } return metadata } /** * The metric when certain elements in the webview are clicked. */ emitUiClick(id: AuthUiClick) { telemetry.ui_click.emit({ elementId: id, }) } /** * Return a comma-delimited list of features for which the connection has access to. */ getAuthEnabledFeatures(conn: SsoConnection | AwsConnection) { const authEnabledFeatures: AuthEnabledFeatures[] = [] if (hasScopes(conn.scopes!, scopesCodeWhispererChat)) { authEnabledFeatures.push('codewhisperer') } if (hasScopes(conn.scopes!, scopesCodeCatalyst)) { authEnabledFeatures.push('codecatalyst') } if (hasScopes(conn.scopes!, scopesSsoAccountAccess)) { authEnabledFeatures.push('awsExplorer') } return authEnabledFeatures.join(',') } getDefaultStartUrl() { return DevSettings.instance.get('autofillStartUrl', '') } cancelAuthFlow() { AuthSSOServer.lastInstance?.cancelCurrentFlow() } validateUrl(url: string) { return isValidUrl(url) } }