Skip to content

Commit 5b2b752

Browse files
committed
Add GCM_INTERACTIVE/credential.interactive setting
Add a setting to disable all user interaction. By setting `GCM_INTERACTIVE` or `credential.interactive` to a 'falsey' value, GCM Core will now fail and return an error if user interaction is required. This is useful in headless and unattended environments, such as build servers, where is is preferable to fail than it is to hang waiting for input from a non-existent user. The setting also existed in the previous GCM for Windows, but its behaviour has been slightly modified to treat 'always' values as 'auto'. See more in the documentation and the code. The default value is `true` / permit interaction.
1 parent 7c26c5f commit 5b2b752

File tree

15 files changed

+363
-28
lines changed

15 files changed

+363
-28
lines changed

docs/configuration.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,37 @@ For the complete list of settings GCM Core understands, see the list below.
2222

2323
## Available settings
2424

25+
### credential.interactive
26+
27+
Permit or disable GCM Core from interacting with the user (showing GUI or TTY prompts). If interaction is required but has been disabled, an error is returned.
28+
29+
This can be helpful when using GCM Core in headless and unattended environments, such as build servers, where it would be preferable to fail than to hang indefinately waiting for a non-existent user.
30+
31+
To disable interactivity set this to `false` or `0`.
32+
33+
#### Compatibility
34+
35+
In previous versions of GCM this setting had a different behavior and accepted other values.
36+
The following table summarizes the change in behavior and the mapping of older values such as `never`:
37+
38+
Value(s)|Old meaning|New meaning
39+
-|-|-
40+
`auto`|Prompt if required – use cached credentials if possible|_(unchanged)_
41+
`never`,<br/>`false`| Never prompt – fail if interaction is required|_(unchanged)_
42+
`always`,<br/>`force`,<br/>`true`|Always prompt – don't use cached credentials|Prompt if required (same as the old `auto` value)
43+
44+
#### Example
45+
46+
```shell
47+
git config --global credential.interactive false
48+
```
49+
50+
Defaults to enabled.
51+
52+
**Also see: [GCM_INTERACTIVE](environment.md#GCM_INTERACTIVE)**
53+
54+
---
55+
2556
### credential.provider
2657

2758
Define the host provider to use when authenticating.

docs/environment.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,45 @@ _No configuration equivalent._
121121

122122
---
123123

124+
### GCM_INTERACTIVE
125+
126+
Permit or disable GCM Core from interacting with the user (showing GUI or TTY prompts). If interaction is required but has been disabled, an error is returned.
127+
128+
This can be helpful when using GCM Core in headless and unattended environments, such as build servers, where it would be preferable to fail than to hang indefinately waiting for a non-existent user.
129+
130+
To disable interactivity set this to `false` or `0`.
131+
132+
#### Compatibility
133+
134+
In previous versions of GCM this setting had a different behavior and accepted other values.
135+
The following table summarizes the change in behavior and the mapping of older values such as `never`:
136+
137+
Value(s)|Old meaning|New meaning
138+
-|-|-
139+
`auto`|Prompt if required – use cached credentials if possible|_(unchanged)_
140+
`never`,<br/>`false`| Never prompt – fail if interaction is required|_(unchanged)_
141+
`always`,<br/>`force`,<br/>`true`|Always prompt – don't use cached credentials|Prompt if required (same as the old `auto` value)
142+
143+
#### Example
144+
145+
##### Windows
146+
147+
```batch
148+
SET GCM_INTERACTIVE=0
149+
```
150+
151+
##### macOS/Linux
152+
153+
```bash
154+
export GCM_INTERACTIVE=0
155+
```
156+
157+
Defaults to enabled.
158+
159+
**Also see: [credential.interactive](configuration.md#credentialinteractive)**
160+
161+
---
162+
124163
### GCM_PROVIDER
125164

126165
Define the host provider to use when authenticating.

src/osx/Microsoft.Authentication.Helper.Mac/Source/main.m

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ int main(int argc, const char * argv[]) {
2828

2929
@autoreleasepool {
3030
int exitCode;
31-
NSError* error;
31+
NSError *error;
32+
NSString *output;
3233

3334
AHLogger *logger = [[AHLogger alloc] init];
3435

@@ -97,24 +98,32 @@ int main(int argc, const char * argv[]) {
9798
NSString* clientId = [configs objectForKey:@"clientId"];
9899
NSString* resource = [configs objectForKey:@"resource"];
99100
NSString* redirectUri = [configs objectForKey:@"redirectUri"];
101+
NSString* interactive = [configs objectForKey:@"interactive"];
100102

101-
NSString *accessToken = [AHGenerateAccessToken generateAccessTokenWithAuthority:authority
102-
clientId:clientId
103-
resource:resource
104-
redirectUri:redirectUri
105-
error:&error
106-
logger:logger];
107-
108-
NSString* output;
109-
110-
if (error == nil && accessToken != nil)
103+
// We only perform interactive flows
104+
if (isTruthy(interactive))
111105
{
112-
output = [NSString stringWithFormat:@"accessToken=%@\n", accessToken];
113-
exitCode = 0;
106+
NSString *accessToken = [AHGenerateAccessToken generateAccessTokenWithAuthority:authority
107+
clientId:clientId
108+
resource:resource
109+
redirectUri:redirectUri
110+
error:&error
111+
logger:logger];
112+
113+
if (error == nil && accessToken != nil)
114+
{
115+
output = [NSString stringWithFormat:@"accessToken=%@\n", accessToken];
116+
exitCode = 0;
117+
}
118+
else
119+
{
120+
output = [NSString stringWithFormat:@"error=%@\n", [error description]];
121+
exitCode = -1;
122+
}
114123
}
115124
else
116125
{
117-
output = [NSString stringWithFormat:@"error=%@\n", [error description]];
126+
output = @"error=Interactivity is required but has been disabled.\n";
118127
exitCode = -1;
119128
}
120129

src/shared/GitHub/GitHubAuthentication.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public async Task<ICredential> GetCredentialsAsync(Uri targetUri)
3131
{
3232
string userName, password;
3333

34+
ThrowIfUserInteractionDisabled();
35+
3436
if (TryFindHelperExecutablePath(out string helperPath))
3537
{
3638
IDictionary<string, string> resultDict = await InvokeHelperAsync(helperPath, "--prompt userpass", null);
@@ -47,7 +49,7 @@ public async Task<ICredential> GetCredentialsAsync(Uri targetUri)
4749
}
4850
else
4951
{
50-
EnsureTerminalPromptsEnabled();
52+
ThrowIfTerminalPromptsDisabled();
5153

5254
Context.Terminal.WriteLine("Enter GitHub credentials for '{0}'...", targetUri);
5355

@@ -57,8 +59,11 @@ public async Task<ICredential> GetCredentialsAsync(Uri targetUri)
5759

5860
return new GitCredential(userName, password);
5961
}
62+
6063
public async Task<string> GetAuthenticationCodeAsync(Uri targetUri, bool isSms)
6164
{
65+
ThrowIfUserInteractionDisabled();
66+
6267
if (TryFindHelperExecutablePath(out string helperPath))
6368
{
6469
IDictionary<string, string> resultDict = await InvokeHelperAsync(helperPath, "--prompt authcode", null);
@@ -72,7 +77,7 @@ public async Task<string> GetAuthenticationCodeAsync(Uri targetUri, bool isSms)
7277
}
7378
else
7479
{
75-
EnsureTerminalPromptsEnabled();
80+
ThrowIfTerminalPromptsDisabled();
7681

7782
Context.Terminal.WriteLine("Two-factor authentication is enabled and an authentication code is required.");
7883

src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ public void BasicAuthentication_GetCredentials_Resource_UserPassPromptReturnsCre
5555
Assert.Equal(testPassword, credential.Password);
5656
}
5757

58+
[Fact]
59+
public void BasicAuthentication_GetCredentials_NoInteraction_ThrowsException()
60+
{
61+
const string testResource = "https://example.com";
62+
63+
var context = new TestCommandContext
64+
{
65+
Settings = {IsInteractionAllowed = false},
66+
};
67+
68+
var basicAuth = new BasicAuthentication(context);
69+
70+
Assert.Throws<InvalidOperationException>(() => basicAuth.GetCredentials(testResource));
71+
}
72+
5873
[Fact]
5974
public void BasicAuthentication_GetCredentials_NoTerminalPrompts_ThrowsException()
6075
{
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using Microsoft.Git.CredentialManager.Authentication;
5+
using Microsoft.Git.CredentialManager.Tests.Objects;
6+
using Xunit;
7+
8+
namespace Microsoft.Git.CredentialManager.Tests.Authentication
9+
{
10+
public class MicrosoftAuthenticationTests
11+
{
12+
[Fact]
13+
public async System.Threading.Tasks.Task MicrosoftAuthentication_GetAccessTokenAsync_NoInteraction_ThrowsException()
14+
{
15+
const string authority = "https://login.microsoftonline.com/common";
16+
const string clientId = "C9E8FDA6-1D46-484C-917C-3DBD518F27C3";
17+
Uri redirectUri = new Uri("https://localhost");
18+
const string resource = "https://graph.microsoft.com";
19+
Uri remoteUri = new Uri("https://example.com");
20+
const string userName = null; // No user to ensure we do not use an existing token
21+
22+
var context = new TestCommandContext
23+
{
24+
Settings = {IsInteractionAllowed = false},
25+
};
26+
27+
var msAuth = new MicrosoftAuthentication(context);
28+
29+
await Assert.ThrowsAsync<InvalidOperationException>(
30+
() => msAuth.GetAccessTokenAsync(authority, clientId, redirectUri, resource, remoteUri, userName));
31+
}
32+
}
33+
}

src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,135 @@ public void Settings_IsTerminalPromptsEnabled_EnvarFalsey_ReturnsFalse()
8787
Assert.False(settings.IsTerminalPromptsEnabled);
8888
}
8989

90+
[Fact]
91+
public void Settings_IsInteractionAllowed_EnvarUnset_ReturnsTrue()
92+
{
93+
var envars = new TestEnvironment();
94+
var git = new TestGit();
95+
96+
var settings = new Settings(envars, git);
97+
98+
Assert.True(settings.IsInteractionAllowed);
99+
}
100+
101+
[Fact]
102+
public void Settings_IsInteractionAllowed_EnvarTruthy_ReturnsTrue()
103+
{
104+
var envars = new TestEnvironment
105+
{
106+
Variables = {[Constants.EnvironmentVariables.GcmInteractive] = "1"}
107+
};
108+
var git = new TestGit();
109+
110+
var settings = new Settings(envars, git);
111+
112+
Assert.True(settings.IsInteractionAllowed);
113+
}
114+
115+
[Fact]
116+
public void Settings_IsInteractionAllowed_EnvarFalsey_ReturnsFalse()
117+
{
118+
var envars = new TestEnvironment
119+
{
120+
Variables = {[Constants.EnvironmentVariables.GcmInteractive] = "0"},
121+
};
122+
var git = new TestGit();
123+
124+
var settings = new Settings(envars, git);
125+
126+
Assert.False(settings.IsInteractionAllowed);
127+
}
128+
129+
[Fact]
130+
public void Settings_IsInteractionAllowed_ConfigAuto_ReturnsTrue()
131+
{
132+
const string section = Constants.GitConfiguration.Credential.SectionName;
133+
const string property = Constants.GitConfiguration.Credential.Interactive;
134+
135+
var envars = new TestEnvironment();
136+
var git = new TestGit();
137+
git.GlobalConfiguration[$"{section}.{property}"] = "auto";
138+
139+
var settings = new Settings(envars, git);
140+
141+
Assert.True(settings.IsInteractionAllowed);
142+
}
143+
144+
[Fact]
145+
public void Settings_IsInteractionAllowed_ConfigAlways_ReturnsTrue()
146+
{
147+
const string section = Constants.GitConfiguration.Credential.SectionName;
148+
const string property = Constants.GitConfiguration.Credential.Interactive;
149+
150+
var envars = new TestEnvironment();
151+
var git = new TestGit();
152+
git.GlobalConfiguration[$"{section}.{property}"] = "always";
153+
154+
var settings = new Settings(envars, git);
155+
156+
Assert.True(settings.IsInteractionAllowed);
157+
}
158+
159+
[Fact]
160+
public void Settings_IsInteractionAllowed_ConfigNever_ReturnsFalse()
161+
{
162+
const string section = Constants.GitConfiguration.Credential.SectionName;
163+
const string property = Constants.GitConfiguration.Credential.Interactive;
164+
165+
var envars = new TestEnvironment();
166+
var git = new TestGit();
167+
git.GlobalConfiguration[$"{section}.{property}"] = "never";
168+
169+
var settings = new Settings(envars, git);
170+
171+
Assert.False(settings.IsInteractionAllowed);
172+
}
173+
174+
[Fact]
175+
public void Settings_IsInteractionAllowed_ConfigTruthy_ReturnsTrue()
176+
{
177+
const string section = Constants.GitConfiguration.Credential.SectionName;
178+
const string property = Constants.GitConfiguration.Credential.Interactive;
179+
180+
var envars = new TestEnvironment();
181+
var git = new TestGit();
182+
git.GlobalConfiguration[$"{section}.{property}"] = "1";
183+
184+
var settings = new Settings(envars, git);
185+
186+
Assert.True(settings.IsInteractionAllowed);
187+
}
188+
189+
[Fact]
190+
public void Settings_IsInteractionAllowed_ConfigFalsey_ReturnsFalse()
191+
{
192+
const string section = Constants.GitConfiguration.Credential.SectionName;
193+
const string property = Constants.GitConfiguration.Credential.Interactive;
194+
195+
var envars = new TestEnvironment();
196+
var git = new TestGit();
197+
git.GlobalConfiguration[$"{section}.{property}"] = "0";
198+
199+
var settings = new Settings(envars, git);
200+
201+
Assert.False(settings.IsInteractionAllowed);
202+
}
203+
204+
[Fact]
205+
public void Settings_IsInteractionAllowed_ConfigNonBooleanyValue_ReturnsTrue()
206+
{
207+
const string section = Constants.GitConfiguration.Credential.SectionName;
208+
const string property = Constants.GitConfiguration.Credential.Interactive;
209+
210+
var envars = new TestEnvironment();
211+
var git = new TestGit();
212+
git.GlobalConfiguration[$"{section}.{property}"] = Guid.NewGuid().ToString();
213+
214+
var settings = new Settings(envars, git);
215+
216+
Assert.True(settings.IsInteractionAllowed);
217+
}
218+
90219
[Fact]
91220
public void Settings_IsTracingEnabled_EnvarUnset_ReturnsFalse()
92221
{

0 commit comments

Comments
 (0)