Skip to content

Commit df90676

Browse files
authored
Merge pull request #212 from mjcheetham/msauth-choice
Allow user to override which interactive authentication flow used for MS auth
2 parents b5a15d8 + 151fec2 commit df90676

File tree

6 files changed

+241
-30
lines changed

6 files changed

+241
-30
lines changed

docs/configuration.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,46 @@ git config --global credential.plaintextStorePath /mnt/external-drive/credential
226226
```
227227

228228
**Also see: [GCM_PLAINTEXT_STORE_PATH](environment.md#GCM_PLAINTEXT_STORE_PATH)**
229+
230+
---
231+
232+
### credential.msauthFlow
233+
234+
Specify which authentication flow should be used when performing Microsoft authentication and an interactive flow is required.
235+
236+
Defaults to the value `auto`.
237+
238+
**Note:** This setting will be ignored if a native authentication helper is configured and available. See [`credential.msauthHelper`](#credentialmsauthhelper) for more information.
239+
240+
Value|Credential Store
241+
-|-
242+
`auto` _(default)_|Select the best option depending on the current environment and platform.
243+
`embedded`|Show a window with embedded web view control.
244+
`system`|Open the user's default web browser.
245+
`devicecode`|Show a device code.
246+
247+
#### Example
248+
249+
```shell
250+
git config --global credential.msauthFlow devicecode
251+
```
252+
253+
**Also see: [GCM_MSAUTH_FLOW](environment.md#GCM_MSAUTH_FLOW)**
254+
255+
---
256+
257+
### credential.msauthHelper
258+
259+
Full path to an external 'helper' tool to which Microsoft authentication should be delegated.
260+
261+
On macOS this defaults to the included native `Microsoft.Authentication.Helper` tool. On all other platforms this is not set.
262+
263+
**Note:** If a helper is set and available then all Microsoft authentication will be delegated to this helper and the [`credential.msauthFlow`](#credentialmsauthflow) setting will be ignored. Setting the value to the empty string (`""`) will unset any default helper.
264+
265+
#### Example
266+
267+
```shell
268+
git config --global credential.msauthHelper "C:\path\to\helper.exe"
269+
```
270+
271+
**Also see: [GCM_MSAUTH_HELPER](environment.md#GCM_MSAUTH_HELPER)**

docs/environment.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,3 +373,58 @@ export GCM_PLAINTEXT_STORE_PATH=/mnt/external-drive/credentials
373373
```
374374

