Skip to content

Commit 7c96816

Browse files
feat: support MFA enforcement for CLI (#14626)
* feat: support mfa setup flow for 401 in vsc atk * feat: display friendly msg for cli since cli doesnt support mfa enforcement yet * feat: block users for vs atk since vs doesnt support mfa enforcement yet * feat: adapt interface to support mfa enforcement for cli * feat: support clamis-challenge parameter * feat: pass claims challenge in interactive flow * fix: ut * test: add ut to fix code coverage * fix: alert
1 parent 73d8488 commit 7c96816

File tree

11 files changed

+162
-46
lines changed

11 files changed

+162
-46
lines changed

packages/api/src/utils/login.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT license.
33
"use strict";
44

5-
import { TokenCredential } from "@azure/core-auth";
5+
import { AccessToken } from "@azure/core-auth";
66
import { ok, Result } from "neverthrow";
77
import { FxError } from "../error";
88

@@ -57,6 +57,19 @@ export type AzureCredential =
5757
certificatePath: string;
5858
};
5959

60+
export interface ITeamsFxTokenCredential {
61+
/**
62+
* Gets the token provided by this credential.
63+
*
64+
* @param scopes - The list of scopes for which the token will have access. wwwAuthenticate is passed to handle 401 error due to MFA enforcement
65+
* @param options - The options used to configure any requests this TokenCredential implementation might make.
66+
*/
67+
getToken(
68+
scopes: string | string[] | AuthenticationWWWAuthenticateRequest,
69+
options?: any
70+
): Promise<AccessToken | null>;
71+
}
72+
6073
export interface AuthenticationWWWAuthenticateRequest {
6174
/**
6275
* The raw WWW-Authenticate header value that triggered this challenge.
@@ -69,7 +82,7 @@ export interface AuthenticationWWWAuthenticateRequest {
6982
* Optional scopes for the session. If not provided, the authentication provider
7083
* may use default scopes or extract them from the challenge.
7184
*/
72-
readonly scopes?: readonly string[];
85+
readonly scopes?: string[];
7386
}
7487

7588
/**
@@ -84,14 +97,14 @@ export interface AzureAccountProvider {
8497
getIdentityCredentialAsync(
8598
showDialog?: boolean,
8699
authenticationSessionRequest?: AuthenticationWWWAuthenticateRequest
87-
): Promise<TokenCredential | undefined>;
100+
): Promise<ITeamsFxTokenCredential | undefined>;
88101

89102
/**
90103
* To support credential per action feature, caller can specify credential info for on demand
91104
* This method will be optional until V3 first release, after that it will be changed to required
92105
* @param credential
93106
*/
94-
getIdentityCredential?(credential: AzureCredential): Promise<TokenCredential | undefined>;
107+
getIdentityCredential?(credential: AzureCredential): Promise<ITeamsFxTokenCredential | undefined>;
95108

