Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Core.Tests/Commands/CapabilityCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
58 changes: 57 additions & 1 deletion src/Core.Tests/Commands/GetCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<IHostProvider>();
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
.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<IHostProvider>();
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
.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<string, string> ParseDictionary(StringBuilder sb) => ParseDictionary(sb.ToString());
Expand Down
25 changes: 21 additions & 4 deletions src/Core/Commands/GetCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Commands/GitCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
{
Expand Down
10 changes: 10 additions & 0 deletions src/Core/Commands/StoreCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
32 changes: 31 additions & 1 deletion src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public static class Constants
/// <summary>
/// The set of Git credential protocol capabilities supported by Git Credential Manager.
/// </summary>
public const GitCapabilities SupportedCapabilities = GitCapabilities.State;
public const GitCapabilities SupportedCapabilities = GitCapabilities.State | GitCapabilities.AuthType;

public static class CredentialProtocol
{
Expand All @@ -57,6 +57,36 @@ public static class CredentialProtocol
/// </summary>
public const string ContinueKey = "continue";

/// <summary>
/// The Git credential protocol attribute name carrying alternative
/// HTTP Authorization scheme type.
/// (string, gated by the <c>authtype</c> capability).
/// </summary>
public const string AuthTypeKey = "authtype";

/// <summary>
/// The Git credential protocol attribute name carrying raw credential data.
/// (string, gated by the <c>authtype</c> capability).
/// </summary>
public const string CredentialKey = "credential";

/// <summary>
/// The Git credential protocol attribute name carrying setting
/// for ephemeral credential type.
/// (string, gated by the <c>authtype</c> capability).
/// </summary>
public const string EphemeralKey = "ephemeral";

/// <summary>
/// The Git credential protocol attribute name for password.
/// </summary>
public const string PasswordKey = "password";

/// <summary>
/// The Git credential protocol attribute name for username.
/// </summary>
public const string UserNameKey = "username";

/// <summary>
/// Prefix that Git Credential Manager reserves on every <c>state[]</c> entry
/// so its state can be distinguished from other helpers' state per
Expand Down
14 changes: 12 additions & 2 deletions src/Core/GitCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public enum GitCapabilities
/// flag that signals a non-final authentication step is expected.
/// </remarks>
State = 1 << 0,

/// <summary>
/// The <c>authtype</c>, <c>credential</c>, and <c>ephemeral</c> attributes are understood.
/// </summary>
/// <remarks>
/// Provides alternative formats for HTTP Authorization header.
/// </remarks>
AuthType = 1 << 1,
}

/// <summary>
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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)),
Expand Down
9 changes: 7 additions & 2 deletions src/Core/GitRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ public GitRequest(IDictionary<string, IList<string>> 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<string> WwwAuth => GetMultiArgumentOrDefault("wwwauth");

/// <summary>
Expand Down
29 changes: 21 additions & 8 deletions src/Core/GitResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class GitResponse
private readonly Dictionary<string, string> _state = new(StringComparer.Ordinal);
private ReadOnlyDictionary<string, string> _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) ||
Expand Down Expand Up @@ -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;
Expand All @@ -81,15 +84,15 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b
/// </summary>
/// <remarks>Equivalent to <see cref="Ok(ICredential)"/>.</remarks>
public GitResponse(ICredential credential)
: this(credential, isContinue: false, isCancelled: false, isYielded: false)
: this(credential, authType: null)
{
}

/// <summary>
/// Construct a successful response carrying the given credential.
/// </summary>
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);

/// <summary>
/// Construct a successful response carrying the given credential and
Expand All @@ -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.
/// </remarks>
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);

/// <summary>
/// Construct a cancellation response: the provider declined to produce a
Expand All @@ -117,7 +120,7 @@ public static GitResponse Continue(ICredential credential) =>
/// cancelled response are ignored.
/// </remarks>
public static GitResponse Cancel() =>
new GitResponse(credential: null, isContinue: false, isCancelled: true, isYielded: false);
new GitResponse(credential: null, isCancelled: true);

/// <summary>
/// Construct a yielded response: the provider has nothing to contribute
Expand All @@ -132,14 +135,24 @@ public static GitResponse Cancel() =>
/// set on a yielded response are ignored.
/// </remarks>
public static GitResponse Yield() =>
new GitResponse(credential: null, isContinue: false, isCancelled: false, isYielded: true);
new GitResponse(credential: null, isYielded: true);

/// <summary>
/// The credential resolved or generated for the request, or <see langword="null"/>
/// when <see cref="IsCancelled"/> or <see cref="IsYielded"/> is <see langword="true"/>.
/// </summary>
public ICredential Credential { get; }

/// <summary>
/// The credential is ephemeral (or generated) and not to be stored on success.
/// </summary>
public bool IsCredentialEphemeral { get; }

/// <summary>
/// The special Autorization type for the supplied credential.
/// </summary>
public string AuthType { get; }

/// <summary>
/// <see langword="true"/> when the provider expects a further round of
/// authentication. The command layer translates this into a <c>continue=1</c>
Expand Down
Loading