POC oidc login

This commit is contained in:
Amelia 2025-05-24 13:57:06 +02:00
parent 6288d89651
commit df9d970a42
48 changed files with 5009 additions and 96 deletions

View file

@ -71,7 +71,7 @@
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />

View file

@ -74,6 +74,18 @@ public class AccountController : BaseApiController
_localizationService = localizationService;
}
[HttpGet]
public async Task<ActionResult<UserDto>> GetCurrentUserAsync()
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences);
if (user == null) throw new UnauthorizedAccessException();
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
return Ok(await ConstructUserDto(user));
}
/// <summary>
/// Update a user's password
/// </summary>
@ -245,6 +257,11 @@ public class AccountController : BaseApiController
}
}
return Ok(await ConstructUserDto(user));
}
private async Task<UserDto> ConstructUserDto(AppUser user)
{
// Update LastActive on account
user.UpdateLastActive();
@ -265,12 +282,11 @@ public class AccountController : BaseApiController
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion))
.Value;
var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName!);
if (pref == null) return Ok(dto);
if (pref == null) return dto;
pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
dto.Preferences = _mapper.Map<UserPreferencesDto>(pref);
return Ok(dto);
return dto;
}
/// <summary>

View file

@ -0,0 +1,23 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Settings;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
[AllowAnonymous]
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork): BaseApiController
{
// TODO: Decide what we want to expose here, not really anything useful in it. But the discussion is needed
// Public endpoint
[HttpGet("config")]
public async Task<ActionResult<OidcConfigDto>> GetOidcConfig()
{
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settings.OidcConfig);
}
}

View file

@ -0,0 +1,32 @@
#nullable enable
namespace API.DTOs.Settings;
public class OidcConfigDto
{
/// <summary>
/// Base url for authority, must have /.well-known/openid-configuration
/// </summary>
public string? Authority { get; set; }
/// <summary>
/// ClientId configured in your OpenID Connect provider
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Create a new account when someone logs in with an unmatched account, if <see cref="RequireVerifiedEmail"/> is true,
/// will account will be verified by default
/// </summary>
public bool ProvisionAccounts { get; set; }
/// <summary>
/// Require emails from OpenIDConnect to be verified before use
/// </summary>
public bool RequireVerifiedEmail { get; set; }
/// <summary>
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
/// </summary>
public bool ProvisionUserSettings { get; set; }
/// <summary>
/// Try logging in automatically when opening the app
/// </summary>
public bool AutoLogin { get; set; }
}

View file

@ -92,6 +92,11 @@ public sealed record ServerSettingDto
/// SMTP Configuration
/// </summary>
public SmtpConfigDto SmtpConfig { get; set; }
/// <summary>
/// OIDC Configuration
/// </summary>
public OidcConfigDto OidcConfig { get; set; }
/// <summary>
/// The Date Kavita was first installed
/// </summary>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class OpenIDConnect : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalId",
table: "AspNetUsers",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExternalId",
table: "AspNetUsers");
}
}
}

View file

@ -85,6 +85,9 @@ namespace API.Data.Migrations
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("ExternalId")
.HasColumnType("TEXT");
b.Property<bool>("HasRunScrobbleEventGeneration")
.HasColumnType("INTEGER");

View file

@ -107,6 +107,7 @@ public interface IUserRepository
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
Task<AppUser?> GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None);
}
public class UserRepository : IUserRepository
@ -557,6 +558,16 @@ public class UserRepository : IUserRepository
.FirstOrDefaultAsync();
}
public async Task<AppUser?> GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None)
{
if (string.IsNullOrEmpty(externalId)) return null;
return await _context.AppUser
.Where(u => u.ExternalId == externalId)
.Includes(includes)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{

View file

@ -252,6 +252,12 @@ public static class Seed
new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
new() { Key = ServerSettingKey.OidcAuthority, Value = Configuration.OidcAuthority },
new() { Key = ServerSettingKey.OidcClientId, Value = Configuration.OidcClientId},
new() { Key = ServerSettingKey.OidcAutoLogin, Value = "false"},
new() { Key = ServerSettingKey.OidcProvisionAccounts, Value = "false"},
new() { Key = ServerSettingKey.OidcRequireVerifiedEmail, Value = "true"},
new() { Key = ServerSettingKey.OidcProvisionUserSettings, Value = "false"},
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
@ -288,6 +294,10 @@ public static class Seed
DirectoryService.BackupDirectory + string.Empty;
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value =
Configuration.CacheSize + string.Empty;
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcAuthority)).Value =
Configuration.OidcAuthority + string.Empty;
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcClientId)).Value =
Configuration.OidcClientId + string.Empty;
await context.SaveChangesAsync();
}

View file

@ -88,6 +88,11 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// <remarks>Kavita+ only</remarks>
public DateTime ScrobbleEventGenerationRan { get; set; }
/// <summary>
/// The sub returned the by OIDC provider
/// </summary>
public string? ExternalId { get; set; }
/// <summary>
/// A list of Series the user doesn't want scrobbling for

View file

@ -197,4 +197,34 @@ public enum ServerSettingKey
/// </summary>
[Description("FirstInstallVersion")]
FirstInstallVersion = 39,
/// <summary>
/// Optional OpenID Connect Authority URL
/// </summary>
[Description("OpenIDConnectAuthority")]
OidcAuthority = 40,
/// <summary>
/// Optional OpenID Connect ClientId, default to kavita
/// </summary>
[Description("OpenIDConnectClientId")]
OidcClientId = 41,
/// <summary>
/// Optional OpenID Connect ClientSecret, required if authority is set
/// </summary>
[Description("OpenIdConnectAutoLogin")]
OidcAutoLogin = 42,
/// <summary>
/// If true, auto creates a new account when someone logs in via OpenID Connect
/// </summary>
[Description("OpenIDConnectCreateAccounts")]
OidcProvisionAccounts = 43,
/// <summary>
/// Require emails to be verified by the OpenID Connect provider when creating accounts on login
/// </summary>
[Description("OpenIDConnectVerifiedEmail")]
OidcRequireVerifiedEmail = 44,
/// <summary>
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
/// </summary>
[Description("OpenIDConnectSyncUserSettings")]
OidcProvisionUserSettings = 45,
}

