Skip to content

Commit b467d37

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

File tree

3 files changed

+97
-10
lines changed

3 files changed

+97
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
[JsonPropertyName("typ")]
13+
public string Type { get; set; }
14+
}
15+
public class TokenPayload
16+
{
17+
[JsonRequired]
18+
[JsonPropertyName("exp")]
19+
public long Expiry { get; set; }
20+
}
21+
public TokenHeader Header { get; } = header;
22+
public TokenPayload Payload { get; } = payload;
23+
public string Signature { get; } = signature;
24+
25+
static public WebToken TryCreate(string value)
26+
{
27+
try
28+
{
29+
var parts = value.Split('.');
30+
if (parts.Length != 3)
31+
{
32+
return null;
33+
}
34+
var header = JsonSerializer.Deserialize<TokenHeader>(Base64UrlConvert.Decode(parts[0]));
35+
var payload = JsonSerializer.Deserialize<TokenPayload>(Base64UrlConvert.Decode(parts[1]));
36+
return new WebToken(header, payload, parts[2]);
37+
}
38+
catch
39+
{
40+
return null;
41+
}
42+
43+
}
44+
45+
static public bool IsExpiredToken(string value)
46+
{
47+
var token = TryCreate(value);
48+
return token != null && token.Payload.Expiry < DateTimeOffset.Now.ToUnixTimeSeconds();
49+
50+
}
51+
}
52+
}

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+
const char base64Character62 = '+';
14+
const char base64Character63 = '/';
15+
const char base64UrlCharacter62 = '-';
16+
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, bool includePadding = true)
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

+15-1
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,19 @@ 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+
if (WebToken.IsExpiredToken(credential.Password))
133+
{
134+
// No existing credential was found, create a new one
135+
Context.Trace.WriteLine("Refreshing expired JWT credential...");
136+
credential = await GenerateCredentialAsync(input);
137+
Context.Trace.WriteLine("Credential created.");
138+
}
139+
return credential;
140+
}
141+
128142
private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config, ITrace2 trace2)
129143
{
130144
// TODO: Determined user info from a webcall? ID token? Need OIDC support
@@ -152,7 +166,7 @@ private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userNa
152166

153167
// Try to use a refresh token if we have one
154168
ICredential refreshToken = Context.CredentialStore.Get(refreshService, userName);
155-
if (refreshToken != null)
169+
if (refreshToken != null && !WebToken.IsExpiredToken(refreshToken.Password))
156170
{
157171
try
158172
{

0 commit comments

Comments
 (0)