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 {