96109
/**
97110
* Azure sign out
@@ -101,7 +114,7 @@ export interface AzureAccountProvider {
101114
* Switch to specified tenant for current user account
102115
* @param tenantId id of tenant that user wants to switch to
103116
*/
104-
switchTenant(tenantId: string): Promise<Result<TokenCredential, FxError>>;
117+
switchTenant(tenantId: string): Promise<Result<ITeamsFxTokenCredential, FxError>>;
105118
/**
106119
* Add update account info callback
107120
* @param name callback name

packages/cli/src/commands/models/accountLoginAzure.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export const accountLoginAzureCommand: CLICommand = {
4343
type: "string",
4444
default: "",
4545
},
46+
{
47+
name: "claims-challenge",
48+
description: commands["auth.login.azure"].options["claims-challenge"],
49+
type: "string",
50+
default: "",
51+
},
4652
],
4753
examples: [
4854
{
@@ -75,7 +81,8 @@ export const accountLoginAzureCommand: CLICommand = {
7581
args.tenant as string,
7682
isSP,
7783
args.username as string,
78-
args.password as string
84+
args.password as string,
85+
args["claims-challenge"] as string
7986
);
8087
return ok(undefined);
8188
},

packages/cli/src/commands/models/accountShow.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,15 @@ class AccountUtils {
7575
tenantId = "",
7676
isServicePrincipal = false,
7777
userName = "",
78-
password = ""
78+
password = "",
79+
claimsChallenge = ""
7980
): Promise<boolean> {
8081
let azureProvider = getAzureProvider();
8182
if (isServicePrincipal === true || (await AzureTokenCIProvider.load())) {
8283
await AzureTokenCIProvider.init(userName, password, tenantId);
8384
azureProvider = AzureTokenCIProvider;
8485
}
85-
const result = await azureProvider.getJsonObject(true, tenantId);
86+
const result = await azureProvider.getJsonObject(true, tenantId, claimsChallenge);
8687
if (result) {
8788
if (tenantId) {
8889
await azureProvider.switchTenant(tenantId);

packages/cli/src/commonlib/azureLogin.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
AzureAccountProvider,
1212
ConfigFolderName,
1313
FxError,
14+
ITeamsFxTokenCredential,
1415
LogLevel as LLevel,
1516
ok,
1617
OptionItem,
@@ -66,6 +67,7 @@ function getConfig(tenantId?: string) {
6667
auth: {
6768
clientId: "7ea7c24c-b1f6-4a20-9d11-9ae12e9e7ac0",
6869
authority: authority,
70+
clientCapabilities: ["CP1"],
6971
},
7072
system: {
7173
loggerOptions: {
@@ -89,7 +91,7 @@ function getConfig(tenantId?: string) {
8991
// @ts-ignore
9092
const memoryDictionary: { [tenantId: string]: MemoryCache } = {};
9193

92-
class TeamsFxTokenCredential implements TokenCredential {
94+
class TeamsFxTokenCredential implements ITeamsFxTokenCredential {
9395
private codeFlowInstance: CodeFlowLogin;
9496
private tenantId: string;
9597

@@ -103,17 +105,11 @@ class TeamsFxTokenCredential implements TokenCredential {
103105
}
104106

105107
async getToken(
106-
scopes: string | string[],
107-
options?: GetTokenOptions | undefined
108+
scopes: string | string[] | AuthenticationWWWAuthenticateRequest,
109+
options?: any
108110
): Promise<AccessToken | null> {
109-
let myScopes: string[] = [];
110-
if (typeof scopes === "string") {
111-
myScopes = [scopes];
112-
} else {
113-
myScopes = scopes;
114-
}
115111
const tokenRes: Result<string, FxError> = await this.codeFlowInstance.getTokenByScopes(
116-
myScopes,
112+
scopes,
117113
true,
118114
this.tenantId
119115
);
@@ -180,14 +176,19 @@ export class AzureAccountManager extends login implements AzureAccountProvider {
180176
getIdentityCredentialAsync(
181177
showDialog = true,
182178
authenticationSessionRequest?: AuthenticationWWWAuthenticateRequest
183-
): Promise<TokenCredential | undefined> {
179+
): Promise<TeamsFxTokenCredential | undefined> {
184180
if (authenticationSessionRequest && authenticationSessionRequest.wwwAuthenticate) {
181+
const claimsChallenge = parseChallenges(authenticationSessionRequest.wwwAuthenticate).claims;
182+
CLILogProvider.necessaryLog(
183+
LLevel.Warning,
184+
`Run the command below to authenticate interactively; additional arguments may be added as needed:\n atk auth login --claims-challenge ${claimsChallenge}`
185+
);
185186
throw new MFARequiredError(cliSource);
186187
}
187188
return Promise.resolve(AzureAccountManager.teamsFxTokenCredential);
188189
}
189190

190-
async switchTenant(tenantId: string): Promise<Result<TokenCredential, FxError>> {
191+
async switchTenant(tenantId: string): Promise<Result<TeamsFxTokenCredential, FxError>> {
191192
await saveTenantId(accountName, tenantId);
192193
return Promise.resolve(ok(AzureAccountManager.teamsFxTokenCredential));
193194
}
@@ -254,10 +255,11 @@ export class AzureAccountManager extends login implements AzureAccountProvider {
254255

255256
async getJsonObject(
256257
showDialog = true,
257-
tenantId?: string
258+
tenantId?: string,
259+
claimsChallenge?: string
258260
): Promise<Record<string, unknown> | undefined> {
259261
const token = await AzureAccountManager.codeFlowInstance.getTokenByScopes(
260-
AzureScopes,
262+
claimsChallenge ? { scopes: AzureScopes, wwwAuthenticate: claimsChallenge } : AzureScopes,
261263
true,
262264
tenantId
263265
);
@@ -564,6 +566,7 @@ async function listAll<T>(
564566
import AzureLoginCI from "./azureLoginCI";
565567
import AzureAccountProviderUserPassword from "./azureLoginUserPassword";
566568
import { cliSource } from "../constants";
569+
import { parseChallenges } from "./common/utils";
567570

568571
// todo delete ciEnabled
569572
const azureLogin = !ui.interactive

packages/cli/src/commonlib/azureLoginCI.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
SubscriptionInfo,
1818
signedIn,
1919
signedOut,
20+
ITeamsFxTokenCredential,
2021
} from "@microsoft/teamsfx-api";
2122
import { LoginStatus, login } from "./common/login";
2223

@@ -32,7 +33,7 @@ import { ConvertTokenToJson } from "./codeFlowLogin";
3233
* Prepare for service principal login, not fully implemented
3334
*/
3435
export class AzureAccountManager extends login implements AzureAccountProvider {
35-
static tokenCredential: TokenCredential;
36+
static tokenCredential: ITeamsFxTokenCredential;
3637

3738
private static subscriptionId: string | undefined;
3839

@@ -91,7 +92,7 @@ export class AzureAccountManager extends login implements AzureAccountProvider {
9192
return false;
9293
}
9394

94-
async getIdentityCredentialAsync(): Promise<TokenCredential | undefined> {
95+
async getIdentityCredentialAsync(): Promise<ITeamsFxTokenCredential | undefined> {
9596
await this.load();
9697
if (AzureAccountManager.tokenCredential == undefined) {
9798
if (await fs.pathExists(AzureAccountManager.secret)) {
@@ -125,7 +126,7 @@ export class AzureAccountManager extends login implements AzureAccountProvider {
125126
return true;
126127
}
127128

128-
switchTenant(tenantId: string): Promise<Result<TokenCredential, FxError>> {
129+
switchTenant(tenantId: string): Promise<Result<ITeamsFxTokenCredential, FxError>> {
129130
return Promise.resolve(ok(AzureAccountManager.tokenCredential));
130131
}
131132

packages/cli/src/commonlib/codeFlowLogin.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22
// Licensed under the MIT license.
33

44
import { AccountInfo, Configuration, PublicClientApplication, TokenCache } from "@azure/msal-node";
5-
import { FxError, LogLevel, Result, SystemError, UserError, err, ok } from "@microsoft/teamsfx-api";
5+
import {
6+
AuthenticationWWWAuthenticateRequest,
7+
FxError,
8+
LogLevel,
9+
Result,
10+
SystemError,
11+
UserError,
12+
err,
13+
ok,
14+
} from "@microsoft/teamsfx-api";
615
import { Mutex } from "async-mutex";
716
import * as crypto from "crypto";
817
import express from "express";
@@ -28,15 +37,9 @@ import {
2837
saveAccountId,
2938
saveTenantId,
3039
} from "./cacheAccess";
31-
import {
32-
MFACode,
33-
azureLoginMessage,
34-
env,
35-
m365LoginMessage,
36-
sendFileTimeout,
37-
} from "./common/constant";
40+
import { azureLoginMessage, env, m365LoginMessage, sendFileTimeout } from "./common/constant";
3841
import CliCodeLogInstance from "./log";
39-
import { featureFlagManager, FeatureFlags } from "@microsoft/teamsfx-core";
42+
import { decodeClaimsChallenge } from "./common/utils";
4043

4144
export class ErrorMessage {
4245
static readonly loginFailureTitle = "LoginFail";
@@ -106,10 +109,22 @@ export class CodeFlowLogin {
106109
}
107110
}
108111

109-
async login(scopes: Array<string>, tenantId?: string): Promise<string> {
112+
async login(
113+
requestScopes: Array<string> | AuthenticationWWWAuthenticateRequest,
114+
tenantId?: string
115+
): Promise<string> {
110116
CliTelemetry.sendTelemetryEvent(TelemetryEvent.AccountLoginStart, {
111117
[TelemetryProperty.AccountType]: this.accountName,
112118
});
119+
let scopes: string[];
120+
let claim = undefined;
121+
if (typeof requestScopes === "object" && "wwwAuthenticate" in requestScopes) {
122+
scopes = requestScopes.scopes ?? [];
123+
claim = decodeClaimsChallenge(requestScopes.wwwAuthenticate);
124+
} else {
125+
scopes = requestScopes;
126+
}
127+
113128
const codeVerifier = CodeFlowLogin.toBase64UrlEncoding(
114129
crypto.randomBytes(32).toString("base64")
115130
);
@@ -143,6 +158,7 @@ export class CodeFlowLogin {
143158
redirectUri: `http://localhost:${serverPort}`,
144159
prompt: "select_account",
145160
authority: authority,
161+
claims: claim,
146162
};
147163

148164
let deferredRedirect: Deferred<string>;
@@ -265,7 +281,7 @@ export class CodeFlowLogin {
265281
}
266282

267283
async getTokenByScopes(
268-
scopes: Array<string>,
284+
scopes: string | string[] | AuthenticationWWWAuthenticateRequest,
269285
refresh = true,
270286
tenantId?: string
271287
): Promise<Result<string, FxError>> {
@@ -276,10 +292,23 @@ export class CodeFlowLogin {
276292
if (!tenantId) {
277293
tenantId = await loadTenantId(this.accountName);
278294
}
295+
279296
if (!this.account) {
280-
const accessToken = await this.login(scopes, tenantId);
297+
const accessToken = await this.login(
298+
typeof scopes === "string" ? [scopes] : scopes,
299+
tenantId
300+
);
281301
return ok(accessToken);
282302
} else {
303+
let myScopes: string[] = [];
304+
if (typeof scopes === "string") {
305+
myScopes = [scopes];
306+
} else if (typeof scopes === "object" && "wwwAuthenticate" in scopes) {
307+
myScopes = (scopes as AuthenticationWWWAuthenticateRequest).scopes ?? [];
308+
} else {
309+
myScopes = scopes;
310+
}
311+
283312
let tenantedAccount: AccountInfo | undefined = undefined;
284313
if (tenantId) {
285314
const allAccounts = await this.msalTokenCache.getAllAccounts();
@@ -289,7 +318,7 @@ export class CodeFlowLogin {
289318
try {
290319
const res = await this.pca.acquireTokenSilent({
291320
account: this.account,
292-
scopes: scopes,
321+
scopes: myScopes,
293322
forceRefresh: tenantedAccount ? false : true,
294323
authority: tenantId
295324
? env.activeDirectoryEndpointUrl + tenantId
@@ -313,7 +342,7 @@ export class CodeFlowLogin {
313342
}
314343
await this.logout();
315344
if (refresh) {
316-
const accessToken = await this.login(scopes, tenantId);
345+
const accessToken = await this.login(myScopes, tenantId);
317346
return ok(accessToken);
318347
}
319348
return err(LoginCodeFlowError(error));
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export function parseChallenges(header: string) {
5+
const schemeSeparator = header.indexOf(" ");
6+
const challenges = header.substring(schemeSeparator + 1).split(",");
7+
const challengeMap: { [key: string]: string } = {};
8+
9+
challenges.forEach((challenge) => {
10+
const [key, value] = challenge.split("=");
11+
challengeMap[key.trim()] = decodeURI(value.replace(/['"]+/g, ""));
12+
});
13+
return challengeMap;
14+
}
15+
16+
export function decodeClaimsChallenge(encodedClaims: string): string | undefined {
17+
try {
18+
return Buffer.from(encodedClaims, "base64").toString("utf8");
19+
} catch (e) {}
20+
return undefined;
21+
}

packages/cli/src/resource/commands.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
"tenant": "Authenticate with a specific Microsoft Entra tenant.",
2929
"service-principal": "Authenticate Azure with a credential representing a service principal",
3030
"username": "Client ID for service principal",
31-
"password": "Provide client secret or a pem file with key and public certificate."
31+
"password": "Provide client secret or a pem file with key and public certificate.",
32+
"claims-challenge": "Base64-encoded claims challenge requested by a resource API in the WWW-Authenticate header."
3233
}
3334
},
3435
"auth.login.m365": {

0 commit comments

Comments
 (0)