Skip to content

Commit bda2a4d

Browse files
authored
Bunch of OIDC fixes and one extra (#4126)
1 parent 947ab75 commit bda2a4d

File tree

20 files changed

+140
-93
lines changed

20 files changed

+140
-93
lines changed

API/Controllers/OPDSController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ private async Task<Tuple<string, string>> GetPrefix()
9191
if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase))
9292
{
9393
// We need to update the Prefix to account for baseUrl
94-
prefix = baseUrl + OpdsService.DefaultApiPrefix;
94+
prefix = baseUrl.TrimEnd('/') + OpdsService.DefaultApiPrefix;
9595
}
9696

9797
return new Tuple<string, string>(baseUrl, prefix);

API/Controllers/OidcController.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
using System;
2-
using System.Linq;
3-
using System.Threading.Tasks;
1+
using System.Threading.Tasks;
42
using API.Extensions;
53
using API.Services;
4+
using Kavita.Common;
65
using Microsoft.AspNetCore.Authentication;
76
using Microsoft.AspNetCore.Authentication.Cookies;
87
using Microsoft.AspNetCore.Authorization;
9-
using Microsoft.AspNetCore.Http;
108
using Microsoft.AspNetCore.Mvc;
119

1210
namespace API.Controllers;
@@ -19,6 +17,11 @@ public class OidcController: ControllerBase
1917
[HttpGet("login")]
2018
public IActionResult Login(string returnUrl = "/")
2119
{
20+
if (returnUrl == "/")
21+
{
22+
returnUrl = Configuration.BaseUrl;
23+
}
24+
2225
var properties = new AuthenticationProperties { RedirectUri = returnUrl };
2326
return Challenge(properties, IdentityServiceExtensions.OpenIdConnect);
2427
}
@@ -29,18 +32,18 @@ public async Task<IActionResult> Logout()
2932

3033
if (!Request.Cookies.ContainsKey(OidcService.CookieName))
3134
{
32-
return Redirect("/");
35+
return Redirect(Configuration.BaseUrl);
3336
}
3437

3538
var res = await Request.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
36-
if (!res.Succeeded || res.Properties == null || string.IsNullOrEmpty(res.Properties.GetString(OidcService.IdToken)))
39+
if (!res.Succeeded || res.Properties == null || string.IsNullOrEmpty(res.Properties.GetTokenValue(OidcService.IdToken)))
3740
{
3841
HttpContext.Response.Cookies.Delete(OidcService.CookieName);
39-
return Redirect("/");
42+
return Redirect(Configuration.BaseUrl);
4043
}
4144

4245
return SignOut(
43-
new AuthenticationProperties { RedirectUri = "/login" },
46+
new AuthenticationProperties { RedirectUri = Configuration.BaseUrl+"login" },
4447
CookieAuthenticationDefaults.AuthenticationScheme,
4548
IdentityServiceExtensions.OpenIdConnect);
4649
}

API/Extensions/ApplicationServiceExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ public static void AddApplicationServices(this IServiceCollection services, ICon
117117
options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB
118118
options.CompactionPercentage = 0.1; // LRU compaction (10%)
119119
});
120+
// Needs to be registered after the memory cache, as it depends on it
120121
services.AddSingleton<ITicketStore, CustomTicketStore>();
121122

