From 9649df6d45a15714ed306f4df084b8985b549cff Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Tue, 25 Aug 2026 16:54:02 +0200 Subject: [PATCH] fix(generic): check expiry of access tokens add token expiry check for credential `password` value add generic Token interface with expiration status add minimal JsonWebToken reader to decode and extract data --- src/Core/Authentication/JsonWebToken.cs | 80 +++++++++++++++++++++++++ src/Core/Authentication/Token.cs | 23 +++++++ src/Core/GenericHostProvider.cs | 18 ++++-- 3 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/Core/Authentication/JsonWebToken.cs create mode 100644 src/Core/Authentication/Token.cs diff --git a/src/Core/Authentication/JsonWebToken.cs b/src/Core/Authentication/JsonWebToken.cs new file mode 100644 index 0000000000..c79801b740 --- /dev/null +++ b/src/Core/Authentication/JsonWebToken.cs @@ -0,0 +1,80 @@ +using System; +using System.Buffers.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GitCredentialManager.Authentication +{ + public partial class JsonWebToken : IToken + { + public bool IsExpired + { + get + { + return Payload.Expiry != null && Payload.Expiry < DateTimeOffset.Now.ToUnixTimeSeconds(); + } + } + public string Type => "Bearer"; + public string Value { get; } + + public class HeaderData + { + [JsonRequired] + [JsonInclude] + [JsonPropertyName("typ")] + public string Type { get; internal set; } + } + + public class PayloadData + { + [JsonInclude] + [JsonPropertyName("exp")] + public long? Expiry { get; internal set; } + } + + + protected HeaderData Header { get; } + protected PayloadData Payload { get; } + protected string Signature { get; } + + public JsonWebToken(string value, HeaderData header, PayloadData payload, string signature) + { + Value = value; + Header = header; + Payload = payload; + Signature = signature; + } + + + [JsonSerializable(typeof(HeaderData))] + private partial class HeaderContext : JsonSerializerContext { } + + [JsonSerializable(typeof(PayloadData))] + private partial class PayloadContext : JsonSerializerContext { } + + + public static bool TryCreate(string value, out JsonWebToken token) + { + try + { + // elements of JWT structure "
.." + var parts = value.Split('.'); + if (parts.Length == 2 || parts.Length == 3) + { + var header = JsonSerializer.Deserialize(Base64Url.DecodeFromChars(parts[0]), HeaderContext.Default.HeaderData); + if ("JWT".Equals(header.Type, StringComparison.OrdinalIgnoreCase)) + { + var payload = JsonSerializer.Deserialize(Base64Url.DecodeFromChars(parts[1]), PayloadContext.Default.PayloadData); + token = new JsonWebToken(value, header, payload, parts.Length > 2 ? parts[2] : null); + return true; + } + } + } + catch { } + + // invalid token data on content mismatch or deserializer exception + token = null; + return false; + } + } +} diff --git a/src/Core/Authentication/Token.cs b/src/Core/Authentication/Token.cs new file mode 100644 index 0000000000..e794f6b156 --- /dev/null +++ b/src/Core/Authentication/Token.cs @@ -0,0 +1,23 @@ +namespace GitCredentialManager.Authentication +{ + public interface IToken + { + public bool IsExpired { get; } + public string Type { get; } + public string Value { get; } + } + + public static class Token + { + public static bool TryCreate(string value, out IToken token) + { + if (JsonWebToken.TryCreate(value, out JsonWebToken jwt)) + { + token = jwt; + return true; + } + token = null; + return false; + } + } +} diff --git a/src/Core/GenericHostProvider.cs b/src/Core/GenericHostProvider.cs index ab17405b69..416c03d1fb 100644 --- a/src/Core/GenericHostProvider.cs +++ b/src/Core/GenericHostProvider.cs @@ -75,17 +75,25 @@ public async Task GetCredentialAsync(GitRequest request) if (credential == null) { _context.Trace.WriteLine("No existing credentials found."); - - // No existing credential was found, create a new one - _context.Trace.WriteLine("Creating new credential..."); - return await GenerateCredentialAsync(request); + } + else if (Token.TryCreate(credential.Password, out var token)) + { + _context.Trace.WriteLine($"Existing token found (type={token.Type})."); + if (!token.IsExpired) { + // TODO: create credential data for token type and value + return new GitResponse(credential); + } + _context.Trace.WriteLine("Credential token is expired."); } else { _context.Trace.WriteLine("Existing credential found."); + return new GitResponse(credential); } - return new GitResponse(credential); + // No valid credential was found, create a new one + _context.Trace.WriteLine("Creating new credential..."); + return await GenerateCredentialAsync(request); } public Task StoreCredentialAsync(GitRequest request)