Skip to content

Commit 6b358ea

Browse files
authored
[rush] Add ChainedCredential to AzureAuthenticationBase to handle auth failover and add support for developer tools credentials (#5169)
* chained credential * rush change * remove API breaking change * use loginFlowFailover to implement ChainedCredentialOrder * prevent infinite failover order * cleanup logic * linter fixes * cleanup * fix typing
1 parent 1a1b336 commit 6b358ea

File tree

4 files changed

+140
-66
lines changed

4 files changed

+140
-66
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Add `ChainedCredential` to `AzureAuthenticationBase` to handle auth failover.",
6+
"type": "none"
7+
},
8+
{
9+
"packageName": "@microsoft/rush",
10+
"comment": "Add support for developer tools credentials to the Azure build cache.",
11+
"type": "none"
12+
}
13+
],
14+
"packageName": "@microsoft/rush"
15+
}

common/reviews/api/rush-azure-storage-build-cache-plugin.api.md

+7-3
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ export abstract class AzureAuthenticationBase {
3535
// (undocumented)
3636
deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void>;
3737
// (undocumented)
38-
protected readonly _failoverOrder: Record<LoginFlowType, LoginFlowType | undefined>;
38+
protected readonly _failoverOrder: {
39+
[key in LoginFlowType]?: LoginFlowType;
40+
} | undefined;
3941
protected abstract _getCacheIdParts(): string[];
4042
// (undocumented)
4143
protected abstract _getCredentialFromTokenAsync(terminal: ITerminal, tokenCredential: TokenCredential, credentialsCache: CredentialCache): Promise<ICredentialResult>;
@@ -85,7 +87,9 @@ export interface IAzureAuthenticationBaseOptions {
8587
credentialUpdateCommandForLogging?: string | undefined;
8688
// (undocumented)
8789
loginFlow?: LoginFlowType;
88-
loginFlowFailover?: Record<LoginFlowType, LoginFlowType | undefined>;
90+
loginFlowFailover?: {
91+
[key in LoginFlowType]?: LoginFlowType;
92+
};
8993
}
9094

9195
// @public (undocumented)
@@ -133,7 +137,7 @@ export interface ITryGetCachedCredentialOptionsThrow extends ITryGetCachedCreden
133137
}
134138

135139
// @public (undocumented)
136-
export type LoginFlowType = 'DeviceCode' | 'InteractiveBrowser' | 'AdoCodespacesAuth';
140+
export type LoginFlowType = 'DeviceCode' | 'InteractiveBrowser' | 'AdoCodespacesAuth' | 'VisualStudioCode' | 'AzureCli' | 'AzureDeveloperCli' | 'AzurePowerShell';
137141