122123
services.AddSwaggerGen(g =>

API/Extensions/IdentityServiceExtensions.cs

Lines changed: 63 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.IdentityModel.Tokens.Jwt;
34
using System.Linq;
45
using System.Security.Claims;
56
using System.Text;
@@ -20,8 +21,10 @@
2021
using Microsoft.Extensions.DependencyInjection;
2122
using Microsoft.Extensions.Hosting;
2223
using Microsoft.Extensions.Logging;
24+
using Microsoft.IdentityModel.Protocols;
2325
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
2426
using Microsoft.IdentityModel.Tokens;
27+
using Serilog;
2528
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
2629
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext;
2730

@@ -139,8 +142,51 @@ private static void SetupOpenIdConnectAuthentication(this IServiceCollection ser
139142
var isDevelopment = environment.IsEnvironment(Environments.Development);
140143
var baseUrl = Configuration.BaseUrl;
141144

142-
var apiPrefix = baseUrl + "api";
143-
var hubsPrefix = baseUrl + "hubs";
145+
const string apiPrefix = "/api";
146+
const string hubsPrefix = "/hubs";
147+
148+
var authority = Configuration.OidcSettings.Authority;
149+
if (!isDevelopment && !authority.StartsWith("https"))
150+
{
151+
Log.Error("OpenIdConnect authority is not using https, you must configure tls for your idp.");
152+
return;
153+
}
154+
155+
var hasTrailingSlash = authority.EndsWith('/');
156+
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
157+
158+
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
159+
url,
160+
new OpenIdConnectConfigurationRetriever(),
161+
new HttpDocumentRetriever { RequireHttps = !isDevelopment }
162+
);
163+
164+
ICollection<string> supportedScopes;
165+
try
166+
{
167+
supportedScopes = configurationManager.GetConfigurationAsync()
168+
.ConfigureAwait(false)
169+
.GetAwaiter()
170+
.GetResult()
171+
.ScopesSupported;
172+
}
173+
catch (Exception ex)
174+
{
175+
// Do not interrupt startup if OIDC fails (Network outage should still allow Kavita to run)
176+
Log.Error(ex, "Failed to load OIDC configuration, OIDC will not be enabled. Restart to retry");
177+
return;
178+
}
179+
180+
List<string> scopes = ["openid", "profile", "offline_access", "roles", "email"];
181+
scopes.AddRange(settings.CustomScopes);
182+
var validScopes = scopes.Where(scope =>
183+
{
184+
if (supportedScopes.Contains(scope))
185+
return true;
186+
187+
Log.Warning("Scope {Scope} is configured, but not supported by your OIDC provider. Skipping", scope);
188+
return false;
189+
}).ToList();
144190

145191
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme).Configure<ITicketStore>((options, store) =>
146192
{
@@ -150,6 +196,7 @@ private static void SetupOpenIdConnectAuthentication(this IServiceCollection ser
150196
options.Cookie.HttpOnly = true;
151197
options.Cookie.IsEssential = true;
152198
options.Cookie.MaxAge = TimeSpan.FromDays(7);
199+
options.Cookie.SameSite = SameSiteMode.Strict;
153200
options.SessionStore = store;
154201

155202
if (isDevelopment)
@@ -193,21 +240,25 @@ private static void SetupOpenIdConnectAuthentication(this IServiceCollection ser
193240

194241
options.SaveTokens = true;
195242
options.GetClaimsFromUserInfoEndpoint = true;
196-
options.Scope.Clear();
197-
options.Scope.Add("openid");
198-
options.Scope.Add("profile");
199-
options.Scope.Add("offline_access");
200-
options.Scope.Add("roles");
201-
options.Scope.Add("email");
202243

203-
foreach (var customScope in settings.CustomScopes)
244+
// Due to some (Authelia) OIDC providers, we need to map these claims explicitly. Such that no flow breaks in the
245+
// OidcService
246+
options.MapInboundClaims = true;
247+
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
248+
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
249+
options.ClaimActions.MapJsonKey(JwtRegisteredClaimNames.PreferredUsername, "preferred_username");
250+
options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
251+
252+
options.Scope.Clear();
253+
foreach (var scope in validScopes)
204254
{
205-
options.Scope.Add(customScope);
255+
options.Scope.Add(scope);
206256
}
207257

258+
208259
options.Events = new OpenIdConnectEvents
209260
{
210-
OnTokenValidated = OidcClaimsPrincipalConverter,
261+
OnTicketReceived = OidcClaimsPrincipalConverter,
211262
OnAuthenticationFailed = ctx =>
212263
{
213264
ctx.Response.Redirect(baseUrl + "login?skipAutoLogin=true&error=" + Uri.EscapeDataString(ctx.Exception.Message));
@@ -252,7 +303,7 @@ private static void SetupOpenIdConnectAuthentication(this IServiceCollection ser
252303
/// Kavita roles the user has
253304
/// </summary>
254305
/// <param name="ctx"></param>
255-
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
306+
private static async Task OidcClaimsPrincipalConverter(TicketReceivedContext ctx)
256307
{
257308
if (ctx.Principal == null) return;
258309

@@ -264,58 +315,16 @@ private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx
264315
}
265316

266317
var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user);
267-
var tokens = CopyOidcTokens(ctx);
268318

269319
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
270320
var principal = new ClaimsPrincipal(identity);
271321

272-
ctx.Properties ??= new AuthenticationProperties();
273-
ctx.Properties.StoreTokens(tokens);
274-
275322
ctx.HttpContext.User = principal;
276323
ctx.Principal = principal;
277324

278325
ctx.Success();
279326
}
280327

281-
/// <summary>
282-
/// Copy tokens returned by the OIDC provider that we require later
283-
/// </summary>
284-
/// <param name="ctx"></param>
285-
/// <returns></returns>
286-
private static List<AuthenticationToken> CopyOidcTokens(TokenValidatedContext ctx)
287-
{
288-
if (ctx.TokenEndpointResponse == null)
289-
{
290-
return [];
291-
}
292-
293-
var tokens = new List<AuthenticationToken>();
294-
295-
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.RefreshToken))
296-
{
297-
tokens.Add(new AuthenticationToken { Name = OidcService.RefreshToken, Value = ctx.TokenEndpointResponse.RefreshToken });
298-
}
299-
else
300-
{
301-
var logger = ctx.HttpContext.RequestServices.GetRequiredService<ILogger<OidcService>>();
302-
logger.LogWarning("OIDC login without refresh token, automatic sync will not work for this user");
303-
}
304-
305-
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.IdToken))
306-
{
307-
tokens.Add(new AuthenticationToken { Name = OidcService.IdToken, Value = ctx.TokenEndpointResponse.IdToken });
308-
}
309-
310-
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.ExpiresIn))
311-
{
312-
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(ctx.TokenEndpointResponse.ExpiresIn));
313-
tokens.Add(new AuthenticationToken { Name = OidcService.ExpiresAt, Value = expiresAt.ToString("o") });
314-
}
315-
316-
return tokens;
317-
}
318-
319328
private static Task SetTokenFromQuery(MessageReceivedContext context)
320329
{
321330
var accessToken = context.Request.Query["access_token"];

API/Logging/LogLevelOptions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ namespace API.Logging;
1313
public static class LogLevelOptions
1414
{
1515
public const string LogFile = "config/logs/kavita.log";
16+
public const string OutputTemplate = "[Kavita] [{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}";
1617
public const bool LogRollingEnabled = true;
1718
/// <summary>
1819
/// Controls the Logging Level of the Application
@@ -37,7 +38,6 @@ public static class LogLevelOptions
3738

3839
public static LoggerConfiguration CreateConfig(LoggerConfiguration configuration)
3940
{
40-
const string outputTemplate = "[Kavita] [{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}";
4141
return configuration
4242
.MinimumLevel
4343
.ControlledBy(LogLevelSwitch)
@@ -51,11 +51,11 @@ public static LoggerConfiguration CreateConfig(LoggerConfiguration configuration
5151
.Enrich.FromLogContext()
5252
.Enrich.WithThreadId()
5353
.Enrich.With(new ApiKeyEnricher())
54-
.WriteTo.Console(new MessageTemplateTextFormatter(outputTemplate))
54+
.WriteTo.Console(new MessageTemplateTextFormatter(OutputTemplate))
5555
.WriteTo.File(LogFile,
5656
shared: true,
5757
rollingInterval: RollingInterval.Day,
58-
outputTemplate: outputTemplate)
58+
outputTemplate: OutputTemplate)
5959
.Filter.ByIncludingOnly(ShouldIncludeLogStatement);
6060
}
6161

API/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
using Serilog.Events;
2727
using Serilog.Sinks.AspNetCore.SignalR.Extensions;
2828
using Log = Serilog.Log;
29+
using MessageTemplateTextFormatter = Serilog.Formatting.Display.MessageTemplateTextFormatter;
2930

3031
namespace API;
3132
#nullable enable
@@ -42,7 +43,7 @@ public static async Task Main(string[] args)
4243
{
4344
Console.OutputEncoding = System.Text.Encoding.UTF8;
4445
Log.Logger = new LoggerConfiguration()
45-
.WriteTo.Console()
46+
.WriteTo.Console(new MessageTemplateTextFormatter(LogLevelOptions.OutputTemplate))
4647
.MinimumLevel
4748
.Information()
4849
.CreateBootstrapLogger();

API/Services/OidcService.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ public async Task ClearOidcIds()
242242
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
243243
if (settings.SyncUserSettings && accessRoles.Count == 0)
244244
{
245+
logger.LogDebug("No valid roles where found under {Claim} with prefix {Prefix}", settings.RolesClaim, settings.RolesPrefix);
245246
throw new KavitaException("errors.oidc.role-not-assigned");
246247
}
247248

@@ -265,6 +266,7 @@ public async Task ClearOidcIds()
265266
var roles = await userManager.GetRolesAsync(user);
266267
if (roles.Count == 0 || (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)))
267268
{
269+
logger.LogDebug("User does not have Login or AdminRole assigned. Has: {Roles}", string.Join(",", roles));
268270
throw new KavitaException("errors.oidc.disabled-account");
269271
}
270272

@@ -360,9 +362,17 @@ private async Task SetDefaults(OidcConfigDto settings, AppUser user)
360362
// Assign libraries
361363
await accountService.UpdateLibrariesForUser(user, settings.DefaultLibraries, settings.DefaultRoles.Contains(PolicyConstants.AdminRole));
362364

363-
// Assign age rating
364-
user.AgeRestriction = settings.DefaultAgeRestriction;
365-
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
365+
// Assign age rating, or bypass if admin
366+
if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
367+
{
368+
user.AgeRestriction = AgeRating.NotApplicable;
369+
user.AgeRestrictionIncludeUnknowns = true;
370+
}
371+
else
372+
{
373+
user.AgeRestriction = settings.DefaultAgeRestriction;
374+
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
375+
}
366376

367377
await unitOfWork.CommitAsync();
368378
}

API/Services/SettingsService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
using Kavita.Common;
2121
using Kavita.Common.EnvironmentInfo;
2222
using Kavita.Common.Helpers;
23+
using Microsoft.Extensions.Hosting;
2324
using Microsoft.Extensions.Logging;
2425
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
2526

@@ -54,6 +55,7 @@ public class SettingsService : ISettingsService
5455
private readonly ITaskScheduler _taskScheduler;
5556
private readonly ILogger<SettingsService> _logger;
5657
private readonly IOidcService _oidcService;
58+
private readonly bool _isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development;
5759

5860
public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService,
5961
ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler,
@@ -532,6 +534,11 @@ public async Task<bool> IsValidAuthority(string authority)
532534
return false;
533535
}
534536

