From 000a1757298d8ec4615ab529f90750ace487344c Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Sat, 26 Sep 2026 22:35:03 +0200 Subject: [PATCH 1/2] protocol: add and wire 'authtype' capability add and register capability flag for 'authtype' create credential output based on Authorization type and support define and use Constants for credential protocol keys move default arguments for GitResponse flags to internal constructor --- .../Commands/CapabilityCommandTests.cs | 2 +- src/Core.Tests/Commands/GetCommandTests.cs | 56 ++++++++++++++++++- src/Core/Commands/GetCommand.cs | 20 +++++-- src/Core/Commands/GitCommandBase.cs | 2 +- src/Core/Commands/StoreCommand.cs | 10 ++++ src/Core/Constants.cs | 25 ++++++++- src/Core/GitCapabilities.cs | 14 ++++- src/Core/GitRequest.cs | 8 ++- src/Core/GitResponse.cs | 22 +++++--- 9 files changed, 139 insertions(+), 20 deletions(-) diff --git a/src/Core.Tests/Commands/CapabilityCommandTests.cs b/src/Core.Tests/Commands/CapabilityCommandTests.cs index 9a90eb58b9..3b2ef862c4 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 3878e5b409..be974b1a18 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,60 @@ 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, 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); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_CapabilityAuthType_UsesAuthTypeFeatures() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential, 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); + } + #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 551e1feac8..5b22d7a8a7 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,21 @@ 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 ]); + } // // Custom additional properties diff --git a/src/Core/Commands/GitCommandBase.cs b/src/Core/Commands/GitCommandBase.cs index 1dfc398a9c..fcb0a0d790 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 09043ff74b..dcc100c981 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 cff7a1a1bd..826f8cf559 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,29 @@ 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 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 2232abbbf8..82de5a779d 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 d742650da5..fbc73da3ee 100644 --- a/src/Core/GitRequest.cs +++ b/src/Core/GitRequest.cs @@ -47,8 +47,12 @@ 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 IList WwwAuth => GetMultiArgumentOrDefault("wwwauth"); /// diff --git a/src/Core/GitResponse.cs b/src/Core/GitResponse.cs index b7baaf3cb4..37893af5d2 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, 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,7 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b } Credential = credential; + AuthType = authType; IsContinue = isContinue; IsCancelled = isCancelled; IsYielded = isYielded; @@ -81,15 +82,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, string authtype = null) => + new(credential, authType: authtype); /// /// Construct a successful response carrying the given credential and @@ -101,8 +102,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, string authType = null) => + new (credential, authType: authType, isContinue: true); /// /// Construct a cancellation response: the provider declined to produce a @@ -117,7 +118,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 +133,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 +141,11 @@ public static GitResponse Yield() => /// public ICredential Credential { 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 From 36f46247274d624e9978debb5bd9f5af52241cc6 Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Wed, 30 Sep 2026 20:36:47 +0200 Subject: [PATCH 2/2] credential: add flag for ephemeral response state emit 'ephemeral' value in GitResponse if set and capability is present check 'ephemeral' setting in GitRequest content --- src/Core.Tests/Commands/GetCommandTests.cs | 6 ++++-- src/Core/Commands/GetCommand.cs | 5 +++++ src/Core/Constants.cs | 7 +++++++ src/Core/GitRequest.cs | 1 + src/Core/GitResponse.cs | 17 ++++++++++++----- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/Core.Tests/Commands/GetCommandTests.cs b/src/Core.Tests/Commands/GetCommandTests.cs index be974b1a18..5993487a45 100644 --- a/src/Core.Tests/Commands/GetCommandTests.cs +++ b/src/Core.Tests/Commands/GetCommandTests.cs @@ -332,7 +332,7 @@ public async Task GetCommand_ExecuteAsync_OutputOrdering_CapabilitiesFirstThenSc public async Task GetCommand_ExecuteAsync_CapabilityAuthType_MissingRevertsToPlainPassword() { ICredential testCredential = new GitCredential("alice", "hunter2"); - var response = GitResponse.Ok(testCredential, authtype: "token"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); var stdin = "protocol=https\nhost=example.com\n\n"; @@ -353,13 +353,14 @@ public async Task GetCommand_ExecuteAsync_CapabilityAuthType_MissingRevertsToPla 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, authtype: "token"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); var stdin = "protocol=https\nhost=example.com\ncapability[]=authtype\n\n"; @@ -380,6 +381,7 @@ public async Task GetCommand_ExecuteAsync_CapabilityAuthType_UsesAuthTypeFeature Assert.DoesNotContain("password=hunter2", actualOutput); Assert.Contains("authtype=token", actualOutput); Assert.Contains("credential=hunter2", actualOutput); + Assert.Contains("ephemeral=1", actualOutput); } #region Helpers diff --git a/src/Core/Commands/GetCommand.cs b/src/Core/Commands/GetCommand.cs index 5b22d7a8a7..6ac5a0756b 100644 --- a/src/Core/Commands/GetCommand.cs +++ b/src/Core/Commands/GetCommand.cs @@ -114,6 +114,11 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv 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/Constants.cs b/src/Core/Constants.cs index 826f8cf559..f2409b0ab5 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -70,6 +70,13 @@ public static class CredentialProtocol /// 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. /// diff --git a/src/Core/GitRequest.cs b/src/Core/GitRequest.cs index fbc73da3ee..fac9c37ec1 100644 --- a/src/Core/GitRequest.cs +++ b/src/Core/GitRequest.cs @@ -52,6 +52,7 @@ public GitRequest(IDictionary> dict) 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 37893af5d2..caaf1b54e6 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, string authType = null, bool isContinue = false, bool isCancelled = false, bool isYielded = false) + 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,7 +71,9 @@ private GitResponse(ICredential credential, string authType = null, bool isConti } Credential = credential; + IsCredentialEphemeral = isEphemeral; AuthType = authType; + IsContinue = isContinue; IsCancelled = isCancelled; IsYielded = isYielded; @@ -89,8 +91,8 @@ public GitResponse(ICredential credential) /// /// Construct a successful response carrying the given credential. /// - public static GitResponse Ok(ICredential credential, string authtype = null) => - new(credential, authType: authtype); + 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 @@ -102,8 +104,8 @@ public static GitResponse Ok(ICredential credential, string authtype = null) => /// 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, string authType = null) => - new (credential, authType: authType, isContinue: true); + 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 @@ -141,6 +143,11 @@ 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. ///