Add tests to #1618 (#1621)

* Refactor OidcTokenProvider to remove dependency on IdentityModel and improve token handling

* Improve OidcTokenProvider error handling and expiry setting

The constructor `OidcTokenProvider` now always sets the `_expiry`
field by calling `GetExpiryFromToken()`, regardless of whether
`_idToken` is null or empty, removing the previous check for a
non-empty `_idToken`.

The `GetExpiryFromToken` method has been updated to handle invalid
JWT token formats more gracefully. Instead of throwing an
`ArgumentException` when the token format is invalid or when the
'exp' claim is missing, the method now returns a default value.

The logic for parsing the JWT token and extracting the 'exp' claim
has been wrapped in a try-catch block. If any exception occurs
during this process, it is caught, and the method returns a default
value instead of throwing an exception.

* Refactor parts initialization inside try block

Moved the initialization of the `parts` variable, which splits the `_idToken` string, inside the `try` block. Removed the previous check for exactly three elements in the `parts` array and the default return value if the check failed.

* Add tests.

---------

Co-authored-by: Boshi Lian <farmer1992@gmail.com>
This commit is contained in:
Brendan Burns
2025-04-17 10:13:07 -07:00
committed by GitHub
parent 675de3da2a
commit 27d706d21e
5 changed files with 200 additions and 80 deletions

View File

@@ -29,6 +29,7 @@
<PackageVersion Include="System.Reactive" Version="6.0.1" />
<PackageVersion Include="System.Text.Json" Version="9.0.0" />
<PackageVersion Include="Vecc.YamlDotNet.Analyzers.StaticGenerator" Version="16.3.0" />
<PackageVersion Include="Wiremock.Net" Version="1.7.4" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.0" />
<PackageVersion Include="Xunit.StaFact" Version="1.1.11" />

View File

