Skip to content

Commit 3a99042

Browse files
committed
Move Microsoft authentication in-proc
Move Microsoft authentication in-process when no external helper application is present. This simplifies the scenario on .NET Framework (Windows) where MSAL supports WinForms-based UI.
1 parent da66716 commit 3a99042

File tree

16 files changed

+215
-549
lines changed

16 files changed

+215
-549
lines changed

Git-Credential-Manager.sln

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestInfrastructure", "src\s
2121
EndProject
2222
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub", "src\shared\GitHub\GitHub.csproj", "{3C840B06-A595-4FD9-9A76-56CD45B14780}"
2323
EndProject
24-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Authentication.Helper.Windows", "src\windows\Microsoft.Authentication.Helper.Windows\Microsoft.Authentication.Helper.Windows.csproj", "{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}"
25-
EndProject
26-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Authentication.Helper.Windows.Tests", "src\windows\Microsoft.Authentication.Helper.Windows.Tests\Microsoft.Authentication.Helper.Windows.Tests.csproj", "{E0391B02-16D5-4B49-9C33-349B25717011}"
27-
EndProject
2824
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shared", "shared", "{D5277A0E-997E-453A-8CB9-4EFCC8B16A29}"
2925
EndProject
3026
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.Tests", "src\shared\GitHub.Tests\GitHub.Tests.csproj", "{3E524EA8-D31A-4394-997C-14B522E3D6FD}"
@@ -160,22 +156,6 @@ Global
160156
{3C840B06-A595-4FD9-9A76-56CD45B14780}.MacDebug|Any CPU.Build.0 = Debug|Any CPU
161157
{3C840B06-A595-4FD9-9A76-56CD45B14780}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU
162158
{3C840B06-A595-4FD9-9A76-56CD45B14780}.MacRelease|Any CPU.Build.0 = Release|Any CPU
163-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
164-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.Release|Any CPU.ActiveCfg = Release|Any CPU
165-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU
166-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
167-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
168-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
169-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU
170-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU
171-
{E0391B02-16D5-4B49-9C33-349B25717011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
172-
{E0391B02-16D5-4B49-9C33-349B25717011}.Release|Any CPU.ActiveCfg = Release|Any CPU
173-
{E0391B02-16D5-4B49-9C33-349B25717011}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU
174-
{E0391B02-16D5-4B49-9C33-349B25717011}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
175-
{E0391B02-16D5-4B49-9C33-349B25717011}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
176-
{E0391B02-16D5-4B49-9C33-349B25717011}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
177-
{E0391B02-16D5-4B49-9C33-349B25717011}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU
178-
{E0391B02-16D5-4B49-9C33-349B25717011}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU
179159
{3E524EA8-D31A-4394-997C-14B522E3D6FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
180160
{3E524EA8-D31A-4394-997C-14B522E3D6FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
181161
{3E524EA8-D31A-4394-997C-14B522E3D6FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -242,7 +222,6 @@ Global
242222
EndGlobalSection
243223
GlobalSection(NestedProjects) = preSolution
244224
{66722747-1B61-40E4-A89B-1AC8E6D62EA9} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
245-
{8B984F78-4EAF-4BC0-A34E-BA3949700ED4} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9}
246225
{D5277A0E-997E-453A-8CB9-4EFCC8B16A29} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
247226
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
248227
{3C840B06-A595-4FD9-9A76-56CD45B14780} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
@@ -251,7 +230,6 @@ Global
251230
{97DC6241-1240-4A85-8035-F8404A983A82} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
252231
{AD41FA1E-51F5-4E4F-B7DA-32F921491313} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
253232
{5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
254-
{E0391B02-16D5-4B49-9C33-349B25717011} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9}
255233
{3E524EA8-D31A-4394-997C-14B522E3D6FD} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
256234
{3D279E2D-E011-45CF-8EA8-3D71D1300443} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
257235
{206430B1-CEED-4C84-8D49-D0A399632202} = {3D279E2D-E011-45CF-8EA8-3D71D1300443}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ public async Task AzureReposProvider_GetCredentialAsync_ReturnsCredential()
164164
.ReturnsAsync(personalAccessToken);
165165

166166
var msAuthMock = new Mock<IMicrosoftAuthentication>();
167-
msAuthMock.Setup(x => x.GetAccessTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedResource, remoteUri))
167+
msAuthMock.Setup(x => x.GetAccessTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedResource, remoteUri, null))
168168
.ReturnsAsync(accessToken);
169169

170170
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);

