Skip to content

Commit d7e4f76

Browse files
committed
fix(generic): check expiry in JWT password values
add minimal JWT data class for content decoding and extraction add decode support to Base64Url converter override GenericHostProvider credential query to check for JWT content add expiry check for JWT refresh token
1 parent b62021f commit d7e4f76

File tree

3 files changed

+107
-11
lines changed

3 files changed

+107
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
5+
namespace GitCredentialManager.Authentication.Oauth.Json
6+
{
7+
public class WebToken(WebToken.TokenHeader header, WebToken.TokenPayload payload, string signature)
8+
{
9+
public class TokenHeader
10+
{
11+
[JsonRequired]
12+
[JsonInclude]
13+
[JsonPropertyName("typ")]
14+
public string Type { get; private set; }
15+
}
16+
public class TokenPayload
17+
{
18+
[JsonRequired]
19+
[JsonInclude]
20+
[JsonPropertyName("exp")]
21+
public long Expiry { get; private set; }
22+
}
23+
public TokenHeader Header { get; } = header;
24+
public TokenPayload Payload { get; } = payload;
25+
public string Signature { get; } = signature;
26+
27+
public bool IsExpired
28+
{
29+
get
30+
{
31+
return Payload.Expiry < DateTimeOffset.Now.ToUnixTimeSeconds();
32+
}
33+
}
34+
35+
static public bool TryCreate(string value, out WebToken jwt)
36+
{
37+
jwt = null;
38+
try
39+
{
40+
var parts = value.Split('.');
41+
if (parts.Length != 3)
42+
{
43+
return false;
44+
}
45+
var header = JsonSerializer.Deserialize<TokenHeader>(Base64UrlConvert.Decode(parts[0]));
46+
if (!"JWT".Equals(header.Type))
47+
{
48+
return false;
49+
}
50+
var payload = JsonSerializer.Deserialize<TokenPayload>(Base64UrlConvert.Decode(parts[1]));
51+
jwt = new WebToken(header, payload, parts[2]);
52+
return true;
53+
}
54+
catch
55+
{
56+
return false;
57+
}
58+
}
59+
}
60+
}

src/shared/Core/Base64UrlConvert.cs

+30-9
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,43 @@ namespace GitCredentialManager
44
{
55
public static class Base64UrlConvert
66
{
7+
8+
// The base64url format is the same as regular base64 format except:
9+
// 1. character 62 is "-" (minus) not "+" (plus)
10+
// 2. character 63 is "_" (underscore) not "/" (slash)
11+
// 3. padding is optional
12+
private const char base64PadCharacter = '=';
13+
private const char base64Character62 = '+';
14+
private const char base64Character63 = '/';
15+
private const char base64UrlCharacter62 = '-';
16+
private const char base64UrlCharacter63 = '_';
17+
718
public static string Encode(byte[] data, bool includePadding = true)
819
{
9-
const char base64PadCharacter = '=';
10-
const char base64Character62 = '+';
11-
const char base64Character63 = '/';
12-
const char base64UrlCharacter62 = '-';
13-
const char base64UrlCharacter63 = '_';
14-
15-
// The base64url format is the same as regular base64 format except:
16-
// 1. character 62 is "-" (minus) not "+" (plus)
17-
// 2. character 63 is "_" (underscore) not "/" (slash)
1820
string base64Url = Convert.ToBase64String(data)
1921
.Replace(base64Character62, base64UrlCharacter62)
2022
.Replace(base64Character63, base64UrlCharacter63);
2123

2224
return includePadding ? base64Url : base64Url.TrimEnd(base64PadCharacter);
2325
}
26+
27+
public static byte[] Decode(string data)
28+
{
29+
string base64 = data
30+
.Replace(base64UrlCharacter62, base64Character62)
31+
.Replace(base64UrlCharacter63, base64Character63);
32+
33+
switch (base64.Length % 4)
34+
{
35+
case 2:
36+
base64 += base64PadCharacter;
37+
goto case 3;
38+
case 3:
39+
base64 += base64PadCharacter;
40+
break;
41+
}
42+
43+
return Convert.FromBase64String(base64);
44+
}
2445
}
2546
}

src/shared/Core/GenericHostProvider.cs

+17-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Threading;
66
using System.Threading.Tasks;
77
using GitCredentialManager.Authentication;
8+
using GitCredentialManager.Authentication.Oauth.Json;
89
using GitCredentialManager.Authentication.OAuth;
910

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

129+
public override async Task<ICredential> GetCredentialAsync(InputArguments input)
130+
{
131+
var credential = await base.GetCredentialAsync(input);
132+
// discard credential if it's an already expired JSON Web Token
133+
if (WebToken.TryCreate(credential.Password, out var token) && token.IsExpired)
134+
{
135+
// No existing credential was found, create a new one
136+
Context.Trace.WriteLine("Refreshing expired JWT credential...");
137+
credential = await GenerateCredentialAsync(input);
138+
Context.Trace.WriteLine("Credential created.");
139+
}
140+
return credential;
141+
}
142+
128143
private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config, ITrace2 trace2)
129144
{
130145
// TODO: Determined user info from a webcall? ID token? Need OIDC support
@@ -150,9 +165,9 @@ private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userNa
150165
string refreshService = new UriBuilder(remoteUri) { Host = $"refresh_token.{remoteUri.Host}" }
151166
.Uri.AbsoluteUri.TrimEnd('/');
152167

153-
// Try to use a refresh token if we have one
168+
// Try to use a refresh token if we have one (unless it's an expired JSON Web Token)
154169
ICredential refreshToken = Context.CredentialStore.Get(refreshService, userName);
155-
if (refreshToken != null)
170+
if (refreshToken != null && !(WebToken.TryCreate(refreshToken.Password, out var token) && token.IsExpired))
156171
{
157172
try
158173
{

0 commit comments

Comments
 (0)