Skip to content

Commit 720a078

Browse files
committed
generic: add OAuth support for browser & devicecode
Add OAuth support for the generic provider offering browser (authcode grant) and device code (device auth grant) support. Device code and mode selection is initially only offered for TTY users.
1 parent 717b822 commit 720a078

File tree

3 files changed

+207
-8
lines changed

3 files changed

+207
-8
lines changed

src/shared/Core.Tests/GenericHostProviderTests.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return
8787
.ReturnsAsync(basicCredential)
8888
.Verifiable();
8989
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
90+
var oauthMock = new Mock<IOAuthAuthentication>();
9091

91-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
92+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
9293

9394
ICredential credential = await provider.GenerateCredentialAsync(input);
9495

@@ -121,8 +122,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic
121122
.ReturnsAsync(basicCredential)
122123
.Verifiable();
123124
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
125+
var oauthMock = new Mock<IOAuthAuthentication>();
124126

125-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
127+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
126128

127129
ICredential credential = await provider.GenerateCredentialAsync(input);
128130

@@ -152,8 +154,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu
152154
.ReturnsAsync(basicCredential)
153155
.Verifiable();
154156
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
157+
var oauthMock = new Mock<IOAuthAuthentication>();
155158

156-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
159+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
157160

158161
ICredential credential = await provider.GenerateCredentialAsync(input);
159162

@@ -199,8 +202,9 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool
199202
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
200203
wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny<Uri>()))
201204
.ReturnsAsync(wiaSupported);
205+
var oauthMock = new Mock<IOAuthAuthentication>();
202206

203-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
207+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
204208

205209
ICredential credential = await provider.GenerateCredentialAsync(input);
206210

@@ -230,8 +234,9 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool
230234
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
231235
wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny<Uri>()))
232236
.ReturnsAsync(wiaSupported);
237+
var oauthMock = new Mock<IOAuthAuthentication>();
233238

234-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
239+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
235240