src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
7979
AzureDevOpsConstants.AadClientId,
8080
AzureDevOpsConstants.AadRedirectUri,
8181
AzureDevOpsConstants.AadResourceId,
82-
remoteUri);
82+
remoteUri,
83+
null);
8384
string atUser = accessToken.GetAzureUserName();
8485
Context.Trace.WriteLineSecrets($"Acquired Azure access token. User='{atUser}' Token='{{0}}'", new object[] {accessToken.EncodedToken});
8586

src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs

Lines changed: 193 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33
using System;
44
using System.Collections.Generic;
55
using System.IO;
6+
using System.Net.Http;
67
using System.Reflection;
78
using System.Threading.Tasks;
9+
using Microsoft.Identity.Client;
10+
using Microsoft.Identity.Client.Extensions.Msal;
811
using Microsoft.IdentityModel.JsonWebTokens;
912

1013
namespace Microsoft.Git.CredentialManager.Authentication
1114
{
1215
public interface IMicrosoftAuthentication
1316
{
1417
Task<JsonWebToken> GetAccessTokenAsync(string authority, string clientId, Uri redirectUri, string resource,
15-
Uri remoteUri);
18+
Uri remoteUri, string userName);
1619
}
1720

1821
public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthentication
@@ -27,18 +30,38 @@ public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthenticat
2730
public MicrosoftAuthentication(ICommandContext context)
2831
: base(context) {}
2932

30-
public async Task<JsonWebToken> GetAccessTokenAsync(string authority, string clientId, Uri redirectUri,
31-
string resource, Uri remoteUri)
33+
#region IMicrosoftAuthentication
34+
35+
public async Task<JsonWebToken> GetAccessTokenAsync(
36+
string authority, string clientId, Uri redirectUri, string resource, Uri remoteUri, string userName)
3237
{
33-
string helperPath = FindHelperExecutablePath();
38+
// If we find an external authentication helper we should delegate everything to it
39+
if (TryFindHelperExecutablePath(out string helperPath))
40+
{
41+
return await GetAccessTokenViaHelperAsync(helperPath,
42+
authority, clientId, redirectUri, resource, remoteUri, userName);
43+
}
44+
45+
// Try to acquire an access token in the current process
46+
string[] scopes = { $"{resource}/.default" };
47+
return await GetAccessTokenInProcAsync(authority, clientId, redirectUri, scopes, userName);
48+
}
3449

