.NET 10 solution for Ping, a Discord style chat app: Domain, Application and Infrastructure projects plus the Api host. SignalR for chat and presence, LiveKit for call tokens, EF Core on Postgres, S3 compatible storage for attachments, and OIDC auth shared with the Musify Zitadel instance. Includes the Compose stack for local development.
131 lines
4.8 KiB
C#
131 lines
4.8 KiB
C#
using Mediator;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Ping.Application.Users;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace Ping.Api.Authentication;
|
|
|
|
public sealed class JwtBearerEventsHandler(
|
|
IMediator mediator,
|
|
IMemoryCache cache,
|
|
IHttpClientFactory httpClientFactory,
|
|
AuthenticationConfiguration authenticationConfiguration,
|
|
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
|
|
{
|
|
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
|
|
|
|
private sealed record UserInfoResponse(
|
|
[property: JsonPropertyName("name")] string? Name,
|
|
[property: JsonPropertyName("preferred_username")] string? PreferredUsername,
|
|
[property: JsonPropertyName("email")] string? Email,
|
|
[property: JsonPropertyName("picture")] string? Picture);
|
|
|
|
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
|
|
{
|
|
var principal = tokenValidatedContext.Principal!;
|
|
|
|
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (!long.TryParse(rawId, out var userId))
|
|
{
|
|
logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId);
|
|
return;
|
|
}
|
|
|
|
var cacheKey = $"user-synced:{userId}";
|
|
if (cache.TryGetValue(cacheKey, out _))
|
|
return;
|
|
|
|
var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted);
|
|
|
|
var username = userInfo?.PreferredUsername
|
|
?? userInfo?.Name
|
|
?? principal.FindFirstValue("preferred_username")
|
|
?? principal.FindFirstValue("name")
|
|
?? principal.FindFirstValue(ClaimTypes.Name)
|
|
?? userInfo?.Email
|
|
?? principal.FindFirstValue("email")
|
|
?? rawId!;
|
|
|
|
var displayName = userInfo?.Name ?? principal.FindFirstValue("name");
|
|
var avatarUrl = userInfo?.Picture ?? principal.FindFirstValue("picture");
|
|
|
|
try
|
|
{
|
|
await mediator.Send(
|
|
new SyncUserCommand(userId, username, displayName, avatarUrl),
|
|
tokenValidatedContext.HttpContext.RequestAborted);
|
|
|
|
cache.Set(cacheKey, true, UserSyncCacheTtl);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId);
|
|
}
|
|
}
|
|
|
|
private async Task<UserInfoResponse?> FetchUserInfoAsync(
|
|
TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken)
|
|
{
|
|
var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString();
|
|
if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
|
return null;
|
|
|
|
var accessToken = authorizationHeader["Bearer ".Length..].Trim();
|
|
if (accessToken.Length == 0)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var client = httpClientFactory.CreateClient();
|
|
var request = new HttpRequestMessage(
|
|
HttpMethod.Get,
|
|
$"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo")
|
|
{
|
|
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) },
|
|
};
|
|
|
|
var response = await client.SendAsync(request, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode);
|
|
return null;
|
|
}
|
|
|
|
return await response.Content.ReadFromJsonAsync<UserInfoResponse>(cancellationToken);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "Failed to fetch userinfo from identity provider");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public override Task AuthenticationFailed(AuthenticationFailedContext context)
|
|
{
|
|
logger.LogWarning(context.Exception, "JWT authentication failed");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public override Task Challenge(JwtBearerChallengeContext context)
|
|
{
|
|
if (context.AuthenticateFailure is not null)
|
|
logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public override Task MessageReceived(MessageReceivedContext context)
|
|
{
|
|
var accessToken = context.Request.Query["access_token"];
|
|
var path = context.HttpContext.Request.Path;
|
|
|
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
|
context.Token = accessToken;
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|