Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ export abstract class AzureAuthenticationBase {
// (undocumented)
deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void>;
// (undocumented)
protected readonly _failoverOrder: Record<LoginFlowType, LoginFlowType | undefined>;
protected readonly _failoverOrder: {
Comment thread
iclanton marked this conversation as resolved.
[key in LoginFlowType]?: LoginFlowType;
} | undefined;
protected abstract _getCacheIdParts(): string[];
// (undocumented)
protected abstract _getCredentialFromTokenAsync(terminal: ITerminal, tokenCredential: TokenCredential, credentialsCache: CredentialCache): Promise<ICredentialResult>;
Expand Down Expand Up @@ -85,7 +87,9 @@ export interface IAzureAuthenticationBaseOptions {
credentialUpdateCommandForLogging?: string | undefined;
// (undocumented)
loginFlow?: LoginFlowType;
loginFlowFailover?: Record<LoginFlowType, LoginFlowType | undefined>;
loginFlowFailover?: {
[key in LoginFlowType]?: LoginFlowType;
};
}

// @public (undocumented)
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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<AccessToken> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<LoginFlowType, LoginFlowType | undefined>;
Comment thread
bmiddha marked this conversation as resolved.
loginFlowFailover?: {
[key in LoginFlowType]?: LoginFlowType;
};
}

/**
Expand All @@ -128,7 +147,11 @@ export abstract class AzureAuthenticationBase {

protected readonly _azureEnvironment: AzureEnvironmentName;
protected readonly _loginFlow: LoginFlowType;
protected readonly _failoverOrder: Record<LoginFlowType, LoginFlowType | undefined>;
protected readonly _failoverOrder:
| {
[key in LoginFlowType]?: LoginFlowType;
}
| undefined;

private __credentialCacheId: string | undefined;
protected get _credentialCacheId(): string {
Expand All @@ -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
};
Expand Down Expand Up @@ -294,8 +321,6 @@ export abstract class AzureAuthenticationBase {
throw new Error(`Unexpected Azure environment: ${this._azureEnvironment}`);
}

let tokenCredential: TokenCredential;

const interactiveCredentialOptions: (
| InteractiveBrowserCredentialNodeOptions
| InteractiveBrowserCredentialInBrowserOptions
Expand All @@ -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<LoginFlowType> = 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;
}
}
}