50+
#endregion
51+
52+
#region Authentication strategies
53+
54+
private async Task<JsonWebToken> GetAccessTokenViaHelperAsync(string helperPath,
55+
string authority, string clientId, Uri redirectUri, string resource, Uri remoteUri, string userName)
56+
{
3557
var inputDict = new Dictionary<string, string>
3658
{
37-
["authority"] = authority,
38-
["clientId"] = clientId,
59+
["authority"] = authority,
60+
["clientId"] = clientId,
3961
["redirectUri"] = redirectUri.AbsoluteUri,
40-
["resource"] = resource,
41-
["remoteUrl"] = remoteUri.ToString(),
62+
["resource"] = resource,
63+
["remoteUrl"] = remoteUri.ToString(),
64+
["username"] = userName,
4265
};
4366

4467
IDictionary<string, string> resultDict = await InvokeHelperAsync(helperPath, null, inputDict);
@@ -51,7 +74,101 @@ public async Task<JsonWebToken> GetAccessTokenAsync(string authority, string cli
5174
return new JsonWebToken(accessToken);
5275
}
5376

54-
private string FindHelperExecutablePath()
77+
private async Task<JsonWebToken> GetAccessTokenInProcAsync(string authority, string clientId, Uri redirectUri, string[] scopes, string userName)
78+
{
79+
IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri);
80+
81+
AuthenticationResult result = null;
82+
83+
// Try silent authentication first if we know about an existing user
84+
if (!string.IsNullOrWhiteSpace(userName))
85+
{
86+
result = await GetAccessTokenSilentlyAsync(app, scopes, userName);
87+
}
88+
89+
// If we failed to acquire an AT silently (either because we don't have an existing user, or the user's RT has expired)
90+
// we need to prompt the user for credentials.
91+
// Depending on the current platform and session type we try to show the most appropriate authentication interface.
92+
if (result is null)
93+
{
94+
#if NETFRAMEWORK
95+
if (PlatformUtils.IsInteractiveSession())
96+
{
97+
result = await app.AcquireTokenInteractive(scopes)
98+
.WithPrompt(Prompt.SelectAccount)
99+
.WithUseEmbeddedWebView(true)
100+
.ExecuteAsync();
101+
}
102+
#elif NETSTANDARD
103+
// MSAL requires the application redirect URI is a loopback address to use the System WebView
104+
if (PlatformUtils.IsInteractiveSession() && app.IsSystemWebViewAvailable && redirectUri.IsLoopback)
105+
{
106+
result = await app.AcquireTokenInteractive(scopes)
107+
.WithPrompt(Prompt.SelectAccount)
108+
.WithSystemWebViewOptions(GetSystemWebViewOptions())
109+
.ExecuteAsync();
110+
}
111+
#endif
112+
// If we do not have a way to show a GUI, use device code flow over the TTY
113+
else
114+
{
115+
EnsureTerminalPromptsEnabled();
116+
117+
result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync();
118+
}
119+
}
120+
121+
return new JsonWebToken(result.AccessToken);
122+
}
123+
124+
private async Task<AuthenticationResult> GetAccessTokenSilentlyAsync(IPublicClientApplication app, string[] scopes, string userName)
125+
{
126+
try
127+
{
128+
Context.Trace.WriteLine($"Attempting to acquire token silently for user '{userName}'...");
129+
130+
// We can either call `app.GetAccountsAsync` and filter through the IAccount objects for the instance with the correct user name,
131+
// or we can just pass the user name string we have as the `loginHint` and let MSAL do exactly that for us instead!
132+
return await app.AcquireTokenSilent(scopes, loginHint: userName).ExecuteAsync();
133+
}
134+
catch (MsalUiRequiredException)
135+
{
136+
Context.Trace.WriteLine("Failed to acquire token silently; user interaction is required.");
137+
return null;
138+
}
139+
}
140+
141+
private async Task<IPublicClientApplication> CreatePublicClientApplicationAsync(string authority, string clientId, Uri redirectUri)
142+
{
143+
var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory);
144+
145+
var appBuilder = PublicClientApplicationBuilder.Create(clientId)
146+
.WithAuthority(authority)
147+
.WithRedirectUri(redirectUri.ToString())
148+
.WithHttpClientFactory(httpFactoryAdaptor);
149+
150+
// Listen to MSAL logs if GCM_TRACE_MSAUTH is set
151+
if (Context.Settings.IsMsalTracingEnabled)
152+
{
153+
// If GCM secret tracing is enabled also enable "PII" logging in MSAL
154+
bool enablePiiLogging = Context.Trace.IsSecretTracingEnabled;
155+
156+
appBuilder.WithLogging(OnMsalLogMessage, LogLevel.Verbose, enablePiiLogging, false);
157+
}
158+
159+
IPublicClientApplication app = appBuilder.Build();
160+
161+
// Try to register the application with the VS token cache
162+
await RegisterVisualStudioTokenCacheAsync(app);
163+
164+
return app;
165+
}
166+
167+
#endregion
168+
169+
#region Helpers
170+
171+
private bool TryFindHelperExecutablePath(out string path)
55172
{
56173
string helperName = Constants.MicrosoftAuthHelperName;
57174

@@ -61,14 +178,76 @@ private string FindHelperExecutablePath()
61178
}
62179

