diff --git a/src/Core.Tests/Commands/CapabilityCommandTests.cs b/src/Core.Tests/Commands/CapabilityCommandTests.cs index 9a90eb58b..3b2ef862c 100644 --- a/src/Core.Tests/Commands/CapabilityCommandTests.cs +++ b/src/Core.Tests/Commands/CapabilityCommandTests.cs @@ -22,7 +22,7 @@ public void CapabilityCommand_Execute_WritesVersionAndAdvertisedCapabilities() Assert.StartsWith("version 0\n", actualOutput); // GCM advertises the state capability. - Assert.Equal("version 0\ncapability state\n", actualOutput); + Assert.Equal("version 0\ncapability state\ncapability authtype\n", actualOutput); } [Fact] diff --git a/src/Core.Tests/Commands/GetCommandTests.cs b/src/Core.Tests/Commands/GetCommandTests.cs index 3878e5b40..5993487a4 100644 --- a/src/Core.Tests/Commands/GetCommandTests.cs +++ b/src/Core.Tests/Commands/GetCommandTests.cs @@ -122,7 +122,7 @@ public async Task GetCommand_ExecuteAsync_NegotiatedCapability_EchoesIntersectio string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); Assert.Contains("capability[]=state\n", actualOutput); - Assert.DoesNotContain("capability[]=authtype", actualOutput); + Assert.Contains("capability[]=authtype\n", actualOutput); } [Fact] @@ -328,6 +328,62 @@ public async Task GetCommand_ExecuteAsync_OutputOrdering_CapabilitiesFirstThenSc "state[] must follow continue=1"); } + [Fact] + public async Task GetCommand_ExecuteAsync_CapabilityAuthType_MissingRevertsToPlainPassword() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); + + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string[] actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n").Split('\n'); + + // Emits regular Credential without 'state[]=authtype' support. + Assert.Contains("username=alice", actualOutput); + Assert.Contains("password=hunter2", actualOutput); + Assert.DoesNotContain("authtype=token", actualOutput); + Assert.DoesNotContain("credential=hunter2", actualOutput); + Assert.DoesNotContain("ephemeral=1", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_CapabilityAuthType_UsesAuthTypeFeatures() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); + + var stdin = "protocol=https\nhost=example.com\ncapability[]=authtype\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string[] actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n").Split('\n'); + + // Ephemeral credential with 'authtype' and 'credential' value + Assert.DoesNotContain("username=alice", actualOutput); + Assert.DoesNotContain("password=hunter2", actualOutput); + Assert.Contains("authtype=token", actualOutput); + Assert.Contains("credential=hunter2", actualOutput); + Assert.Contains("ephemeral=1", actualOutput); + } + #region Helpers private static IDictionary ParseDictionary(StringBuilder sb) => ParseDictionary(sb.ToString()); diff --git a/src/Core/Commands/GetCommand.cs b/src/Core/Commands/GetCommand.cs index 551e1feac..6ac5a0756 100644 --- a/src/Core/Commands/GetCommand.cs +++ b/src/Core/Commands/GetCommand.cs @@ -59,6 +59,7 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv // Negotiate capabilities by intersecting what Git advertised with what GCM supports. // Capability-gated output fields may only be emitted for capabilities in this set. GitCapabilities negotiated = request.Capabilities & Constants.SupportedCapabilities; + bool authTypeCapNegotiated = (negotiated & GitCapabilities.AuthType) != 0; bool stateCapNegotiated = (negotiated & GitCapabilities.State) != 0; Context.Trace.WriteLine($"Git capability: {request.Capabilities}"); @@ -98,10 +99,26 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv // // Credential // - stdout.WriteLine($"username={credential.Account}"); - Context.Trace.WriteLine($"\tusername={credential.Account}"); - stdout.WriteLine($"password={credential.Password}"); - Context.Trace.WriteLineSecrets("\tpassword={0}", new object[] { credential.Password }); + var authtype = response.AuthType; + if (authTypeCapNegotiated && authtype is not null) + { + stdout.WriteLine($"{Constants.CredentialProtocol.AuthTypeKey}={authtype}"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.AuthTypeKey}={authtype}"); + stdout.WriteLine($"{Constants.CredentialProtocol.CredentialKey}={credential.Password}"); + Context.Trace.WriteLineSecrets("\t" + Constants.CredentialProtocol.CredentialKey + "={0}", [credential.Password]); + } + else + { + stdout.WriteLine($"{Constants.CredentialProtocol.UserNameKey}={credential.Account}"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.UserNameKey}={credential.Account}"); + stdout.WriteLine($"{Constants.CredentialProtocol.PasswordKey}={credential.Password}"); + Context.Trace.WriteLineSecrets("\t" + Constants.CredentialProtocol.PasswordKey + "={0}", [ credential.Password ]); + } + if (authTypeCapNegotiated && response.IsCredentialEphemeral) + { + stdout.WriteLine($"{Constants.CredentialProtocol.EphemeralKey}=1"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.EphemeralKey}=1"); + } // // Custom additional properties diff --git a/src/Core/Commands/GitCommandBase.cs b/src/Core/Commands/GitCommandBase.cs index 1dfc398a9..fcb0a0d79 100644 --- a/src/Core/Commands/GitCommandBase.cs +++ b/src/Core/Commands/GitCommandBase.cs @@ -51,7 +51,7 @@ internal async Task ExecuteAsync() // Determine the host provider Context.Trace.WriteLine("Detecting host provider for request:"); - Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password" }, StringComparer.OrdinalIgnoreCase); + Context.Trace.WriteDictionarySecrets(inputDict, [ "password", "credential" ], StringComparer.OrdinalIgnoreCase); IHostProvider provider; using (Trace2.StartRegion("git_cmd", "resolve_provider")) { diff --git a/src/Core/Commands/StoreCommand.cs b/src/Core/Commands/StoreCommand.cs index 09043ff74..dcc100c98 100644 --- a/src/Core/Commands/StoreCommand.cs +++ b/src/Core/Commands/StoreCommand.cs @@ -24,6 +24,16 @@ protected override void EnsureMinimumRequest(GitRequest request) { base.EnsureMinimumRequest(request); + // When "authtype" is set, Git uses "credential" field for the secret + if (request.AuthType is not null) + { + if (request.Credential is null) + { + throw new InvalidOperationException("Missing 'credential' request argument"); + } + return; + } + // An empty string username/password are valid inputs, so only check for `null` (not provided) if (request.UserName is null) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cff7a1a1b..f2409b0ab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -36,7 +36,7 @@ public static class Constants /// /// The set of Git credential protocol capabilities supported by Git Credential Manager. /// - public const GitCapabilities SupportedCapabilities = GitCapabilities.State; + public const GitCapabilities SupportedCapabilities = GitCapabilities.State | GitCapabilities.AuthType; public static class CredentialProtocol { @@ -57,6 +57,36 @@ public static class CredentialProtocol /// public const string ContinueKey = "continue"; + /// + /// The Git credential protocol attribute name carrying alternative + /// HTTP Authorization scheme type. + /// (string, gated by the authtype capability). + /// + public const string AuthTypeKey = "authtype"; + + /// + /// The Git credential protocol attribute name carrying raw credential data. + /// (string, gated by the authtype capability). + /// + public const string CredentialKey = "credential"; + + /// + /// The Git credential protocol attribute name carrying setting + /// for ephemeral credential type. + /// (string, gated by the authtype capability). + /// + public const string EphemeralKey = "ephemeral"; + + /// + /// The Git credential protocol attribute name for password. + /// + public const string PasswordKey = "password"; + + /// + /// The Git credential protocol attribute name for username. + /// + public const string UserNameKey = "username"; + /// /// Prefix that Git Credential Manager reserves on every state[] entry /// so its state can be distinguished from other helpers' state per diff --git a/src/Core/GitCapabilities.cs b/src/Core/GitCapabilities.cs index 2232abbbf..82de5a779 100644 --- a/src/Core/GitCapabilities.cs +++ b/src/Core/GitCapabilities.cs @@ -38,6 +38,14 @@ public enum GitCapabilities /// flag that signals a non-final authentication step is expected. /// State = 1 << 0, + + /// + /// The authtype, credential, and ephemeral attributes are understood. + /// + /// + /// Provides alternative formats for HTTP Authorization header. + /// + AuthType = 1 << 1, } /// @@ -66,7 +74,8 @@ public static GitCapabilities ParseName(string name) // handling is implemented. return name.ToLowerInvariant() switch { - "state" => GitCapabilities.State, + Constants.CredentialProtocol.StateKey => GitCapabilities.State, + Constants.CredentialProtocol.AuthTypeKey => GitCapabilities.AuthType, _ => GitCapabilities.None, }; } @@ -89,7 +98,8 @@ public static string ToProtocolName(GitCapabilities capability) // protocol name distinct from its .NET enum name). return capability switch { - GitCapabilities.State => "state", + GitCapabilities.State => Constants.CredentialProtocol.StateKey, + GitCapabilities.AuthType => Constants.CredentialProtocol.AuthTypeKey, GitCapabilities.None => throw new ArgumentException( "Cannot render the None capability to a protocol name.", nameof(capability)), diff --git a/src/Core/GitRequest.cs b/src/Core/GitRequest.cs index d742650da..fac9c37ec 100644 --- a/src/Core/GitRequest.cs +++ b/src/Core/GitRequest.cs @@ -47,8 +47,13 @@ public GitRequest(IDictionary> dict) public string Protocol => GetArgumentOrDefault("protocol"); public string Host => GetArgumentOrDefault("host"); public string Path => GetArgumentOrDefault("path"); - public string UserName => GetArgumentOrDefault("username"); - public string Password => GetArgumentOrDefault("password"); + + public string AuthType => GetArgumentOrDefault(Constants.CredentialProtocol.AuthTypeKey); + public string Credential => GetArgumentOrDefault(Constants.CredentialProtocol.CredentialKey); + public string UserName => GetArgumentOrDefault(Constants.CredentialProtocol.UserNameKey); + public string Password => GetArgumentOrDefault(Constants.CredentialProtocol.PasswordKey); + public bool IsEphemeral => StringExtensions.IsTruthy(GetArgumentOrDefault(Constants.CredentialProtocol.EphemeralKey)); + public IList WwwAuth => GetMultiArgumentOrDefault("wwwauth"); /// diff --git a/src/Core/GitResponse.cs b/src/Core/GitResponse.cs index b7baaf3cb..caaf1b54e 100644 --- a/src/Core/GitResponse.cs +++ b/src/Core/GitResponse.cs @@ -43,7 +43,7 @@ public class GitResponse private readonly Dictionary _state = new(StringComparer.Ordinal); private ReadOnlyDictionary _stateView; - private GitResponse(ICredential credential, bool isContinue, bool isCancelled, bool isYielded) + private GitResponse(ICredential credential, bool isEphemeral = false, string authType = null, bool isContinue = false, bool isCancelled = false, bool isYielded = false) { // At most one of Continue, Cancel, Yield may be set (Ok is "none of them"). if ((isContinue && isCancelled) || @@ -71,6 +71,9 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b } Credential = credential; + IsCredentialEphemeral = isEphemeral; + AuthType = authType; + IsContinue = isContinue; IsCancelled = isCancelled; IsYielded = isYielded; @@ -81,15 +84,15 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b /// /// Equivalent to . public GitResponse(ICredential credential) - : this(credential, isContinue: false, isCancelled: false, isYielded: false) + : this(credential, authType: null) { } /// /// Construct a successful response carrying the given credential. /// - public static GitResponse Ok(ICredential credential) => - new GitResponse(credential, isContinue: false, isCancelled: false, isYielded: false); + public static GitResponse Ok(ICredential credential, bool isEphemeral = false, string authtype = null) => + new(credential, isEphemeral: isEphemeral, authType: authtype); /// /// Construct a successful response carrying the given credential and @@ -101,8 +104,8 @@ public static GitResponse Ok(ICredential credential) => /// multistage HTTP authentication (NTLM/Kerberos) and any flow where the /// helper wants to be invoked again after the next server response. /// - public static GitResponse Continue(ICredential credential) => - new GitResponse(credential, isContinue: true, isCancelled: false, isYielded: false); + public static GitResponse Continue(ICredential credential, bool isEphemeral = false, string authType = null) => + new (credential, isEphemeral: isEphemeral, authType: authType, isContinue: true); /// /// Construct a cancellation response: the provider declined to produce a @@ -117,7 +120,7 @@ public static GitResponse Continue(ICredential credential) => /// cancelled response are ignored. /// public static GitResponse Cancel() => - new GitResponse(credential: null, isContinue: false, isCancelled: true, isYielded: false); + new GitResponse(credential: null, isCancelled: true); /// /// Construct a yielded response: the provider has nothing to contribute @@ -132,7 +135,7 @@ public static GitResponse Cancel() => /// set on a yielded response are ignored. /// public static GitResponse Yield() => - new GitResponse(credential: null, isContinue: false, isCancelled: false, isYielded: true); + new GitResponse(credential: null, isYielded: true); /// /// The credential resolved or generated for the request, or @@ -140,6 +143,16 @@ public static GitResponse Yield() => /// public ICredential Credential { get; } + /// + /// The credential is ephemeral (or generated) and not to be stored on success. + /// + public bool IsCredentialEphemeral { get; } + + /// + /// The special Autorization type for the supplied credential. + /// + public string AuthType { get; } + /// /// when the provider expects a further round of /// authentication. The command layer translates this into a continue=1