Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 245 additions & 2 deletions ClickView.GoodStuff.sln

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="IdentityModel" Version="7.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.19.1" />
<PackageReference Include="System.Text.Json" Version="10.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Abstractions\src\ClickView.GoodStuff.AspNetCore.Authentication.Abstractions.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ namespace ClickView.GoodStuff.AspNetCore.Authentication.Endpoints;
using System.Threading;
using System.Threading.Tasks;
using Abstractions;
using IdentityModel;
using Infrastructure;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using TokenValidation;

internal sealed class BackChannelLogoutEndpoint(
Expand All @@ -17,7 +17,9 @@ internal sealed class BackChannelLogoutEndpoint(
ILogger<BackChannelLogoutEndpoint> logger)
: IEndpoint
{
public async Task ProcessAsync(HttpContext context, CancellationToken token = default)
private const string LogoutTokenFormField = "logout_token";

public async Task ProcessAsync(HttpContext context, CancellationToken cancellationToken = default)
{
logger.LogDebug("Processing Back-Channel logout");

Expand All @@ -34,7 +36,7 @@ public async Task ProcessAsync(HttpContext context, CancellationToken token = de

try
{
var logoutToken = context.Request.Form[OidcConstants.BackChannelLogoutRequest.LogoutToken]
var logoutToken = context.Request.Form[LogoutTokenFormField]
.FirstOrDefault();

if (string.IsNullOrWhiteSpace(logoutToken))
Expand All @@ -45,9 +47,9 @@ public async Task ProcessAsync(HttpContext context, CancellationToken token = de
return;
}

var user = await tokenValidator.ValidateLogoutTokenAsync(logoutToken);
var user = await tokenValidator.ValidateLogoutTokenAsync(logoutToken, cancellationToken);

var sessionId = user.FindFirst(JwtClaimTypes.SessionId)?.Value;
var sessionId = user.FindFirst(JwtRegisteredClaimNames.Sid)?.Value;
if (string.IsNullOrWhiteSpace(sessionId))
{
logger.LogWarning("Backchannel logout does not contain a session id");
Expand All @@ -56,7 +58,7 @@ public async Task ProcessAsync(HttpContext context, CancellationToken token = de
return;
}

await sessionStore.DeleteBySessionIdAsync(sessionId, token);
await sessionStore.DeleteBySessionIdAsync(sessionId, cancellationToken);

logger.LogInformation("Back-Channel logout successful for SessionId: {Sid}", sessionId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ namespace ClickView.GoodStuff.AspNetCore.Authentication.Infrastructure;

internal interface IEndpoint
{
Task ProcessAsync(HttpContext context, CancellationToken token = default);
Task ProcessAsync(HttpContext context, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
namespace ClickView.GoodStuff.AspNetCore.Authentication.Infrastructure;

using System;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Abstractions;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;

internal sealed class UserSessionTicketStore(
IUserSessionStore userSessionStore,
Expand All @@ -15,7 +16,7 @@ internal sealed class UserSessionTicketStore(
{
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var key = CryptoRandom.CreateUniqueId(format: CryptoRandom.OutputFormat.Hex);
var key = RandomNumberGenerator.GetHexString(32);

var userSession = CreateUserSession(key, ticket);

Expand Down Expand Up @@ -66,8 +67,8 @@ private UserSession CreateUserSession(string key, AuthenticationTicket ticket)

ArgumentNullException.ThrowIfNull(ticket);

var subject = ticket.Principal.FindFirst(JwtClaimTypes.Subject)?.Value;
var sessionId = ticket.Principal.FindFirst(JwtClaimTypes.SessionId)?.Value;
var subject = ticket.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
var sessionId = ticket.Principal.FindFirst(JwtRegisteredClaimNames.Sid)?.Value;

return new UserSession(key, ticketSerializer.Serialize(ticket))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace ClickView.GoodStuff.AspNetCore.Authentication.TokenValidation;

internal interface ITokenValidator
{
Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken);
Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken, string? validAudience);
Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken, CancellationToken cancellationToken = default);
Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken, string? validAudience, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace ClickView.GoodStuff.AspNetCore.Authentication.TokenValidation;

using System;

internal sealed class InvalidLogoutTokenException : Exception
{
public InvalidLogoutTokenException(string message) : base(message)
{
}

public InvalidLogoutTokenException(string message, Exception? innerException) : base(message, innerException)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
namespace ClickView.GoodStuff.AspNetCore.Authentication.TokenValidation;

using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel;
using IdentityModel.Client;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using JsonWebKeySet = IdentityModel.Jwk.JsonWebKeySet;

internal sealed class TokenValidator : ITokenValidator
{
private static readonly JsonWebTokenHandler _handler = new();

private readonly TokenValidatorOptions _options;
private readonly DiscoveryCache _discoveryCache;
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configurationManager;

private const string RoleClaimType = "role";
private const string EventsClaimType = "events";
private const string BackChannelScheme = "http://schemas.openid.net/event/backchannel-logout";

public TokenValidator(IOptions<TokenValidatorOptions> options)
Expand All @@ -28,77 +30,77 @@ public TokenValidator(IOptions<TokenValidatorOptions> options)
throw new ArgumentException("Authority must be set");

_options = o;
_discoveryCache = new DiscoveryCache(o.Authority.AbsoluteUri);
_configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
new Uri(o.Authority, ".well-known/openid-configuration").AbsoluteUri,
new OpenIdConnectConfigurationRetriever());
}

internal TokenValidator(TokenValidatorOptions options, IConfigurationManager<OpenIdConnectConfiguration> configurationManager)
{
_options = options;
_configurationManager = configurationManager;
}

public Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken)
public Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken, CancellationToken cancellationToken = default)
{
return ValidateLogoutTokenAsync(logoutToken, _options.DefaultAudience);
return ValidateLogoutTokenAsync(logoutToken, _options.DefaultAudience, cancellationToken);
}

public async Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken, string? validAudience)
public async Task<ClaimsPrincipal> ValidateLogoutTokenAsync(string logoutToken, string? validAudience,
CancellationToken cancellationToken = default)
{
var claims = await ValidateJwtAsync(logoutToken, validAudience);
var claims = await ValidateJwtAsync(logoutToken, validAudience, cancellationToken);

if (claims.FindFirst(JwtClaimTypes.Subject) == null && claims.FindFirst(JwtClaimTypes.SessionId) == null)
if (claims.FindFirst(JwtRegisteredClaimNames.Sub) == null &&
claims.FindFirst(JwtRegisteredClaimNames.Sid) == null)
{
throw new Exception(
$"Invalid logout token. {JwtClaimTypes.Subject} or {JwtClaimTypes.SessionId} missing");
throw new InvalidLogoutTokenException(
$"Invalid logout token. {JwtRegisteredClaimNames.Sub} or {JwtRegisteredClaimNames.Sid} missing");
}

var nonce = claims.FindFirst(JwtClaimTypes.Nonce)?.Value;
var nonce = claims.FindFirst(JwtRegisteredClaimNames.Nonce)?.Value;

if (!string.IsNullOrWhiteSpace(nonce))
throw new Exception($"Invalid logout token. {JwtClaimTypes.Nonce} missing");
throw new InvalidLogoutTokenException($"Invalid logout token. {JwtRegisteredClaimNames.Nonce} missing");

var eventsJson = claims.FindFirst(JwtClaimTypes.Events)?.Value;
if (claims.FindFirst(JwtRegisteredClaimNames.Jti) == null)
throw new InvalidLogoutTokenException($"Invalid logout token. {JwtRegisteredClaimNames.Jti} missing");

var eventsJson = claims.FindFirst(EventsClaimType)?.Value;

if (string.IsNullOrWhiteSpace(eventsJson))
throw new Exception($"Invalid logout token. {JwtClaimTypes.Events} missing");
throw new InvalidLogoutTokenException($"Invalid logout token. {EventsClaimType} missing");

var events = JsonDocument.Parse(eventsJson).RootElement;
var logoutEvent = events.TryGetString(BackChannelScheme);

if (logoutEvent == null)
throw new Exception("Invalid logout token");
if (!events.TryGetProperty(BackChannelScheme, out var backChannelEvent) ||
backChannelEvent.ValueKind != JsonValueKind.Object)
{
throw new InvalidLogoutTokenException("Invalid logout token");
}

return claims;
}

private async Task<ClaimsPrincipal> ValidateJwtAsync(string jwt, string? validAudience)
private async Task<ClaimsPrincipal> ValidateJwtAsync(string jwt, string? validAudience,
CancellationToken cancellationToken)
{
var discoveryDocument = await _discoveryCache.GetAsync();
var openIdConnectConfiguration = await _configurationManager.GetConfigurationAsync(cancellationToken);

var parameters = new TokenValidationParameters
{
ValidIssuer = discoveryDocument.Issuer,
ValidIssuer = openIdConnectConfiguration.Issuer,
ValidAudience = validAudience,
IssuerSigningKeys = GetSecurityKeys(discoveryDocument.KeySet),

NameClaimType = JwtClaimTypes.Name,
RoleClaimType = JwtClaimTypes.Role
IssuerSigningKeys = openIdConnectConfiguration.SigningKeys,
NameClaimType = JwtRegisteredClaimNames.Name,
RoleClaimType = RoleClaimType
};

var handler = new JwtSecurityTokenHandler();
handler.InboundClaimTypeMap.Clear();

return handler.ValidateToken(jwt, parameters, out _);
}
var result = await _handler.ValidateTokenAsync(jwt, parameters);

private static IEnumerable<SecurityKey> GetSecurityKeys(JsonWebKeySet? keySet)
{
if (keySet is null)
yield break;
if (!result.IsValid)
throw new InvalidLogoutTokenException("Invalid logout token", result.Exception);

foreach (var webKey in keySet.Keys)
{
var e = Base64Url.Decode(webKey.E);
var n = Base64Url.Decode(webKey.N);

yield return new RsaSecurityKey(new RSAParameters { Exponent = e, Modulus = n })
{
KeyId = webKey.Kid
};
}
return new ClaimsPrincipal(result.ClaimsIdentity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\src\ClickView.GoodStuff.AspNetCore.Authentication.csproj" />
</ItemGroup>

</Project>
Loading
Loading