63180
string executableDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
64-
string path = Path.Combine(executableDirectory, helperName);
65-
if (!Context.FileSystem.FileExists(path))
181+
path = Path.Combine(executableDirectory, helperName);
182+
return Context.FileSystem.FileExists(path);
183+
}
184+
185+
private async Task RegisterVisualStudioTokenCacheAsync(IPublicClientApplication app)
186+
{
187+
Context.Trace.WriteLine("Configuring Visual Studio token cache...");
188+
189+
// We currently only support Visual Studio on Windows
190+
if (PlatformUtils.IsWindows())
66191
{
67-
// We expect to have a helper on Windows and Mac
68-
throw new Exception($"Cannot find required helper '{helperName}' in '{executableDirectory}'");
192+
// The Visual Studio MSAL cache is located at "%LocalAppData%\.IdentityService\msal.cache" on Windows.
193+
// We use the MSAL extension library to provide us consistent cache file access semantics (synchronisation, etc)
194+
// as Visual Studio itself follows, as well as other Microsoft developer tools such as the Azure PowerShell CLI.
195+
const string cacheFileName = "msal.cache";
196+
string appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
197+
string cacheDirectory = Path.Combine(appData, ".IdentityService");
198+
199+
var storageProps = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory, app.AppConfig.ClientId).Build();
200+
201+
var helper = await MsalCacheHelper.CreateAsync(storageProps);
202+
helper.RegisterCache(app.UserTokenCache);
203+
204+
Context.Trace.WriteLine("Visual Studio token cache configured.");
69205
}
206+
else
207+
{
208+
string osType = PlatformUtils.GetPlatformInformation().OperatingSystemType;
209+
Context.Trace.WriteLine($"Visual Studio token cache integration is not supported on {osType}.");
210+
}
211+
}
70212

71-
return path;
213+
private static SystemWebViewOptions GetSystemWebViewOptions()
214+
{
215+
// TODO: add nicer HTML success and error pages
216+
return new SystemWebViewOptions();
72217
}
218+
219+
private Task ShowDeviceCodeInTty(DeviceCodeResult dcr)
220+
{
221+
Context.Terminal.WriteLine(dcr.Message);
222+
223+
return Task.CompletedTask;
224+
}
225+
226+
private void OnMsalLogMessage(LogLevel level, string message, bool containspii)
227+
{
228+
Context.Trace.WriteLine($"[{level.ToString()}] {message}", memberName: "MSAL");
229+
}
230+
231+
private class MsalHttpClientFactoryAdaptor : IMsalHttpClientFactory
232+
{
233+
private readonly IHttpClientFactory _factory;
234+
private HttpClient _instance;
235+
236+
public MsalHttpClientFactoryAdaptor(IHttpClientFactory factory)
237+
{
238+
EnsureArgument.NotNull(factory, nameof(factory));
239+
240+
_factory = factory;
241+
}
242+
243+
public HttpClient GetHttpClient()
244+
{
245+
// MSAL calls this method each time it wants to use an HTTP client.
246+
// We ensure we only create a single instance to avoid socket exhaustion.
247+
return _instance ?? (_instance = _factory.CreateClient());
248+
}
249+
}
250+
251+
#endregion
73252
}
74253
}

src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
<ItemGroup>
1818
<PackageReference Include="LibGit2Sharp.NativeBinaries" Version="2.0.278" />
1919
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="5.5.0" />
20+
<PackageReference Include="Microsoft.Identity.Client" Version="4.3.0" />
21+
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="2.1.0-preview" />
2022
</ItemGroup>
2123