236241
ICredential credential = await provider.GenerateCredentialAsync(input);
237242

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using GitCredentialManager.Authentication.OAuth;
7+
8+
namespace GitCredentialManager.Authentication
9+
{
10+
[Flags]
11+
public enum OAuthAuthenticationModes
12+
{
13+
None = 0,
14+
Browser = 1 << 0,
15+
DeviceCode = 1 << 1,
16+
17+
All = Browser | DeviceCode
18+
}
19+
20+
public interface IOAuthAuthentication
21+
{
22+
Task<OAuthAuthenticationModes> GetAuthenticationModeAsync(string resource, OAuthAuthenticationModes modes);
23+
24+
Task<OAuth2TokenResult> GetTokenByBrowserAsync(OAuth2Client client, string[] scopes);
25+
26+
Task<OAuth2TokenResult> GetTokenByDeviceCodeAsync(OAuth2Client client, string[] scopes);
27+
}
28+
29+
public class OAuthAuthentication : AuthenticationBase, IOAuthAuthentication
30+
{
31+
public OAuthAuthentication(ICommandContext context)
32+
: base (context) { }
33+
34+
public async Task<OAuthAuthenticationModes> GetAuthenticationModeAsync(
35+
string resource, OAuthAuthenticationModes modes)
36+
{
37+
EnsureArgument.NotNullOrWhiteSpace(resource, nameof(resource));
38+
39+
ThrowIfUserInteractionDisabled();
40+
41+
// Browser requires a desktop session!
42+
if (!Context.SessionManager.IsDesktopSession)
43+
{
44+
modes &= ~OAuthAuthenticationModes.Browser;
45+
}
46+
47+
// We need at least one mode!
48+
if (modes == OAuthAuthenticationModes.None)
49+
{
50+
throw new ArgumentException(@$"Must specify at least one {nameof(OAuthAuthenticationModes)}", nameof(modes));
51+
}
52+
53+
// If there is no mode choice to be made then just return that result
54+
if (modes == OAuthAuthenticationModes.Browser ||
55+
modes == OAuthAuthenticationModes.DeviceCode)
56+
{
57+
return modes;
58+
}
59+
60+
ThrowIfTerminalPromptsDisabled();
61+
62+
switch (modes)
63+
{
64+
case OAuthAuthenticationModes.Browser:
65+
return OAuthAuthenticationModes.Browser;
66+
67+
case OAuthAuthenticationModes.DeviceCode:
68+
return OAuthAuthenticationModes.DeviceCode;
69+
70+
default:
71+
var menuTitle = $"Select an authentication method for '{resource}'";
72+
var menu = new TerminalMenu(Context.Terminal, menuTitle);
73+
74+
TerminalMenuItem browserItem = null;
75+
TerminalMenuItem deviceItem = null;
76+
77+
if ((modes & OAuthAuthenticationModes.Browser) != 0) browserItem = menu.Add("Web browser");
78+
if ((modes & OAuthAuthenticationModes.DeviceCode) != 0) deviceItem = menu.Add("Device code");
79+
80+
// Default to the 'first' choice in the menu
81+
TerminalMenuItem choice = menu.Show(0);
82+
83+
if (choice == browserItem) goto case OAuthAuthenticationModes.Browser;
84+
if (choice == deviceItem) goto case OAuthAuthenticationModes.DeviceCode;
85+
86+
throw new Exception();
87+
}
88+
89+
}
90+
91+
public async Task<OAuth2TokenResult> GetTokenByBrowserAsync(OAuth2Client client, string[] scopes)
92+
{
93+
ThrowIfUserInteractionDisabled();
94+
95+
// We require a desktop session to launch the user's default web browser
96+
if (!Context.SessionManager.IsDesktopSession)
97+
{
98+
throw new InvalidOperationException("Browser authentication requires a desktop session");
99+
}
100+
101+
var browserOptions = new OAuth2WebBrowserOptions();
102+
var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions);
103+
var authCode = await client.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None);
104+
return await client.GetTokenByAuthorizationCodeAsync(authCode, CancellationToken.None);
105+
}
106+
107+
public async Task<OAuth2TokenResult> GetTokenByDeviceCodeAsync(OAuth2Client client, string[] scopes)
108+
{
109+
ThrowIfUserInteractionDisabled();
110+
111+
OAuth2DeviceCodeResult dcr = await client.GetDeviceCodeAsync(scopes, CancellationToken.None);
112+
113+
ThrowIfTerminalPromptsDisabled();
114+
115+
string deviceMessage = $"To complete authentication please visit {dcr.VerificationUri} and enter the following code:" +
116+
Environment.NewLine +
117+
dcr.UserCode;
118+
Context.Terminal.WriteLine(deviceMessage);
119+
120+
return await client.GetTokenByDeviceCodeAsync(dcr, CancellationToken.None);
121+
}
122+
}
123+
}

src/shared/Core/GenericHostProvider.cs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,37 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Net.Http;
5+
using System.Threading;
46
using System.Threading.Tasks;
57
using GitCredentialManager.Authentication;
8+
using GitCredentialManager.Authentication.OAuth;
69