View file

@ -80,6 +80,8 @@ public static class ApplicationServiceExtensions
services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>();
services.AddScoped<IWantToReadSyncService, WantToReadSyncService>();
services.AddScoped<IOidcService, OidcService>();
services.AddSqLite();
services.AddSignalR(opt => opt.EnableDetailedErrors = true);

View file

@ -8,6 +8,8 @@ namespace API.Extensions;
public static class ClaimsPrincipalExtensions
{
private const string NotAuthenticatedMessage = "User is not authenticated";
private static readonly string EmailVerifiedClaimType = "email_verified";
/// <summary>
/// Get's the authenticated user's username
/// </summary>
@ -26,4 +28,17 @@ public static class ClaimsPrincipalExtensions
var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage);
return int.Parse(userClaim.Value);
}
public static bool HasVerifiedEmail(this ClaimsPrincipal user)
{
var emailVerified = user.FindFirst(EmailVerifiedClaimType);
if (emailVerified == null) return false;
if (!bool.TryParse(emailVerified.Value, out bool emailVerifiedValue) || !emailVerifiedValue)
{
return false;
}
return true;
}
}

View file

@ -1,20 +1,34 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities;
using API.Helpers;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext;
namespace API.Extensions;
#nullable enable
public static class IdentityServiceExtensions
{
private const string DynamicJwt = nameof(DynamicJwt);
private const string OpenIdConnect = nameof(OpenIdConnect);
private const string LocalIdentity = nameof(LocalIdentity);
public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config)
{
services.Configure<IdentityOptions>(options =>
@ -47,42 +61,139 @@ public static class IdentityServiceExtensions
.AddRoleValidator<RoleValidator<AppRole>>()
.AddEntityFrameworkStores<DataContext>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
var auth = services.AddAuthentication(DynamicJwt)
.AddPolicyScheme(DynamicJwt, JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters()
var iss = Configuration.OidcAuthority;
var enabled = Configuration.OidcEnabled;
options.ForwardDefaultSelector = context =>
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
ValidateIssuer = false,
ValidateAudience = false,
ValidIssuer = "Kavita"
};
if (!enabled)
return LocalIdentity;
options.Events = new JwtBearerEvents()
{
OnMessageReceived = context =>
var fullAuth =
context.Request.Headers["Authorization"].FirstOrDefault() ??
context.Request.Query["access_token"].FirstOrDefault();
var token = fullAuth?.TrimPrefix("Bearer ");
if (string.IsNullOrEmpty(token))
return LocalIdentity;
var handler = new JwtSecurityTokenHandler();
try
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
// Only use query string based token on SignalR hubs
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
var jwt = handler.ReadJwtToken(token);
if (jwt.Issuer == iss) return OpenIdConnect;
}
catch
{
/* Swallow */
}
return LocalIdentity;
};
});
if (Configuration.OidcEnabled)
{
services.AddScoped<IClaimsTransformation, RolesClaimsTransformation>();
// TODO: Investigate on how to make this not hardcoded at startup
auth.AddJwtBearer(OpenIdConnect, options =>
{
options.Authority = Configuration.OidcAuthority;
options.Audience = Configuration.OidcClientId;
options.RequireHttpsMetadata = options.Authority.StartsWith("https://");
options.TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = Configuration.OidcClientId,
ValidIssuer = Configuration.OidcAuthority,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
RequireExpirationTime = true,
ValidateLifetime = true,
RequireSignedTokens = true
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = SetTokenFromQuery,
OnTokenValidated = OidcClaimsPrincipalConverter
};
});
}
auth.AddJwtBearer(LocalIdentity, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
ValidateIssuer = false,
ValidateAudience = false,
ValidIssuer = "Kavita"
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = SetTokenFromQuery
};
});
services.AddAuthorization(opt =>
{
opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole));
opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
opt.AddPolicy("RequireDownloadRole",
policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
opt.AddPolicy("RequireChangePasswordRole",
policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
});
return services;
}
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
{
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
if (ctx.Principal == null) return;
var user = await oidcService.LoginOrCreate(ctx.Principal);
if (user == null) return;
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 = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
var roles = await userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
claims.AddRange(ctx.Principal.Claims);
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
ctx.HttpContext.User = principal;
ctx.Principal = principal;
ctx.Success();
}
private static Task SetTokenFromQuery(MessageReceivedContext context)
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
// Only use query string based token on SignalR hubs
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) context.Token = accessToken;
return Task.CompletedTask;
}
}

View file

@ -52,4 +52,13 @@ public static class StringExtensions
{
return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture);
}
public static string? TrimPrefix(this string? value, string prefix)
{
if (string.IsNullOrEmpty(value)) return value;
if (!value.StartsWith(prefix)) return value;
return value.Substring(prefix.Length);
}
}

View file

@ -129,6 +129,30 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.FirstInstallVersion:
destination.FirstInstallVersion = row.Value;
break;
case ServerSettingKey.OidcAuthority:
destination.OidcConfig ??= new OidcConfigDto();
destination.OidcConfig.Authority = row.Value;
break;
case ServerSettingKey.OidcClientId:
destination.OidcConfig ??= new OidcConfigDto();
destination.OidcConfig.ClientId = row.Value;
break;
case ServerSettingKey.OidcAutoLogin:
destination.OidcConfig ??= new OidcConfigDto();
destination.OidcConfig.AutoLogin = bool.Parse(row.Value);
break;
case ServerSettingKey.OidcProvisionAccounts:
destination.OidcConfig ??= new OidcConfigDto();
destination.OidcConfig.ProvisionAccounts = bool.Parse(row.Value);
break;
case ServerSettingKey.OidcRequireVerifiedEmail:
destination.OidcConfig ??= new OidcConfigDto();
destination.OidcConfig.RequireVerifiedEmail = bool.Parse(row.Value);
break;
case ServerSettingKey.OidcProvisionUserSettings:
destination.OidcConfig ??= new OidcConfigDto();
destination.OidcConfig.ProvisionUserSettings = bool.Parse(row.Value);
break;
case ServerSettingKey.LicenseKey:
case ServerSettingKey.EnableAuthentication:
case ServerSettingKey.EmailServiceUrl:

View file

@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
namespace API.Helpers;
/// <summary>
/// Adds assigned roles from Keycloak under the default <see cref="ClaimTypes.Role"/> claim
/// </summary>
public class RolesClaimsTransformation: IClaimsTransformation
{
private const string ResourceAccessClaim = "resource_access";
private string _clientId;
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var resourceAccess = principal.FindFirst(ResourceAccessClaim);
if (resourceAccess == null) return Task.FromResult(principal);
var resources = JsonSerializer.Deserialize<Dictionary<string, Resource>>(resourceAccess.Value);
if (resources == null) return Task.FromResult(principal);
if (string.IsNullOrEmpty(_clientId))
{
_clientId = Configuration.OidcClientId;
}
var kavitaResource = resources.GetValueOrDefault(_clientId);
if (kavitaResource == null) return Task.FromResult(principal);
foreach (var role in kavitaResource.Roles)
{
((ClaimsIdentity)principal.Identity)?.AddClaim(new Claim(ClaimTypes.Role, role));
}
return Task.FromResult(principal);
}
private sealed class Resource
{
[JsonPropertyName("roles")]
public IList<string> Roles { get; set; } = [];
}
}

View file

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using System.Web;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Account;
using API.Entities;
using API.Errors;
@ -29,6 +30,9 @@ public interface IAccountService
Task<bool> HasBookmarkPermission(AppUser? user);
Task<bool> HasDownloadPermission(AppUser? user);
Task<bool> CanChangeAgeRestriction(AppUser? user);
Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole);
Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles);
}
public class AccountService : IAccountService
@ -143,4 +147,56 @@ public class AccountService : IAccountService
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
}
public async Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole)
{
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
List<Library> libraries;
if (hasAdminRole)
{
_logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries",
user.UserName);
libraries = allLibraries;
}
else
{
// Remove user from all libraries
foreach (var lib in allLibraries)
{
lib.AppUsers ??= new List<AppUser>();
lib.AppUsers.Remove(user);
user.RemoveSideNavFromLibrary(lib);
}
libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(librariesIds, LibraryIncludes.AppUser)).ToList();
}
foreach (var lib in libraries)
{
lib.AppUsers ??= new List<AppUser>();
lib.AppUsers.Add(user);
user.CreateSideNavFromLibrary(lib);
}
}
public async Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles)
{
var existingRoles = await _userManager.GetRolesAsync(user);
var hasAdminRole = roles.Contains(PolicyConstants.AdminRole);
if (!hasAdminRole)
{
roles.Add(PolicyConstants.PlebRole);
}
if (existingRoles.Except(roles).Any() || roles.Except(existingRoles).Any())
{
var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles);
if (!roleResult.Succeeded) return roleResult.Errors;
roleResult = await _userManager.AddToRolesAsync(user, roles);
if (!roleResult.Succeeded) return roleResult.Errors;
}
return [];
}
}

209
API/Services/OidcService.cs Normal file
View file

@ -0,0 +1,209 @@
#nullable enable
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IOidcService
{
/// <summary>
/// Returns the user authenticated with OpenID Connect
/// </summary>
/// <param name="principal"></param>
/// <returns></returns>
/// <exception cref="KavitaException">if any requirements aren't met</exception>
Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal);
}
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
IUnitOfWork unitOfWork, IMapper mapper, IAccountService accountService): IOidcService
{
private const string LibraryAccessClaim = "library";
private const string AgeRatingClaim = "AgeRating";
public async Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal)
{
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(externalId))
throw new KavitaException("oidc.errors.missing-external-id");
var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences);
if (user != null)
{
// await ProvisionUserSettings(settings, principal, user);
return user;
}
var email = principal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email))
throw new KavitaException("oidc.errors.missing-email");
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
throw new KavitaException("oidc.errors.email-not-verified");
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences)
?? await NewUserFromOpenIdConnect(settings, principal);
if (user == null) return null;
user.ExternalId = externalId;
// await ProvisionUserSettings(settings, principal, user);
var roles = await userManager.GetRolesAsync(user);
if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole))
throw new KavitaException("oidc.errors.disabled-account");
return user;
}
private async Task<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal)
{
if (!settings.ProvisionAccounts) return null;
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null;
var name = claimsPrincipal.FindFirstValue(ClaimTypes.Name);
name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName);
name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname);
name ??= emailClaim.Value;
var other = await unitOfWork.UserRepository.GetUserByUsernameAsync(name);
if (other != null)
{
// We match by email, so this will always be unique
name = emailClaim.Value;
}
// TODO: Move to account service, as we're sharing code with AccountController
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).ToString());
throw new KavitaException("oidc.errors.creating-user");
}
AddDefaultStreamsToUser(user, mapper);
if (settings.RequireVerifiedEmail)
{
// Email has been verified by OpenID Connect provider
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
await userManager.ConfirmEmailAsync(user, token);
}
await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
await unitOfWork.CommitAsync();
return user;
}
/// <summary>
/// Updates roles, library access and age rating. Does not assign admin role, or to admin roles
/// </summary>
/// <param name="settings"></param>
/// <param name="claimsPrincipal"></param>
/// <param name="user"></param>
/// <remarks>Extra feature, little buggy for now</remarks>
private async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
if (!settings.ProvisionUserSettings) return;
var userRoles = await userManager.GetRolesAsync(user);
if (userRoles.Contains(PolicyConstants.AdminRole)) return;
await SyncRoles(claimsPrincipal, user);
await SyncLibraries(claimsPrincipal, user);
SyncAgeRating(claimsPrincipal, user);
if (unitOfWork.HasChanges())
await unitOfWork.CommitAsync();
}
private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var roles = claimsPrincipal.FindAll(ClaimTypes.Role)
.Select(r => r.Value)
.Where(r => PolicyConstants.ValidRoles.Contains(r))
.Where(r => r != PolicyConstants.AdminRole)
.ToList();
if (roles.Count == 0) return;
var errors = await accountService.UpdateRolesForUser(user, roles);
if (errors.Any()) throw new KavitaException("oidc.errors.syncing-user");
}
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var libraryAccess = claimsPrincipal
.FindAll(LibraryAccessClaim)
.Select(r => r.Value)
.ToList();
if (libraryAccess.Count == 0) return;
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList();
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole);
}
private static void SyncAgeRating(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var ageRatings = claimsPrincipal
.FindAll(AgeRatingClaim)
.Select(r => r.Value)
.ToList();
if (ageRatings.Count == 0) return;
var highestAgeRating = AgeRating.NotApplicable;
foreach (var ar in ageRatings)
{
if (!Enum.TryParse(ar, out AgeRating ageRating)) continue;
if (ageRating > highestAgeRating)
{
highestAgeRating = ageRating;
}
}
user.AgeRestriction = highestAgeRating;
}
// DUPLICATED CODE
private static void AddDefaultStreamsToUser(AppUser user, IMapper mapper)
{
foreach (var newStream in Seed.DefaultStreams.Select(mapper.Map<AppUserDashboardStream, AppUserDashboardStream>))
{
user.DashboardStreams.Add(newStream);
}
foreach (var stream in Seed.DefaultSideNavStreams.Select(mapper.Map<AppUserSideNavStream, AppUserSideNavStream>))
{
user.SideNavStreams.Add(stream);
}
}
}