2224
</Project>

src/shared/Microsoft.Git.CredentialManager/PlatformUtils.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ namespace Microsoft.Git.CredentialManager
77
{
88
public static class PlatformUtils
99
{
10+
/// <summary>
11+
/// Determine if the current session is interactive (can display UI).
12+
/// </summary>
13+
/// <returns>True if the session is interactive, false otherwise.</returns>
14+
public static bool IsInteractiveSession()
15+
{
16+
return Environment.UserInteractive;
17+
}
18+
1019
/// <summary>
1120
/// Get information about the current platform (OS and CLR details).
1221
/// </summary>

src/windows/Installer.Windows/Setup.iss

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,17 @@ Filename: "{app}\git-credential-manager-core.exe"; Parameters: "unconfigure"; Fl
8080

8181
[Files]
8282
Source: "{#PayloadDir}\git-credential-manager-core.exe"; DestDir: "{app}"; Flags: ignoreversion
83+
Source: "{#PayloadDir}\git-credential-manager-core.exe.config"; DestDir: "{app}"; Flags: ignoreversion
84+
Source: "{#PayloadDir}\git2-572e4d8.dll"; DestDir: "{app}"; Flags: ignoreversion
8385
Source: "{#PayloadDir}\GitHub.dll"; DestDir: "{app}"; Flags: ignoreversion
8486
Source: "{#PayloadDir}\GitHub.UI.dll"; DestDir: "{app}"; Flags: ignoreversion
8587
Source: "{#PayloadDir}\GitHub.Authentication.Helper.exe"; DestDir: "{app}"; Flags: ignoreversion
86-
Source: "{#PayloadDir}\git2-572e4d8.dll"; DestDir: "{app}"; Flags: ignoreversion
87-
Source: "{#PayloadDir}\Microsoft.Authentication.Helper.exe"; DestDir: "{app}"; Flags: ignoreversion
88-
Source: "{#PayloadDir}\Microsoft.Authentication.Helper.exe.config"; DestDir: "{app}"; Flags: ignoreversion
88+
Source: "{#PayloadDir}\GitHub.Authentication.Helper.exe.config"; DestDir: "{app}"; Flags: ignoreversion
8989
Source: "{#PayloadDir}\Microsoft.AzureRepos.dll"; DestDir: "{app}"; Flags: ignoreversion
9090
Source: "{#PayloadDir}\Microsoft.Git.CredentialManager.dll"; DestDir: "{app}"; Flags: ignoreversion
9191
Source: "{#PayloadDir}\Microsoft.Identity.Client.dll"; DestDir: "{app}"; Flags: ignoreversion
9292
Source: "{#PayloadDir}\Microsoft.Identity.Client.Extensions.Msal.dll"; DestDir: "{app}"; Flags: ignoreversion
93-
Source: "{#PayloadDir}\Microsoft.IdentityModel.JsonWebTokens.dll"; DestDir: "{app}"; Flags: ignoreversion
94-
Source: "{#PayloadDir}\Microsoft.IdentityModel.Logging.dll"; DestDir: "{app}"; Flags: ignoreversion
95-
Source: "{#PayloadDir}\Microsoft.IdentityModel.Tokens.dll"; DestDir: "{app}"; Flags: ignoreversion
93+
Source: "{#PayloadDir}\Microsoft.IdentityModel.JsonWebTokens.dll"; DestDir: "{app}"; Flags: ignoreversion
94+
Source: "{#PayloadDir}\Microsoft.IdentityModel.Logging.dll"; DestDir: "{app}"; Flags: ignoreversion
95+
Source: "{#PayloadDir}\Microsoft.IdentityModel.Tokens.dll"; DestDir: "{app}"; Flags: ignoreversion
96+
Source: "{#PayloadDir}\Newtonsoft.Json.dll"; DestDir: "{app}"; Flags: ignoreversion

0 commit comments

Comments
 (0)