Skip to content

Commit 02ba62f

Browse files
authored
Workaround MSAL.NET issue with MSA-PT account silent auth (#1321)
When we have a Microsoft Account (MSA) in the cache and attempt to do a silent authentication, if we're an MSA-PT app we need to specify the special MSA transfer tenant ID to make sure we get the a token silently, correctly. See the [issue](AzureAD/microsoft-authentication-library-for-dotnet#3077) in the MSAL repo for more information. Fixes: #1297
2 parents 2fcbd77 + 725ab49 commit 02ba62f

File tree

5 files changed

+70
-23
lines changed

5 files changed

+70
-23
lines changed

src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public async System.Threading.Tasks.Task MicrosoftAuthentication_GetAccessTokenA
2424
var msAuth = new MicrosoftAuthentication(context);
2525

2626
await Assert.ThrowsAsync<Trace2InvalidOperationException>(
27-
() => msAuth.GetTokenAsync(authority, clientId, redirectUri, scopes, userName));
27+
() => msAuth.GetTokenAsync(authority, clientId, redirectUri, scopes, userName, false));
2828
}
2929
}
3030
}

src/shared/Core/Authentication/MicrosoftAuthentication.cs

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.IO;
4+
using System.Linq;
45
using System.Net.Http;
56
using System.Threading.Tasks;
67
using GitCredentialManager.Interop.Windows.Native;
@@ -23,7 +24,7 @@ namespace GitCredentialManager.Authentication
2324
public interface IMicrosoftAuthentication
2425
{
2526
Task<IMicrosoftAuthenticationResult> GetTokenAsync(string authority, string clientId, Uri redirectUri,
26-
string[] scopes, string userName);
27+
string[] scopes, string userName, bool msaPt = false);
2728
}
2829

2930
public interface IMicrosoftAuthenticationResult
@@ -59,26 +60,31 @@ public MicrosoftAuthentication(ICommandContext context)
5960
#region IMicrosoftAuthentication
6061

6162
public async Task<IMicrosoftAuthenticationResult> GetTokenAsync(
62-
string authority, string clientId, Uri redirectUri, string[] scopes, string userName)
63+
string authority, string clientId, Uri redirectUri, string[] scopes, string userName, bool msaPt)
6364
{
6465
// Check if we can and should use OS broker authentication
6566
bool useBroker = CanUseBroker();
6667
Context.Trace.WriteLine(useBroker
6768
? "OS broker is available and enabled."
6869
: "OS broker is not available or enabled.");
6970

71+
if (msaPt)
72+
{
73+
Context.Trace.WriteLine("MSA passthrough is enabled.");
74+
}
75+
7076
try
7177
{
7278
// Create the public client application for authentication
73-
IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri, useBroker);
79+
IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri, useBroker, msaPt);
7480

7581
AuthenticationResult result = null;
7682

7783
// Try silent authentication first if we know about an existing user
7884
bool hasExistingUser = !string.IsNullOrWhiteSpace(userName);
7985
if (hasExistingUser)
8086
{
81-
result = await GetAccessTokenSilentlyAsync(app, scopes, userName);
87+
result = await GetAccessTokenSilentlyAsync(app, scopes, userName, msaPt);
8288
}
8389