537+
if (!_isDevelopment && !authority.StartsWith("https"))
538+
{
539+
return false;
540+
}
541+
535542
try
536543
{
537544
var hasTrailingSlash = authority.EndsWith('/');

API/Services/Store/CustomTicketStore.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77

88
namespace API.Services.Store;
99

10+
/// <summary>
11+
/// The <see cref="ITicketStore"/> is used as <see cref="CookieAuthenticationOptions.SessionStore"/> for the OIDC implementation
12+
/// The full AuthenticationTicket cannot be included in the Cookie as popular reverse proxies (like nginx) will deny the request
13+
/// due the large header size. Instead, the key is used.
14+
/// </summary>
15+
/// <param name="cache"></param>
16+
/// <remarks>Note that this store is in memory, so OIDC authenticated users are logged out after restart</remarks>
1017
public class CustomTicketStore(IMemoryCache cache): ITicketStore
1118
{
1219

UI/Web/src/app/_services/account.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ export class AccountService {
300300
this.messageHub.stopHubConnection();
301301

302302
if (!user.token) {
303-
window.location.href = '/oidc/logout';
303+
window.location.href = this.baseUrl.substring(0, environment.apiUrl.indexOf("api")) + 'oidc/logout';
304304
return;
305305
}
306306

0 commit comments

Comments
 (0)