diff --git a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs index 0e1a70659..4fa74f347 100644 --- a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs +++ b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs @@ -16,7 +16,7 @@ public async Task MicrosoftAuthentication_GetTokenForUserAsync_NoInteraction_Thr const string clientId = "C9E8FDA6-1D46-484C-917C-3DBD518F27C3"; Uri redirectUri = new Uri("https://localhost"); string[] scopes = {"user.read"}; - const string userName = null; // No user to ensure we do not use an existing token + IMicrosoftAccount account = null; // No account to ensure we do not use an existing token var context = new TestCommandContext { @@ -26,7 +26,7 @@ public async Task MicrosoftAuthentication_GetTokenForUserAsync_NoInteraction_Thr var msAuth = new MicrosoftAuthentication(context); await Assert.ThrowsAsync( - () => msAuth.GetTokenForUserAsync(authority, clientId, redirectUri, scopes, userName, false)); + () => msAuth.GetTokenForUserAsync(authority, clientId, redirectUri, scopes, account, false)); } [Theory] diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 5d65fa982..2f8634bca 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; using System.Net.Http; -using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using GitCredentialManager.Interop.Windows.Native; using Microsoft.Identity.Client; @@ -25,6 +24,28 @@ namespace GitCredentialManager.Authentication { public interface IMicrosoftAuthentication { + /// + /// Enumerate the user accounts that are available and have cached tokens for the given client. + /// + /// Client ID. + /// Use MSA-Passthrough behavior when constructing the client. + /// User accounts. + Task> GetUserAccountsAsync(string clientId, bool msaPt = false); + + /// + /// Remove a user account from the cache for the given client. + /// + /// Client ID. + /// Account to remove. Resolved against the MSAL cache by + /// when present, otherwise by + /// . + /// Use MSA-Passthrough behavior when constructing the client. + /// + /// if a matching account was found and removed, otherwise + /// . + /// + Task RemoveUserAccountAsync(string clientId, IMicrosoftAccount account, bool msaPt = false); + /// /// Acquire an access token for a user principal. /// @@ -32,11 +53,14 @@ public interface IMicrosoftAuthentication /// Client ID. /// Redirect URI for the client. /// Set of scopes to request. - /// Optional user name for an existing account. + /// Optional account to acquire a token for. + /// lets MSAL pick interactively. When set, resolved against the MSAL cache by + /// when present, otherwise by + /// . /// Use MSA-Passthrough behavior when authenticating. /// Authentication result. Task GetTokenForUserAsync(string authority, string clientId, Uri redirectUri, - string[] scopes, string userName, bool msaPt = false); + string[] scopes, IMicrosoftAccount account, bool msaPt = false); /// /// Acquire an access token for the given service principal with the specified scopes. @@ -44,7 +68,7 @@ Task GetTokenForUserAsync(string authority, stri /// Service principal identity. /// Scopes to request. /// Authentication result. - Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes); + Task GetTokenForServicePrincipalAsync(MicrosoftServicePrincipalIdentity sp, string[] scopes); /// /// Acquire a token using the managed identity in the current environment. @@ -74,52 +98,138 @@ Task GetTokenForUserAsync(string authority, stri Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes); } - public class ServicePrincipalIdentity + public interface IMicrosoftAuthenticationResult { - /// - /// Client ID of the service principal. - /// - public string Id { get; set; } + string AccessToken { get; } + IMicrosoftAccount Account { get; } /// - /// Tenant ID of the service principal. + /// How this token was acquired. Use + /// to fold this down to "did MSAL show the user UI". /// - public string TenantId { get; set; } + MicrosoftAuthenticationFlow Flow { get; } + } + + /// + /// Identifies how a Microsoft authentication result was produced. + /// + public enum MicrosoftAuthenticationFlow + { + /// The flow was not classified by the implementation. + Unknown = 0, + + /// Service principal client credentials grant. + ServicePrincipal, + + /// Azure managed identity (system or user-assigned). + ManagedIdentity, + /// Workload federation (federated identity credentials). + WorkloadFederation, + + /// User-flow token returned silently from MSAL's own cache. + Silent, + + /// User-flow token returned silently from the OS broker. + BrokerSilent, + + /// User-flow token acquired via interactive UI in the OS broker. + BrokerInteractive, + + /// User-flow token acquired via an interactive embedded WebView (.NET Framework only). + EmbeddedWebView, + + /// User-flow token acquired via the system default browser. + SystemWebView, + + /// User-flow token acquired via the device code flow. + DeviceCode, + } + + public static class MicrosoftAuthenticationFlowExtensions + { /// - /// Certificate used to authenticate the service principal. + /// True when the flow showed UI to the user during token acquisition. The OS-account-default + /// confirmation prompt is GCM chrome around silent acquisition and is not reflected here. /// - /// - /// If both and are set, the certificate will be used. - /// - public X509Certificate2 Certificate { get; set; } + public static bool IsInteractive(this MicrosoftAuthenticationFlow flow) => flow switch + { + MicrosoftAuthenticationFlow.BrokerInteractive => true, + MicrosoftAuthenticationFlow.EmbeddedWebView => true, + MicrosoftAuthenticationFlow.SystemWebView => true, + MicrosoftAuthenticationFlow.DeviceCode => true, + _ => false, + }; + } + public interface IMicrosoftAccount : IEquatable + { /// - /// Secret used to authenticate the service principal. + /// Opaque, stable identifier for the account in MSAL's cache. Use this to refer to + /// the account from persistent records — it survives UPN renames. /// - /// - /// If both and are set, the certificate will be used. - /// - public string ClientSecret { get; set; } + string HomeAccountId { get; } /// - /// Whether the authentication should send X5C + /// User principal name (typically an email address); suitable for display. /// - public bool SendX5C { get; set; } + string UserName { get; } } - public interface IMicrosoftAuthenticationResult + public sealed class MicrosoftAccount : IMicrosoftAccount { - string AccessToken { get; } - string AccountUpn { get; } + internal static MicrosoftAccount FromMsalAccount(IAccount msalAccount) + { + EnsureArgument.NotNull(msalAccount, nameof(msalAccount)); + return new MicrosoftAccount(msalAccount.HomeAccountId.Identifier, msalAccount.Username); + } + + public MicrosoftAccount(string homeAccountId, string userName) + { + UserName = userName; + HomeAccountId = homeAccountId; + } + + public string HomeAccountId { get; } + public string UserName { get; } + + // Both fields are compared case-insensitively to match MSAL: AccountId.Equals on the + // identifier uses OrdinalIgnoreCase, and UPNs are case-insensitive per RFC 5321. + public bool Equals(IMicrosoftAccount other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return StringComparer.OrdinalIgnoreCase.Equals(HomeAccountId, other.HomeAccountId) + && StringComparer.OrdinalIgnoreCase.Equals(UserName, other.UserName); + } + + public override bool Equals(object obj) => obj is IMicrosoftAccount other && Equals(other); + + public override int GetHashCode() + { + int h1 = HomeAccountId is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(HomeAccountId); + int h2 = UserName is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(UserName); + unchecked { return (h1 * 397) ^ h2; } + } } - public enum MicrosoftAuthenticationFlowType + public static class MicrosoftAuthenticationExtensions { - Auto = 0, - EmbeddedWebView = 1, - SystemWebView = 2, - DeviceCode = 3 + /// + /// Convenience overload of + /// that takes a bare UPN string. + /// + [Obsolete("Construct a MicrosoftAccount and call the IMicrosoftAccount overload directly.")] + public static Task GetTokenForUserAsync( + this IMicrosoftAuthentication msAuth, + string authority, string clientId, Uri redirectUri, string[] scopes, string userName, bool msaPt = false) + { + EnsureArgument.NotNull(msAuth, nameof(msAuth)); + IMicrosoftAccount account = string.IsNullOrWhiteSpace(userName) + ? null + : new MicrosoftAccount(homeAccountId: null, userName: userName); + return msAuth.GetTokenForUserAsync(authority, clientId, redirectUri, scopes, account, msaPt); + } } public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthentication @@ -136,8 +246,95 @@ public MicrosoftAuthentication(ICommandContext context) #region IMicrosoftAuthentication + public async Task> GetUserAccountsAsync(string clientId, bool msaPt = false) + { + var uiCts = new CancellationTokenSource(); + + bool useBroker = CanUseBroker(); + Context.Trace.WriteLine(useBroker + ? "OS broker is available and enabled." + : "OS broker is not available or enabled."); + + IPublicClientApplication app = await CreatePublicClientApplicationAsync( + authority: null, clientId, redirectUri: null, useBroker, msaPt, uiCts); + + IEnumerable accounts = await app.GetAccountsAsync(); + + return accounts.Select(MicrosoftAccount.FromMsalAccount).ToList(); + } + + public async Task RemoveUserAccountAsync(string clientId, IMicrosoftAccount account, bool msaPt = false) + { + EnsureArgument.NotNull(account, nameof(account)); + + var uiCts = new CancellationTokenSource(); + + bool useBroker = CanUseBroker(); + Context.Trace.WriteLine(useBroker + ? "OS broker is available and enabled." + : "OS broker is not available or enabled."); + + IPublicClientApplication app = await CreatePublicClientApplicationAsync( + authority: null, clientId, redirectUri: null, useBroker, msaPt, uiCts); + + IReadOnlyList cached = (await app.GetAccountsAsync()).ToList(); + IAccount match = ResolveAccount(cached, account); + if (match is null) + { + Context.Trace.WriteLine($"No cached account matches '{DescribeAccount(account)}'."); + return false; + } + + await app.RemoveAsync(match); + Context.Trace.WriteLine($"Removed cached account '{match.HomeAccountId?.Identifier}' ({match.Username})."); + return true; + } + + /// + /// Resolve an against a set of MSAL-cached accounts. + /// Precedence: when is set, match on it and + /// trace-warn when the cached account's UPN differs from the supplied one (HomeAccountId + /// wins). When only is set, match on UPN and + /// trace-warn when multiple cached accounts share that UPN (first-match returned). + /// + private IAccount ResolveAccount(IReadOnlyList cached, IMicrosoftAccount account) + { + if (!string.IsNullOrWhiteSpace(account.HomeAccountId)) + { + IAccount byId = cached.FirstOrDefault(a => + StringComparer.OrdinalIgnoreCase.Equals(a.HomeAccountId?.Identifier, account.HomeAccountId)); + if (byId != null && !string.IsNullOrWhiteSpace(account.UserName) && + !StringComparer.OrdinalIgnoreCase.Equals(byId.Username, account.UserName)) + { + Context.Trace.WriteLine( + $"Cached account UPN '{byId.Username}' differs from supplied UPN '{account.UserName}' " + + $"for HomeAccountId '{account.HomeAccountId}'; using HomeAccountId."); + } + return byId; + } + + if (!string.IsNullOrWhiteSpace(account.UserName)) + { + IAccount[] byName = cached + .Where(a => StringComparer.OrdinalIgnoreCase.Equals(a.Username, account.UserName)) + .ToArray(); + if (byName.Length > 1) + { + Context.Trace.WriteLine( + $"{byName.Length} cached accounts share UPN '{account.UserName}'; using the first " + + "(provide a HomeAccountId to disambiguate)."); + } + return byName.FirstOrDefault(); + } + + return null; + } + + private static string DescribeAccount(IMicrosoftAccount account) => + !string.IsNullOrWhiteSpace(account.HomeAccountId) ? account.HomeAccountId : account.UserName; + public async Task GetTokenForUserAsync( - string authority, string clientId, Uri redirectUri, string[] scopes, string userName, bool msaPt) + string authority, string clientId, Uri redirectUri, string[] scopes, IMicrosoftAccount account, bool msaPt) { var uiCts = new CancellationTokenSource(); @@ -158,12 +355,34 @@ public async Task GetTokenForUserAsync( IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri, useBroker, msaPt, uiCts); AuthenticationResult result = null; + MicrosoftAuthenticationFlow flow = MicrosoftAuthenticationFlow.Unknown; + + // Resolve the account against the MSAL cache once (HomeAccountId-first, UPN-fallback, + // with trace warnings on mismatch/ambiguity). + IAccount resolvedAccount = null; + if (account is not null) + { + IReadOnlyList cached = (await app.GetAccountsAsync()).ToList(); + resolvedAccount = ResolveAccount(cached, account); + if (resolvedAccount is null) + { + Context.Trace.WriteLine($"No cached account matches '{DescribeAccount(account)}'."); + } + } - // Try silent authentication first if we know about an existing user - bool hasExistingUser = !string.IsNullOrWhiteSpace(userName); + // Try silent authentication first if we resolved an account against the cache. + bool hasExistingUser = resolvedAccount is not null; if (hasExistingUser) { - result = await GetAccessTokenSilentlyAsync(app, scopes, userName, msaPt); + result = await GetAccessTokenSilentlyAsync(app, scopes, resolvedAccount, msaPt); + if (result is not null) + { + // Tokens returned by the OS broker carry TokenSource.Broker; anything else + // (MSAL's own cache, or a refresh against the identity provider) doesn't. + flow = result.AuthenticationResultMetadata?.TokenSource == TokenSource.Broker + ? MicrosoftAuthenticationFlow.BrokerSilent + : MicrosoftAuthenticationFlow.Silent; + } } // @@ -201,12 +420,18 @@ public async Task GetTokenForUserAsync( // account then the user may become stuck in a loop of authentication failures. if (!hasExistingUser && Context.Settings.UseMsAuthDefaultAccount) { - result = await GetAccessTokenSilentlyAsync(app, scopes, null, msaPt); + result = await GetAccessTokenSilentlyAsync(app, scopes, PublicClientApplication.OperatingSystemAccount, msaPt); if (result is null || !await UseDefaultAccountAsync(result.Account.Username)) { result = null; } + else + { + // This branch is broker-only by construction; a non-null result here + // can only have come from the broker's cache. + flow = MicrosoftAuthenticationFlow.BrokerSilent; + } } if (result is null) @@ -217,25 +442,26 @@ public async Task GetTokenForUserAsync( // We must configure the system webview as a fallback .WithSystemWebViewOptions(GetSystemWebViewOptions()) .ExecuteAsync(); + flow = MicrosoftAuthenticationFlow.BrokerInteractive; } } else { // Check for a user flow preference if they've specified one - MicrosoftAuthenticationFlowType flowType = GetFlowType(); + InteractiveFlowType flowType = GetFlowType(); switch (flowType) { - case MicrosoftAuthenticationFlowType.Auto: + case InteractiveFlowType.Auto: if (CanUseEmbeddedWebView()) - goto case MicrosoftAuthenticationFlowType.EmbeddedWebView; + goto case InteractiveFlowType.EmbeddedWebView; if (CanUseSystemWebView(app, redirectUri)) - goto case MicrosoftAuthenticationFlowType.SystemWebView; + goto case InteractiveFlowType.SystemWebView; // Fall back to device code flow - goto case MicrosoftAuthenticationFlowType.DeviceCode; + goto case InteractiveFlowType.DeviceCode; - case MicrosoftAuthenticationFlowType.EmbeddedWebView: + case InteractiveFlowType.EmbeddedWebView: Context.Trace.WriteLine("Performing interactive auth with embedded web view..."); EnsureCanUseEmbeddedWebView(); result = await app.AcquireTokenInteractive(scopes) @@ -243,32 +469,35 @@ public async Task GetTokenForUserAsync( .WithUseEmbeddedWebView(true) .WithEmbeddedWebViewOptions(GetEmbeddedWebViewOptions()) .ExecuteAsync(); + flow = MicrosoftAuthenticationFlow.EmbeddedWebView; break; - case MicrosoftAuthenticationFlowType.SystemWebView: + case InteractiveFlowType.SystemWebView: Context.Trace.WriteLine("Performing interactive auth with system web view..."); EnsureCanUseSystemWebView(app, redirectUri); result = await app.AcquireTokenInteractive(scopes) .WithPrompt(Prompt.SelectAccount) .WithSystemWebViewOptions(GetSystemWebViewOptions()) .ExecuteAsync(); + flow = MicrosoftAuthenticationFlow.SystemWebView; break; - case MicrosoftAuthenticationFlowType.DeviceCode: + case InteractiveFlowType.DeviceCode: Context.Trace.WriteLine("Performing interactive auth with device code..."); // We don't have a way to display a device code without a terminal at the moment // TODO: introduce a small GUI window to show a code if no TTY exists ThrowIfTerminalPromptsDisabled(); result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync(); + flow = MicrosoftAuthenticationFlow.DeviceCode; break; default: - goto case MicrosoftAuthenticationFlowType.Auto; + goto case InteractiveFlowType.Auto; } } } - return new MsalResult(result); + return new MsalResult(result, flow); } finally { @@ -277,7 +506,7 @@ public async Task GetTokenForUserAsync( } } - public async Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes) + public async Task GetTokenForServicePrincipalAsync(MicrosoftServicePrincipalIdentity sp, string[] scopes) { IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(sp); @@ -286,7 +515,7 @@ public async Task GetTokenForServicePrincipalAsy Context.Trace.WriteLine($"Sending with X5C: '{sp.SendX5C}'."); AuthenticationResult result = await app.AcquireTokenForClient(scopes).WithSendX5C(sp.SendX5C).ExecuteAsync();; - return new MsalResult(result); + return new MsalResult(result, MicrosoftAuthenticationFlow.ServicePrincipal); } catch (Exception ex) { @@ -310,7 +539,7 @@ public async Task GetTokenForManagedIdentityAsyn try { AuthenticationResult result = await app.AcquireTokenForManagedIdentity(resource).ExecuteAsync(); - return new MsalResult(result); + return new MsalResult(result, MicrosoftAuthenticationFlow.ManagedIdentity); } catch (Exception ex) { @@ -330,7 +559,7 @@ public async Task GetTokenUsingWorkloadFederatio .ExecuteAsync() .ConfigureAwait(false); - return new MsalResult(result); + return new MsalResult(result, MicrosoftAuthenticationFlow.WorkloadFederation); } private async Task GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _) @@ -457,7 +686,7 @@ await AvaloniaUi.ShowViewAsync( } } - internal MicrosoftAuthenticationFlowType GetFlowType() + internal InteractiveFlowType GetFlowType() { if (Context.Settings.TryGetSetting( Constants.EnvironmentVariables.MsAuthFlow, @@ -469,13 +698,13 @@ internal MicrosoftAuthenticationFlowType GetFlowType() switch (valueStr.ToLowerInvariant()) { case "auto": - return MicrosoftAuthenticationFlowType.Auto; + return InteractiveFlowType.Auto; case "embedded": - return MicrosoftAuthenticationFlowType.EmbeddedWebView; + return InteractiveFlowType.EmbeddedWebView; case "system": - return MicrosoftAuthenticationFlowType.SystemWebView; + return InteractiveFlowType.SystemWebView; default: - if (Enum.TryParse(valueStr, ignoreCase: true, out MicrosoftAuthenticationFlowType value)) + if (Enum.TryParse(valueStr, ignoreCase: true, out InteractiveFlowType value)) return value; break; } @@ -483,52 +712,36 @@ internal MicrosoftAuthenticationFlowType GetFlowType() Context.Streams.Error.WriteLine($"warning: unknown Microsoft Authentication flow type '{valueStr}'; using 'auto'"); } - return MicrosoftAuthenticationFlowType.Auto; + return InteractiveFlowType.Auto; } /// - /// Obtain an access token without showing UI or prompts. + /// Obtain an access token without showing UI or prompts for the given cached account. + /// Pass to acquire silently + /// against the broker's default OS account (broker only). /// private async Task GetAccessTokenSilentlyAsync( - IPublicClientApplication app, string[] scopes, string userName, bool msaPt) + IPublicClientApplication app, string[] scopes, IAccount account, bool msaPt) { try { - if (userName is null) + string label = ReferenceEquals(account, PublicClientApplication.OperatingSystemAccount) + ? "current operating system account" + : $"account '{account.Username}'"; + Context.Trace.WriteLine($"Attempting to acquire token silently for {label}..."); + + var atsBuilder = app.AcquireTokenSilent(scopes, account); + + // If we are operating with an MSA passthrough app we need to ensure that we target the + // special MSA 'transfer' tenant explicitly. This is a workaround for MSAL issue: + // https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/3077 + if (msaPt && Guid.TryParse(account.HomeAccountId?.TenantId, out Guid homeTenantId) && + homeTenantId == Constants.MsaHomeTenantId) { - Context.Trace.WriteLine( - "Attempting to acquire token silently for current operating system account..."); - - return await app.AcquireTokenSilent(scopes, PublicClientApplication.OperatingSystemAccount) - .ExecuteAsync(); + atsBuilder = atsBuilder.WithTenantId(Constants.MsaTransferTenantId.ToString("D")); } - else - { - Context.Trace.WriteLine($"Attempting to acquire token silently for user '{userName}'..."); - - // Enumerate all accounts and find the one matching the user name - IEnumerable accounts = await app.GetAccountsAsync(); - IAccount account = accounts.FirstOrDefault(x => - StringComparer.OrdinalIgnoreCase.Equals(x.Username, userName)); - if (account is null) - { - Context.Trace.WriteLine($"No cached account found for user '{userName}'..."); - return null; - } - - var atsBuilder = app.AcquireTokenSilent(scopes, account); - - // Is we are operating with an MSA passthrough app we need to ensure that we target the - // special MSA 'transfer' tenant explicitly. This is a workaround for MSAL issue: - // https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/3077 - if (msaPt && Guid.TryParse(account.HomeAccountId.TenantId, out Guid homeTenantId) && - homeTenantId == Constants.MsaHomeTenantId) - { - atsBuilder = atsBuilder.WithTenantId(Constants.MsaTransferTenantId.ToString("D")); - } - return await atsBuilder.ExecuteAsync(); - } + return await atsBuilder.ExecuteAsync(); } catch (MsalUiRequiredException) { @@ -549,10 +762,20 @@ private async Task CreatePublicClientApplicationAsync( var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); var appBuilder = PublicClientApplicationBuilder.Create(clientId) - .WithAuthority(authority) - .WithRedirectUri(redirectUri.ToString()) .WithHttpClientFactory(httpFactoryAdaptor); + // Authority and redirect URI are only relevant for token-acquisition flows; when omitted we let + // MSAL fall back to its built-in defaults (currently AAD /common and the native-client redirect). + // Cache-only operations such as account enumeration do not depend on either. + if (!string.IsNullOrWhiteSpace(authority)) + { + appBuilder.WithAuthority(authority); + } + if (redirectUri != null) + { + appBuilder.WithRedirectUri(redirectUri.ToString()); + } + // Listen to MSAL logs if GCM_TRACE_MSAUTH is set if (Context.Settings.IsMsalTracingEnabled) { @@ -617,7 +840,7 @@ private async Task CreatePublicClientApplicationAsync( return app; } - private async Task CreateConfidentialClientApplicationAsync(ServicePrincipalIdentity sp) + private async Task CreateConfidentialClientApplicationAsync(MicrosoftServicePrincipalIdentity sp) { var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); @@ -1015,19 +1238,28 @@ private void EnsureCanUseSystemWebView(IPublicClientApplication app, Uri redirec } } + internal enum InteractiveFlowType + { + Auto = 0, + EmbeddedWebView = 1, + SystemWebView = 2, + DeviceCode = 3 + } + #endregion private class MsalResult : IMicrosoftAuthenticationResult { - private readonly AuthenticationResult _msalResult; - - public MsalResult(AuthenticationResult msalResult) + public MsalResult(AuthenticationResult msalResult, MicrosoftAuthenticationFlow flow = MicrosoftAuthenticationFlow.Unknown) { - _msalResult = msalResult; + AccessToken = msalResult.AccessToken; + Account = MicrosoftAccount.FromMsalAccount(msalResult.Account); + Flow = flow; } - public string AccessToken => _msalResult.AccessToken; - public string AccountUpn => _msalResult.Account?.Username; + public string AccessToken { get; } + public IMicrosoftAccount Account { get; } + public MicrosoftAuthenticationFlow Flow { get; } } } } diff --git a/src/shared/Core/Authentication/MicrosoftServicePrincipalIdentity.cs b/src/shared/Core/Authentication/MicrosoftServicePrincipalIdentity.cs new file mode 100644 index 000000000..efef802f5 --- /dev/null +++ b/src/shared/Core/Authentication/MicrosoftServicePrincipalIdentity.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography.X509Certificates; + +namespace GitCredentialManager.Authentication; + +public class MicrosoftServicePrincipalIdentity +{ + /// + /// Client ID of the service principal. + /// + public string Id { get; set; } + + /// + /// Tenant ID of the service principal. + /// + public string TenantId { get; set; } + + /// + /// Certificate used to authenticate the service principal. + /// + /// + /// If both and are set, the certificate will be used. + /// + public X509Certificate2 Certificate { get; set; } + + /// + /// Secret used to authenticate the service principal. + /// + /// + /// If both and are set, the certificate will be used. + /// + public string ClientSecret { get; set; } + + /// + /// Whether the authentication should send X5C + /// + public bool SendX5C { get; set; } +} diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index aeedf8d3e..105e79391 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -158,6 +158,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; + var expectedAccount = new MicrosoftAccount(homeAccountId: null, userName: urlAccount); var authResult = CreateAuthResult(urlAccount, accessToken); var context = new TestCommandContext(); @@ -170,7 +171,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -208,6 +209,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; + var expectedAccount = new MicrosoftAccount(homeAccountId: null, userName: urlAccount); var authResult = CreateAuthResult(urlAccount, accessToken); var context = new TestCommandContext(); @@ -220,7 +222,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -254,6 +256,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; + IMicrosoftAccount expectedAccount = null; var account = "jane.doe"; var authResult = CreateAuthResult(account, accessToken); @@ -267,7 +270,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -303,6 +306,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; + IMicrosoftAccount expectedAccount = null; var account = "john.doe"; var authResult = CreateAuthResult(account, accessToken); @@ -315,7 +319,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var azDevOpsMock = new Mock(MockBehavior.Strict); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -353,6 +357,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; var account = "john.doe"; + var expectedAccount = new MicrosoftAccount(homeAccountId: null, userName: account); var authResult = CreateAuthResult(account, accessToken); var context = new TestCommandContext(); @@ -364,7 +369,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var azDevOpsMock = new Mock(MockBehavior.Strict); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, account, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -401,6 +406,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; + IMicrosoftAccount expectedAccount = null; var account = "john.doe"; var authResult = CreateAuthResult(account, accessToken); @@ -414,7 +420,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -450,6 +456,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_OrgInUserName_No var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; + IMicrosoftAccount expectedAccount = null; var personalAccessToken = "PERSONAL-ACCESS-TOKEN"; var account = "john.doe"; var authResult = CreateAuthResult(account, accessToken); @@ -462,7 +469,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_OrgInUserName_No .ReturnsAsync(personalAccessToken); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -496,6 +503,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; var expectedScopes = AzureDevOpsConstants.AzureDevOpsDefaultScopes; var accessToken = "ACCESS-TOKEN"; + IMicrosoftAccount expectedAccount = null; var personalAccessToken = "PERSONAL-ACCESS-TOKEN"; var account = "john.doe"; var authResult = CreateAuthResult(account, accessToken); @@ -508,7 +516,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge .ReturnsAsync(personalAccessToken); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, expectedAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -881,7 +889,7 @@ public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_Returns var msAuthMock = new Mock(); msAuthMock.Setup(x => - x.GetTokenForServicePrincipalAsync(It.IsAny(), It.IsAny())) + x.GetTokenForServicePrincipalAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); @@ -894,7 +902,7 @@ public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_Returns Assert.Equal(accessToken, credential.Password); msAuthMock.Verify(x => x.GetTokenForServicePrincipalAsync( - It.Is(sp => sp.TenantId == tenantId && sp.Id == clientId), + It.Is(sp => sp.TenantId == tenantId && sp.Id == clientId), It.Is(scopes => scopes.Length == 1 && scopes[0] == AzureDevOpsConstants.AzureDevOpsDefaultScopes[0])), Times.Once); } @@ -1071,7 +1079,7 @@ private static IMicrosoftAuthenticationResult CreateAuthResult(string upn, strin { return new MockMsAuthResult { - AccountUpn = upn, + Account = new MicrosoftAccount(homeAccountId: null, userName: upn), AccessToken = token, }; } @@ -1079,8 +1087,8 @@ private static IMicrosoftAuthenticationResult CreateAuthResult(string upn, strin private class MockMsAuthResult : IMicrosoftAuthenticationResult { public string AccessToken { get; set; } - public string AccountUpn { get; set; } - public string TokenSource { get; set; } + public IMicrosoftAccount Account { get; set; } + public MicrosoftAuthenticationFlow Flow { get; set; } } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index db38dc4b7..740087016 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -94,7 +94,7 @@ public async Task GetCredentialAsync(GitRequest request) ); } - if (UseServicePrincipal(out ServicePrincipalIdentity sp)) + if (UseServicePrincipal(out MicrosoftServicePrincipalIdentity sp)) { _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); var azureResult = await _msAuth.GetTokenForServicePrincipalAsync(sp, AzureDevOpsConstants.AzureDevOpsDefaultScopes); @@ -133,7 +133,7 @@ public async Task GetCredentialAsync(GitRequest request) // Include the username request here so that we may use it as an override // for user account lookups when getting Azure Access Tokens. var azureResult = await GetAzureAccessTokenAsync(request); - var azureCredential = new GitCredential(azureResult.AccountUpn, azureResult.AccessToken); + var azureCredential = new GitCredential(azureResult.Account.UserName, azureResult.AccessToken); return new GitResponse(azureCredential); } } @@ -265,7 +265,7 @@ private async Task GeneratePersonalAccessTokenAsync(GitRequest requ null, msaPt: true); _context.Trace.WriteLineSecrets( - $"Acquired Azure access token. Account='{result.AccountUpn}' Token='{{0}}'", new object[] {result.AccessToken}); + $"Acquired Azure access token. Account='{result.Account.UserName}' Token='{{0}}'", new object[] {result.AccessToken}); // Ask the Azure DevOps instance to create a new PAT var patScopes = new[] @@ -280,7 +280,7 @@ private async Task GeneratePersonalAccessTokenAsync(GitRequest requ patScopes); _context.Trace.WriteLineSecrets("PAT created. PAT='{0}'", new object[] {pat}); - return new GitCredential(result.AccountUpn, pat); + return new GitCredential(result.Account.UserName, pat); } private async Task GetAzureAccessTokenAsync(GitRequest request) @@ -352,7 +352,7 @@ private async Task GetAzureAccessTokenAsync(GitR userName, msaPt: true); _context.Trace.WriteLineSecrets( - $"Acquired Azure access token. Account='{result.AccountUpn}' Token='{{0}}'", new object[] {result.AccessToken}); + $"Acquired Azure access token. Account='{result.Account.UserName}' Token='{{0}}'", new object[] {result.AccessToken}); return result; } @@ -516,7 +516,7 @@ private bool UsePersonalAccessTokens() return defaultValue; } - private bool UseServicePrincipal(out ServicePrincipalIdentity sp) + private bool UseServicePrincipal(out MicrosoftServicePrincipalIdentity sp) { if (!_context.Settings.TryGetSetting( AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalId, @@ -547,7 +547,7 @@ private bool UseServicePrincipal(out ServicePrincipalIdentity sp) string tenantId = split[0]; string clientId = split[1]; - sp = new ServicePrincipalIdentity + sp = new MicrosoftServicePrincipalIdentity { Id = clientId, TenantId = tenantId,