View file

@ -10,6 +10,7 @@ using API.Entities.Enums;
using API.Extensions;
using API.Logging;
using API.Services.Tasks.Scanner;
using Flurl.Http;
using Hangfire;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
@ -172,7 +173,7 @@ public class SettingsService : ISettingsService
updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto);
UpdateEmailSettings(setting, updateSettingsDto);
await UpdateOidcSettings(setting, updateSettingsDto);
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
@ -346,6 +347,26 @@ public class SettingsService : ISettingsService
return updateSettingsDto;
}
private async Task<bool> IsValidAuthority(string authority)
{
if (string.IsNullOrEmpty(authority))
{
return false;
}
var url = authority + "/.well-known/openid-configuration";
try
{
var resp = await url.GetAsync();
return resp.StatusCode == 200;
}
catch (Exception e)
{
_logger.LogError(e, "OpenIdConfiguration failed: {Reason}", e.Message);
return false;
}
}
private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
{
_directoryService.ExistOrCreate(bookmarkDirectory);
@ -379,6 +400,52 @@ public class SettingsService : ISettingsService
return false;
}
private async Task UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{
if (setting.Key == ServerSettingKey.OidcAuthority &&
updateSettingsDto.OidcConfig.Authority + string.Empty != setting.Value)
{
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
{
throw new KavitaException("oidc-invalid-authority");
}
setting.Value = updateSettingsDto.OidcConfig.Authority + string.Empty;
Configuration.OidcAuthority = setting.Value;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.OidcClientId &&
updateSettingsDto.OidcConfig.ClientId + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OidcConfig.ClientId + string.Empty;
Configuration.OidcClientId = setting.Value;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.OidcAutoLogin &&
updateSettingsDto.OidcConfig.AutoLogin + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OidcConfig.AutoLogin + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.OidcProvisionAccounts &&
updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.OidcProvisionUserSettings &&
updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
}
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{
if (setting.Key == ServerSettingKey.EmailHost &&

View file

@ -14,6 +14,8 @@ public static class Configuration
public const int DefaultHttpPort = 5000;
public const int DefaultTimeOutSecs = 90;
public const long DefaultCacheMemory = 75;
public const string DefaultOidcAuthority = "";
public const string DefaultOidcClientId = "kavita";
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
@ -50,6 +52,20 @@ public static class Configuration
set => SetCacheSize(GetAppSettingFilename(), value);
}
public static string OidcAuthority
{
get => GetOidcAuthority(GetAppSettingFilename());
set => SetOidcAuthority(GetAppSettingFilename(), value);
}
public static string OidcClientId
{
get => GetOidcClientId(GetAppSettingFilename());
set => SetOidcClientId(GetAppSettingFilename(), value);
}
public static bool OidcEnabled => GetOidcAuthority(GetAppSettingFilename()) != "";
public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename());
private static string GetAppSettingFilename()
@ -312,6 +328,74 @@ public static class Configuration
}
#endregion
#region OIDC
private static string GetOidcAuthority(string filePath)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
return jsonObj.OidcAuthority;
}
catch (Exception ex)
{
Console.WriteLine("Error reading app settings: " + ex.Message);
}
return string.Empty;
}
private static void SetOidcAuthority(string filePath, string authority)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
jsonObj.OidcAuthority = authority;
json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
catch (Exception)
{
/* Swallow exception */
}
}
private static string GetOidcClientId(string filePath)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
return jsonObj.OidcAudience;
}
catch (Exception ex)
{
Console.WriteLine("Error reading app settings: " + ex.Message);
}
return string.Empty;
}
private static void SetOidcClientId(string filePath, string audience)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
jsonObj.OidcAudience = audience;
json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
catch (Exception)
{
/* Swallow exception */
}
}
#endregion
private sealed class AppSettings
{
public string TokenKey { get; set; }
@ -326,6 +410,8 @@ public static class Configuration
public long Cache { get; set; } = DefaultCacheMemory;
// ReSharper disable once MemberHidesStaticFromOuterClass
public bool AllowIFraming { get; init; } = false;
public string OidcAuthority { get; set; } = DefaultOidcAuthority;
public string OidcAudience { get; set; } = DefaultOidcClientId;
#pragma warning restore S3218
}
}

View file

