Skip to content

Commit 9a5130f

Browse files
authored
Merge pull request #3705 from iclanton/rush-azure-storage-plugin-refactor
[rush] Refactor @rushstack/rush-azure-storage-build-cache-plugin to expose an API for generating and caching Azure credentials for other workloads, in addition to Storage.
2 parents c0168b6 + 759bdca commit 9a5130f

8 files changed

+274
-146
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Refactor @rushstack/rush-azure-storage-build-cache-plugin to expose an API for generating and caching Azure credentials for other workloads, in addition to Storage.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

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

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,69 @@
55
```ts
66

77
import { AzureAuthorityHosts } from '@azure/identity';
8+
import { DeviceCodeCredential } from '@azure/identity';
89
import type { IRushPlugin } from '@rushstack/rush-sdk';
910
import type { ITerminal } from '@rushstack/node-core-library';
1011
import type { RushConfiguration } from '@rushstack/rush-sdk';
1112
import type { RushSession } from '@rushstack/rush-sdk';
1213

14+
// @public (undocumented)
15+
export abstract class AzureAuthenticationBase {
16+
constructor(options: IAzureAuthenticationBaseOptions);
17+
// (undocumented)
18+
protected readonly _azureEnvironment: AzureEnvironmentName;
19+
// (undocumented)
20+
protected abstract readonly _credentialKindForLogging: string;
21+
// (undocumented)
22+
protected abstract readonly _credentialNameForCache: string;
23+
// (undocumented)
24+
protected abstract readonly _credentialUpdateCommandForLogging: string | undefined;
25+
// (undocumented)
26+
deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void>;
27+
protected abstract _getCacheIdParts(): string[];
28+
// (undocumented)
29+
protected abstract _getCredentialFromDeviceCodeAsync(terminal: ITerminal, deviceCodeCredential: DeviceCodeCredential): Promise<ICredentialResult>;
30+
// (undocumented)
31+
tryGetCachedCredentialAsync(doNotThrowIfExpired?: boolean): Promise<string | undefined>;
32+
// (undocumented)
33+
updateCachedCredentialAsync(terminal: ITerminal, credential: string): Promise<void>;
34+
updateCachedCredentialInteractiveAsync(terminal: ITerminal, onlyIfExistingCredentialExpiresAfter?: Date): Promise<void>;
35+
}
36+
1337
// @public (undocumented)
1438
export type AzureEnvironmentName = keyof typeof AzureAuthorityHosts;
1539

1640
// @public (undocumented)
17-
export class AzureStorageAuthentication {
41+
export class AzureStorageAuthentication extends AzureAuthenticationBase {
1842
constructor(options: IAzureStorageAuthenticationOptions);
1943
// (undocumented)
20-
protected readonly _azureEnvironment: AzureEnvironmentName;
44+
protected readonly _credentialKindForLogging: string;
2145
// (undocumented)
22-
deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void>;
46+
protected readonly _credentialNameForCache: string;
47+
// (undocumented)
48+
protected readonly _credentialUpdateCommandForLogging: string;
49+
// (undocumented)
50+
protected _getCacheIdParts(): string[];
51+
// (undocumented)
52+
protected _getCredentialFromDeviceCodeAsync(terminal: ITerminal, deviceCodeCredential: DeviceCodeCredential): Promise<ICredentialResult>;
2353
// (undocumented)
2454
protected readonly _isCacheWriteAllowedByConfiguration: boolean;
2555
// (undocumented)
2656
protected readonly _storageAccountName: string;
2757
// (undocumented)
28-
protected get _storageAccountUrl(): string;
58+
protected readonly _storageAccountUrl: string;
2959
// (undocumented)
3060
protected readonly _storageContainerName: string;
31-
// (undocumented)
32-
tryGetCachedCredentialAsync(): Promise<string | undefined>;
33-
// (undocumented)
34-
updateCachedCredentialAsync(terminal: ITerminal, credential: string): Promise<void>;
35-
updateCachedCredentialInteractiveAsync(terminal: ITerminal, onlyIfExistingCredentialExpiresAfter?: Date): Promise<void>;
3661
}
3762

3863
// @public (undocumented)
39-
export interface IAzureStorageAuthenticationOptions {
64+
export interface IAzureAuthenticationBaseOptions {
4065
// (undocumented)
4166
azureEnvironment?: AzureEnvironmentName;
67+
}
68+
69+
// @public (undocumented)
70+
export interface IAzureStorageAuthenticationOptions extends IAzureAuthenticationBaseOptions {
4271
// (undocumented)
4372
isCacheWriteAllowed: boolean;
4473
// (undocumented)
@@ -47,6 +76,14 @@ export interface IAzureStorageAuthenticationOptions {
4776
storageContainerName: string;
4877
}
4978

79+
// @public (undocumented)
80+
export interface ICredentialResult {
81+
// (undocumented)
82+
credentialString: string;
83+
// (undocumented)
84+
expiresOn: Date | undefined;
85+
}
86+
5087
// @public (undocumented)
5188
class RushAzureStorageBuildCachePlugin implements IRushPlugin {
5289
// (undocumented)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { DeviceCodeCredential, type DeviceCodeInfo, AzureAuthorityHosts } from '@azure/identity';
5+
import type { ITerminal } from '@rushstack/node-core-library';
6+
import { CredentialCache, type ICredentialCacheEntry } from '@rushstack/rush-sdk';
7+
import { PrintUtilities } from '@rushstack/terminal';
8+
9+
/**
10+
* @public
11+
*/
12+
export type AzureEnvironmentName = keyof typeof AzureAuthorityHosts;
13+
14+
/**
15+
* @public
16+
*/
17+
export interface IAzureAuthenticationBaseOptions {
18+
azureEnvironment?: AzureEnvironmentName;
19+
}
20+
21+
/**
22+
* @public
23+
*/ export interface ICredentialResult {
24+
credentialString: string;
25+
expiresOn: Date | undefined;
26+
}
27+
28+
/**
29+
* @public
30+
*/
31+
export abstract class AzureAuthenticationBase {
32+
protected abstract readonly _credentialNameForCache: string;
33+
protected abstract readonly _credentialKindForLogging: string;
34+
protected abstract readonly _credentialUpdateCommandForLogging: string | undefined;
35+
36+
protected readonly _azureEnvironment: AzureEnvironmentName;
37+
38+
private __credentialCacheId: string | undefined;
39+
private get _credentialCacheId(): string {
40+
if (!this.__credentialCacheId) {
41+
const cacheIdParts: string[] = [
42+
this._credentialNameForCache,
43+
this._azureEnvironment,
44+
...this._getCacheIdParts()
45+
];
46+
47+
this.__credentialCacheId = cacheIdParts.join('|');
48+
}
49+
50+
return this.__credentialCacheId;
51+
}
52+
53+
public constructor(options: IAzureAuthenticationBaseOptions) {
54+
this._azureEnvironment = options.azureEnvironment || 'AzurePublicCloud';
55+
}
56+
57+
public async updateCachedCredentialAsync(terminal: ITerminal, credential: string): Promise<void> {
58+
await CredentialCache.usingAsync(
59+
{
60+
supportEditing: true
61+
},
62+
async (credentialsCache: CredentialCache) => {
63+
credentialsCache.setCacheEntry(this._credentialCacheId, credential);
64+
await credentialsCache.saveIfModifiedAsync();
65+
}
66+
);
67+
}
68+
69+
/**
70+
* Launches an interactive flow to renew a cached credential.
71+
*
72+
* @param terminal - The terminal to log output to
73+
* @param onlyIfExistingCredentialExpiresAfter - If specified, and a cached credential exists that is still valid
74+
* after the date specified, no action will be taken.
75+
*/
76+
public async updateCachedCredentialInteractiveAsync(
77+
terminal: ITerminal,
78+
onlyIfExistingCredentialExpiresAfter?: Date
79+
): Promise<void> {
80+
await CredentialCache.usingAsync(
81+
{
82+
supportEditing: true
83+
},
84+
async (credentialsCache: CredentialCache) => {
85+
if (onlyIfExistingCredentialExpiresAfter) {
86+
const existingCredentialExpiration: Date | undefined = credentialsCache.tryGetCacheEntry(
87+
this._credentialCacheId
88+
)?.expires;
89+
if (
90+
existingCredentialExpiration &&
91+
existingCredentialExpiration > onlyIfExistingCredentialExpiresAfter
92+
) {
93+
return;
94+
}
95+
}
96+
97+
const credential: ICredentialResult = await this._getCredentialAsync(terminal);
98+
credentialsCache.setCacheEntry(
99+
this._credentialCacheId,
100+
credential.credentialString,
101+
credential.expiresOn
102+
);
103+
await credentialsCache.saveIfModifiedAsync();
104+
}
105+
);
106+
}
107+
108+
public async deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void> {
109+
await CredentialCache.usingAsync(
110+
{
111+
supportEditing: true
112+
},
113+
async (credentialsCache: CredentialCache) => {
114+
credentialsCache.deleteCacheEntry(this._credentialCacheId);
115+
await credentialsCache.saveIfModifiedAsync();
116+
}
117+
);
118+
}
119+
120+
public async tryGetCachedCredentialAsync(doNotThrowIfExpired?: boolean): Promise<string | undefined> {
121+
let cacheEntry: ICredentialCacheEntry | undefined;
122+
await CredentialCache.usingAsync(
123+
{
124+
supportEditing: false
125+
},
126+
(credentialsCache: CredentialCache) => {
127+
cacheEntry = credentialsCache.tryGetCacheEntry(this._credentialCacheId);
128+
}
129+
);
130+
131+
const expirationTime: number | undefined = cacheEntry?.expires?.getTime();
132+
if (expirationTime && expirationTime < Date.now()) {
133+
if (!doNotThrowIfExpired) {
134+
let errorMessage: string = `Cached Azure ${this._credentialKindForLogging} credentials have expired.`;
135+
if (this._credentialUpdateCommandForLogging) {
136+
errorMessage += ` Update the credentials by running "${this._credentialUpdateCommandForLogging}".`;
137+
}
138+
139+
throw new Error(errorMessage);
140+
} else {
141+
return undefined;
142+
}
143+
} else {
144+
return cacheEntry?.credential;
145+
}
146+
}
147+
148+
/**
149+
* Get parts of the cache ID that are specific to the credential type. Note that this should
150+
* not contain the Azure environment or the {@link AzureAuthenticationBase._credentialNameForCache}
151+
* value, as those are added automatically.
152+
*/
153+
protected abstract _getCacheIdParts(): string[];
154+
155+
protected abstract _getCredentialFromDeviceCodeAsync(
156+
terminal: ITerminal,
157+
deviceCodeCredential: DeviceCodeCredential
158+
): Promise<ICredentialResult>;
159+
160+
private async _getCredentialAsync(terminal: ITerminal): Promise<ICredentialResult> {
161+
const authorityHost: string | undefined = AzureAuthorityHosts[this._azureEnvironment];
162+
if (!authorityHost) {
163+
throw new Error(`Unexpected Azure environment: ${this._azureEnvironment}`);
164+
}
165+
166+
const deviceCodeCredential: DeviceCodeCredential = new DeviceCodeCredential({
167+
authorityHost: authorityHost,
168+
userPromptCallback: (deviceCodeInfo: DeviceCodeInfo) => {
169+
PrintUtilities.printMessageInBox(deviceCodeInfo.message, terminal);
170+
}
171+
});
172+
173+
return await this._getCredentialFromDeviceCodeAsync(terminal, deviceCodeCredential);
174+
}
175+
}

0 commit comments

Comments
 (0)