fix: oidc (#633)
* fix: oidc * revert: verbose oidc logs * fix: actually commit changes * chore: cleanup var name * chore: address pr feedback
This commit is contained in:
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.Rest;
|
||||
using IdentityModel.OidcClient;
|
||||
using k8s.Exceptions;
|
||||
@@ -13,7 +14,6 @@ namespace k8s.Authentication
|
||||
private OidcClient _oidcClient;
|
||||
private string _idToken;
|
||||
private string _refreshToken;
|
||||
private string _accessToken;
|
||||
private DateTime _expiry;
|
||||
|
||||
public OidcTokenProvider(string clientId, string clientSecret, string idpIssuerUrl, string idToken, string refreshToken)
|
||||
@@ -21,16 +21,34 @@ namespace k8s.Authentication
|
||||
_idToken = idToken;
|
||||
_refreshToken = refreshToken;
|
||||
_oidcClient = getClient(clientId, clientSecret, idpIssuerUrl);
|
||||
_expiry = getExpiryFromToken();
|
||||
}
|
||||
|
||||
public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_accessToken == null || DateTime.UtcNow.AddSeconds(30) > _expiry)
|
||||
if (_idToken == null || DateTime.UtcNow.AddSeconds(30) > _expiry)
|
||||
{
|
||||
await RefreshToken().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
return new AuthenticationHeaderValue("Bearer", _idToken);
|
||||
}
|
||||
|
||||
private DateTime getExpiryFromToken()
|
||||
{
|
||||
int expiry;
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
try
|
||||
{
|
||||
var token = handler.ReadJwtToken(_idToken);
|
||||
expiry = token.Payload.Exp ?? 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
expiry = 0;
|
||||
}
|
||||
|
||||
return DateTimeOffset.FromUnixTimeSeconds(expiry).UtcDateTime;
|
||||
}
|
||||
|
||||
private OidcClient getClient(string clientId, string clientSecret, string idpIssuerUrl)
|
||||
@@ -49,9 +67,13 @@ namespace k8s.Authentication
|
||||
{
|
||||
try
|
||||
{
|
||||
var result =
|
||||
await _oidcClient.RefreshTokenAsync(_refreshToken).ConfigureAwait(false);
|
||||
_accessToken = result.AccessToken;
|
||||
var result = await _oidcClient.RefreshTokenAsync(_refreshToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsError)
|
||||
{
|
||||
throw new Exception(result.Error);
|
||||
}
|
||||
|
||||
_idToken = result.IdentityToken;
|
||||
_refreshToken = result.RefreshToken;
|
||||
_expiry = result.AccessTokenExpiration;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<PackageReference Include="Portable.BouncyCastle" Version="1.8.1.3" />
|
||||
<PackageReference Include="Microsoft.Rest.ClientRuntime" Version="2.3.10" />
|
||||
<PackageReference Include="prometheus-net" Version="4.1.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.11.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="8.1.2" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
|
||||
<PackageReference Include="System.Buffers" Version="4.5.1" />
|
||||
|
||||
@@ -9,6 +9,8 @@ using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using k8s.Authentication;
|
||||
using k8s.Exceptions;
|
||||
using k8s.KubeConfigModels;
|
||||
using k8s.Models;
|
||||
using k8s.Tests.Mock;
|
||||
@@ -440,6 +442,87 @@ namespace k8s.Tests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oidc()
|
||||
{
|
||||
var clientId = "CLIENT_ID";
|
||||
var clientSecret = "CLIENT_SECRET";
|
||||
var idpIssuerUrl = "https://idp.issuer.url";
|
||||
var unexpiredIdToken = "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjAsImV4cCI6MjAwMDAwMDAwMH0.8Ata5uKlrqYfeIaMwS91xVgVFHu7ntHx1sGN95i2Zho";
|
||||
var expiredIdToken = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.f37LFpIw_XIS5TZt3wdtEjjyCNshYy03lOWpyDViRM0";
|
||||
var refreshToken = "REFRESH_TOKEN";
|
||||
|
||||
using (var server = new MockKubeApiServer(testOutput, cxt =>
|
||||
{
|
||||
var header = cxt.Request.Headers["Authorization"].FirstOrDefault();
|
||||
|
||||
var expect = new AuthenticationHeaderValue("Bearer", unexpiredIdToken).ToString();
|
||||
|
||||
if (header != expect)
|
||||
{
|
||||
cxt.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}))
|
||||
{
|
||||
{
|
||||
// use unexpired id token as bearer, do not attempt to refresh
|
||||
var client = new Kubernetes(new KubernetesClientConfiguration
|
||||
{
|
||||
Host = server.Uri.ToString(),
|
||||
AccessToken = unexpiredIdToken,
|
||||
TokenProvider = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, unexpiredIdToken, refreshToken),
|
||||
});
|
||||
|
||||
var listTask = ExecuteListPods(client);
|
||||
Assert.True(listTask.Response.IsSuccessStatusCode);
|
||||
Assert.Equal(1, listTask.Body.Items.Count);
|
||||
}
|
||||
|
||||
{
|
||||
// attempt to refresh id token when expired
|
||||
var client = new Kubernetes(new KubernetesClientConfiguration
|
||||
{
|
||||
Host = server.Uri.ToString(),
|
||||
AccessToken = expiredIdToken,
|
||||
TokenProvider = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, expiredIdToken, refreshToken),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
PeelAggregate(() => ExecuteListPods(client));
|
||||
Assert.True(false, "should not be here");
|
||||
}
|
||||
catch (KubernetesClientException e)
|
||||
{
|
||||
Assert.StartsWith("Unable to refresh OIDC token.", e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// attempt to refresh id token when null
|
||||
var client = new Kubernetes(new KubernetesClientConfiguration
|
||||
{
|
||||
Host = server.Uri.ToString(),
|
||||
AccessToken = expiredIdToken,
|
||||
TokenProvider = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, null, refreshToken),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
PeelAggregate(() => ExecuteListPods(client));
|
||||
Assert.True(false, "should not be here");
|
||||
}
|
||||
catch (KubernetesClientException e)
|
||||
{
|
||||
Assert.StartsWith("Unable to refresh OIDC token.", e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShouldThrowUnauthorized(Kubernetes client)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using k8s.Authentication;
|
||||
using k8s.Exceptions;
|
||||
using k8s.KubeConfigModels;
|
||||
using System;
|
||||
@@ -245,6 +246,18 @@ namespace k8s.Tests
|
||||
Assert.Equal("secret", cfg.Password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks oidc authentication provider information is read properly
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UserOidcAuthentication()
|
||||
{
|
||||
var fi = new FileInfo("assets/kubeconfig.user-oidc.yml");
|
||||
var cfg = KubernetesClientConfiguration.BuildConfigFromConfigFile(fi, useRelativePaths: false);
|
||||
Assert.Equal("ID_TOKEN", cfg.AccessToken);
|
||||
Assert.IsType<OidcTokenProvider>(cfg.TokenProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that a KubeConfigException is thrown when user cannot be found in users
|
||||
/// </summary>
|
||||
|
||||
57
tests/KubernetesClient.Tests/OidcAuthTests.cs
Normal file
57
tests/KubernetesClient.Tests/OidcAuthTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using k8s.Authentication;
|
||||
using k8s.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace k8s.Tests
|
||||
{
|
||||
public class OidcAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestOidcAuth()
|
||||
{
|
||||
var clientId = "CLIENT_ID";
|
||||
var clientSecret = "CLIENT_SECRET";
|
||||
var idpIssuerUrl = "https://idp.issuer.url";
|
||||
var unexpiredIdToken = "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjAsImV4cCI6MjAwMDAwMDAwMH0.8Ata5uKlrqYfeIaMwS91xVgVFHu7ntHx1sGN95i2Zho";
|
||||
var expiredIdToken = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.f37LFpIw_XIS5TZt3wdtEjjyCNshYy03lOWpyDViRM0";
|
||||
var refreshToken = "REFRESH_TOKEN";
|
||||
|
||||
// use unexpired id token as bearer, do not attempt to refresh
|
||||
var auth = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, unexpiredIdToken, refreshToken);
|
||||
var result = await auth.GetAuthenticationHeaderAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
result.Scheme.Should().Be("Bearer");
|
||||
result.Parameter.Should().Be(unexpiredIdToken);
|
||||
|
||||
try
|
||||
{
|
||||
// attempt to refresh id token when expired
|
||||
auth = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, expiredIdToken, refreshToken);
|
||||
result = await auth.GetAuthenticationHeaderAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
result.Scheme.Should().Be("Bearer");
|
||||
result.Parameter.Should().Be(expiredIdToken);
|
||||
Assert.True(false, "should not be here");
|
||||
}
|
||||
catch (KubernetesClientException e)
|
||||
{
|
||||
Assert.StartsWith("Unable to refresh OIDC token.", e.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// attempt to refresh id token when null
|
||||
auth = new OidcTokenProvider(clientId, clientSecret, idpIssuerUrl, null, refreshToken);
|
||||
result = await auth.GetAuthenticationHeaderAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
result.Scheme.Should().Be("Bearer");
|
||||
result.Parameter.Should().Be(expiredIdToken);
|
||||
Assert.True(false, "should not be here");
|
||||
}
|
||||
catch (KubernetesClientException e)
|
||||
{
|
||||
Assert.StartsWith("Unable to refresh OIDC token.", e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using k8s.Authentication;
|
||||
using Xunit;
|
||||
|
||||
namespace k8s.Tests
|
||||
{
|
||||
public class TokenFileAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestToken()
|
||||
{
|
||||
var auth = new TokenFileAuth("assets/token1");
|
||||
@@ -20,7 +22,7 @@ namespace k8s.Tests
|
||||
result.Scheme.Should().Be("Bearer");
|
||||
result.Parameter.Should().Be("token1");
|
||||
|
||||
auth.TokenExpiresAt = DateTime.UtcNow;
|
||||
auth.TokenExpiresAt = DateTime.UtcNow.AddSeconds(-1);
|
||||
result = await auth.GetAuthenticationHeaderAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
result.Scheme.Should().Be("Bearer");
|
||||
result.Parameter.Should().Be("token2");
|
||||
|
||||
28
tests/KubernetesClient.Tests/assets/kubeconfig.user-oidc.yml
Normal file
28
tests/KubernetesClient.Tests/assets/kubeconfig.user-oidc.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
# Sample file based on https://kubernetes.io/docs/tasks/access-application-cluster/authenticate-across-clusters-kubeconfig/
|
||||
# WARNING: File includes minor fixes
|
||||
---
|
||||
current-context: federal-context
|
||||
apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority: assets/ca.crt
|
||||
server: https://horse.org:4443
|
||||
name: horse-cluster
|
||||
contexts:
|
||||
- context:
|
||||
cluster: horse-cluster
|
||||
namespace: chisel-ns
|
||||
user: green-user
|
||||
name: federal-context
|
||||
kind: Config
|
||||
users:
|
||||
- name: green-user
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
client-id: CLIENT_ID
|
||||
client-secret: CLIENT_SECRET
|
||||
id-token: ID_TOKEN
|
||||
idp-issuer-url: IDP_ISSUER_URL
|
||||
refresh-token: REFRESH_TOKEN
|
||||
name: oidc
|
||||
Reference in New Issue
Block a user