375375
**Also see: [credential.plaintextStorePath](configuration.md#credentialplaintextstorepath)**
376+
377+
---
378+
379+
### GCM_MSAUTH_FLOW
380+
381+
Specify which authentication flow should be used when performing Microsoft authentication and an interactive flow is required.
382+
383+
Defaults to the value `auto`.
384+
385+
**Note:** This setting will be ignored if a native authentication helper is configured and available. See [`GCM_MSAUTH_HELPER`](#gcm_msauth_helper) for more information.
386+
387+
Value|Credential Store
388+
-|-
389+
`auto` _(default)_|Select the best option depending on the current environment and platform.
390+
`embedded`|Show a window with embedded web view control.
391+
`system`|Open the user's default web browser.
392+
`devicecode`|Show a device code.
393+
394+
##### Windows
395+
396+
```batch
397+
SET GCM_MSAUTH_FLOW="devicecode"
398+
```
399+
400+
##### macOS/Linux
401+
402+
```bash
403+
export GCM_MSAUTH_FLOW="devicecode"
404+
```
405+
406+
**Also see: [credential.msauthFlow](configuration.md#credentialmsauthflow)**
407+
408+
---
409+
410+
### GCM_MSAUTH_HELPER
411+
412+
Full path to an external 'helper' tool to which Microsoft authentication should be delegated.
413+
414+
On macOS this defaults to the included native `Microsoft.Authentication.Helper` tool. On all other platforms this is not set.
415+
416+
**Note:** If a helper is set and available then all Microsoft authentication will be delegated to this helper and the [`GCM_MSAUTH_FLOW`](#gcm_msauth_flow) setting will be ignored. Setting the value to the empty string (`""`) will unset any default helper.
417+
418+
##### Windows
419+
420+
```batch
421+
SET GCM_MSAUTH_HELPER="C:\path\to\helper.exe"
422+
```
423+
424+
##### macOS/Linux
425+
426+
```bash
427+
export GCM_MSAUTH_HELPER="/usr/local/bin/msauth-helper"
428+
```
429+
430+
**Also see: [credential.msauthHelper](configuration.md#credentialmsauthhelper)**

src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ internal static class AzureDevOpsConstants
1313
// We share this to be able to consume existing access tokens from the VS caches
1414
public const string AadClientId = "872cd9fa-d31f-45e0-9eab-6e460a02d1f1";
1515

16-
// Standard redirect URI for native client 'v1 protocol' applications
17-
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v1-protocols-oauth-code#request-an-authorization-code
18-
public static readonly Uri AadRedirectUri = new Uri("urn:ietf:wg:oauth:2.0:oob");
16+
// Redirect URI specified by the Visual Studio application configuration
17+
public static readonly Uri AadRedirectUri = new Uri("http://localhost");
1918

2019
public const string VstsHostSuffix = ".visualstudio.com";
2120
public const string AzureDevOpsHost = "dev.azure.com";

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ protected bool TryFindHelperExecutablePath(string envar, string configName, stri
106106
helperName = PlatformUtils.IsWindows() ? $"{defaultValue}.exe" : defaultValue;
107107
}
108108

109+
// If the user set the helper override to the empty string then they are signalling not to use a helper
110+
if (string.IsNullOrEmpty(helperName))
111+
{
112+
path = null;
113+
return false;
114+
}
115+
109116
if (Path.IsPathRooted(helperName))
110117
{
111118
path = helperName;

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

Lines changed: 132 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Collections.Generic;
55
using System.IO;
66
using System.Net.Http;
7+
using System.Runtime.InteropServices;
78
using System.Threading.Tasks;
89
using Microsoft.Identity.Client;
910
using Microsoft.Identity.Client.Extensions.Msal;
@@ -17,6 +18,14 @@ Task<JsonWebToken> GetAccessTokenAsync(string authority, string clientId, Uri re
1718
Uri remoteUri, string userName);
1819
}
1920

21+
public enum MicrosoftAuthenticationFlowType
22+
{
23+
Auto = 0,
24+
EmbeddedWebView = 1,
25+
SystemWebView = 2,
26+
DeviceCode = 3
27+
}
28+
2029
public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthentication
2130
{
2231
public static readonly string[] AuthorityIds =
@@ -97,7 +106,8 @@ private async Task<JsonWebToken> GetAccessTokenInProcAsync(string authority, str
97106
// If we failed to acquire an AT silently (either because we don't have an existing user, or the user's RT has expired)
98107
// we need to prompt the user for credentials.
99108
//
100-
// Depending on the current platform and session type we try to show the most appropriate authentication interface:
109+
// If the user has expressed a preference in how the want to perform the interactive authentication flows then we respect that.
110+
// Otherwise, depending on the current platform and session type we try to show the most appropriate authentication interface:
101111
//
102112
// On .NET Framework MSAL supports the WinForms based 'embedded' webview UI. For Windows + .NET Framework this is the
103113
// best and natural experience.
@@ -111,41 +121,86 @@ private async Task<JsonWebToken> GetAccessTokenInProcAsync(string authority, str
111121
//
112122
// The device code flow has no limitations other than a way to communicate to the user the code required to authenticate.
113123
//
114-
115-
// If the user has disabled interaction all we can do is fail at this point
116-
ThrowIfUserInteractionDisabled();
117-
118124
if (result is null)
119125
{
120-
#if NETFRAMEWORK
121-
// If we're in an interactive session and on .NET Framework, let MSAL show the WinForms-based embeded UI
122-
if (Context.SessionManager.IsDesktopSession)
126+
// If the user has disabled interaction all we can do is fail at this point
127+
ThrowIfUserInteractionDisabled();
128+
129+
// Check for a user flow preference
130+
MicrosoftAuthenticationFlowType flowType = GetFlowType();
131+
switch (flowType)
123132
{
124-
result = await app.AcquireTokenInteractive(scopes)
125-
.WithPrompt(Prompt.SelectAccount)
126-
.WithUseEmbeddedWebView(true)
127-
.ExecuteAsync();
133+
case MicrosoftAuthenticationFlowType.Auto:
134+
if (CanUseEmbeddedWebView())
135+
goto case MicrosoftAuthenticationFlowType.EmbeddedWebView;
136+
137+
if (CanUseSystemWebView(app, redirectUri))
138+
goto case MicrosoftAuthenticationFlowType.SystemWebView;
139+
140+
// Fall back to device code flow
141+
goto case MicrosoftAuthenticationFlowType.DeviceCode;
142+
143+
case MicrosoftAuthenticationFlowType.EmbeddedWebView:
144+
Context.Trace.WriteLine("Performing interactive auth with embedded web view...");
145+
EnsureCanUseEmbeddedWebView();
146+
result = await app.AcquireTokenInteractive(scopes)
147+
.WithPrompt(Prompt.SelectAccount)
148+
.WithUseEmbeddedWebView(true)
149+
.ExecuteAsync();
150+
break;
151+
152+
case MicrosoftAuthenticationFlowType.SystemWebView:
153+
Context.Trace.WriteLine("Performing interactive auth with system web view...");
154+
EnsureCanUseSystemWebView(app, redirectUri);
155+
result = await app.AcquireTokenInteractive(scopes)
156+
.WithPrompt(Prompt.SelectAccount)
157+
.WithSystemWebViewOptions(GetSystemWebViewOptions())
158+
.ExecuteAsync();
159+
break;
160+
161+
case MicrosoftAuthenticationFlowType.DeviceCode:
162+
Context.Trace.WriteLine("Performing interactive auth with device code...");
163+
// We don't have a way to display a device code without a terminal at the moment
164+
// TODO: introduce a small GUI window to show a code if no TTY exists
165+
ThrowIfTerminalPromptsDisabled();
166+
result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync();
167+
break;
168+
169+
default:
170+
goto case MicrosoftAuthenticationFlowType.Auto;
128171
}
129-
#elif NETSTANDARD
130-
// MSAL requires the application redirect URI is a loopback address to use the System WebView
131-
if (Context.SessionManager.IsDesktopSession && app.IsSystemWebViewAvailable && redirectUri.IsLoopback)
172+
}
173+
174+
return new JsonWebToken(result.AccessToken);
175+
}
176+
177+
private MicrosoftAuthenticationFlowType GetFlowType()
178+
{
179+
if (Context.Settings.TryGetSetting(
180+
Constants.EnvironmentVariables.MsAuthFlow,
181+
Constants.GitConfiguration.Credential.SectionName,
182+
Constants.GitConfiguration.Credential.MsAuthFlow,
183+
out string valueStr))
184+
{
185+
Context.Trace.WriteLine($"Microsoft auth flow overriden to '{valueStr}'.");
186+
switch (valueStr.ToLowerInvariant())
132187
{
133-
result = await app.AcquireTokenInteractive(scopes)
134-
.WithPrompt(Prompt.SelectAccount)
135-
.WithSystemWebViewOptions(GetSystemWebViewOptions())
136-
.ExecuteAsync();
188+
case "auto":
189+
return MicrosoftAuthenticationFlowType.Auto;
190+
case "embedded":
191+
return MicrosoftAuthenticationFlowType.EmbeddedWebView;
192+
case "system":
193+
return MicrosoftAuthenticationFlowType.SystemWebView;
194+
default:
195+
if (Enum.TryParse(valueStr, ignoreCase: true, out MicrosoftAuthenticationFlowType value))
196+
return value;
197+
break;
137198
}
138-
#endif
139-
// If we do not have a way to show a GUI, use device code flow over the TTY
140-
else
141-
{
142-
ThrowIfTerminalPromptsDisabled();
143199

144-
result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync();
145-
}
200+
Context.Streams.Error.WriteLine($"warning: unknown Microsoft Authentication flow type '{valueStr}'; using 'auto'");
146201
}
147202

148-
return new JsonWebToken(result.AccessToken);
203+
return MicrosoftAuthenticationFlowType.Auto;
149204
}
150205

151206
/// <summary>
@@ -274,5 +329,55 @@ public HttpClient GetHttpClient()
274329
}
275330

276331
#endregion
332+
333+
#region Auth flow capability detection
334+
335+
private bool CanUseEmbeddedWebView()
336+
{
337+
// If we're in an interactive session and on .NET Framework then MSAL can show the WinForms-based embedded UI
338+
#if NETFRAMEWORK
339+
return Context.SessionManager.IsDesktopSession;
340+
#else
341+
return false;
342+
#endif
343+
}
344+
345+
private void EnsureCanUseEmbeddedWebView()
346+
{
347+
#if NETFRAMEWORK
348+
if (!Context.SessionManager.IsDesktopSession)
349+
{
350+
throw new InvalidOperationException("Embedded web view is not available without a desktop session.");
351+
}
352+
#else
353+
throw new InvalidOperationException("Embedded web view is not available on .NET Core.");
354+
#endif
355+
}
356+
357+
private bool CanUseSystemWebView(IPublicClientApplication app, Uri redirectUri)
358+
{
359+
// MSAL requires the application redirect URI is a loopback address to use the System WebView
360+
return Context.SessionManager.IsDesktopSession && app.IsSystemWebViewAvailable && redirectUri.IsLoopback;
361+
}
362+
363+
private void EnsureCanUseSystemWebView(IPublicClientApplication app, Uri redirectUri)
364+
{
365+
if (!Context.SessionManager.IsDesktopSession)
366+
{
367+
throw new InvalidOperationException("System web view is not available without a desktop session.");
368+
}
369+
370+
if (!app.IsSystemWebViewAvailable)
371+
{
372+
throw new InvalidOperationException("System web view is not available on this platform.");
373+
}
374+
375+
if (!redirectUri.IsLoopback)
376+
{
377+
throw new InvalidOperationException("System web view is not available for this service configuration.");
378+
}
379+
}
380+
381+
#endregion
277382
}
278383
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public static class EnvironmentVariables
5151
public const string GitSslNoVerify = "GIT_SSL_NO_VERIFY";
5252
public const string GcmInteractive = "GCM_INTERACTIVE";
5353
public const string GcmParentWindow = "GCM_MODAL_PARENTHWND";
54+
public const string MsAuthFlow = "GCM_MSAUTH_FLOW";
5455
public const string MsAuthHelper = "GCM_MSAUTH_HELPER";
5556
public const string GcmCredNamespace = "GCM_NAMESPACE";
5657
public const string GcmCredentialStore = "GCM_CREDENTIAL_STORE";
@@ -80,6 +81,7 @@ public static class Credential
8081
public const string HttpsProxy = "httpsProxy";
8182
public const string UseHttpPath = "useHttpPath";
8283
public const string Interactive = "interactive";
84+
public const string MsAuthFlow = "msauthFlow";
8385
public const string MsAuthHelper = "msauthHelper";
8486
public const string CredNamespace = "namespace";
8587
public const string CredentialStore = "credentialStore";

0 commit comments

Comments
 (0)