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+
}
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
}
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)