diff --git a/src/shared/Core/Authentication/OAuth/Json/WebToken.cs b/src/shared/Core/Authentication/OAuth/Json/WebToken.cs new file mode 100644 index 000000000..5e18ae61b --- /dev/null +++ b/src/shared/Core/Authentication/OAuth/Json/WebToken.cs @@ -0,0 +1,60 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GitCredentialManager.Authentication.Oauth.Json +{ + public class WebToken(WebToken.TokenHeader header, WebToken.TokenPayload payload, string signature) + { + 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(Base64UrlConvert.Decode(parts[0])); + if (!"JWT".Equals(header.Type)) + { + return false; + } + var payload = JsonSerializer.Deserialize(Base64UrlConvert.Decode(parts[1])); + jwt = new WebToken(header, payload, parts[2]); + 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 9f087ca5b..2b0faeaf9 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -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 @@ -125,6 +126,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 (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 GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config, ITrace2 trace2) { // TODO: Determined user info from a webcall? ID token? Need OIDC support @@ -150,9 +165,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 && !(WebToken.TryCreate(refreshToken.Password, out var token) && token.IsExpired)) { try {