138142
// @public (undocumented)
139143
class RushAzureStorageBuildCachePlugin implements IRushPlugin {

rush-plugins/rush-azure-storage-build-cache-plugin/src/AdoCodespacesAuthCredential.ts

+37-26
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33
import { Executable } from '@rushstack/node-core-library';
4-
import type { AccessToken, GetTokenOptions, TokenCredential } from '@azure/identity';
4+
import {
5+
CredentialUnavailableError,
6+
type AccessToken,
7+
type GetTokenOptions,
8+
type TokenCredential
9+
} from '@azure/identity';
510

611
interface IDecodedJwt {
712
header: {
@@ -34,39 +39,45 @@ interface IDecodedJwt {
3439
export class AdoCodespacesAuthCredential implements TokenCredential {
3540
// eslint-disable-next-line @typescript-eslint/naming-convention
3641
public async getToken(scopes: string | [string], options?: GetTokenOptions): Promise<AccessToken> {
37-
let scope: string;
38-
if (Array.isArray(scopes)) {
39-
if (scopes.length > 1) {
40-
throw new Error('Only one scope is supported');
41-
} else if ((scopes as string[]).length === 0) {
42-
throw new Error('A scope must be provided.');
42+
try {
43+
let scope: string;
44+
if (Array.isArray(scopes)) {
45+
if (scopes.length > 1) {
46+
throw new Error('Only one scope is supported');
47+
} else if ((scopes as string[]).length === 0) {
48+
throw new Error('A scope must be provided.');
49+
} else {
50+
scope = scopes[0];
51+
}
4352
} else {
44-
scope = scopes[0];
53+
scope = scopes;
4554
}
46-
} else {
47-
scope = scopes;
48-
}
49-
const azureAuthHelperExec: string = 'azure-auth-helper';
55+
const azureAuthHelperExec: string = 'azure-auth-helper';
5056

51-
const token: string = Executable.spawnSync(azureAuthHelperExec, ['get-access-token', scope]).stdout;
57+
const token: string = Executable.spawnSync(azureAuthHelperExec, ['get-access-token', scope]).stdout;
5258

53-
let expiresOnTimestamp: number;
59+
let expiresOnTimestamp: number;
5460

55-
try {
56-
const decodedToken: IDecodedJwt = this._decodeToken(token);
57-
if (decodedToken?.payload?.exp) {
58-
expiresOnTimestamp = decodedToken.payload.exp * 1000;
59-
} else {
60-
expiresOnTimestamp = Date.now() + 3600000;
61+
try {
62+
const decodedToken: IDecodedJwt = this._decodeToken(token);
63+
if (decodedToken?.payload?.exp) {
64+
expiresOnTimestamp = decodedToken.payload.exp * 1000;
65+
} else {
66+
expiresOnTimestamp = Date.now() + 3600000;
67+
}
68+
} catch (error) {
69+
throw new Error(`Failed to decode the token: ${error}`);
6170
}
71+
72+
return {
73+
token,
74+
expiresOnTimestamp
75+
};
6276
} catch (error) {
63-
throw new Error(`Failed to decode the token: ${error}`);
77+
throw new CredentialUnavailableError(
78+
`Failed to get token from Azure DevOps Codespaces Authentication: ${error.message}`
79+
);
6480
}
65-
66-
return {
67-
token,
68-
expiresOnTimestamp
69-
};
7081
}
7182

7283
private _decodeToken(token: string): IDecodedJwt {

rush-plugins/rush-azure-storage-build-cache-plugin/src/AzureAuthenticationBase.ts

+81-37
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@ import {
99
type InteractiveBrowserCredentialInBrowserOptions,
1010
InteractiveBrowserCredential,
1111
type InteractiveBrowserCredentialNodeOptions,
12-
type TokenCredential
12+
type TokenCredential,
13+
ChainedTokenCredential,
14+
VisualStudioCodeCredential,
15+
AzureCliCredential,
16+
AzureDeveloperCliCredential,
17+
AzurePowerShellCredential
1318
} from '@azure/identity';
19+
import type { TokenCredentialOptions } from '@azure/identity';
20+
import { AdoCodespacesAuthCredential } from './AdoCodespacesAuthCredential';
1421
import type { ITerminal } from '@rushstack/terminal';
1522
import { CredentialCache } from '@rushstack/rush-sdk';
1623
// Use a separate import line so the .d.ts file ends up with an `import type { ... }`
1724
// See https://github.com/microsoft/rushstack/issues/3432
1825
import type { ICredentialCacheEntry } from '@rushstack/rush-sdk';
1926
import { PrintUtilities } from '@rushstack/terminal';
20-
import { AdoCodespacesAuthCredential } from './AdoCodespacesAuthCredential';
2127

2228
/**
2329
* @public
@@ -80,7 +86,14 @@ export type AzureEnvironmentName = keyof typeof AzureAuthorityHosts;
8086
/**
8187
* @public
8288
*/
83-
export type LoginFlowType = 'DeviceCode' | 'InteractiveBrowser' | 'AdoCodespacesAuth';
89+
export type LoginFlowType =
90+
| 'DeviceCode'
91+
| 'InteractiveBrowser'
92+
| 'AdoCodespacesAuth'
93+
| 'VisualStudioCode'
94+
| 'AzureCli'
95+
| 'AzureDeveloperCli'
96+
| 'AzurePowerShell';
8497

8598
/**
8699
* @public
@@ -97,13 +110,19 @@ export interface IAzureAuthenticationBaseOptions {
97110
* @defaultValue
98111
* ```json
99112
* {
100-
* "AdoCodespacesAuth": "InteractiveBrowser",
113+
* "AdoCodespacesAuth": "VisualStudioCode",
114+
* "VisualStudioCode": "AzureCli",
115+
* "AzureCli": "AzureDeveloperCli",
116+
* "AzureDeveloperCli": "AzurePowerShell",
117+
* "AzurePowerShell": "InteractiveBrowser",
101118
* "InteractiveBrowser": "DeviceCode",
102-
* "DeviceCode": null
119+
* "DeviceCode": undefined
103120
* }
104121
* ```
105122
*/
106-
loginFlowFailover?: Record<LoginFlowType, LoginFlowType | undefined>;
123+
loginFlowFailover?: {
124+
[key in LoginFlowType]?: LoginFlowType;
125+
};
107126
}
108127

109128
/**
@@ -128,7 +147,11 @@ export abstract class AzureAuthenticationBase {
128147

129148
protected readonly _azureEnvironment: AzureEnvironmentName;
130149
protected readonly _loginFlow: LoginFlowType;
131-
protected readonly _failoverOrder: Record<LoginFlowType, LoginFlowType | undefined>;
150+
protected readonly _failoverOrder:
151+
| {
152+
[key in LoginFlowType]?: LoginFlowType;
153+
}
154+
| undefined;
132155

133156
private __credentialCacheId: string | undefined;
134157
protected get _credentialCacheId(): string {
@@ -148,13 +171,17 @@ export abstract class AzureAuthenticationBase {
148171
public constructor(options: IAzureAuthenticationBaseOptions) {
149172
const {
150173
azureEnvironment = 'AzurePublicCloud',
151-
loginFlow = process.env.CODESPACES === 'true' ? 'AdoCodespacesAuth' : 'InteractiveBrowser'
174+
loginFlow = process.env.CODESPACES === 'true' ? 'AdoCodespacesAuth' : 'VisualStudioCode'
152175
} = options;
153176
this._azureEnvironment = azureEnvironment;
154177
this._credentialUpdateCommandForLogging = options.credentialUpdateCommandForLogging;
155178
this._loginFlow = loginFlow;
156179
this._failoverOrder = options.loginFlowFailover || {
157-
AdoCodespacesAuth: 'InteractiveBrowser',
180+
AdoCodespacesAuth: 'VisualStudioCode',
181+
VisualStudioCode: 'AzureCli',
182+
AzureCli: 'AzureDeveloperCli',
183+
AzureDeveloperCli: 'AzurePowerShell',
184+
AzurePowerShell: 'InteractiveBrowser',
158185
InteractiveBrowser: 'DeviceCode',
159186
DeviceCode: undefined
160187
};
@@ -294,8 +321,6 @@ export abstract class AzureAuthenticationBase {
294321
throw new Error(`Unexpected Azure environment: ${this._azureEnvironment}`);
295322
}
296323

297-
let tokenCredential: TokenCredential;
298-
299324
const interactiveCredentialOptions: (
300325
| InteractiveBrowserCredentialNodeOptions
301326
| InteractiveBrowserCredentialInBrowserOptions
@@ -305,40 +330,59 @@ export abstract class AzureAuthenticationBase {
305330
authorityHost
306331
};
307332

308-
switch (loginFlow) {
309-
case 'AdoCodespacesAuth': {
310-
tokenCredential = new AdoCodespacesAuthCredential();
311-
break;
312-
}
313-
case 'InteractiveBrowser': {
314-
tokenCredential = new InteractiveBrowserCredential(interactiveCredentialOptions);
315-
break;
333+
const deviceCodeCredentialOptions: DeviceCodeCredentialOptions = {
334+
...this._additionalDeviceCodeCredentialOptions,
335+
...interactiveCredentialOptions,
336+
userPromptCallback: (deviceCodeInfo: DeviceCodeInfo) => {
337+
PrintUtilities.printMessageInBox(deviceCodeInfo.message, terminal);
316338
}
317-
case 'DeviceCode': {
318-
tokenCredential = new DeviceCodeCredential({
319-
...interactiveCredentialOptions,
320-
userPromptCallback: (deviceCodeInfo: DeviceCodeInfo) => {
321-
PrintUtilities.printMessageInBox(deviceCodeInfo.message, terminal);
322-
}
323-
});
324-
break;
325-
}
326-
default: {
327-
throw new Error(`Unsupported login flow: ${loginFlow}`);
339+
};
340+
341+
const options: TokenCredentialOptions = { authorityHost };
342+
const priority: Set<LoginFlowType> = new Set([loginFlow]);
343+
for (const credType of priority) {
344+
const next: LoginFlowType | undefined = this._failoverOrder?.[credType];
345+
if (next) {
346+
priority.add(next);
328347
}
329348
}
330349

350+
const knownCredentialTypes: Record<
351+
LoginFlowType,
352+
new (options: TokenCredentialOptions) => TokenCredential
353+
> = {
354+
DeviceCode: class extends DeviceCodeCredential {
355+
public new(credentialOptions: DeviceCodeCredentialOptions): DeviceCodeCredential {
356+
return new DeviceCodeCredential({
357+
...deviceCodeCredentialOptions,
358+
...credentialOptions
359+
});
360+
}
361+
},
362+
InteractiveBrowser: class extends InteractiveBrowserCredential {
363+
public new(credentialOptions: InteractiveBrowserCredentialNodeOptions): InteractiveBrowserCredential {
364+
return new InteractiveBrowserCredential({ ...interactiveCredentialOptions, ...credentialOptions });
365+
}
366+
},
367+
AdoCodespacesAuth: AdoCodespacesAuthCredential,
368+
VisualStudioCode: VisualStudioCodeCredential,
369+
AzureCli: AzureCliCredential,
370+
AzureDeveloperCli: AzureDeveloperCliCredential,
371+
AzurePowerShell: AzurePowerShellCredential
372+
};
373+
374+
const credentials: TokenCredential[] = Array.from(
375+
priority,
376+
(credType) => new knownCredentialTypes[credType](options)
377+
);
378+
379+
const tokenCredential: TokenCredential = new ChainedTokenCredential(...credentials);
380+
331381
try {
332382
return await this._getCredentialFromTokenAsync(terminal, tokenCredential, credentialsCache);
333383
} catch (error) {
334384
terminal.writeVerbose(`Failed to get credentials with ${loginFlow}: ${error}`);
335-
const fallbackFlow: LoginFlowType | undefined = this._failoverOrder[loginFlow];
336-
if (fallbackFlow) {
337-
terminal.writeVerbose(`Falling back to ${fallbackFlow} login flow`);
338-
return this._getCredentialAsync(terminal, fallbackFlow, credentialsCache);
339-
} else {
340-
throw error;
341-
}
385+
throw error;
342386
}
343387
}
344388
}

0 commit comments

Comments
 (0)