710
namespace GitCredentialManager
811
{
912
public class GenericHostProvider : HostProvider
1013
{
1114
private readonly IBasicAuthentication _basicAuth;
1215
private readonly IWindowsIntegratedAuthentication _winAuth;
16+
private readonly IOAuthAuthentication _oauth;
1317

1418
public GenericHostProvider(ICommandContext context)
15-
: this(context, new BasicAuthentication(context), new WindowsIntegratedAuthentication(context)) { }
19+
: this(context, new BasicAuthentication(context), new WindowsIntegratedAuthentication(context),
20+
new OAuthAuthentication(context)) { }
1621

1722
public GenericHostProvider(ICommandContext context,
1823
IBasicAuthentication basicAuth,
19-
IWindowsIntegratedAuthentication winAuth)
24+
IWindowsIntegratedAuthentication winAuth,
25+
IOAuthAuthentication oauth)
2026
: base(context)
2127
{
2228
EnsureArgument.NotNull(basicAuth, nameof(basicAuth));
2329
EnsureArgument.NotNull(winAuth, nameof(winAuth));
30+
EnsureArgument.NotNull(oauth, nameof(oauth));
2431

2532
_basicAuth = basicAuth;
2633
_winAuth = winAuth;
34+
_oauth = oauth;
2735
}
2836

2937
public override string Id => "generic";
@@ -68,7 +76,7 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
6876
Context.Trace.WriteLine($"\tUseAuthHeader = {oauthConfig.UseAuthHeader}");
6977
Context.Trace.WriteLine($"\tDefaultUserName = {oauthConfig.DefaultUserName}");
7078

71-
throw new NotImplementedException();
79+
return await GetOAuthAccessToken(uri, input.UserName, oauthConfig);
7280
}
7381
// Try detecting WIA for this remote, if permitted
7482
else if (IsWindowsAuthAllowed)
@@ -106,6 +114,65 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
106114
return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName);
107115
}
108116

117+
private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config)
118+
{
119+
// TODO: Determined user info from a webcall? ID token? Need OIDC support
120+
string oauthUser = userName ?? config.DefaultUserName;
121+
122+
var client = new OAuth2Client(
123+
HttpClient,
124+
config.Endpoints,
125+
config.ClientId,
126+
config.RedirectUri,
127+
config.ClientSecret,
128+
Context.Trace,
129+
config.UseAuthHeader);
130+
131+
// Determine which interactive OAuth mode to use. Start by checking for mode preference in config
132+
var supportedModes = OAuthAuthenticationModes.All;
133+
if (Context.Settings.TryGetSetting(
134+
Constants.EnvironmentVariables.OAuthAuthenticationModes,
135+
Constants.GitConfiguration.Credential.SectionName,
136+
Constants.GitConfiguration.Credential.OAuthAuthenticationModes,
137+
out string authModesStr))
138+
{
139+
if (Enum.TryParse(authModesStr, true, out supportedModes) && supportedModes != OAuthAuthenticationModes.None)
140+
{
141+
Context.Trace.WriteLine($"Supported authentication modes override present: {supportedModes}");
142+
}
143+
else
144+
{
145+
Context.Trace.WriteLine($"Invalid value for supported authentication modes override setting: '{authModesStr}'");
146+
}
147+
}
148+
149+
// If the server doesn't support device code we need to remove it as an option here
150+
if (!config.SupportsDeviceCode)
151+
{
152+
supportedModes &= ~OAuthAuthenticationModes.DeviceCode;
153+
}
154+
155+
// Prompt the user to select a mode
156+
OAuthAuthenticationModes mode = await _oauth.GetAuthenticationModeAsync(remoteUri.ToString(), supportedModes);
157+
158+
OAuth2TokenResult tokenResult;
159+
switch (mode)
160+
{
161+
case OAuthAuthenticationModes.Browser:
162+
tokenResult = await _oauth.GetTokenByBrowserAsync(client, config.Scopes);
163+
break;
164+
165+
case OAuthAuthenticationModes.DeviceCode:
166+
tokenResult = await _oauth.GetTokenByDeviceCodeAsync(client, config.Scopes);
167+
break;
168+
169+
default:
170+
throw new Exception("No authentication mode selected!");
171+
}
172+
173+
return new GitCredential(oauthUser, tokenResult.AccessToken);
174+
}
175+
109176
/// <summary>
110177
/// Check if the user permits checking for Windows Integrated Authentication.
111178
/// </summary>
@@ -131,9 +198,13 @@ private bool IsWindowsAuthAllowed
131198
}
132199
}
133200

201+
private HttpClient _httpClient;
202+
private HttpClient HttpClient => _httpClient ?? (_httpClient = Context.HttpClientFactory.CreateClient());
203+
134204
protected override void ReleaseManagedResources()
135205
{
136206
_winAuth.Dispose();
207+
_httpClient?.Dispose();
137208
base.ReleaseManagedResources();
138209
}
139210
}

0 commit comments

Comments
 (0)