Kavita/API/Services/OidcService.cs
Joe Milazzo 26ff71f42b
OPDS Enhancements, Epub fixes, and a lot more (#4035)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: Fabian Pammer <fpammer@mantro.net>
Co-authored-by: Vinícius Licz <vinilicz@gmail.com>
2025-09-20 13:16:21 -07:00

679 lines
27 KiB
C#

#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Email;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using Hangfire;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace API.Services;
public interface IOidcService
{
/// <summary>
/// Returns the user authenticated with OpenID Connect
/// </summary>
/// <param name="request"></param>
/// <param name="principal"></param>
/// <returns></returns>
/// <exception cref="KavitaException">if any requirements aren't met</exception>
Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal);
/// <summary>
/// Refresh the token inside the cookie when it's close to expiring. And sync the user
/// </summary>
/// <param name="ctx"></param>
/// <returns></returns>
/// <remarks>If the token is refreshed successfully, updates the last active time of the suer</remarks>
Task<AppUser?> RefreshCookieToken(CookieValidatePrincipalContext ctx);
/// <summary>
/// Remove <see cref="AppUser.OidcId"/> from all users
/// </summary>
/// <returns></returns>
Task ClearOidcIds();
}
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
IUnitOfWork unitOfWork, IAccountService accountService, IEmailService emailService): IOidcService
{
public const string LibraryAccessPrefix = "library-";
public const string AgeRestrictionPrefix = "age-restriction-";
public const string IncludeUnknowns = "include-unknowns";
public const string RefreshToken = "refresh_token";
public const string IdToken = "id_token";
public const string ExpiresAt = "expires_at";
/// The name of the Auth Cookie set by .NET
public const string CookieName = ".AspNetCore.Cookies";
/// <summary>
/// The ConfigurationManager will refresh the configuration periodically to ensure the data stays up to date
/// We can store the same one indefinitely as the authority does not change unless Kavita is restarted
/// </summary>
/// <remarks>The ConfigurationManager has its own lock, it loads data thread safe</remarks>
private static readonly ConfigurationManager<OpenIdConnectConfiguration> OidcConfigurationManager;
private static readonly ConcurrentDictionary<string, bool> RefreshInProgress = new();
private static readonly ConcurrentDictionary<string, DateTimeOffset> LastFailedRefresh = new();
#pragma warning disable S3963
static OidcService()
{
var authority = Configuration.OidcSettings.Authority;
var hasTrailingSlash = authority.EndsWith('/');
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
OidcConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
url,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = url.StartsWith("https") }
);
}
#pragma warning restore S3963
public async Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal)
{
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
var oidcId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(oidcId))
{
throw new KavitaException("errors.oidc.missing-external-id");
}
var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
if (user != null)
{
await SyncUserSettings(request, settings, principal, user);
return user;
}
var email = principal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email))
{
throw new KavitaException("errors.oidc.missing-email");
}
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
{
throw new KavitaException("errors.oidc.email-not-verified");
}
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
if (user != null)
{
// Don't allow taking over accounts
// This could happen if the user changes their email in OIDC, and then someone else uses the old one
if (!string.IsNullOrEmpty(user.OidcId))
{
throw new KavitaException("errors.oidc.email-in-use");
}
logger.LogDebug("User {UserName} has matched on email to {OidcId}", user.Id, oidcId);
user.OidcId = oidcId;
await unitOfWork.CommitAsync();
await SyncUserSettings(request, settings, principal, user);
return user;
}
return await CreateNewAccount(request, principal, settings, oidcId);
}
public async Task<AppUser?> RefreshCookieToken(CookieValidatePrincipalContext ctx)
{
if (ctx.Principal == null) return null;
var user = await unitOfWork.UserRepository.GetUserByIdAsync(ctx.Principal.GetUserId()) ?? throw new UnauthorizedAccessException();
var key = ctx.Principal.GetUsername();
var refreshToken = ctx.Properties.GetTokenValue(RefreshToken);
if (string.IsNullOrEmpty(refreshToken)) return user;
var expiresAt = ctx.Properties.GetTokenValue(ExpiresAt);
if (string.IsNullOrEmpty(expiresAt)) return user;
// Do not spam refresh if it failed
if (LastFailedRefresh.TryGetValue(key, out var time) && time.AddMinutes(30) < DateTimeOffset.UtcNow) return user;
var tokenExpiry = DateTimeOffset.ParseExact(expiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
if (tokenExpiry >= DateTimeOffset.UtcNow.AddSeconds(30)) return user;
// Ensure we're not refreshing twice
if (!RefreshInProgress.TryAdd(key, true)) return user;
try
{
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
var tokenResponse = await RefreshTokenAsync(settings, refreshToken);
if (!string.IsNullOrEmpty(tokenResponse.Error))
{
logger.LogTrace("Failed to refresh token : {Error} - {Description}", tokenResponse.Error, tokenResponse.ErrorDescription);
LastFailedRefresh.TryAdd(key, DateTimeOffset.UtcNow);
return user;
}
var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(tokenResponse.ExpiresIn));
ctx.Properties.UpdateTokenValue(ExpiresAt, newExpiresAt.ToString("o"));
ctx.Properties.UpdateTokenValue(RefreshToken, tokenResponse.RefreshToken);
ctx.Properties.UpdateTokenValue(IdToken, tokenResponse.IdToken);
ctx.ShouldRenew = true;
try
{
user.UpdateLastActive();
if (unitOfWork.HasChanges())
{
await unitOfWork.CommitAsync();
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
if (string.IsNullOrEmpty(tokenResponse.IdToken))
{
logger.LogTrace("The OIDC provider did not return an id token in the refresh response, continuous sync is not supported");
return user;
}
await SyncUserSettings(ctx, settings, tokenResponse.IdToken, user);
logger.LogTrace("Automatically refreshed token for user {UserId}", ctx.Principal?.GetUserId());
}
finally
{
RefreshInProgress.TryRemove(key, out _);
LastFailedRefresh.TryRemove(key, out _);
}
return user;
}
public async Task ClearOidcIds()
{
var users = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in users)
{
user.OidcId = null;
}
await unitOfWork.CommitAsync();
}
/// <summary>
/// Tries to construct a new account from the OIDC Principal, may fail if required conditions aren't met
/// </summary>
/// <param name="request"></param>
/// <param name="principal"></param>
/// <param name="settings"></param>
/// <param name="oidcId"></param>
/// <returns></returns>
/// <exception cref="KavitaException"></exception>
private async Task<AppUser?> CreateNewAccount(HttpRequest request, ClaimsPrincipal principal, OidcConfigDto settings, string oidcId)
{
var accessRoles = principal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix)
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
if (settings.SyncUserSettings && accessRoles.Count == 0)
{
throw new KavitaException("errors.oidc.role-not-assigned");
}
AppUser? user;
try
{
user = await NewUserFromOpenIdConnect(request, settings, principal, oidcId);
}
catch (KavitaException e)
{
throw;
}
catch (Exception e)
{
logger.LogError(e, "An error occured creating a new user");
throw new KavitaException("errors.oidc.creating-user");
}
if (user == null) return null;
var roles = await userManager.GetRolesAsync(user);
if (roles.Count == 0 || (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)))
{
throw new KavitaException("errors.oidc.disabled-account");
}
return user;
}
/// <summary>
/// Find the best available name from claims
/// </summary>
/// <param name="claimsPrincipal"></param>
/// <param name="orEqualTo">Also return if the claim is equal to this value</param>
/// <returns></returns>
public async Task<string?> FindBestAvailableName(ClaimsPrincipal claimsPrincipal, string? orEqualTo = null)
{
var nameCandidates = new[]
{
claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername),
claimsPrincipal.FindFirstValue(ClaimTypes.Name),
claimsPrincipal.FindFirstValue(ClaimTypes.GivenName),
claimsPrincipal.FindFirstValue(ClaimTypes.Surname)
};
foreach (var name in nameCandidates.Where(n => !string.IsNullOrEmpty(n)))
{
if (name == orEqualTo || await IsNameAvailable(name))
{
return name;
}
}
return null;
}
private async Task<bool> IsNameAvailable(string? name)
{
return !(await accountService.ValidateUsername(name)).Any();
}
private async Task<AppUser?> NewUserFromOpenIdConnect(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId)
{
if (!settings.ProvisionAccounts) return null;
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
if (string.IsNullOrWhiteSpace(emailClaim?.Value)) return null;
var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value;
logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name.Censor(), externalId);
var user = new AppUserBuilder(name, emailClaim.Value,
await unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build();
var res = await userManager.CreateAsync(user);
if (!res.Succeeded)
{
logger.LogError("Failed to create new user from OIDC: {Errors}",
res.Errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.creating-user");
}
if (claimsPrincipal.HasVerifiedEmail())
{
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
await userManager.ConfirmEmailAsync(user, token);
}
user.OidcId = externalId;
user.IdentityProvider = IdentityProvider.OpenIdConnect;
await accountService.SeedUser(user);
await SyncUserSettings(request, settings, claimsPrincipal, user);
await SetDefaults(settings, user);
await unitOfWork.CommitAsync();
return user;
}
/// <summary>
/// Assign configured defaults (libraries, age ratings, roles) to the newly created user
/// </summary>
private async Task SetDefaults(OidcConfigDto settings, AppUser user)
{
if (settings.SyncUserSettings) return;
logger.LogDebug("Assigning defaults to newly created user; Roles: {Roles}, Libraries: {Libraries}, AgeRating: {AgeRating}, IncludeUnknowns: {IncludeUnknowns}",
settings.DefaultRoles, settings.DefaultLibraries, settings.DefaultAgeRestriction, settings.DefaultIncludeUnknowns);
// Assign roles
var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles);
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
// Assign libraries
await accountService.UpdateLibrariesForUser(user, settings.DefaultLibraries, settings.DefaultRoles.Contains(PolicyConstants.AdminRole));
// Assign age rating
user.AgeRestriction = settings.DefaultAgeRestriction;
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
await unitOfWork.CommitAsync();
}
/// <summary>
/// Syncs the given user to the principal found in the id token
/// </summary>
/// <param name="ctx"></param>
/// <param name="settings"></param>
/// <param name="idToken"></param>
/// <param name="user"></param>
/// <exception cref="UnauthorizedAccessException">If syncing fails</exception>
private async Task SyncUserSettings(CookieValidatePrincipalContext ctx, OidcConfigDto settings, string idToken, AppUser user)
{
if (!settings.SyncUserSettings || user.IdentityProvider != IdentityProvider.OpenIdConnect) return;
try
{
var newPrincipal = await ParseIdToken(settings, idToken);
await SyncUserSettings(ctx.HttpContext.Request, settings, newPrincipal, user);
}
catch (KavitaException ex)
{
logger.LogError(ex, "Failed to sync user after token refresh");
throw new UnauthorizedAccessException(ex.Message);
}
}
/// <summary>
/// Updates roles, library access and age rating restriction. Will not modify the default admin
/// </summary>
/// <param name="request"></param>
/// <param name="settings"></param>
/// <param name="claimsPrincipal"></param>
/// <param name="user"></param>
public async Task SyncUserSettings(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
if (!settings.SyncUserSettings || user.IdentityProvider != IdentityProvider.OpenIdConnect) return;
// Never sync the default user
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
if (defaultAdminUser.Id == user.Id) return;
logger.LogDebug("Syncing user {UserId} from OIDC", user.Id);
try
{
await SyncEmail(request, settings, claimsPrincipal, user);
await SyncUsername(claimsPrincipal, user);
await SyncRoles(settings, claimsPrincipal, user);
await SyncLibraries(settings, claimsPrincipal, user);
await SyncAgeRestriction(settings, claimsPrincipal, user);
if (unitOfWork.HasChanges())
{
await unitOfWork.CommitAsync();
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to sync user {UserId} from OIDC", user.Id);
await unitOfWork.RollbackAsync();
throw new KavitaException("errors.oidc.syncing-user", ex);
}
}
private async Task SyncEmail(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email) || user.Email == email) return;
if (settings.RequireVerifiedEmail && !claimsPrincipal.HasVerifiedEmail())
{
throw new KavitaException("errors.oidc.email-not-verified");
}
// Ensure no other user uses this email
var other = await userManager.FindByEmailAsync(email);
if (other != null)
{
throw new KavitaException("errors.oidc.email-in-use");
}
// The email is verified, we can go ahead and change & confirm it
if (claimsPrincipal.HasVerifiedEmail())
{
var res = await userManager.SetEmailAsync(user, email);
if (!res.Succeeded)
{
logger.LogError("Failed to update email for user {UserId} from OIDC {Errors}", user.Id, res.Errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.failed-to-update-email");
}
user.EmailConfirmed = true;
await userManager.UpdateAsync(user);
return;
}
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
var isValidEmailAddress = !string.IsNullOrEmpty(user.Email) && emailService.IsValidEmail(user.Email);
var isEmailSetup = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetup();
var shouldEmailUser = isEmailSetup || !isValidEmailAddress;
user.EmailConfirmed = !shouldEmailUser;
user.ConfirmationToken = token;
await userManager.UpdateAsync(user);
var emailLink = await emailService.GenerateEmailLink(request, user.ConfirmationToken, "confirm-email-update", email);
logger.LogCritical("[Update Email]: Automatic email update after OIDC sync, email Link for {UserId}: {Link}", user.Id, emailLink);
if (!shouldEmailUser)
{
logger.LogInformation("Cannot email admin, email not setup or admin email invalid");
return;
}
if (!isValidEmailAddress)
{
logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email.Censor());
return;
}
try
{
var invitingUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
BackgroundJob.Enqueue(() => emailService.SendEmailChangeEmail(new ConfirmationEmailDto()
{
EmailAddress = string.IsNullOrEmpty(user.Email) ? email : user.Email,
InstallId = BuildInfo.Version.ToString(),
InvitingUser = invitingUser.UserName,
ServerConfirmationLink = emailLink,
}));
}
catch (Exception)
{
/* Swallow exception */
}
}
private async Task SyncUsername(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var bestName = await FindBestAvailableName(claimsPrincipal, user.UserName);
if (bestName == null || bestName == user.UserName) return;
var res = await userManager.SetUserNameAsync(user, bestName);
if (!res.Succeeded)
{
logger.LogError("Failed to update username for user {UserId} to {NewUserName} from OIDC {Errors}", user.Id,
bestName.Censor(), res.Errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.failed-to-update-username");
}
}
private async Task SyncRoles(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
var roles = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix)
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles);
var errors = (await accountService.UpdateRolesForUser(user, roles)).ToList();
if (errors.Any())
{
logger.LogError("Failed to sync roles {Errors}", errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.syncing-user");
}
}
private async Task SyncLibraries(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
var libraryAccessPrefix = settings.RolesPrefix + LibraryAccessPrefix;
var libraryAccess = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, libraryAccessPrefix);
logger.LogDebug("Syncing libraries for user {UserId}, found library roles {Roles}", user.Id, libraryAccess);
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
// Distinct to ensure each library (id) is only present once
var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).Distinct().ToList();
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole);
}
private async Task SyncAgeRestriction(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{
logger.LogDebug("User {UserId} is admin, granting access to all age ratings", user.Id);
user.AgeRestriction = AgeRating.NotApplicable;
user.AgeRestrictionIncludeUnknowns = true;
return;
}
var ageRatingPrefix = settings.RolesPrefix + AgeRestrictionPrefix;
var ageRatings = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, ageRatingPrefix);
logger.LogDebug("Syncing age restriction for user {UserId}, found restrictions {Restrictions}", user.Id, ageRatings);
if (ageRatings.Count == 0 || (ageRatings.Count == 1 && ageRatings.Contains(IncludeUnknowns)))
{
logger.LogDebug("No age restriction found in roles, setting to NotApplicable and Include Unknowns: {IncludeUnknowns}", settings.DefaultIncludeUnknowns);
user.AgeRestriction = AgeRating.NotApplicable;
user.AgeRestrictionIncludeUnknowns = true;
return;
}
var highestAgeRestriction = AgeRating.NotApplicable;
foreach (var ar in ageRatings)
{
if (!EnumExtensions.TryParse(ar, out AgeRating ageRating))
{
logger.LogDebug("Age Restriction role configured that failed to map to a known age rating: {RoleName}", AgeRestrictionPrefix+ar);
continue;
}
if (ageRating > highestAgeRestriction)
{
highestAgeRestriction = ageRating;
}
}
user.AgeRestriction = highestAgeRestriction;
user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns);
logger.LogDebug("Synced age restriction for user {UserId}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}",
user.Id, user.AgeRestriction, user.AgeRestrictionIncludeUnknowns);
}
/// <summary>
/// Loads the discovery document if not already loaded, then refreshed the tokens for the user
/// </summary>
/// <param name="dto"></param>
/// <param name="refreshToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private static async Task<OpenIdConnectMessage> RefreshTokenAsync(OidcConfigDto dto, string refreshToken)
{
var discoveryDocument = await OidcConfigurationManager.GetConfigurationAsync();
var msg = new
{
grant_type = RefreshToken,
refresh_token = refreshToken,
client_id = dto.ClientId,
client_secret = dto.Secret,
};
var json = await discoveryDocument.TokenEndpoint
.AllowAnyHttpStatus()
.PostUrlEncodedAsync(msg)
.ReceiveString();
return new OpenIdConnectMessage(json);
}
/// <summary>
/// Loads the discovery document if not already loaded, then parses the given id token securely
/// </summary>
/// <param name="dto"></param>
/// <param name="idToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private static async Task<ClaimsPrincipal> ParseIdToken(OidcConfigDto dto, string idToken)
{
var discoveryDocument = await OidcConfigurationManager.GetConfigurationAsync();
var tokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = discoveryDocument.Issuer,
ValidAudience = dto.ClientId,
IssuerSigningKeys = discoveryDocument.SigningKeys,
ValidateIssuerSigningKey = true,
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(idToken, tokenValidationParameters, out _);
return principal;
}
/// <summary>
/// Return a list of claims in the same way the NativeJWT token would map them.
/// Optionally include original claims if the claims are needed later in the pipeline
/// </summary>
/// <param name="services"></param>
/// <param name="principal"></param>
/// <param name="user"></param>
/// <param name="includeOriginalClaims"></param>
/// <returns></returns>
public static async Task<List<Claim>> ConstructNewClaimsList(IServiceProvider services, ClaimsPrincipal? principal, AppUser user, bool includeOriginalClaims = true)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
new(ClaimTypes.Name, user.UserName ?? string.Empty),
};
var userManager = services.GetRequiredService<UserManager<AppUser>>();
var roles = await userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
if (includeOriginalClaims)
{
claims.AddRange(principal?.Claims ?? []);
}
return claims;
}
}