From 2efb13801fbbad502bc395d4102f07145630b959 Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Fri, 14 Nov 2025 16:25:19 +0100 Subject: [PATCH] fix(generic): check expiry of structured tokens add decode support to Base64Url converter override GenericHostProvider credential query to check for token expiry add expiry check for refresh token add generic StructuredToken class with expiry status property add minimal JWT data classes for content decoding and extraction --- .../Core/Authentication/StructuredToken.cs | 58 +++++++++++++++++++ src/shared/Core/Base64UrlConvert.cs | 39 ++++++++++--- src/shared/Core/GenericHostProvider.cs | 18 +++++- 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 src/shared/Core/Authentication/StructuredToken.cs diff --git a/src/shared/Core/Authentication/StructuredToken.cs b/src/shared/Core/Authentication/StructuredToken.cs new file mode 100644 index 000000000..6dfc1fff2 --- /dev/null +++ b/src/shared/Core/Authentication/StructuredToken.cs @@ -0,0 +1,58 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GitCredentialManager.Authentication +{ + public abstract class StructuredToken + { + private class JwtHeader + { + [JsonRequired] + [JsonInclude] + [JsonPropertyName("typ")] + public string Type { get; private set; } + } + private class JwtPayload : StructuredToken + { + [JsonRequired] + [JsonInclude] + [JsonPropertyName("exp")] + public long Expiry { get; private set; } + + public override bool IsExpired + { + get + { + return Expiry < DateTimeOffset.Now.ToUnixTimeSeconds(); + } + } + } + + public abstract bool IsExpired { get; } + + public static bool TryCreate(string value, out StructuredToken jwt) + { + jwt = null; + try + { + var parts = value.Split('.'); + if (parts.Length != 3) + { + return false; + } + var header = JsonSerializer.Deserialize(Base64UrlConvert.Decode(parts[0])); + if (!"JWT".Equals(header.Type, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + jwt = JsonSerializer.Deserialize(Base64UrlConvert.Decode(parts[1])); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/src/shared/Core/Base64UrlConvert.cs b/src/shared/Core/Base64UrlConvert.cs index 7b2fce035..3a0f299f8 100644 --- a/src/shared/Core/Base64UrlConvert.cs +++ b/src/shared/Core/Base64UrlConvert.cs @@ -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); + } } } diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 19e1d6733..73994907e 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -125,6 +125,20 @@ public override async Task GenerateCredentialAsync(InputArguments i return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName); } + public override async Task GetCredentialAsync(InputArguments input) + { + var credential = await base.GetCredentialAsync(input); + // discard credential if it's an already expired JSON Web Token + if (StructuredToken.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 GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config, ITrace2 trace2) { // TODO: Determined user info from a webcall? ID token? Need OIDC support @@ -150,9 +164,9 @@ private async Task 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 && !(StructuredToken.TryCreate(refreshToken.Password, out var token) && token.IsExpired)) { try {