@ -33,6 +33,7 @@
"@siemens/ngx-datatable": "^22.4.1",
"@swimlane/ngx-charts": "^22.0.0-alpha.0",
"@tweenjs/tween.js": "^25.0.0",
"angular-oauth2-oidc": "^19.0.0",
"bootstrap": "^5.3.2",
"charts.css": "^1.1.0",
"file-saver": "^2.0.5",
@ -541,7 +542,6 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz",
"integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==",
"dev": true,
"dependencies": {
"@babel/core": "7.26.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -569,7 +569,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@ -584,7 +583,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"dev": true,
"engines": {
"node": ">= 14.16.0"
},
@ -4378,6 +4376,19 @@
}
}
},
"node_modules/angular-oauth2-oidc": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-19.0.0.tgz",
"integrity": "sha512-EogHyF7MpCJSjSKIyVmdB8pJu7dU5Ilj9VNVSnFbLng4F77PIlaE4egwKUlUvk0i4ZvmO9rLXNQCm05R7Tyhcw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.5.2"
},
"peerDependencies": {
"@angular/common": ">=19.0.0",
"@angular/core": ">=19.0.0"
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -4906,8 +4917,7 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
@ -5354,7 +5364,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -5364,7 +5373,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8181,8 +8189,7 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"node_modules/replace-in-file": {
"version": "7.1.0",
@ -8403,7 +8410,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
"devOptional": true
},
"node_modules/sass": {
"version": "1.85.0",
@ -8468,7 +8475,6 @@
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
@ -9093,7 +9099,6 @@
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -41,6 +41,7 @@
"@siemens/ngx-datatable": "^22.4.1",
"@swimlane/ngx-charts": "^22.0.0-alpha.0",
"@tweenjs/tween.js": "^25.0.0",
"angular-oauth2-oidc": "^19.0.0",
"bootstrap": "^5.3.2",
"charts.css": "^1.1.0",
"file-saver": "^2.0.5",

View file

@ -16,7 +16,7 @@ export class JwtInterceptor implements HttpInterceptor {
if (user) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${user.token}`
Authorization: `Bearer ${user.oidcToken ?? user.token}`
}
});
}

View file

@ -4,6 +4,9 @@ import {Preferences} from './preferences/preferences';
// This interface is only used for login and storing/retrieving JWT from local storage
export interface User {
username: string;
// This is set by the oidc service, will always take precedence over the Kavita generated token
// When set, the refresh logic for the Kavita token will not run
oidcToken: string;
token: string;
refreshToken: string;
roles: string[];

View file

@ -0,0 +1,9 @@
import {Routes} from "@angular/router";
import {OidcCallbackComponent} from "../registration/oidc-callback/oidc-callback.component";
export const routes: Routes = [
{
path: 'callback',
component: OidcCallbackComponent,
}
];

View file

@ -1,4 +1,4 @@
import {HttpClient} from '@angular/common/http';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {DestroyRef, inject, Injectable} from '@angular/core';
import {Observable, of, ReplaySubject, shareReplay} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators';
@ -90,6 +90,10 @@ export class AccountService {
});
}
oidcEnabled() {
return this.httpClient.get<boolean>(this.baseUrl + "oidc/enabled");
}
canInvokeAction(user: User, action: Action) {
const isAdmin = this.hasAdminRole(user);
const canDownload = this.hasDownloadRole(user);
@ -167,6 +171,22 @@ export class AccountService {
);
}
loginByToken(token: string) {
const headers = new HttpHeaders({
"Authorization": `Bearer ${token}`
})
return this.httpClient.get<User>(this.baseUrl + 'account', {headers}).pipe(
tap((response: User) => {
const user = response;
if (user) {
user.oidcToken = token;
this.setCurrentUser(user);
}
}),
takeUntilDestroyed(this.destroyRef)
);
}
setCurrentUser(user?: User, refreshConnections = true) {
const isSameUser = this.currentUser === user;
@ -202,7 +222,10 @@ export class AccountService {
this.messageHub.createHubConnection(this.currentUser);
this.licenseService.hasValidLicense().subscribe();
}
this.startRefreshTokenTimer();
// oidc handles refreshing itself
if (!this.currentUser.oidcToken) {
this.startRefreshTokenTimer();
}
}
}

View file

@ -160,7 +160,7 @@ export class MessageHubService {
createHubConnection(user: User) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'messages', {
accessTokenFactory: () => user.token
accessTokenFactory: () => user.oidcToken ?? user.token
})
.withAutomaticReconnect()
//.withStatefulReconnect() // Requires signalr@8.0

View file

@ -0,0 +1,127 @@
import {DestroyRef, Injectable} from '@angular/core';
import {OAuthService} from "angular-oauth2-oidc";
import {BehaviorSubject, from} from "rxjs";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {OidcConfig} from "../admin/_models/oidc-config";
import {AccountService} from "./account.service";
import {NavService} from "./nav.service";
import {Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {take} from "rxjs/operators";
@Injectable({
providedIn: 'root'
})
export class OidcService {
/*
TODO: Further cleanup, nicer handling for the user
See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards
Service: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/master/src/app/core/auth.service.ts
*/
baseUrl = environment.apiUrl;
settingsSource = new BehaviorSubject<OidcConfig | null>(null);
settings$ = this.settingsSource.asObservable();
constructor(
private oauth2: OAuthService,
private httpClient: HttpClient,
private accountService: AccountService,
private navService: NavService,
private router: Router,
private destroyRef: DestroyRef,
) {
this.config().subscribe(oidcSetting => {
if (!oidcSetting.authority) {
return
}
this.oauth2.configure({
issuer: oidcSetting.authority,
clientId: oidcSetting.clientId,
requireHttps: oidcSetting.authority.startsWith("https://"),
redirectUri: window.location.origin + "/oidc/callback",
postLogoutRedirectUri: window.location.origin + "/login",
showDebugInformation: true,
responseType: 'code',
scope: "openid profile email roles offline_access",
});
this.settingsSource.next(oidcSetting);
this.oauth2.setupAutomaticSilentRefresh();
this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
if (event.type !== "token_refreshed") return;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) return; // Don't update tokens when we're not logged in. But what's going on?
// TODO: Do we need to refresh the SignalR connection here?
user.oidcToken = this.token;
});
});
from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({
next: success => {
if (!success) return;
this.tryLogin();
},
error: error => {
console.log(error);
}
});
})
}
private tryLogin() {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) return;
if (this.token) {
this.accountService.loginByToken(this.token).subscribe({
next: _ => {
this.doLogin();
}
});
}
});
}
oidcLogin() {
this.oauth2.initLoginFlow();
}
config() {
return this.httpClient.get<OidcConfig>(this.baseUrl + "oidc/config");
}
get token() {
return this.oauth2.getAccessToken();
}
logout() {
this.oauth2.logOut();
}
private doLogin() {
this.navService.showNavBar();
this.navService.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
}
}
}

View file

@ -0,0 +1,9 @@
export interface OidcConfig {
authority: string;
clientId: string;
provisionAccounts: boolean;
requireVerifiedEmail: boolean;
provisionUserSettings: boolean;
autoLogin: boolean;
}

View file

@ -1,6 +1,7 @@
import {EncodeFormat} from "./encode-format";
import {CoverImageSize} from "./cover-image-size";
import {SmtpConfig} from "./smtp-config";
import {OidcConfig} from "./oidc-config";
export interface ServerSettings {
cacheDirectory: string;
@ -25,6 +26,7 @@ export interface ServerSettings {
onDeckUpdateDays: number;
coverImageSize: CoverImageSize;
smtpConfig: SmtpConfig;
oidcConfig: OidcConfig;
installId: string;
installVersion: string;
}

View file

@ -0,0 +1,111 @@
<ng-container *transloco="let t; prefix:'oidc.settings'">
<div class="position-relative">
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save()">{{t('save')}}</button>
</div>
<form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
</div>
<h4>{{t('provider')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('authority'); as formControl) {
<app-setting-item [title]="t('authority')" [subtitle]="t('authority-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-authority" class="form-control"
formControlName="authority" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('clientId'); as formControl) {
<app-setting-item [title]="t('clientId')" [subtitle]="t('clientId-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-clientId" aria-describedby="oidc-clientId-validations" class="form-control"
formControlName="clientId" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-clientId-validations" class="invalid-feedback">
@if (formControl.errors?.requiredIf) {
<div>{{t('field-required', {name: 'clientId', other: formControl.errors?.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('behaviour')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('provisionAccounts'); as formControl) {
<app-setting-switch [title]="t('provisionAccounts')" [subtitle]="t('provisionAccounts-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provisionAccounts" type="checkbox" class="form-check-input" formControlName="provisionAccounts">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('requireVerifiedEmail'); as formControl) {
<app-setting-switch [title]="t('requireVerifiedEmail')" [subtitle]="t('requireVerifiedEmail-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="requireVerifiedEmail" type="checkbox" class="form-check-input" formControlName="requireVerifiedEmail">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('provisionUserSettings'); as formControl) {
<app-setting-switch [title]="t('provisionUserSettings')" [subtitle]="t('provisionUserSettings-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provisionUserSettings" type="checkbox" class="form-check-input" formControlName="provisionUserSettings">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('autoLogin'); as formControl) {
<app-setting-switch [title]="t('autoLogin')" [subtitle]="t('autoLogin-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="autoLogin" type="checkbox" class="form-check-input" formControlName="autoLogin">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</ng-container>
</form>
</ng-container>

View file

@ -0,0 +1,8 @@
.invalid-feedback {
display: inherit;
}
.custom-position {
right: 5px;
top: -42px;
}

View file

@ -0,0 +1,92 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {ServerSettings} from "../_models/server-settings";
import {
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn
} from "@angular/forms";
import {SettingsService} from "../settings.service";
import {OidcConfig} from "../_models/oidc-config";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
@Component({
selector: 'app-manage-open-idconnect',
imports: [
TranslocoDirective,
ReactiveFormsModule,
SettingItemComponent,
SettingSwitchComponent
],
templateUrl: './manage-open-idconnect.component.html',
styleUrl: './manage-open-idconnect.component.scss'
})
export class ManageOpenIDConnectComponent implements OnInit {
serverSettings!: ServerSettings;
oidcSettings!: OidcConfig;
settingsForm: FormGroup = new FormGroup({});
constructor(
private settingsService: SettingsService,
private cdRef: ChangeDetectorRef,
) {
}
ngOnInit(): void {
this.settingsService.getServerSettings().subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings = this.serverSettings.oidcConfig;
// TODO: Validator for authority, /.well-known/openid-configuration endpoint must be reachable
this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, []));
this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')]));
this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, []));
this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, []));
this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, []));
this.cdRef.markForCheck();
}
})
}
save() {
const data = this.settingsForm.getRawValue();
const newSettings = Object.assign({}, this.serverSettings);
newSettings.oidcConfig = data as OidcConfig;
this.settingsService.updateServerSettings(newSettings).subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings = data.oidcConfig;
this.cdRef.markForCheck();
},
error: error => {
console.error(error);
}
})
}
requiredIf(other: string): ValidatorFn {
return (control): ValidationErrors | null => {
const otherControl = this.settingsForm.get(other);
if (!otherControl) return null;
if (otherControl.invalid) return null;
const v = otherControl.value;
if (!v || v.length === 0) return null;
const own = control.value;
if (own && own.length > 0) return null;
return {'requiredIf': {'other': other, 'otherValue': v}}
}
}
}

View file

@ -104,6 +104,10 @@ const routes: Routes = [
path: 'login',
loadChildren: () => import('./_routes/registration.router.module').then(m => m.routes) // TODO: Refactor so we just use /registration/login going forward
},
{
path: 'oidc',
loadChildren: () => import('./_routes/oidc-routing.module').then(m => m.routes)
},
{path: 'libraries', pathMatch: 'full', redirectTo: 'home'},
{path: '**', pathMatch: 'prefix', redirectTo: 'home'},
{path: '**', pathMatch: 'full', redirectTo: 'home'},

View file

@ -25,6 +25,7 @@ import {TranslocoService} from "@jsverse/transloco";
import {VersionService} from "./_services/version.service";
import {LicenseService} from "./_services/license.service";
import {LocalizationService} from "./_services/localization.service";
import {OidcService} from "./_services/oidc.service";
@Component({
selector: 'app-root',
@ -51,6 +52,7 @@ export class AppComponent implements OnInit {
private readonly document = inject(DOCUMENT);
private readonly translocoService = inject(TranslocoService);
private readonly versionService = inject(VersionService); // Needs to be injected to run background job
private readonly oidcService = inject(OidcService); // Needed to auto login
private readonly licenseService = inject(LicenseService);
private readonly localizationService = inject(LocalizationService);

View file

@ -47,6 +47,7 @@ import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.comp
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {WikiLink} from "../../../_models/wiki";
import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component";
import {OidcService} from "../../../_services/oidc.service";
@Component({
selector: 'app-nav-header',
@ -64,6 +65,7 @@ export class NavHeaderComponent implements OnInit {
private readonly searchService = inject(SearchService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly accountService = inject(AccountService);
private readonly oidcService = inject(OidcService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly navService = inject(NavService);
@ -135,6 +137,7 @@ export class NavHeaderComponent implements OnInit {
this.accountService.logout();
this.navService.hideNavBar();
this.navService.hideSideNav();
this.oidcService.logout();
this.router.navigateByUrl('/login');
}

View file

@ -0,0 +1,15 @@
<ng-container *transloco="let t; read: 'oidc'">
<app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body>
@if (error.length > 0) {
<div class="invalid-feedback mb-2">
{{t(error)}}
</div>
}
<button class="btn btn-outline-primary" (click)="goToLogin()">{{t('login')}}</button>
</ng-container>
</app-splash-container>
</ng-container>

View file

@ -0,0 +1,3 @@
.invalid-feedback {
display: inherit;
}

View file

@ -0,0 +1,48 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {SplashContainerComponent} from "../_components/splash-container/splash-container.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service";
import {Router} from "@angular/router";
import {NavService} from "../../_services/nav.service";
import {take} from "rxjs/operators";
import {OidcService} from "../../_services/oidc.service";
@Component({
selector: 'app-oidc-callback',
imports: [
SplashContainerComponent,
TranslocoDirective
],
templateUrl: './oidc-callback.component.html',
styleUrl: './oidc-callback.component.scss'
})
export class OidcCallbackComponent implements OnInit{
error: string = '';
constructor(
private accountService: AccountService,
private router: Router,
private navService: NavService,
private readonly cdRef: ChangeDetectorRef,
private oidcService: OidcService,
) {
this.navService.hideNavBar();
this.navService.hideSideNav();
}
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.navService.showNavBar();
this.navService.showSideNav();
this.router.navigateByUrl('/home');
this.cdRef.markForCheck();
}
});
}
goToLogin() {
this.router.navigateByUrl('/login');
}
}

View file

@ -26,6 +26,11 @@
</div>
</div>
</form>
@if (oidcEnabled) {
<button [ngbTooltip]="t('oidc-tooltip')" class="btn btn-outline-primary mt-2" (click)="oidcService.oidcLogin()">{{t('oidc')}}</button>
}
</ng-container>
</ng-container>
</app-splash-container>

View file

@ -1,7 +1,7 @@
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { AccountService } from '../../_services/account.service';
@ -10,6 +10,9 @@ import { NavService } from '../../_services/nav.service';
import { NgIf } from '@angular/common';
import { SplashContainerComponent } from '../_components/splash-container/splash-container.component';
import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
import {environment} from "../../../environments/environment";
import {OidcService} from "../../_services/oidc.service";
import {forkJoin} from "rxjs";
@Component({
@ -17,10 +20,12 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
templateUrl: './user-login.component.html',
styleUrls: ['./user-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective]
imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective, NgbTooltip]
})
export class UserLoginComponent implements OnInit {
baseUrl = environment.apiUrl;
loginForm: FormGroup = new FormGroup({
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required, Validators.maxLength(256), Validators.minLength(6), Validators.pattern("^.{6,256}$")])
@ -35,10 +40,18 @@ export class UserLoginComponent implements OnInit {
*/
isLoaded: boolean = false;
isSubmitting = false;
oidcEnabled = false;
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
private toastr: ToastrService, private navService: NavService,
private readonly cdRef: ChangeDetectorRef, private route: ActivatedRoute) {
constructor(
private accountService: AccountService,
private router: Router,
private memberService: MemberService,
private toastr: ToastrService,
private navService: NavService,
private readonly cdRef: ChangeDetectorRef,
private route: ActivatedRoute,
protected oidcService: OidcService,
) {
this.navService.hideNavBar();
this.navService.hideSideNav();
}
@ -71,6 +84,18 @@ export class UserLoginComponent implements OnInit {
if (val != null && val.length > 0) {
this.login(val);
}
const skipAutoLogin = params.get('skipAutoLogin') === 'true';
this.oidcService.settings$.subscribe(cfg => {
if (!cfg) return;
this.oidcEnabled = cfg.authority !== "";
this.cdRef.markForCheck();
if (cfg.autoLogin && !skipAutoLogin) {
this.oidcService.oidcLogin()
}
});
});
}
@ -83,18 +108,8 @@ export class UserLoginComponent implements OnInit {
this.cdRef.markForCheck();
this.accountService.login(model).subscribe(() => {
this.loginForm.reset();
this.navService.showNavBar();
this.navService.showSideNav();
this.doLogin()
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
}
this.isSubmitting = false;
this.cdRef.markForCheck();
}, err => {
@ -103,4 +118,19 @@ export class UserLoginComponent implements OnInit {
this.cdRef.markForCheck();
});
}
private doLogin() {
this.navService.showNavBar();
this.navService.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
}
}
}

View file

@ -17,6 +17,14 @@
}
}
@defer (when fragment === SettingsTabId.OpenIDConnect; prefetch on idle) {
@if (fragment === SettingsTabId.OpenIDConnect) {
<div class="col-xxl-6 col-12">
<app-manage-open-idconnect></app-manage-open-idconnect>
</div>
}
}
@defer (when fragment === SettingsTabId.Email; prefetch on idle) {
@if (fragment === SettingsTabId.Email) {
<div class="col-xxl-6 col-12">

View file

@ -52,43 +52,45 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb
import {
ManageMetadataSettingsComponent
} from "../../../admin/manage-metadata-settings/manage-metadata-settings.component";
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
@Component({
selector: 'app-settings',
imports: [
ChangeAgeRestrictionComponent,
ChangeEmailComponent,
ChangePasswordComponent,
ManageDevicesComponent,
ManageOpdsComponent,
ManageScrobblingProvidersComponent,
ManageUserPreferencesComponent,
SideNavCompanionBarComponent,
ThemeManagerComponent,
TranslocoDirective,
UserStatsComponent,
AsyncPipe,
LicenseComponent,
ManageEmailSettingsComponent,
ManageLibraryComponent,
ManageMediaSettingsComponent,
ManageSettingsComponent,
ManageSystemComponent,
ManageTasksSettingsComponent,
ManageUsersComponent,
ServerStatsComponent,
SettingFragmentPipe,
ManageScrobblingComponent,
ManageMediaIssuesComponent,
ManageCustomizationComponent,
ImportMalCollectionComponent,
ImportCblComponent,
ManageMatchedMetadataComponent,
ManageUserTokensComponent,
EmailHistoryComponent,
ScrobblingHoldsComponent,
ManageMetadataSettingsComponent
],
imports: [
ChangeAgeRestrictionComponent,
ChangeEmailComponent,
ChangePasswordComponent,
ManageDevicesComponent,
ManageOpdsComponent,
ManageScrobblingProvidersComponent,
ManageUserPreferencesComponent,
SideNavCompanionBarComponent,
ThemeManagerComponent,
TranslocoDirective,
UserStatsComponent,
AsyncPipe,
LicenseComponent,
ManageEmailSettingsComponent,
ManageLibraryComponent,
ManageMediaSettingsComponent,
ManageSettingsComponent,
ManageSystemComponent,
ManageTasksSettingsComponent,
ManageUsersComponent,
ServerStatsComponent,
SettingFragmentPipe,
ManageScrobblingComponent,
ManageMediaIssuesComponent,
ManageCustomizationComponent,
ImportMalCollectionComponent,
ImportCblComponent,
ManageMatchedMetadataComponent,
ManageUserTokensComponent,
EmailHistoryComponent,
ScrobblingHoldsComponent,
ManageMetadataSettingsComponent,
ManageOpenIDConnectComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -21,6 +21,7 @@ export enum SettingsTabId {
// Admin
General = 'admin-general',
OpenIDConnect = 'admin-oidc',
Email = 'admin-email',
Media = 'admin-media',
Users = 'admin-users',
@ -122,6 +123,7 @@ export class PreferenceNavComponent implements AfterViewInit {
title: 'server-section-title',
children: [
new SideNavItem(SettingsTabId.General, [Role.Admin]),
new SideNavItem(SettingsTabId.OpenIDConnect, [Role.Admin]),
new SideNavItem(SettingsTabId.Media, [Role.Admin]),
new SideNavItem(SettingsTabId.Email, [Role.Admin]),
new SideNavItem(SettingsTabId.Users, [Role.Admin]),

View file

@ -5,7 +5,42 @@
"password": "{{common.password}}",
"password-validation": "{{validation.password-validation}}",
"forgot-password": "Forgot Password?",
"submit": "Sign in"
"submit": "Sign in",
"oidc": "Log in with OpenID Connect",
"oidc-tooltip": "This will connect you to an external site"
},
"oidc": {
"title": "OpenID Connect Callback",
"login": "Back to login screen",
"errors": {
"missing-external-id": "OpenID Connect provider did not return a valid identifier",
"missing-email": "OpenID Connect provider did not return a valid email",
"email-not-verified": "Your email must be verified to allow logging in via OpenID Connect",
"no-account": "No matching account found",
"disabled-account": "This account is disabled, please contact an administrator"
},
"settings": {
"save": "{{common.save}}",
"notice": "Notice",
"restart-required": "Changing OpenID Connect settings requires a restart",
"provider": "Provider",
"behaviour": "Behaviour",
"field-required": "{{name}} is required when {{other}} is set",
"authority": "Authority",
"authority-tooltip": "The URL to your OpenID Connect provider",
"clientId": "Client ID",
"clientId-tooltip": "The ClientID set in your OIDC provider, can be anything",
"provisionAccounts": "Provision accounts",
"provisionAccounts-tooltip": "Create a new account when someone logs in via OIDC, without already having an account",
"requireVerifiedEmail": "Require verified emails",
"requireVerifiedEmail-tooltip": "Requires emails to be verified when creation an account or matching with existing ones. A newly created account with a verified email, will be auto verified on Kavita's side",
"provisionUserSettings": "Provision user settings",
"provisionUserSettings-tooltip": "Synchronise Kavita user settings (roles, libraries, age rating) with those provided by the OIDC. See documentation for more information",
"autoLogin": "Auto login",
"autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen"
}
},
"dashboard": {
@ -1698,6 +1733,7 @@
"import-section-title": "Import",
"kavitaplus-section-title": "Kavita+",
"admin-general": "General",
"admin-oidc": "OpenID Connect",
"admin-users": "Users",
"admin-libraries": "Libraries",
"admin-media": "Media",

View file

@ -20,6 +20,7 @@ import {distinctUntilChanged} from "rxjs/operators";
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations';
import {HttpLoader} from "./httpLoader";
import {provideOAuthClient} from "angular-oauth2-oidc";
const disableAnimations = !('animate' in document.documentElement);
@ -146,6 +147,7 @@ bootstrapApplication(AppComponent, {
useFactory: getBaseHref,
deps: [PlatformLocation]
},
provideOAuthClient(),
provideHttpClient(withInterceptorsFromDi())
]
} as ApplicationConfig)