Bunch of OIDC fixes and one extra (#4126)

This commit is contained in:
Fesaa
2025-10-21 22:07:04 +02:00
committed by GitHub
parent 947ab758ca
commit bda2a4d50d
20 changed files with 140 additions and 93 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ public class OpdsController : BaseApiController
if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase))
{
// We need to update the Prefix to account for baseUrl
prefix = baseUrl + OpdsService.DefaultApiPrefix;
prefix = baseUrl.TrimEnd('/') + OpdsService.DefaultApiPrefix;
}
return new Tuple<string, string>(baseUrl, prefix);
+11 -8
View File
@@ -1,12 +1,10 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Threading.Tasks;
using API.Extensions;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
@@ -19,6 +17,11 @@ public class OidcController: ControllerBase
[HttpGet("login")]
public IActionResult Login(string returnUrl = "/")
{
if (returnUrl == "/")
{
returnUrl = Configuration.BaseUrl;
}
var properties = new AuthenticationProperties { RedirectUri = returnUrl };
return Challenge(properties, IdentityServiceExtensions.OpenIdConnect);
}
@@ -29,18 +32,18 @@ public class OidcController: ControllerBase
if (!Request.Cookies.ContainsKey(OidcService.CookieName))
{
return Redirect("/");
return Redirect(Configuration.BaseUrl);
}
var res = await Request.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!res.Succeeded || res.Properties == null || string.IsNullOrEmpty(res.Properties.GetString(OidcService.IdToken)))
if (!res.Succeeded || res.Properties == null || string.IsNullOrEmpty(res.Properties.GetTokenValue(OidcService.IdToken)))
{
HttpContext.Response.Cookies.Delete(OidcService.CookieName);
return Redirect("/");
return Redirect(Configuration.BaseUrl);
}
return SignOut(
new AuthenticationProperties { RedirectUri = "/login" },
new AuthenticationProperties { RedirectUri = Configuration.BaseUrl+"login" },
CookieAuthenticationDefaults.AuthenticationScheme,
IdentityServiceExtensions.OpenIdConnect);
}
@@ -117,6 +117,7 @@ public static class ApplicationServiceExtensions
options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB
options.CompactionPercentage = 0.1; // LRU compaction (10%)
});
// Needs to be registered after the memory cache, as it depends on it
services.AddSingleton<ITicketStore, CustomTicketStore>();
services.AddSwaggerGen(g =>
+63 -54
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
@@ -20,8 +21,10 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext;
@@ -139,8 +142,51 @@ public static class IdentityServiceExtensions
var isDevelopment = environment.IsEnvironment(Environments.Development);
var baseUrl = Configuration.BaseUrl;
var apiPrefix = baseUrl + "api";
var hubsPrefix = baseUrl + "hubs";
const string apiPrefix = "/api";
const string hubsPrefix = "/hubs";
var authority = Configuration.OidcSettings.Authority;
if (!isDevelopment && !authority.StartsWith("https"))
{
Log.Error("OpenIdConnect authority is not using https, you must configure tls for your idp.");
return;
}
var hasTrailingSlash = authority.EndsWith('/');
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
url,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = !isDevelopment }
);
ICollection<string> supportedScopes;
try
{
supportedScopes = configurationManager.GetConfigurationAsync()
.ConfigureAwait(false)
.GetAwaiter()
.GetResult()
.ScopesSupported;
}
catch (Exception ex)
{
// Do not interrupt startup if OIDC fails (Network outage should still allow Kavita to run)
Log.Error(ex, "Failed to load OIDC configuration, OIDC will not be enabled. Restart to retry");
return;
}
List<string> scopes = ["openid", "profile", "offline_access", "roles", "email"];
scopes.AddRange(settings.CustomScopes);
var validScopes = scopes.Where(scope =>
{
if (supportedScopes.Contains(scope))
return true;
Log.Warning("Scope {Scope} is configured, but not supported by your OIDC provider. Skipping", scope);
return false;
}).ToList();
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme).Configure<ITicketStore>((options, store) =>
{
@@ -150,6 +196,7 @@ public static class IdentityServiceExtensions
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.MaxAge = TimeSpan.FromDays(7);
options.Cookie.SameSite = SameSiteMode.Strict;
options.SessionStore = store;
if (isDevelopment)
@@ -193,21 +240,25 @@ public static class IdentityServiceExtensions
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("offline_access");
options.Scope.Add("roles");
options.Scope.Add("email");
foreach (var customScope in settings.CustomScopes)
// Due to some (Authelia) OIDC providers, we need to map these claims explicitly. Such that no flow breaks in the
// OidcService
options.MapInboundClaims = true;
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
options.ClaimActions.MapJsonKey(JwtRegisteredClaimNames.PreferredUsername, "preferred_username");
options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
options.Scope.Clear();
foreach (var scope in validScopes)
{
options.Scope.Add(customScope);
options.Scope.Add(scope);
}
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = OidcClaimsPrincipalConverter,
OnTicketReceived = OidcClaimsPrincipalConverter,
OnAuthenticationFailed = ctx =>
{
ctx.Response.Redirect(baseUrl + "login?skipAutoLogin=true&error=" + Uri.EscapeDataString(ctx.Exception.Message));
@@ -252,7 +303,7 @@ public static class IdentityServiceExtensions
/// Kavita roles the user has
/// </summary>
/// <param name="ctx"></param>
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
private static async Task OidcClaimsPrincipalConverter(TicketReceivedContext ctx)
{
if (ctx.Principal == null) return;
@@ -264,58 +315,16 @@ public static class IdentityServiceExtensions
}
var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user);
var tokens = CopyOidcTokens(ctx);
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
ctx.Properties ??= new AuthenticationProperties();
ctx.Properties.StoreTokens(tokens);
ctx.HttpContext.User = principal;
ctx.Principal = principal;
ctx.Success();
}
/// <summary>
/// Copy tokens returned by the OIDC provider that we require later
/// </summary>
/// <param name="ctx"></param>
/// <returns></returns>
private static List<AuthenticationToken> CopyOidcTokens(TokenValidatedContext ctx)
{
if (ctx.TokenEndpointResponse == null)
{
return [];
}
var tokens = new List<AuthenticationToken>();
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.RefreshToken))
{
tokens.Add(new AuthenticationToken { Name = OidcService.RefreshToken, Value = ctx.TokenEndpointResponse.RefreshToken });
}
else
{
var logger = ctx.HttpContext.RequestServices.GetRequiredService<ILogger<OidcService>>();
logger.LogWarning("OIDC login without refresh token, automatic sync will not work for this user");
}
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.IdToken))
{
tokens.Add(new AuthenticationToken { Name = OidcService.IdToken, Value = ctx.TokenEndpointResponse.IdToken });
}
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.ExpiresIn))
{
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(ctx.TokenEndpointResponse.ExpiresIn));
tokens.Add(new AuthenticationToken { Name = OidcService.ExpiresAt, Value = expiresAt.ToString("o") });
}
return tokens;
}
private static Task SetTokenFromQuery(MessageReceivedContext context)
{
var accessToken = context.Request.Query["access_token"];
+3 -3
View File
@@ -13,6 +13,7 @@ namespace API.Logging;
public static class LogLevelOptions
{
public const string LogFile = "config/logs/kavita.log";
public const string OutputTemplate = "[Kavita] [{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}";
public const bool LogRollingEnabled = true;
/// <summary>
/// Controls the Logging Level of the Application
@@ -37,7 +38,6 @@ public static class LogLevelOptions
public static LoggerConfiguration CreateConfig(LoggerConfiguration configuration)
{
const string outputTemplate = "[Kavita] [{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}";
return configuration
.MinimumLevel
.ControlledBy(LogLevelSwitch)
@@ -51,11 +51,11 @@ public static class LogLevelOptions
.Enrich.FromLogContext()
.Enrich.WithThreadId()
.Enrich.With(new ApiKeyEnricher())
.WriteTo.Console(new MessageTemplateTextFormatter(outputTemplate))
.WriteTo.Console(new MessageTemplateTextFormatter(OutputTemplate))
.WriteTo.File(LogFile,
shared: true,
rollingInterval: RollingInterval.Day,
outputTemplate: outputTemplate)
outputTemplate: OutputTemplate)
.Filter.ByIncludingOnly(ShouldIncludeLogStatement);
}
+2 -1
View File
@@ -26,6 +26,7 @@ using Serilog;
using Serilog.Events;
using Serilog.Sinks.AspNetCore.SignalR.Extensions;
using Log = Serilog.Log;
using MessageTemplateTextFormatter = Serilog.Formatting.Display.MessageTemplateTextFormatter;
namespace API;
#nullable enable
@@ -42,7 +43,7 @@ public class Program
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.Console(new MessageTemplateTextFormatter(LogLevelOptions.OutputTemplate))
.MinimumLevel
.Information()
.CreateBootstrapLogger();
+13 -3
View File
@@ -242,6 +242,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
if (settings.SyncUserSettings && accessRoles.Count == 0)
{
logger.LogDebug("No valid roles where found under {Claim} with prefix {Prefix}", settings.RolesClaim, settings.RolesPrefix);
throw new KavitaException("errors.oidc.role-not-assigned");
}
@@ -265,6 +266,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
var roles = await userManager.GetRolesAsync(user);
if (roles.Count == 0 || (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)))
{
logger.LogDebug("User does not have Login or AdminRole assigned. Has: {Roles}", string.Join(",", roles));
throw new KavitaException("errors.oidc.disabled-account");
}
@@ -360,9 +362,17 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
// Assign libraries
await accountService.UpdateLibrariesForUser(user, settings.DefaultLibraries, settings.DefaultRoles.Contains(PolicyConstants.AdminRole));
// Assign age rating
user.AgeRestriction = settings.DefaultAgeRestriction;
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
// Assign age rating, or bypass if admin
if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{
user.AgeRestriction = AgeRating.NotApplicable;
user.AgeRestrictionIncludeUnknowns = true;
}
else
{
user.AgeRestriction = settings.DefaultAgeRestriction;
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
}
await unitOfWork.CommitAsync();
}
+7
View File
@@ -20,6 +20,7 @@ using Hangfire;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
@@ -54,6 +55,7 @@ public class SettingsService : ISettingsService
private readonly ITaskScheduler _taskScheduler;
private readonly ILogger<SettingsService> _logger;
private readonly IOidcService _oidcService;
private readonly bool _isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development;
public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService,
ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler,
@@ -532,6 +534,11 @@ public class SettingsService : ISettingsService
return false;
}
if (!_isDevelopment && !authority.StartsWith("https"))
{
return false;
}
try
{
var hasTrailingSlash = authority.EndsWith('/');
+7
View File
@@ -7,6 +7,13 @@ using Microsoft.Extensions.Caching.Memory;
namespace API.Services.Store;
/// <summary>
/// The <see cref="ITicketStore"/> is used as <see cref="CookieAuthenticationOptions.SessionStore"/> for the OIDC implementation
/// The full AuthenticationTicket cannot be included in the Cookie as popular reverse proxies (like nginx) will deny the request
/// due the large header size. Instead, the key is used.
/// </summary>
/// <param name="cache"></param>
/// <remarks>Note that this store is in memory, so OIDC authenticated users are logged out after restart</remarks>
public class CustomTicketStore(IMemoryCache cache): ITicketStore
{
+1 -1
View File
@@ -300,7 +300,7 @@ export class AccountService {
this.messageHub.stopHubConnection();
if (!user.token) {
window.location.href = '/oidc/logout';
window.location.href = this.baseUrl.substring(0, environment.apiUrl.indexOf("api")) + 'oidc/logout';
return;
}
@@ -21,7 +21,7 @@ import {ActionService} from "../../_services/action.service";
import {DownloadService} from "../../shared/_services/download.service";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
import {forkJoin, Observable, of, tap} from "rxjs";
import {concat, forkJoin, Observable, of, tap} from "rxjs";
import {map, switchMap} from "rxjs/operators";
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
@@ -269,7 +269,7 @@ export class EditChapterModalComponent implements OnInit {
apis.push(this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover, !this.coverImageReset));
}
forkJoin(apis).subscribe(results => {
concat(...apis).subscribe(results => {
this.modal.close({success: true, chapter: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: needsReload, isDeleted: false} as EditChapterModalCloseResult);
});
}
@@ -29,6 +29,9 @@
@if (formControl.errors?.invalidUri) {
<div>{{t('invalid-uri')}}</div>
}
@if (formControl.errors?.requireTls) {
<div>{{t('tls-required')}}</div>
}
</div>
}
</ng-template>
@@ -179,7 +182,7 @@
</ng-template>
<ng-template #edit>
<select class="form-select" formControlName="defaultAgeRestriction">
<option value="-1">{{t('no-restriction')}}</option>
<option [ngValue]="-1">{{t('no-restriction')}}</option>
@for (ageRating of ageRatings(); track ageRating.value) {
<option [ngValue]="ageRating.value">{{ageRating.title}}</option>
}
@@ -43,6 +43,7 @@ import {
import {
SettingMultiTextFieldComponent
} from "../../settings/_components/setting-multi-text-field/setting-multi-text-field.component";
import {environment} from "../../../environments/environment";
type OidcFormGroup = FormGroup<{
autoLogin: FormControl<boolean>;
@@ -193,6 +194,10 @@ export class ManageOpenIDConnectComponent implements OnInit {
return of(null);
}
if (environment.production && !uri.startsWith("https")) {
return of({'requireTls': {'uri': uri}} as ValidationErrors);
}
try {
new URL(uri);
} catch {
@@ -11,7 +11,7 @@ import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, forkJoin, switchMap, tap} from 'rxjs';
import {concat, debounceTime, distinctUntilChanged, forkJoin, switchMap, tap} from 'rxjs';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {UserCollection} from 'src/app/_models/collection-tag';
@@ -218,7 +218,7 @@ export class EditCollectionTagsComponent implements OnInit {
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover));
}
forkJoin(apis).subscribe(() => {
concat(...apis).subscribe(() => {
this.modal.close({success: true, coverImageUpdated: selectedIndex > 0});
this.toastr.success(translate('toasts.collection-updated'));
});
@@ -18,7 +18,7 @@ import {
NgbNavLink,
NgbNavOutlet
} from '@ng-bootstrap/ng-bootstrap';
import {forkJoin, Observable, of, tap} from 'rxjs';
import {concat, forkJoin, Observable, of, tap} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings';
@@ -576,7 +576,7 @@ export class EditSeriesModalComponent implements OnInit {
this.saveNestedComponents.emit();
forkJoin(apis).subscribe(results => {
concat(...apis).subscribe(results => {
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, updateExternal: this.hasForcedKPlus});
});
}
@@ -22,7 +22,7 @@ import {
import {PersonService} from "../../../_services/person.service";
import {translate, TranslocoDirective} from '@jsverse/transloco';
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
import {forkJoin, map, of} from "rxjs";
import {concat, forkJoin, map, of} from "rxjs";
import {UploadService} from "../../../_services/upload.service";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {AccountService} from "../../../_services/account.service";
@@ -111,12 +111,6 @@ export class EditPersonModalComponent implements OnInit {
save() {
const apis = [];
const hasCoverChanges = this.touchedCoverImage || this.coverImageReset;
if (hasCoverChanges) {
apis.push(this.uploadService.updatePersonCoverImage(this.person.id, this.selectedCover, !this.coverImageReset));
}
const person: Person = {
id: this.person.id,
coverImageLocked: this.person.coverImageLocked,
@@ -132,7 +126,13 @@ export class EditPersonModalComponent implements OnInit {
};
apis.push(this.personService.updatePerson(person));
forkJoin(apis).subscribe(_ => {
const hasCoverChanges = this.touchedCoverImage || this.coverImageReset;
if (hasCoverChanges) {
apis.push(this.uploadService.updatePersonCoverImage(this.person.id, this.selectedCover, !this.coverImageReset));
}
// Run api calls in sequency to prevent them from overwriting each-other in a race condition
concat(...apis).subscribe(_ => {
this.modal.close({success: true, coverImageUpdate: hasCoverChanges, person: person});
});
}
@@ -200,7 +200,7 @@ export class EditPersonModalComponent implements OnInit {
if (!asin || asin.trim().length === 0) {
return of(null);
}
return this.personService.isValidAsin(asin).pipe(map(valid => {
if (valid) {
return null;
@@ -10,7 +10,7 @@ import {
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbTooltip, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { debounceTime, distinctUntilChanged, forkJoin, switchMap, tap } from 'rxjs';
import {concat, debounceTime, distinctUntilChanged, forkJoin, switchMap, tap} from 'rxjs';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { ReadingList } from 'src/app/_models/reading-list';
import { AccountService } from 'src/app/_services/account.service';
@@ -118,7 +118,7 @@ export class EditReadingListModalComponent implements OnInit {
apis.push(this.uploadService.updateReadingListCoverImage(this.readingList.id, this.selectedCover))
}
forkJoin(apis).subscribe(results => {
concat(...apis).subscribe(results => {
this.readingList.title = model.title;
this.readingList.summary = model.summary;
this.readingList.coverImageLocked = this.coverImageLocked;
@@ -33,7 +33,7 @@
@if (showOidcButton()) {
<a
class="btn btn-outline-primary mt-2 d-flex justify-content-center align-items-center gap-2"
href="oidc/login"
[href]="baseUrl + 'oidc/login'"
>
<app-image height="36px" width="36px" [imageUrl]="'assets/icons/open-id-connect-logo.svg'" [styles]="{'object-fit': 'contains'}" />
{{oidcConfig()?.providerName || t('oidc')}}
@@ -39,7 +39,7 @@ export class UserLoginComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
protected readonly settingsService = inject(SettingsService);
baseUrl = environment.apiUrl;
baseUrl = environment.apiUrl.substring(0, environment.apiUrl.indexOf("api"));
loginForm: FormGroup = new FormGroup({
username: new FormControl('', [Validators.required]),
@@ -89,7 +89,7 @@ export class UserLoginComponent implements OnInit {
if (!oidcConfig || skipAutoLogin === undefined) return;
if (oidcConfig.autoLogin && !skipAutoLogin) {
window.location.href = '/oidc/login';
window.location.href = this.baseUrl + 'oidc/login';
}
});
}
+2 -1
View File
@@ -20,12 +20,13 @@
"save": "{{common.save}}",
"save-success": "Successfully updated your OIDC settings",
"notice": "Notice",
"restart-required": "Changing Authority or Client ID requires a manual restart of Kavita to take effect.",
"restart-required": "Changing Provider or advanced settings requires a manual restart of Kavita to take effect.",
"provider-title": "Provider",
"provider-tooltip": "Provider settings require you to manually click Save. Kavita must be configured as a confidential client and needs a redirect URL. See the <a href='https://wiki.kavitareader.com/guides/admin-settings/open-id-connect/' target='_blank' rel='noreferrer noopener'>wiki</a> for more details.",
"behavior-title": "Behavior",
"other-field-required": "{{validation.other-field-required}}",
"invalid-uri": "{{validation.invalid-uri}}",
"tls-required": "The OIDC provider must use tls (https)",
"manual-save-label": "Changing provider settings requires a manual save",
"authority-label": "Authority",