using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using API.Constants; using API.Data; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using Xunit; namespace API.Tests.Services; public class OidcServiceTests: AbstractDbTest { [Fact] public async Task UserSync_Username() { await ResetDb(); var (oidcService, _, _, userManager) = await Setup(); var user = new AppUserBuilder("holo", "holo@localhost") .WithIdentityProvider(IdentityProvider.OpenIdConnect) .Build(); var res = await userManager.CreateAsync(user); Assert.Empty(res.Errors); Assert.True(res.Succeeded); var claims = new List() { new (ClaimTypes.Name, "amelia"), new (ClaimTypes.GivenName, "Lawrence"), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; // name is updated as the current username is not found, amelia is skipped as it is alredy in use await oidcService.SyncUserSettings(null!, settings, principal, user); var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal("Lawrence", user.UserName); claims = new List() { new (ClaimTypes.Name, "amelia"), new (ClaimTypes.GivenName, "Lawrence"), new (ClaimTypes.Surname, "Norah"), }; identity = new ClaimsIdentity(claims); principal = new ClaimsPrincipal(identity); // Ensure a name longer down the list isn't picked if the current username is found await oidcService.SyncUserSettings(null!, settings, principal, user); dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal("Lawrence", user.UserName); } [Fact] public async Task UserSync_CustomClaim() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); UnitOfWork.LibraryRepository.Add(mangaLib); UnitOfWork.LibraryRepository.Add(lightNovelsLib); await UnitOfWork.CommitAsync(); const string claim = "groups"; var claims = new List() { new (claim, PolicyConstants.LoginRole), new (claim, PolicyConstants.DownloadRole), new (ClaimTypes.Role, PolicyConstants.PromoteRole), new (claim, OidcService.AgeRestrictionPrefix + "M"), new (claim, OidcService.LibraryAccessPrefix + "Manga"), new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, RolesClaim = claim, }; await oidcService.SyncUserSettings(null!, settings, principal, user); // Check correct roles assigned var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); Assert.Contains(PolicyConstants.LoginRole, userRoles); Assert.Contains(PolicyConstants.DownloadRole, userRoles); Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles); // Check correct libraries var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); Assert.Single(libraries); Assert.Contains(mangaLib.Name, libraries); Assert.DoesNotContain(lightNovelsLib.Name, libraries); // Check correct age restrictions var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); Assert.False(dbUser.AgeRestrictionIncludeUnknowns); } [Fact] public async Task UserSync_CustomPrefix() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); UnitOfWork.LibraryRepository.Add(mangaLib); UnitOfWork.LibraryRepository.Add(lightNovelsLib); await UnitOfWork.CommitAsync(); const string prefix = "kavita-"; var claims = new List() { new (ClaimTypes.Role, prefix + PolicyConstants.LoginRole), new (ClaimTypes.Role, prefix + PolicyConstants.DownloadRole), new (ClaimTypes.Role, PolicyConstants.PromoteRole), new (ClaimTypes.Role, prefix + OidcService.AgeRestrictionPrefix + "M"), new (ClaimTypes.Role, prefix + OidcService.LibraryAccessPrefix + "Manga"), new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, RolesPrefix = prefix, }; await oidcService.SyncUserSettings(null!, settings, principal, user); // Check correct roles assigned var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); Assert.Contains(PolicyConstants.LoginRole, userRoles); Assert.Contains(PolicyConstants.DownloadRole, userRoles); Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles); // Check correct libraries var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); Assert.Single(libraries); Assert.Contains(mangaLib.Name, libraries); Assert.DoesNotContain(lightNovelsLib.Name, libraries); // Check correct age restrictions var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); Assert.False(dbUser.AgeRestrictionIncludeUnknowns); } [Fact] public async Task SyncRoles() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var claims = new List() { new (ClaimTypes.Role, PolicyConstants.LoginRole), new (ClaimTypes.Role, PolicyConstants.DownloadRole), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; await oidcService.SyncUserSettings(null!, settings, principal, user); var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); Assert.Contains(PolicyConstants.LoginRole, userRoles); Assert.Contains(PolicyConstants.DownloadRole, userRoles); // Only give one role claims = [new Claim(ClaimTypes.Role, PolicyConstants.LoginRole)]; identity = new ClaimsIdentity(claims); principal = new ClaimsPrincipal(identity); await oidcService.SyncUserSettings(null!, settings, principal, user); userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); Assert.Contains(PolicyConstants.LoginRole, userRoles); Assert.DoesNotContain(PolicyConstants.DownloadRole, userRoles); } [Fact] public async Task SyncLibraries() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); UnitOfWork.LibraryRepository.Add(mangaLib); UnitOfWork.LibraryRepository.Add(lightNovelsLib); await UnitOfWork.CommitAsync(); var claims = new List() { new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Manga"), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; await oidcService.SyncUserSettings(null!, settings, principal, user); var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); Assert.Single(libraries); Assert.Contains(mangaLib.Name, libraries); Assert.DoesNotContain(lightNovelsLib.Name, libraries); // Only give access to the other library claims = [new Claim(ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels")]; identity = new ClaimsIdentity(claims); principal = new ClaimsPrincipal(identity); await oidcService.SyncUserSettings(null!, settings, principal, user); // Check access has swicthed libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); Assert.Single(libraries); Assert.Contains(lightNovelsLib.Name, libraries); Assert.DoesNotContain(mangaLib.Name, libraries); } [Fact] public async Task SyncAgeRestrictions_NoRestrictions() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var claims = new List() { new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Not Applicable"), new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; await oidcService.SyncUserSettings(null!, settings, principal, user); var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); Assert.True(dbUser.AgeRestrictionIncludeUnknowns); } [Fact] public async Task SyncAgeRestrictions_IncludeUnknowns() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var claims = new List() { new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"), new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; await oidcService.SyncUserSettings(null!, settings, principal, user); var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); Assert.True(dbUser.AgeRestrictionIncludeUnknowns); } [Fact] public async Task SyncAgeRestriction_AdminNone() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var claims = new List() { new (ClaimTypes.Role, PolicyConstants.AdminRole), new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; await oidcService.SyncUserSettings(null!, settings, principal, user); var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); Assert.True(dbUser.AgeRestrictionIncludeUnknowns); } [Fact] public async Task SyncAgeRestriction_MultipleAgeRestrictionClaims() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var claims = new List() { new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"), new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; await oidcService.SyncUserSettings(null!, settings, principal, user); var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); } [Fact] public async Task SyncAgeRestriction_NoAgeRestrictionClaims() { await ResetDb(); var (oidcService, user, _, _) = await Setup(); var identity = new ClaimsIdentity([]); var principal = new ClaimsPrincipal(identity); var settings = new OidcConfigDto { SyncUserSettings = true, }; await oidcService.SyncUserSettings(null!, settings, principal, user); var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); Assert.True(dbUser.AgeRestrictionIncludeUnknowns); // Also default to no restrictions when only include unknowns is present identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns)]); principal = new ClaimsPrincipal(identity); await oidcService.SyncUserSettings(null!, settings, principal, user); dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(dbUser); Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); Assert.True(dbUser.AgeRestrictionIncludeUnknowns); } [Fact] public async Task SyncUserSettings_DontChangeDefaultAdmin() { await ResetDb(); var (oidcService, _, _, userManager) = await Setup(); // Make user default user var user = await UnitOfWork.UserRepository.GetDefaultAdminUser(); var settings = new OidcConfigDto { SyncUserSettings = true, }; var claims = new List() { new (ClaimTypes.Role, PolicyConstants.ChangePasswordRole), new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); await oidcService.SyncUserSettings(null!, settings, principal, user); var userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); Assert.NotNull(userFromDb); Assert.NotEqual(AgeRating.Teen, userFromDb.AgeRestriction); var newUser = new AppUserBuilder("NotAnAdmin", "NotAnAdmin@localhost") .WithIdentityProvider(IdentityProvider.OpenIdConnect) .Build(); var res = await userManager.CreateAsync(newUser); Assert.Empty(res.Errors); Assert.True(res.Succeeded); await oidcService.SyncUserSettings(null!, settings, principal, newUser); userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(newUser.Id); Assert.NotNull(userFromDb); Assert.True(await userManager.IsInRoleAsync(newUser, PolicyConstants.ChangePasswordRole)); Assert.Equal(AgeRating.Teen, userFromDb.AgeRestriction); } [Fact] public async Task FindBestAvailableName_NoDuplicates() { await ResetDb(); var (oidcService, _, _, userManager) = await Setup(); const string preferredName = "PreferredName"; const string name = "Name"; const string givenName = "GivenName"; const string surname = "Surname"; const string email = "Email"; var claims = new List() { new(JwtRegisteredClaimNames.PreferredUsername, preferredName), new(ClaimTypes.Name, name), new(ClaimTypes.GivenName, givenName), new(ClaimTypes.Surname, surname), new(ClaimTypes.Email, email), }; var identity = new ClaimsIdentity(claims); var principal = new ClaimsPrincipal(identity); var bestName = await oidcService.FindBestAvailableName(principal); Assert.NotNull(bestName); Assert.Equal(preferredName, bestName); // Create user with this name to make the method fallback to the next claim var user = new AppUserBuilder(bestName, bestName).Build(); var res = await userManager.CreateAsync(user); // This has actual information as to why it would fail, so we check it to make sure if the test fail here we know why Assert.Empty(res.Errors); Assert.True(res.Succeeded); // Fallback to name bestName = await oidcService.FindBestAvailableName(principal); Assert.NotNull(bestName); Assert.Equal(name, bestName); user = new AppUserBuilder(bestName, bestName).Build(); res = await userManager.CreateAsync(user); Assert.Empty(res.Errors); Assert.True(res.Succeeded); // Fallback to given name bestName = await oidcService.FindBestAvailableName(principal); Assert.NotNull(bestName); Assert.Equal(givenName, bestName); user = new AppUserBuilder(bestName, bestName).Build(); res = await userManager.CreateAsync(user); Assert.Empty(res.Errors); Assert.True(res.Succeeded); // Fallback to surname bestName = await oidcService.FindBestAvailableName(principal); Assert.NotNull(bestName); Assert.Equal(surname, bestName); user = new AppUserBuilder(bestName, bestName).Build(); res = await userManager.CreateAsync(user); Assert.Empty(res.Errors); Assert.True(res.Succeeded); // When none are found, returns null bestName = await oidcService.FindBestAvailableName(principal); Assert.Null(bestName); } private async Task<(OidcService, AppUser, IAccountService, UserManager)> Setup() { var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost") .WithRole(PolicyConstants.AdminRole) .Build(); var user = new AppUserBuilder("amelia", "amelia@localhost") .WithIdentityProvider(IdentityProvider.OpenIdConnect) .Build(); var roleStore = new RoleStore< AppRole, DataContext, int, IdentityUserRole, IdentityRoleClaim >(Context); var roleManager = new RoleManager( roleStore, [new RoleValidator()], new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), Substitute.For>>()); foreach (var role in PolicyConstants.ValidRoles) { if (!await roleManager.RoleExistsAsync(role)) { await roleManager.CreateAsync(new AppRole { Name = role, }); } } var userStore = new UserStore< AppUser, AppRole, DataContext, int, IdentityUserClaim, AppUserRole, IdentityUserLogin, IdentityUserToken, IdentityRoleClaim >(Context); var userManager = new UserManager(userStore, new OptionsWrapper(new IdentityOptions()), new PasswordHasher(), [new UserValidator()], [new PasswordValidator()], new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, Substitute.For>>()); // Create users with the UserManager such that the SecurityStamp is set await userManager.CreateAsync(user); await userManager.CreateAsync(defaultAdmin); var accountService = new AccountService(userManager, Substitute.For>(), UnitOfWork, Mapper, Substitute.For()); var oidcService = new OidcService(Substitute.For>(), userManager, UnitOfWork, accountService, Substitute.For()); return (oidcService, user, accountService, userManager); } protected override async Task ResetDb() { Context.AppUser.RemoveRange(Context.AppUser); Context.Library.RemoveRange(Context.Library); await UnitOfWork.CommitAsync(); } }