3
3
using System ;
4
4
using System . Collections . Generic ;
5
5
using System . IO ;
6
+ using System . Net . Http ;
6
7
using System . Reflection ;
7
8
using System . Threading . Tasks ;
9
+ using Microsoft . Identity . Client ;
10
+ using Microsoft . Identity . Client . Extensions . Msal ;
8
11
using Microsoft . IdentityModel . JsonWebTokens ;
9
12
10
13
namespace Microsoft . Git . CredentialManager . Authentication
11
14
{
12
15
public interface IMicrosoftAuthentication
13
16
{
14
17
Task < JsonWebToken > GetAccessTokenAsync ( string authority , string clientId , Uri redirectUri , string resource ,
15
- Uri remoteUri ) ;
18
+ Uri remoteUri , string userName ) ;
16
19
}
17
20
18
21
public class MicrosoftAuthentication : AuthenticationBase , IMicrosoftAuthentication
@@ -27,18 +30,38 @@ public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthenticat
27
30
public MicrosoftAuthentication ( ICommandContext context )
28
31
: base ( context ) { }
29
32
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 )
32
37
{
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
+ }
34
49
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
+ {
35
57
var inputDict = new Dictionary < string , string >
36
58
{
37
- [ "authority" ] = authority ,
38
- [ "clientId" ] = clientId ,
59
+ [ "authority" ] = authority ,
60
+ [ "clientId" ] = clientId ,
39
61
[ "redirectUri" ] = redirectUri . AbsoluteUri ,
40
- [ "resource" ] = resource ,
41
- [ "remoteUrl" ] = remoteUri . ToString ( ) ,
62
+ [ "resource" ] = resource ,
63
+ [ "remoteUrl" ] = remoteUri . ToString ( ) ,
64
+ [ "username" ] = userName ,
42
65
} ;
43
66
44
67
IDictionary < string , string > resultDict = await InvokeHelperAsync ( helperPath , null , inputDict ) ;
@@ -51,7 +74,101 @@ public async Task<JsonWebToken> GetAccessTokenAsync(string authority, string cli
51
74
return new JsonWebToken ( accessToken ) ;
52
75
}
53
76
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 )
55
172
{
56
173
string helperName = Constants . MicrosoftAuthHelperName ;
57
174
@@ -61,14 +178,76 @@ private string FindHelperExecutablePath()
61
178
}
62
179
63
180
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 ( ) )
66
191
{
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." ) ;
69
205
}
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
+ }
70
212
71
- return path ;
213
+ private static SystemWebViewOptions GetSystemWebViewOptions ( )
214
+ {
215
+ // TODO: add nicer HTML success and error pages
216
+ return new SystemWebViewOptions ( ) ;
72
217
}
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
73
252
}
74
253
}
0 commit comments