* 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:
@@ -1,54 +1,55 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="AutoMapper" Version="13.0.1" />
|
<PackageVersion Include="AutoMapper" Version="13.0.1" />
|
||||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.5.0" />
|
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.5.0" />
|
||||||
<PackageVersion Include="FluentAssertions" Version="7.0.0" />
|
<PackageVersion Include="FluentAssertions" Version="7.0.0" />
|
||||||
<PackageVersion Include="Fractions" Version="7.3.0" />
|
<PackageVersion Include="Fractions" Version="7.3.0" />
|
||||||
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
||||||
<PackageVersion Include="JsonPatch.Net" Version="2.1.0" />
|
<PackageVersion Include="JsonPatch.Net" Version="2.1.0" />
|
||||||
<PackageVersion Include="MartinCostello.Logging.XUnit" Version="0.5.1" />
|
<PackageVersion Include="MartinCostello.Logging.XUnit" Version="0.5.1" />
|
||||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.0" />
|
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageVersion Include="Microsoft.TestPlatform.ObjectModel" Version="17.12.0" />
|
<PackageVersion Include="Microsoft.TestPlatform.ObjectModel" Version="17.12.0" />
|
||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
|
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
|
||||||
<PackageVersion Include="Nito.AsyncEx.Coordination" Version="5.1.2" />
|
<PackageVersion Include="Nito.AsyncEx.Coordination" Version="5.1.2" />
|
||||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.7.0" />
|
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.7.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.8.1" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.8.1" />
|
||||||
<PackageVersion Include="Portable.BouncyCastle" Version="1.9.0" />
|
<PackageVersion Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||||
<PackageVersion Include="SharpZipLib" Version="1.4.2" />
|
<PackageVersion Include="SharpZipLib" Version="1.4.2" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.0" />
|
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.0" />
|
||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
|
||||||
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="21.2.1" />
|
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="21.2.1" />
|
||||||
<PackageVersion Include="System.Reactive" Version="6.0.1" />
|
<PackageVersion Include="System.Reactive" Version="6.0.1" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="9.0.0" />
|
<PackageVersion Include="System.Text.Json" Version="9.0.0" />
|
||||||
<PackageVersion Include="Vecc.YamlDotNet.Analyzers.StaticGenerator" Version="16.3.0" />
|
<PackageVersion Include="Vecc.YamlDotNet.Analyzers.StaticGenerator" Version="16.3.0" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
<PackageVersion Include="Wiremock.Net" Version="1.7.4" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.0" />
|
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||||
<PackageVersion Include="Xunit.StaFact" Version="1.1.11" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.0" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
<PackageVersion Include="Xunit.StaFact" Version="1.1.11" />
|
||||||
</ItemGroup>
|
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
||||||
<ItemGroup>
|
</ItemGroup>
|
||||||
<PackageVersion Include="Autofac" Version="8.2.0" />
|
<ItemGroup>
|
||||||
<PackageVersion Include="CaseExtensions" Version="1.1.0" />
|
<PackageVersion Include="Autofac" Version="8.2.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.4.0" />
|
<PackageVersion Include="CaseExtensions" Version="1.1.0" />
|
||||||
<PackageVersion Include="Namotion.Reflection" Version="3.0.1" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.4.0" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageVersion Include="Namotion.Reflection" Version="3.0.1" />
|
||||||
<PackageVersion Include="NJsonSchema" Version="10.9.0" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageVersion Include="NSwag.Core" Version="13.20.0" />
|
<PackageVersion Include="NJsonSchema" Version="10.9.0" />
|
||||||
<PackageVersion Include="Scriban" Version="5.9.1" />
|
<PackageVersion Include="NSwag.Core" Version="13.20.0" />
|
||||||
</ItemGroup>
|
<PackageVersion Include="Scriban" Version="5.9.1" />
|
||||||
<ItemGroup>
|
</ItemGroup>
|
||||||
<GlobalPackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
|
<ItemGroup>
|
||||||
<GlobalPackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
<GlobalPackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
|
||||||
<GlobalPackageReference Include="Microsoft.VisualStudio.SlnGen" Version="12.0.3" />
|
<GlobalPackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||||
<GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.7.112" />
|
<GlobalPackageReference Include="Microsoft.VisualStudio.SlnGen" Version="12.0.3" />
|
||||||
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.1.118" />
|
<GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.7.112" />
|
||||||
</ItemGroup>
|
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.1.118" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
using IdentityModel.OidcClient;
|
|
||||||
using k8s.Exceptions;
|
using k8s.Exceptions;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace k8s.Authentication
|
namespace k8s.Authentication
|
||||||
{
|
{
|
||||||
public class OidcTokenProvider : ITokenProvider
|
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 _idToken;
|
||||||
private string _refreshToken;
|
private string _refreshToken;
|
||||||
private DateTimeOffset _expiry;
|
private DateTimeOffset _expiry;
|
||||||
|
|
||||||
public OidcTokenProvider(string clientId, string clientSecret, string idpIssuerUrl, string idToken, string refreshToken)
|
public OidcTokenProvider(string clientId, string clientSecret, string idpIssuerUrl, string idToken, string refreshToken)
|
||||||
{
|
{
|
||||||
|
_clientId = clientId;
|
||||||
|
_clientSecret = clientSecret;
|
||||||
|
_idpIssuerUrl = idpIssuerUrl;
|
||||||
_idToken = idToken;
|
_idToken = idToken;
|
||||||
_refreshToken = refreshToken;
|
_refreshToken = refreshToken;
|
||||||
_oidcClient = getClient(clientId, clientSecret, idpIssuerUrl);
|
_expiry = GetExpiryFromToken();
|
||||||
_expiry = getExpiryFromToken();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
|
public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
|
||||||
@@ -30,49 +35,77 @@ namespace k8s.Authentication
|
|||||||
return new AuthenticationHeaderValue("Bearer", _idToken);
|
return new AuthenticationHeaderValue("Bearer", _idToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DateTime getExpiryFromToken()
|
private DateTimeOffset GetExpiryFromToken()
|
||||||
{
|
{
|
||||||
long expiry;
|
|
||||||
var handler = new JwtSecurityTokenHandler();
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var token = handler.ReadJwtToken(_idToken);
|
var parts = _idToken.Split('.');
|
||||||
expiry = token.Payload.Expiration ?? 0;
|
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
|
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,
|
case 2: output += "=="; break;
|
||||||
ClientSecret = clientSecret ?? "",
|
case 3: output += "="; break;
|
||||||
Authority = idpIssuerUrl,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return new OidcClient(options);
|
return Convert.FromBase64String(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshToken()
|
private async Task RefreshToken()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _oidcClient.RefreshTokenAsync(_refreshToken).ConfigureAwait(false);
|
using var httpClient = new HttpClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, _idpIssuerUrl);
|
||||||
|
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "grant_type", "refresh_token" },
|
||||||
|
{ "client_id", _clientId },
|
||||||
|
{ "client_secret", _clientSecret },
|
||||||
|
{ "refresh_token", _refreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
if (result.IsError)
|
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))
|
||||||
{
|
{
|
||||||
throw new Exception(result.Error);
|
_idToken = idTokenElement.GetString();
|
||||||
}
|
}
|
||||||
|
|
||||||
_idToken = result.IdentityToken;
|
if (jsonDocument.RootElement.TryGetProperty("refresh_token", out var refreshTokenElement))
|
||||||
_refreshToken = result.RefreshToken;
|
{
|
||||||
_expiry = result.AccessTokenExpiration;
|
_refreshToken = refreshTokenElement.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonDocument.RootElement.TryGetProperty("expires_in", out var expiresInElement))
|
||||||
|
{
|
||||||
|
var expiresIn = expiresInElement.GetInt32();
|
||||||
|
_expiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
|
||||||
<PackageReference Include="IdentityModel.OidcClient" />
|
|
||||||
<PackageReference Include="Fractions" />
|
<PackageReference Include="Fractions" />
|
||||||
<PackageReference Include="YamlDotNet" />
|
<PackageReference Include="YamlDotNet" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="System.Reactive" />
|
<PackageReference Include="System.Reactive" />
|
||||||
<PackageReference Include="Nito.AsyncEx" />
|
<PackageReference Include="Nito.AsyncEx" />
|
||||||
<PackageReference Include="Portable.BouncyCastle" />
|
<PackageReference Include="Portable.BouncyCastle" />
|
||||||
|
<PackageReference Include="Wiremock.Net" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using k8s.Authentication;
|
using k8s.Authentication;
|
||||||
using k8s.Exceptions;
|
using k8s.Exceptions;
|
||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using WireMock.Server;
|
||||||
|
using WireMock.RequestBuilders;
|
||||||
|
using WireMock.ResponseBuilders;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace k8s.Tests
|
namespace k8s.Tests
|
||||||
@@ -53,5 +58,87 @@ namespace k8s.Tests
|
|||||||
Assert.StartsWith("Unable to refresh OIDC token.", e.Message);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user