diff --git a/packages/zowe-explorer-api/src/profiles/AuthHandler.ts b/packages/zowe-explorer-api/src/profiles/AuthHandler.ts new file mode 100644 index 000000000..f4cd7ebfb --- /dev/null +++ b/packages/zowe-explorer-api/src/profiles/AuthHandler.ts @@ -0,0 +1,156 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { Gui } from "../globals"; +import { CorrelatedError } from "../utils"; +import * as imperative from "@zowe/imperative"; +import { DeferredPromise } from "../utils"; +import { IZoweTreeNode } from "../tree"; + +export interface IAuthMethods { + ssoLogin: (node?: IZoweTreeNode, profileName?: string) => PromiseLike; + promptCredentials: (profile: string | imperative.IProfileLoaded, rePrompt?: boolean) => PromiseLike; +} + +export interface AuthPromptOpts extends IAuthMethods { + isUsingTokenAuth?: boolean; + errorCorrelation?: CorrelatedError; +} + +type ProfileLike = string | imperative.IProfileLoaded; +export class AuthHandler { + private static lockedProfiles: Map> = new Map(); + + /** + * Function that checks whether a profile is using token based authentication + * @param {string[]} secureProfileProps Secure properties for the service profile + * @param {string[]} baseSecureProfileProps Base profile's secure properties (optional) + * @returns {Promise} a boolean representing whether token based auth is being used or not + */ + public static async isUsingTokenAuth(secureProfileProps: string[], baseSecureProfileProps?: string[]): Promise { + const profileUsesBasicAuth = secureProfileProps.includes("user") && secureProfileProps.includes("password"); + if (secureProfileProps.includes("tokenValue")) { + return secureProfileProps.includes("tokenValue") && !profileUsesBasicAuth; + } + return baseSecureProfileProps?.includes("tokenValue") && !profileUsesBasicAuth; + } + + public static async unlockProfile(profileName: string) { + const deferred = this.lockedProfiles.get(profileName); + if (deferred) { + deferred.resolve(); + this.lockedProfiles.delete(profileName); + } + } + + public static async promptForAuthentication( + imperativeError: imperative.ImperativeError, + profile: ProfileLike, + opts: AuthPromptOpts + ): Promise { + const profileName = typeof profile === "string" ? profile : profile.name; + if (imperativeError.mDetails.additionalDetails) { + const tokenError: string = imperativeError.mDetails.additionalDetails; + if (tokenError.includes("Token is not valid or expired.") || opts.isUsingTokenAuth) { + const message = "Log in to Authentication Service"; + const userResp = await Gui.showMessage(opts.errorCorrelation?.message ?? imperativeError.message, { + items: [message], + vsCodeOpts: { modal: true }, + }); + if (userResp === message) { + if (await opts.ssoLogin(null, profileName)) { + // SSO login was successful, unlock profile + AuthHandler.unlockProfile(profileName); + return true; + } + } + return false; + } + } + const checkCredsButton = "Update Credentials"; + const creds = await Gui.errorMessage(opts.errorCorrelation?.message ?? imperativeError.message, { + items: [checkCredsButton], + vsCodeOpts: { modal: true }, + }).then(async (selection) => { + if (selection !== checkCredsButton) { + return; + } + return opts.promptCredentials(profile, true); + }); + + if (creds != null) { + // New creds were provided, unlock profile + AuthHandler.unlockProfile(profileName); + return true; + } + return false; + } + + public static async lockProfile(profile: ProfileLike, imperativeError: imperative.ImperativeError, opts: AuthPromptOpts): Promise { + const profileName = typeof profile === "string" ? profile : profile.name; + if (this.lockedProfiles.has(profileName)) { + return this.lockedProfiles.get(profileName)!.promise; + } + const deferred = new DeferredPromise(); + this.lockedProfiles.set(profileName, deferred); + + // Prompt the user to re-authenticate + const credsEntered = await AuthHandler.promptForAuthentication(imperativeError, profile, opts); + + // If the user failed to re-authenticate, reject the promise + // TODO: more manual testing + if (!credsEntered) { + deferred.reject(); + } + + return deferred.promise; + } + + public static async isLocked(profile: ProfileLike) { + return this.lockedProfiles.has(typeof profile === "string" ? profile : profile.name); + } + + public static async waitIfLocked(profile: ProfileLike): Promise { + const deferred = this.lockedProfiles.get(typeof profile === "string" ? profile : profile.name); + if (deferred) { + await deferred.promise; + } + } +} + +export function withCredentialManagement any | PromiseLike>( + authMethods: IAuthMethods, + profile: ProfileLike, + apiMethod: T +): T { + return async function (...args: any[]) { + await AuthHandler.waitIfLocked(profile); + + try { + return await apiMethod(...args); + } catch (error) { + if (error instanceof imperative.ImperativeError) { + const imperativeError: imperative.ImperativeError = error as imperative.ImperativeError; + const httpErrorCode = Number(imperativeError.mDetails.errorCode); + if ( + httpErrorCode === imperative.RestConstants.HTTP_STATUS_401 || + imperativeError.message.includes("All configured authentication methods failed") + ) { + await AuthHandler.lockProfile(profile, imperativeError, { ...authMethods }); + return await apiMethod(...args); + } else { + throw error; + } + } + throw error; + } + } as T; +} diff --git a/packages/zowe-explorer-api/src/profiles/index.ts b/packages/zowe-explorer-api/src/profiles/index.ts index bda888f97..182cf564f 100644 --- a/packages/zowe-explorer-api/src/profiles/index.ts +++ b/packages/zowe-explorer-api/src/profiles/index.ts @@ -9,6 +9,7 @@ * */ +export * from "./AuthHandler"; export * from "./Validation"; export * from "./UserSettings"; export * from "./ProfilesCache"; diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts b/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts index 759eb43b7..955a3a8d1 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts @@ -433,7 +433,7 @@ export class DatasetFSProvider extends BaseProvider implements vscode.FileSystem } catch (error) { //Response will error if the file is not found //Callers of fetchDatasetAtUri() do not expect it to throw an error - AuthUtils.promptForAuthError(error, metadata.profile); + await AuthUtils.promptForAuthOnError(error, metadata.profile); return null; } } diff --git a/packages/zowe-explorer/src/trees/job/JobFSProvider.ts b/packages/zowe-explorer/src/trees/job/JobFSProvider.ts index 2aee3ed16..a3e14f223 100644 --- a/packages/zowe-explorer/src/trees/job/JobFSProvider.ts +++ b/packages/zowe-explorer/src/trees/job/JobFSProvider.ts @@ -227,7 +227,7 @@ export class JobFSProvider extends BaseProvider implements vscode.FileSystemProv bufBuilder.write(await jesApi.getSpoolContentById(jobEntry.job.jobname, jobEntry.job.jobid, spoolEntry.spool.id)); } } catch (err) { - AuthUtils.promptForAuthError(err, spoolEntry.metadata.profile); + await AuthUtils.promptForAuthOnError(err, spoolEntry.metadata.profile); throw err; } diff --git a/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts b/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts index 087316acf..543957539 100644 --- a/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts +++ b/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts @@ -290,7 +290,7 @@ export class UssFSProvider extends BaseProvider implements vscode.FileSystemProv if (err instanceof Error) { ZoweLogger.error(err.message); } - AuthUtils.promptForAuthError(err, metadata.profile); + await AuthUtils.promptForAuthOnError(err, metadata.profile); return; } diff --git a/packages/zowe-explorer/src/utils/AuthUtils.ts b/packages/zowe-explorer/src/utils/AuthUtils.ts index 523f57469..aa627276a 100644 --- a/packages/zowe-explorer/src/utils/AuthUtils.ts +++ b/packages/zowe-explorer/src/utils/AuthUtils.ts @@ -11,7 +11,7 @@ import * as util from "util"; import * as vscode from "vscode"; -import { imperative, Gui, MainframeInteraction, IZoweTreeNode, ErrorCorrelator, ZoweExplorerApiType, CorrelatedError } from "@zowe/zowe-explorer-api"; +import { imperative, Gui, MainframeInteraction, IZoweTreeNode, ErrorCorrelator, ZoweExplorerApiType, AuthHandler } from "@zowe/zowe-explorer-api"; import { Constants } from "../configuration/Constants"; import { ZoweLogger } from "../tools/ZoweLogger"; import { SharedTreeProviders } from "../trees/shared/SharedTreeProviders"; @@ -24,52 +24,24 @@ interface ErrorContext { } export class AuthUtils { - public static async promptForAuthentication( - imperativeError: imperative.ImperativeError, - profile: imperative.IProfileLoaded, - correlation?: CorrelatedError - ): Promise { - if (imperativeError.mDetails.additionalDetails) { - const tokenError: string = imperativeError.mDetails.additionalDetails; - const isTokenAuth = await AuthUtils.isUsingTokenAuth(profile.name); - - if (tokenError.includes("Token is not valid or expired.") || isTokenAuth) { - const message = vscode.l10n.t("Log in to Authentication Service"); - const userResp = await Gui.showMessage(correlation?.message ?? imperativeError.message, { - items: [message], - vsCodeOpts: { modal: true }, - }); - return userResp === message ? Constants.PROFILES_CACHE.ssoLogin(null, profile.name) : false; - } - } - const checkCredsButton = vscode.l10n.t("Update Credentials"); - const creds = await Gui.errorMessage(correlation?.message ?? imperativeError.message, { - items: [checkCredsButton], - vsCodeOpts: { modal: true }, - }).then(async (selection) => { - if (selection !== checkCredsButton) { - return; - } - return Constants.PROFILES_CACHE.promptCredentials(profile, true); - }); - return creds != null ? true : false; - } - - public static promptForAuthError(err: Error, profile: imperative.IProfileLoaded): void { + public static async promptForAuthOnError(err: Error, profile: imperative.IProfileLoaded): Promise { if ( err instanceof imperative.ImperativeError && profile != null && (Number(err.errorCode) === imperative.RestConstants.HTTP_STATUS_401 || err.message.includes("All configured authentication methods failed")) ) { - const correlation = ErrorCorrelator.getInstance().correlateError(ZoweExplorerApiType.All, err, { + const errorCorrelation = ErrorCorrelator.getInstance().correlateError(ZoweExplorerApiType.All, err, { templateArgs: { - profileName: profile.name - } + profileName: profile.name, + }, }); - void AuthUtils.promptForAuthentication(err, profile, correlation).catch( - (error) => error instanceof Error && ZoweLogger.error(error.message) - ); + await AuthHandler.promptForAuthentication(err, profile, { + ssoLogin: Constants.PROFILES_CACHE.ssoLogin.bind(Constants.PROFILES_CACHE), + promptCredentials: Constants.PROFILES_CACHE.promptCredentials.bind(Constants.PROFILES_CACHE), + isUsingTokenAuth: await AuthUtils.isUsingTokenAuth(profile.name), + errorCorrelation, + }).catch((error) => error instanceof Error && ZoweLogger.error(error.message)); } } @@ -97,7 +69,7 @@ export class AuthUtils { ZoweLogger.error(`${errorDetails.toString()}\n` + util.inspect({ errorDetails, ...{ ...moreInfo, profile: undefined } }, { depth: null })); const profile = typeof moreInfo.profile === "string" ? Constants.PROFILES_CACHE.loadNamedProfile(moreInfo.profile) : moreInfo?.profile; - const correlation = ErrorCorrelator.getInstance().correlateError(moreInfo?.apiType ?? ZoweExplorerApiType.All, errorDetails, { + const errorCorrelation = ErrorCorrelator.getInstance().correlateError(moreInfo?.apiType ?? ZoweExplorerApiType.All, errorDetails, { profileType: profile?.type, ...Object.keys(moreInfo).reduce((all, k) => (typeof moreInfo[k] === "string" ? { ...all, [k]: moreInfo[k] } : all), {}), templateArgs: { profileName: profile?.name ?? "", ...moreInfo?.templateArgs }, @@ -114,14 +86,19 @@ export class AuthUtils { (httpErrorCode === imperative.RestConstants.HTTP_STATUS_401 || imperativeError.message.includes("All configured authentication methods failed")) ) { - return AuthUtils.promptForAuthentication(imperativeError, profile, correlation); + return AuthHandler.promptForAuthentication(imperativeError, profile, { + ssoLogin: Constants.PROFILES_CACHE.ssoLogin.bind(Constants.PROFILES_CACHE), + promptCredentials: Constants.PROFILES_CACHE.promptCredentials.bind(Constants.PROFILES_CACHE), + isUsingTokenAuth: await AuthUtils.isUsingTokenAuth(profile.name), + errorCorrelation, + }); } } if (errorDetails.toString().includes("Could not find profile")) { return false; } - void ErrorCorrelator.getInstance().displayCorrelatedError(correlation, { templateArgs: { profileName: profile?.name ?? "" } }); + void ErrorCorrelator.getInstance().displayCorrelatedError(errorCorrelation, { templateArgs: { profileName: profile?.name ?? "" } }); return false; } @@ -195,13 +172,9 @@ export class AuthUtils { * @returns {Promise} a boolean representing whether token based auth is being used or not */ public static async isUsingTokenAuth(profileName: string): Promise { - const secureProfileProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(profileName); - const profileUsesBasicAuth = secureProfileProps.includes("user") && secureProfileProps.includes("password"); - if (secureProfileProps.includes("tokenValue")) { - return secureProfileProps.includes("tokenValue") && !profileUsesBasicAuth; - } + const secureProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(profileName); const baseProfile = Constants.PROFILES_CACHE.getDefaultProfile("base"); - const secureBaseProfileProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(baseProfile?.name); - return secureBaseProfileProps.includes("tokenValue") && !profileUsesBasicAuth; + const baseSecureProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(baseProfile?.name); + return AuthHandler.isUsingTokenAuth(secureProps, baseSecureProps); } }