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
60 changes: 60 additions & 0 deletions src/shared/Core/Authentication/OAuth/Json/WebToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace GitCredentialManager.Authentication.Oauth.Json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
namespace GitCredentialManager.Authentication.Oauth.Json
namespace GitCredentialManager.Authentication.OAuth.Json

Copy link
Contributor Author

@becm becm Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, silly oversight.
Considering #268 may expand use to other providers, this is (if kept) maybe better to be renamed to GitCredentialManager.Authentication.JsonWebToken?

Only saw there was an existing related issue way after adding the initial PR as a base of discussion.

{
public class WebToken(WebToken.TokenHeader header, WebToken.TokenPayload payload, string signature)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of writing our own, could we not instead use the JsonWebToken type from the Microsoft.IdentityModel.JsonWebTokens package?

We'd still need the TryCreate method to split the header and payload string parts, but no need to decode the components or carry out our own deserialisation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seemed easier to just add some simple code instead of depending on a full-blown JWT implementation.
No reservations on replacing this from my side.
(I might however not be the best candidate to get this right on first try)

{
public class TokenHeader
{
[JsonRequired]
[JsonInclude]
[JsonPropertyName("typ")]
public string Type { get; private set; }
}
public class TokenPayload
{
[JsonRequired]
[JsonInclude]
[JsonPropertyName("exp")]
public long Expiry { get; private set; }
}
public TokenHeader Header { get; } = header;
public TokenPayload Payload { get; } = payload;
public string Signature { get; } = signature;

public bool IsExpired
{
get
{
return Payload.Expiry < DateTimeOffset.Now.ToUnixTimeSeconds();
}
}

static public bool TryCreate(string value, out WebToken jwt)
{
jwt = null;
try
{
var parts = value.Split('.');
if (parts.Length != 3)
{
return false;
}
var header = JsonSerializer.Deserialize<TokenHeader>(Base64UrlConvert.Decode(parts[0]));
if (!"JWT".Equals(header.Type))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should do a case insensitive comparison here.

Suggested change
if (!"JWT".Equals(header.Type))
if (!StringComparer.OrdinalIgnoreCase.Equals(header.Type, "jwt"))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stuck with the recommended variant, no problem expanding acceptance.

{
return false;
}
var payload = JsonSerializer.Deserialize<TokenPayload>(Base64UrlConvert.Decode(parts[1]));
jwt = new WebToken(header, payload, parts[2]);
return true;
}
catch
{
return false;
}
}
}
}
39 changes: 30 additions & 9 deletions src/shared/Core/Base64UrlConvert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,43 @@ namespace GitCredentialManager
{
public static class Base64UrlConvert
{

// The base64url format is the same as regular base64 format except:
// 1. character 62 is "-" (minus) not "+" (plus)
// 2. character 63 is "_" (underscore) not "/" (slash)
// 3. padding is optional
private const char base64PadCharacter = '=';
private const char base64Character62 = '+';
private const char base64Character63 = '/';
private const char base64UrlCharacter62 = '-';
private const char base64UrlCharacter63 = '_';

public static string Encode(byte[] data, bool includePadding = true)
{
const char base64PadCharacter = '=';
const char base64Character62 = '+';
const char base64Character63 = '/';
const char base64UrlCharacter62 = '-';
const char base64UrlCharacter63 = '_';

// The base64url format is the same as regular base64 format except:
// 1. character 62 is "-" (minus) not "+" (plus)
// 2. character 63 is "_" (underscore) not "/" (slash)
string base64Url = Convert.ToBase64String(data)
.Replace(base64Character62, base64UrlCharacter62)
.Replace(base64Character63, base64UrlCharacter63);

return includePadding ? base64Url : base64Url.TrimEnd(base64PadCharacter);
}

public static byte[] Decode(string data)
{
string base64 = data
.Replace(base64UrlCharacter62, base64Character62)
.Replace(base64UrlCharacter63, base64Character63);

switch (base64.Length % 4)
{
case 2:
base64 += base64PadCharacter;
goto case 3;
case 3:
base64 += base64PadCharacter;
break;
}

return Convert.FromBase64String(base64);
}
}
}
19 changes: 17 additions & 2 deletions src/shared/Core/GenericHostProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using GitCredentialManager.Authentication;
using GitCredentialManager.Authentication.Oauth.Json;
using GitCredentialManager.Authentication.OAuth;

namespace GitCredentialManager
Expand Down Expand Up @@ -125,6 +126,20 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName);
}

public override async Task<ICredential> GetCredentialAsync(InputArguments input)
{
var credential = await base.GetCredentialAsync(input);
// discard credential if it's an already expired JSON Web Token
if (WebToken.TryCreate(credential.Password, out var token) && token.IsExpired)
{
// No existing credential was found, create a new one
Context.Trace.WriteLine("Refreshing expired JWT credential...");
credential = await GenerateCredentialAsync(input);
Context.Trace.WriteLine("Credential created.");
}
return credential;
}

private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config, ITrace2 trace2)
{
// TODO: Determined user info from a webcall? ID token? Need OIDC support
Expand All @@ -150,9 +165,9 @@ private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userNa
string refreshService = new UriBuilder(remoteUri) { Host = $"refresh_token.{remoteUri.Host}" }
.Uri.AbsoluteUri.TrimEnd('/');

// Try to use a refresh token if we have one
// Try to use a refresh token if we have one (unless it's an expired JSON Web Token)
ICredential refreshToken = Context.CredentialStore.Get(refreshService, userName);
if (refreshToken != null)
if (refreshToken != null && !(WebToken.TryCreate(refreshToken.Password, out var token) && token.IsExpired))
{
try
{
Expand Down