Skip to content

Commit 2efb138

Browse files
committed
fix(generic): check expiry of structured tokens
add decode support to Base64Url converter override GenericHostProvider credential query to check for token expiry add expiry check for refresh token add generic StructuredToken class with expiry status property add minimal JWT data classes for content decoding and extraction
1 parent 5c8e528 commit 2efb138

File tree

3 files changed

+104
-11
lines changed

3 files changed

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

src/shared/Core/Base64UrlConvert.cs

Lines changed: 30 additions & 9 deletions
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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
125125
return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName);
126126
}
127127

128+
public override async Task<ICredential> GetCredentialAsync(InputArguments input)
129+
{
130+
var credential = await base.GetCredentialAsync(input);
131+
// discard credential if it's an already expired JSON Web Token
132+
if (StructuredToken.TryCreate(credential.Password, out var token) && token.IsExpired)
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
@@ -150,9 +164,9 @@ private async Task<ICredential> GetOAuthAccessToken(Uri remoteUri, string userNa
150164
string refreshService = new UriBuilder(remoteUri) { Host = $"refresh_token.{remoteUri.Host}" }
151165
.Uri.AbsoluteUri.TrimEnd('/');
152166

153-
// Try to use a refresh token if we have one
167+
// Try to use a refresh token if we have one (unless it's an expired JSON Web Token)
154168
ICredential refreshToken = Context.CredentialStore.Get(refreshService, userName);
155-
if (refreshToken != null)
169+
if (refreshToken != null && !(StructuredToken.TryCreate(refreshToken.Password, out var token) && token.IsExpired))
156170
{
157171
try
158172
{

0 commit comments

Comments
 (0)