diff --git a/common/changes/@microsoft/rush/bmiddha-chained-authentication_2025-03-21-17-39.json b/common/changes/@microsoft/rush/bmiddha-chained-authentication_2025-03-21-17-39.json new file mode 100644 index 00000000000..f5cfd43757e --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-chained-authentication_2025-03-21-17-39.json @@ -0,0 +1,15 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add `ChainedCredential` to `AzureAuthenticationBase` to handle auth failover.", + "type": "none" + }, + { + "packageName": "@microsoft/rush", + "comment": "Add support for developer tools credentials to the Azure build cache.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-azure-storage-build-cache-plugin.api.md b/common/reviews/api/rush-azure-storage-build-cache-plugin.api.md index 5237a79fced..572b699ae06 100644 --- a/common/reviews/api/rush-azure-storage-build-cache-plugin.api.md +++ b/common/reviews/api/rush-azure-storage-build-cache-plugin.api.md @@ -35,7 +35,9 @@ export abstract class AzureAuthenticationBase { // (undocumented) deleteCachedCredentialsAsync(terminal: ITerminal): Promise; // (undocumented) - protected readonly _failoverOrder: Record; + protected readonly _failoverOrder: { + [key in LoginFlowType]?: LoginFlowType; + } | undefined; protected abstract _getCacheIdParts(): string[]; // (undocumented) protected abstract _getCredentialFromTokenAsync(terminal: ITerminal, tokenCredential: TokenCredential, credentialsCache: CredentialCache): Promise; @@ -85,7 +87,9 @@ export interface IAzureAuthenticationBaseOptions { credentialUpdateCommandForLogging?: string | undefined; // (undocumented) loginFlow?: LoginFlowType; - loginFlowFailover?: Record; + loginFlowFailover?: { + [key in LoginFlowType]?: LoginFlowType; + }; } // @public (undocumented) @@ -133,7 +137,7 @@ export interface ITryGetCachedCredentialOptionsThrow extends ITryGetCachedCreden } // @public (undocumented) -export type LoginFlowType = 'DeviceCode' | 'InteractiveBrowser' | 'AdoCodespacesAuth'; +export type LoginFlowType = 'DeviceCode' | 'InteractiveBrowser' | 'AdoCodespacesAuth' | 'VisualStudioCode' | 'AzureCli' | 'AzureDeveloperCli' | 'AzurePowerShell'; // @public (undocumented) class RushAzureStorageBuildCachePlugin implements IRushPlugin { diff --git a/rush-plugins/rush-azure-storage-build-cache-plugin/src/AdoCodespacesAuthCredential.ts b/rush-plugins/rush-azure-storage-build-cache-plugin/src/AdoCodespacesAuthCredential.ts index 3f3f478f935..8ca8b4c0bee 100644 --- a/rush-plugins/rush-azure-storage-build-cache-plugin/src/AdoCodespacesAuthCredential.ts +++ b/rush-plugins/rush-azure-storage-build-cache-plugin/src/AdoCodespacesAuthCredential.ts @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. import { Executable } from '@rushstack/node-core-library'; -import type { AccessToken, GetTokenOptions, TokenCredential } from '@azure/identity'; +import { + CredentialUnavailableError, + type AccessToken, + type GetTokenOptions, + type TokenCredential +} from '@azure/identity'; interface IDecodedJwt { header: { @@ -34,39 +39,45 @@ interface IDecodedJwt { export class AdoCodespacesAuthCredential implements TokenCredential { // eslint-disable-next-line @typescript-eslint/naming-convention public async getToken(scopes: string | [string], options?: GetTokenOptions): Promise { - let scope: string; - if (Array.isArray(scopes)) { - if (scopes.length > 1) { - throw new Error('Only one scope is supported'); - } else if ((scopes as string[]).length === 0) { - throw new Error('A scope must be provided.'); + try { + let scope: string; + if (Array.isArray(scopes)) { + if (scopes.length > 1) { + throw new Error('Only one scope is supported'); + } else if ((scopes as string[]).length === 0) { + throw new Error('A scope must be provided.'); + } else { + scope = scopes[0]; + } } else { - scope = scopes[0]; + scope = scopes; } - } else { - scope = scopes; - } - const azureAuthHelperExec: string = 'azure-auth-helper'; + const azureAuthHelperExec: string = 'azure-auth-helper'; - const token: string = Executable.spawnSync(azureAuthHelperExec, ['get-access-token', scope]).stdout; + const token: string = Executable.spawnSync(azureAuthHelperExec, ['get-access-token', scope]).stdout; - let expiresOnTimestamp: number; + let expiresOnTimestamp: number; - try { - const decodedToken: IDecodedJwt = this._decodeToken(token); - if (decodedToken?.payload?.exp) { - expiresOnTimestamp = decodedToken.payload.exp * 1000; - } else { - expiresOnTimestamp = Date.now() + 3600000; + try { + const decodedToken: IDecodedJwt = this._decodeToken(token); + if (decodedToken?.payload?.exp) { + expiresOnTimestamp = decodedToken.payload.exp * 1000; + } else { + expiresOnTimestamp = Date.now() + 3600000; + } + } catch (error) { + throw new Error(`Failed to decode the token: ${error}`); } + + return { + token, + expiresOnTimestamp + }; } catch (error) { - throw new Error(`Failed to decode the token: ${error}`); + throw new CredentialUnavailableError( + `Failed to get token from Azure DevOps Codespaces Authentication: ${error.message}` + ); } - - return { - token, - expiresOnTimestamp - }; } private _decodeToken(token: string): IDecodedJwt { diff --git a/rush-plugins/rush-azure-storage-build-cache-plugin/src/AzureAuthenticationBase.ts b/rush-plugins/rush-azure-storage-build-cache-plugin/src/AzureAuthenticationBase.ts index 0d0cbf5d87a..94c6fb71984 100644 --- a/rush-plugins/rush-azure-storage-build-cache-plugin/src/AzureAuthenticationBase.ts +++ b/rush-plugins/rush-azure-storage-build-cache-plugin/src/AzureAuthenticationBase.ts @@ -9,15 +9,21 @@ import { type InteractiveBrowserCredentialInBrowserOptions, InteractiveBrowserCredential, type InteractiveBrowserCredentialNodeOptions, - type TokenCredential + type TokenCredential, + ChainedTokenCredential, + VisualStudioCodeCredential, + AzureCliCredential, + AzureDeveloperCliCredential, + AzurePowerShellCredential } from '@azure/identity'; +import type { TokenCredentialOptions } from '@azure/identity'; +import { AdoCodespacesAuthCredential } from './AdoCodespacesAuthCredential'; import type { ITerminal } from '@rushstack/terminal'; import { CredentialCache } from '@rushstack/rush-sdk'; // Use a separate import line so the .d.ts file ends up with an `import type { ... }` // See https://github.com/microsoft/rushstack/issues/3432 import type { ICredentialCacheEntry } from '@rushstack/rush-sdk'; import { PrintUtilities } from '@rushstack/terminal'; -import { AdoCodespacesAuthCredential } from './AdoCodespacesAuthCredential'; /** * @public @@ -80,7 +86,14 @@ export type AzureEnvironmentName = keyof typeof AzureAuthorityHosts; /** * @public */ -export type LoginFlowType = 'DeviceCode' | 'InteractiveBrowser' | 'AdoCodespacesAuth'; +export type LoginFlowType = + | 'DeviceCode' + | 'InteractiveBrowser' + | 'AdoCodespacesAuth' + | 'VisualStudioCode' + | 'AzureCli' + | 'AzureDeveloperCli' + | 'AzurePowerShell'; /** * @public @@ -97,13 +110,19 @@ export interface IAzureAuthenticationBaseOptions { * @defaultValue * ```json * { - * "AdoCodespacesAuth": "InteractiveBrowser", + * "AdoCodespacesAuth": "VisualStudioCode", + * "VisualStudioCode": "AzureCli", + * "AzureCli": "AzureDeveloperCli", + * "AzureDeveloperCli": "AzurePowerShell", + * "AzurePowerShell": "InteractiveBrowser", * "InteractiveBrowser": "DeviceCode", - * "DeviceCode": null + * "DeviceCode": undefined * } * ``` */ - loginFlowFailover?: Record; + loginFlowFailover?: { + [key in LoginFlowType]?: LoginFlowType; + }; } /** @@ -128,7 +147,11 @@ export abstract class AzureAuthenticationBase { protected readonly _azureEnvironment: AzureEnvironmentName; protected readonly _loginFlow: LoginFlowType; - protected readonly _failoverOrder: Record; + protected readonly _failoverOrder: + | { + [key in LoginFlowType]?: LoginFlowType; + } + | undefined; private __credentialCacheId: string | undefined; protected get _credentialCacheId(): string { @@ -148,13 +171,17 @@ export abstract class AzureAuthenticationBase { public constructor(options: IAzureAuthenticationBaseOptions) { const { azureEnvironment = 'AzurePublicCloud', - loginFlow = process.env.CODESPACES === 'true' ? 'AdoCodespacesAuth' : 'InteractiveBrowser' + loginFlow = process.env.CODESPACES === 'true' ? 'AdoCodespacesAuth' : 'VisualStudioCode' } = options; this._azureEnvironment = azureEnvironment; this._credentialUpdateCommandForLogging = options.credentialUpdateCommandForLogging; this._loginFlow = loginFlow; this._failoverOrder = options.loginFlowFailover || { - AdoCodespacesAuth: 'InteractiveBrowser', + AdoCodespacesAuth: 'VisualStudioCode', + VisualStudioCode: 'AzureCli', + AzureCli: 'AzureDeveloperCli', + AzureDeveloperCli: 'AzurePowerShell', + AzurePowerShell: 'InteractiveBrowser', InteractiveBrowser: 'DeviceCode', DeviceCode: undefined }; @@ -294,8 +321,6 @@ export abstract class AzureAuthenticationBase { throw new Error(`Unexpected Azure environment: ${this._azureEnvironment}`); } - let tokenCredential: TokenCredential; - const interactiveCredentialOptions: ( | InteractiveBrowserCredentialNodeOptions | InteractiveBrowserCredentialInBrowserOptions @@ -305,40 +330,59 @@ export abstract class AzureAuthenticationBase { authorityHost }; - switch (loginFlow) { - case 'AdoCodespacesAuth': { - tokenCredential = new AdoCodespacesAuthCredential(); - break; - } - case 'InteractiveBrowser': { - tokenCredential = new InteractiveBrowserCredential(interactiveCredentialOptions); - break; + const deviceCodeCredentialOptions: DeviceCodeCredentialOptions = { + ...this._additionalDeviceCodeCredentialOptions, + ...interactiveCredentialOptions, + userPromptCallback: (deviceCodeInfo: DeviceCodeInfo) => { + PrintUtilities.printMessageInBox(deviceCodeInfo.message, terminal); } - case 'DeviceCode': { - tokenCredential = new DeviceCodeCredential({ - ...interactiveCredentialOptions, - userPromptCallback: (deviceCodeInfo: DeviceCodeInfo) => { - PrintUtilities.printMessageInBox(deviceCodeInfo.message, terminal); - } - }); - break; - } - default: { - throw new Error(`Unsupported login flow: ${loginFlow}`); + }; + + const options: TokenCredentialOptions = { authorityHost }; + const priority: Set = new Set([loginFlow]); + for (const credType of priority) { + const next: LoginFlowType | undefined = this._failoverOrder?.[credType]; + if (next) { + priority.add(next); } } + const knownCredentialTypes: Record< + LoginFlowType, + new (options: TokenCredentialOptions) => TokenCredential + > = { + DeviceCode: class extends DeviceCodeCredential { + public new(credentialOptions: DeviceCodeCredentialOptions): DeviceCodeCredential { + return new DeviceCodeCredential({ + ...deviceCodeCredentialOptions, + ...credentialOptions + }); + } + }, + InteractiveBrowser: class extends InteractiveBrowserCredential { + public new(credentialOptions: InteractiveBrowserCredentialNodeOptions): InteractiveBrowserCredential { + return new InteractiveBrowserCredential({ ...interactiveCredentialOptions, ...credentialOptions }); + } + }, + AdoCodespacesAuth: AdoCodespacesAuthCredential, + VisualStudioCode: VisualStudioCodeCredential, + AzureCli: AzureCliCredential, + AzureDeveloperCli: AzureDeveloperCliCredential, + AzurePowerShell: AzurePowerShellCredential + }; + + const credentials: TokenCredential[] = Array.from( + priority, + (credType) => new knownCredentialTypes[credType](options) + ); + + const tokenCredential: TokenCredential = new ChainedTokenCredential(...credentials); + try { return await this._getCredentialFromTokenAsync(terminal, tokenCredential, credentialsCache); } catch (error) { terminal.writeVerbose(`Failed to get credentials with ${loginFlow}: ${error}`); - const fallbackFlow: LoginFlowType | undefined = this._failoverOrder[loginFlow]; - if (fallbackFlow) { - terminal.writeVerbose(`Falling back to ${fallbackFlow} login flow`); - return this._getCredentialAsync(terminal, fallbackFlow, credentialsCache); - } else { - throw error; - } + throw error; } } }