@@ -1,23 +1,28 @@
using IdentityModel.OidcClient;
using k8s.Exceptions;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
namespace k8s.Authentication
{
public class OidcTokenProvider : ITokenProvider
{
private readonly OidcClient _oidcClient;
private readonly string _clientId;
private readonly string _clientSecret;
private readonly string _idpIssuerUrl;
private string _idToken;
private string _refreshToken;
private DateTimeOffset _expiry;
public OidcTokenProvider(string clientId, string clientSecret, string idpIssuerUrl, string idToken, string refreshToken)
{
_clientId = clientId;
_clientSecret = clientSecret;
_idpIssuerUrl = idpIssuerUrl;
_idToken = idToken;
_refreshToken = refreshToken;
_oidcClient = getClient(clientId, clientSecret, idpIssuerUrl);
_expiry = getExpiryFromToken();
_expiry = GetExpiryFromToken();
}
public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
@@ -30,49 +35,77 @@ namespace k8s.Authentication
return new AuthenticationHeaderValue("Bearer", _idToken);
}
private DateTime getExpiryFromToken()
private DateTimeOffset GetExpiryFromToken()
{
long expiry;
var handler = new JwtSecurityTokenHandler();
try
{
var token = handler.ReadJwtToken(_idToken);
expiry = token.Payload.Expiration ?? 0;
var parts = _idToken.Split('.');
var payload = parts[1];
var jsonBytes = Base64UrlDecode(payload);
var json = Encoding.UTF8.GetString(jsonBytes);
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("exp", out var expElement))
{
var exp = expElement.GetInt64();
return DateTimeOffset.FromUnixTimeSeconds(exp);
}
}
catch
{
expiry = 0;
// ignore to default
}
return DateTimeOffset.FromUnixTimeSeconds(expiry).UtcDateTime;
return default;
}
private OidcClient getClient(string clientId, string clientSecret, string idpIssuerUrl)
private static byte[] Base64UrlDecode(string input)
{
OidcClientOptions options = new OidcClientOptions
var output = input.Replace('-', '+').Replace('_', '/');
switch (output.Length % 4)
{
ClientId = clientId,
ClientSecret = clientSecret ?? "",
Authority = idpIssuerUrl,
};
case 2: output += "=="; break;
case 3: output += "="; break;
}
return new OidcClient(options);
return Convert.FromBase64String(output);
}
private async Task RefreshToken()
{
try
{
var result = await _oidcClient.RefreshTokenAsync(_refreshToken).ConfigureAwait(false);
if (result.IsError)
using var httpClient = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, _idpIssuerUrl);
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
throw new Exception(result.Error);
{ "grant_type", "refresh_token" },
{ "client_id", _clientId },
{ "client_secret", _clientSecret },
{ "refresh_token", _refreshToken },
});
var response = await httpClient.SendAsync(request).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var jsonDocument = JsonDocument.Parse(responseContent);
if (jsonDocument.RootElement.TryGetProperty("id_token", out var idTokenElement))
{
_idToken = idTokenElement.GetString();
}
_idToken = result.IdentityToken;
_refreshToken = result.RefreshToken;
_expiry = result.AccessTokenExpiration;
if (jsonDocument.RootElement.TryGetProperty("refresh_token", out var refreshTokenElement))
{
_refreshToken = refreshTokenElement.GetString();
}
if (jsonDocument.RootElement.TryGetProperty("expires_in", out var expiresInElement))
{
var expiresIn = expiresInElement.GetInt32();
_expiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
}
}
catch (Exception e)
{

View File

@@ -7,8 +7,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Fractions" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>

View File

@@ -13,6 +13,7 @@
<PackageReference Include="System.Reactive" />
<PackageReference Include="Nito.AsyncEx" />
<PackageReference Include="Portable.BouncyCastle" />
<PackageReference Include="Wiremock.Net" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,8 +1,13 @@
using FluentAssertions;
using k8s.Authentication;
using k8s.Exceptions;
using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using WireMock.Server;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using Xunit;
namespace k8s.Tests
@@ -53,5 +58,87 @@ namespace k8s.Tests
Assert.StartsWith("Unable to refresh OIDC token.", e.Message);
}
}
[Fact]
public async Task TestOidcAuthWithWireMock()
{
// Arrange
var server = WireMockServer.Start();
var idpIssuerUrl = server.Url + "/token";
var clientId = "CLIENT_ID";
var clientSecret = "CLIENT_SECRET";
var expiredIdToken = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.f37LFpIw_XIS5TZt3wdtEjjyCNshYy03lOWpyDViRM0";
var refreshToken = "REFRESH_TOKEN";
var newIdToken = "NEW_ID_TOKEN";
var expiresIn = 3600;
// Simulate a successful token refresh response
server
.Given(Request.Create().WithPath("/token").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(HttpStatusCode.OK)
.WithBody($@"{{
""id_token"": ""{newIdToken}"",
""refresh_token"": ""{refreshToken}"",
""expires_in"": {expiresIn}
}}"));
var auth = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, expiredIdToken, refreshToken);
// Act
var result = await auth.GetAuthenticationHeaderAsync(CancellationToken.None);
// Assert
result.Scheme.Should().Be("Bearer");
result.Parameter.Should().Be(newIdToken);
// Verify that the expiry is set correctly
var expectedExpiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
var actualExpiry = typeof(OidcTokenProvider)
.GetField("_expiry", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?.GetValue(auth) as DateTimeOffset?;
actualExpiry.Should().NotBeNull();
actualExpiry.Value.Should().BeCloseTo(expectedExpiry, precision: TimeSpan.FromSeconds(5));
// Verify that the refresh token is set correctly
var actualRefreshToken = typeof(OidcTokenProvider)
.GetField("_refreshToken", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?.GetValue(auth) as string;
actualRefreshToken.Should().NotBeNull();
actualRefreshToken.Should().Be(refreshToken);
// Stop the server
server.Stop();
}
[Fact]
public async Task TestOidcAuthWithServerError()
{
// Arrange
var server = WireMockServer.Start();
var idpIssuerUrl = server.Url + "/token";
var clientId = "CLIENT_ID";
var clientSecret = "CLIENT_SECRET";
var expiredIdToken = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.f37LFpIw_XIS5TZt3wdtEjjyCNshYy03lOWpyDViRM0";
var refreshToken = "REFRESH_TOKEN";
// Simulate a server error response
server
.Given(Request.Create().WithPath("/token").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(HttpStatusCode.InternalServerError)
.WithBody(@"{ ""error"": ""server_error"" }"));
var auth = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, expiredIdToken, refreshToken);
// Act & Assert
var exception = await Assert.ThrowsAsync<KubernetesClientException>(
() => auth.GetAuthenticationHeaderAsync(CancellationToken.None));
exception.Message.Should().StartWith("Unable to refresh OIDC token.");
exception.InnerException.Message.Should().Contain("500");
// Stop the server
server.Stop();
}
}
}