8490
//
@@ -116,7 +122,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenAsync(
116122
// account then the user may become stuck in a loop of authentication failures.
117123
if (!hasExistingUser && Context.Settings.UseMsAuthDefaultAccount)
118124
{
119-
result = await GetAccessTokenSilentlyAsync(app, scopes, null);
125+
result = await GetAccessTokenSilentlyAsync(app, scopes, null, msaPt);
120126

121127
if (result is null || !await UseDefaultAccountAsync(result.Account.Username))
122128
{
@@ -281,34 +287,62 @@ internal MicrosoftAuthenticationFlowType GetFlowType()
281287
/// <summary>
282288
/// Obtain an access token without showing UI or prompts.
283289
/// </summary>
284-
private async Task<AuthenticationResult> GetAccessTokenSilentlyAsync(IPublicClientApplication app, string[] scopes, string userName)
290+
private async Task<AuthenticationResult> GetAccessTokenSilentlyAsync(
291+
IPublicClientApplication app, string[] scopes, string userName, bool msaPt)
285292
{
286293
try
287294
{
288295
if (userName is null)
289296
{
290-
Context.Trace.WriteLine("Attempting to acquire token silently for current operating system account...");
297+
Context.Trace.WriteLine(
298+
"Attempting to acquire token silently for current operating system account...");
291299

292-
return await app.AcquireTokenSilent(scopes, PublicClientApplication.OperatingSystemAccount).ExecuteAsync();
300+
return await app.AcquireTokenSilent(scopes, PublicClientApplication.OperatingSystemAccount)
301+
.ExecuteAsync();
293302
}
294303
else
295304
{
296305
Context.Trace.WriteLine($"Attempting to acquire token silently for user '{userName}'...");
297306

298-
// We can either call `app.GetAccountsAsync` and filter through the IAccount objects for the instance with the correct user name,
299-
// or we can just pass the user name string we have as the `loginHint` and let MSAL do exactly that for us instead!
300-
return await app.AcquireTokenSilent(scopes, loginHint: userName).ExecuteAsync();
307+
// Enumerate all accounts and find the one matching the user name
308+
IEnumerable<IAccount> accounts = await app.GetAccountsAsync();
309+
IAccount account = accounts.FirstOrDefault(x =>
310+
StringComparer.OrdinalIgnoreCase.Equals(x.Username, userName));
311+
if (account is null)
312+
{
313+
Context.Trace.WriteLine($"No cached account found for user '{userName}'...");
314+
return null;
315+
}
316+
317+
var atsBuilder = app.AcquireTokenSilent(scopes, account);
318+
319+
// Is we are operating with an MSA passthrough app we need to ensure that we target the
320+
// special MSA 'transfer' tenant explicitly. This is a workaround for MSAL issue:
321+
// https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/3077
322+
if (msaPt && Guid.TryParse(account.HomeAccountId.TenantId, out Guid homeTenantId) &&
323+
homeTenantId == Constants.MsaHomeTenantId)
324+
{
325+
atsBuilder = atsBuilder.WithTenantId(Constants.MsaTransferTenantId.ToString("D"));
326+
}
327+
328+
return await atsBuilder.ExecuteAsync();
301329
}
302330
}
303331
catch (MsalUiRequiredException)
304332
{
305333
Context.Trace.WriteLine("Failed to acquire token silently; user interaction is required.");
306334
return null;
307335
}
336+
catch (Exception ex)
337+
{
338+
Context.Trace.WriteLine("Failed to acquire token silently.");
339+
Context.Trace.WriteException(ex);
340+
return null;
341+
}
308342
}
309343

310344
private async Task<IPublicClientApplication> CreatePublicClientApplicationAsync(
311-
string authority, string clientId, Uri redirectUri, bool enableBroker)
345+
string authority, string clientId, Uri redirectUri, bool enableBroker, bool msaPt)
312346
{
313347
var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory);
314348

@@ -370,7 +404,7 @@ private async Task<IPublicClientApplication> CreatePublicClientApplicationAsync(
370404
new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
371405
{
372406
Title = "Git Credential Manager",
373-
MsaPassthrough = true,
407+
MsaPassthrough = msaPt,
374408
}
375409
);
376410
#endif

src/shared/Core/Constants.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ public static class Constants
1818

1919
public static readonly Guid DevBoxPartnerId = new("e3171dd9-9a5f-e5be-b36c-cc7c4f3f3bcf");
2020

21+
/// <summary>
22+
/// Home tenant ID for Microsoft Accounts (MSA).
23+
/// </summary>
24+
public static readonly Guid MsaHomeTenantId = new("9188040d-6c67-4c5b-b112-36a304b66dad");
25+
26+
/// <summary>
27+
/// Special tenant ID for transferring between Microsoft Account (MSA) native tokens
28+
/// and AAD tokens. Only required for MSA-Passthrough applications.
29+
/// </summary>
30+
public static readonly Guid MsaTransferTenantId = new("f8cdef31-a31e-4b4a-93e4-5f571e91255a");
31+
2132
public static class CredentialStoreNames
2233
{
2334
public const string WindowsCredentialManager = "wincredman";

src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_
170170
azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl);
171171

172172
var msAuthMock = new Mock<IMicrosoftAuthentication>(MockBehavior.Strict);
173-
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount))
173+
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true))
174174
.ReturnsAsync(authResult);
175175

176176
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
@@ -219,7 +219,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_
219219
azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl);
220220

221221
var msAuthMock = new Mock<IMicrosoftAuthentication>(MockBehavior.Strict);
222-
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount))
222+
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true))
223223
.ReturnsAsync(authResult);
224224

225225
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
@@ -268,7 +268,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_
268268
azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl);
269269

270270
var msAuthMock = new Mock<IMicrosoftAuthentication>(MockBehavior.Strict);
271-
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
271+
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true))
272272
.ReturnsAsync(authResult);
273273

274274
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
@@ -315,7 +315,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_
315315
var azDevOpsMock = new Mock<IAzureDevOpsRestApi>(MockBehavior.Strict);
316316

317317
var msAuthMock = new Mock<IMicrosoftAuthentication>(MockBehavior.Strict);
318-
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
318+
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true))
319319
.ReturnsAsync(authResult);
320320

321321
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
@@ -363,7 +363,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_
363363
var azDevOpsMock = new Mock<IAzureDevOpsRestApi>(MockBehavior.Strict);
364364

365365
var msAuthMock = new Mock<IMicrosoftAuthentication>(MockBehavior.Strict);
366-
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, account))
366+
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, account, true))
367367
.ReturnsAsync(authResult);
368368

369369
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
@@ -413,7 +413,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit
413413
azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl);
414414

415415
var msAuthMock = new Mock<IMicrosoftAuthentication>(MockBehavior.Strict);
416-
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
416+
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true))
417417
.ReturnsAsync(authResult);
418418

419419
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
@@ -462,7 +462,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge
462462
.ReturnsAsync(personalAccessToken);
463463

464464
var msAuthMock = new Mock<IMicrosoftAuthentication>(MockBehavior.Strict);
465-
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
465+
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true))
466466
.ReturnsAsync(authResult);
467467

468468
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);

src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ private async Task<ICredential> GeneratePersonalAccessTokenAsync(InputArguments
202202
GetClientId(),
203203
GetRedirectUri(),
204204
AzureDevOpsConstants.AzureDevOpsDefaultScopes,
205-
null);
205+
null,
206+
msaPt: true);
206207
_context.Trace.WriteLineSecrets(
207208
$"Acquired Azure access token. Account='{result.AccountUpn}' Token='{{0}}'", new object[] {result.AccessToken});
208209

@@ -293,7 +294,8 @@ private async Task<IMicrosoftAuthenticationResult> GetAzureAccessTokenAsync(Inpu
293294
GetClientId(),
294295
GetRedirectUri(),
295296
AzureDevOpsConstants.AzureDevOpsDefaultScopes,
296-
userName);
297+
userName,
298+
msaPt: true);
297299
_context.Trace.WriteLineSecrets(
298300
$"Acquired Azure access token. Account='{result.AccountUpn}' Token='{{0}}'", new object[] {result.AccessToken});
299301

0 commit comments

Comments
 (0)