Sign in
- @if (_setupRequired)
+ @if (_oidc)
+ {
+ @* No form here on purpose: under OIDC there are no credentials for this page to take, and a
+ password box that cannot work is worse than a redirect. OnInitialized challenges before
+ this ever renders; this is what a visitor sees if the redirect is slow. *@
+
Redirecting to your identity provider…
+ }
+ else if (_setupRequired)
{
This instance has not been set up yet. Check the host log for the setup link —
@@ -56,8 +65,30 @@
private string? _error;
private bool _setupRequired;
+ bool _oidc;
+
protected override async Task OnInitializedAsync()
{
+ _oidc = string.Equals(Config["Auth:Provider"], "oidc", StringComparison.OrdinalIgnoreCase);
+
+ if (_oidc)
+ {
+ var http = HttpContextAccessor.HttpContext;
+
+ // Challenge rather than render: the IdP owns sign-in entirely, so the only useful thing
+ // this route can do is hand the visitor over — carrying the return URL, so they land where
+ // they were going rather than on the home page.
+ if (http is not null && http.User.Identity?.IsAuthenticated != true)
+ {
+ await http.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme,
+ new AuthenticationProperties { RedirectUri = ReturnUrl ?? "/" });
+ return;
+ }
+
+ Nav.NavigateTo(ReturnUrl ?? "/", forceLoad: true);
+ return;
+ }
+
Input ??= new();
_setupRequired = await SetupState.IsSetupRequiredAsync();
}
diff --git a/src/Presentation/Web/Components/Pages/Logout.razor b/src/Presentation/Web/Components/Pages/Logout.razor
index 42b38af..6f7f656 100644
--- a/src/Presentation/Web/Components/Pages/Logout.razor
+++ b/src/Presentation/Web/Components/Pages/Logout.razor
@@ -2,8 +2,10 @@
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
+@using Microsoft.AspNetCore.Authentication.OpenIdConnect
@inject IHttpContextAccessor HttpContextAccessor
@inject NavigationManager Nav
+@inject IConfiguration Config
@* Static SSR: clearing the cookie needs HttpContext, same as sign-in. *@
@@ -17,8 +19,19 @@
protected override async Task OnInitializedAsync()
{
var http = HttpContextAccessor.HttpContext;
- if (http is not null)
- await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+ if (http is null) return;
+
+ await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+
+ if (string.Equals(Config["Auth:Provider"], "oidc", StringComparison.OrdinalIgnoreCase))
+ {
+ // Federated, not just local. Dropping our cookie alone leaves the IdP session intact, so
+ // the next visit signs straight back in without a prompt — which reads as "logout is
+ // broken" rather than as single sign-on working.
+ await http.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
+ new AuthenticationProperties { RedirectUri = "/" });
+ return;
+ }
Nav.NavigateTo("login", forceLoad: true);
}
diff --git a/src/Presentation/Web/Krautwatch.Web.csproj b/src/Presentation/Web/Krautwatch.Web.csproj
index da6af54..6c6a884 100644
--- a/src/Presentation/Web/Krautwatch.Web.csproj
+++ b/src/Presentation/Web/Krautwatch.Web.csproj
@@ -6,6 +6,11 @@
cf5c4310-4a7a-4978-94bd-612641415b12
+
+
+
+
+
diff --git a/src/Presentation/Web/Program.cs b/src/Presentation/Web/Program.cs
index 851753f..c05b955 100644
--- a/src/Presentation/Web/Program.cs
+++ b/src/Presentation/Web/Program.cs
@@ -6,6 +6,9 @@
using Krautwatch.Web;
using Krautwatch.Web.Components;
using Microsoft.AspNetCore.Authentication.Cookies;
+using Krautwatch.Domain.Interfaces;
+using Microsoft.AspNetCore.Authentication;
+using Krautwatch.Domain.Options;
using Microsoft.AspNetCore.RateLimiting;
// Krautwatch standalone UI (Blazor Server). A first-party console to search the catalog, queue a
@@ -67,11 +70,26 @@
// ──────────────────────────────────────────────────────────────
var authProvider = (builder.Configuration["Auth:Provider"] ?? "local").ToLowerInvariant();
+// Only `local` has an administrator to create; see SetupStateHandler.
+builder.Services.AddSingleton(new LocalAdminRequirement(authProvider is "local"));
+
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor(); // the auth pages need HttpContext to write the cookie
builder.Services.AddAuthorization();
-builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+// Bound even when unused, so a misconfigured provider fails where it is read rather than at the first
+// sign-in attempt.
+var oidcOptions = new OidcOptions();
+builder.Configuration.GetSection(OidcOptions.SectionName).Bind(oidcOptions);
+
+// Indexed keys bind on their own; a plain comma-separated value does not, and that is the only shape
+// a compose .env can hold comfortably. Accept both.
+if (builder.Configuration[$"{OidcOptions.SectionName}:RequiredGroups"] is { Length: > 0 } groupList)
+ oidcOptions.RequiredGroups = OidcOptions.ParseGroups(groupList);
+builder.Services.AddSingleton(oidcOptions);
+
+var authentication = builder.Services
+ .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
@@ -88,6 +106,59 @@
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
});
+if (authProvider is "oidc")
+{
+ // Everything real is the IdP's job. What is left here is the cookie the rest of the app already
+ // understands, and one rule of our own: which groups may sign in (#48).
+ authentication.AddOpenIdConnect(options =>
+ {
+ options.Authority = oidcOptions.Authority;
+ options.ClientId = oidcOptions.ClientId;
+
+ // The stored secret may be a pointer rather than the secret, exactly like an *arr API key.
+ // Resolved at the point of use, from a scope built for the purpose — this runs while the
+ // container is still being configured, so there is no request scope to borrow.
+ using (var scope = builder.Services.BuildServiceProvider().CreateScope())
+ {
+ var secrets = scope.ServiceProvider.GetRequiredService();
+ var resolved = secrets.Resolve(oidcOptions.ClientSecret);
+ options.ClientSecret = resolved.Origin == SecretOrigin.Unresolved ? null : resolved.Value;
+ }
+
+ options.ResponseType = "code";
+ options.UsePkce = true;
+
+ // Nothing here calls the IdP's APIs on the user's behalf, so keeping their tokens would be
+ // storing credentials we have no use for.
+ options.SaveTokens = false;
+ options.GetClaimsFromUserInfoEndpoint = true;
+ options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+
+ options.Scope.Clear();
+ foreach (var scopeName in oidcOptions.Scopes)
+ options.Scope.Add(scopeName);
+
+ // Authentik emits `groups` as a JSON array inside the profile scope; without an explicit map
+ // it lands as a raw claim rather than something ClaimsPrincipal can be asked about.
+ options.ClaimActions.MapJsonKey(oidcOptions.GroupsClaim, oidcOptions.GroupsClaim);
+ options.TokenValidationParameters.RoleClaimType = oidcOptions.GroupsClaim;
+
+ options.Events.OnTokenValidated = context =>
+ {
+ var groups = context.Principal?.FindAll(oidcOptions.GroupsClaim).Select(c => c.Value) ?? [];
+
+ if (!oidcOptions.Admits(groups))
+ {
+ // Refused here rather than signed in and bounced off every page afterwards, which
+ // would read as a broken UI instead of a deliberate "you may not use this".
+ context.Fail("Not a member of any group permitted to use Krautwatch.");
+ }
+
+ return Task.CompletedTask;
+ };
+ });
+}
+
if (authProvider is not "none")
{
// A single-admin login with no throttling is an online password-guessing target.
@@ -145,11 +216,17 @@
app.MapRazorComponents().AddInteractiveServerRenderMode();
+// Creating the first *arr instance from the environment has nothing to do with how humans sign in, so
+// it runs whatever the provider is. It briefly did not: #122 inserted this call under the guard below,
+// which quietly disabled it for every deployment not using local auth.
+await BootstrapArrInstancesAsync(app);
+
// First-run: print the gated setup link. The token lives in memory for this process only, so it rotates
// on restart, and whoever can read the log (the operator) is the only one able to claim the instance.
+// Local only — there is no administrator to create when an IdP owns identity, and none at all under
+// `none`, so printing it there would be an instruction that leads nowhere.
if (authProvider is "local")
- await BootstrapArrInstancesAsync(app);
-await LogSetupLinkIfRequiredAsync(app);
+ await LogSetupLinkIfRequiredAsync(app);
app.Run();
diff --git a/tests/Application.Tests/OidcAuthorizationTests.cs b/tests/Application.Tests/OidcAuthorizationTests.cs
new file mode 100644
index 0000000..5f7f7ec
--- /dev/null
+++ b/tests/Application.Tests/OidcAuthorizationTests.cs
@@ -0,0 +1,106 @@
+using Krautwatch.Application.Auth;
+using Krautwatch.Domain.Interfaces;
+using Krautwatch.Domain.Options;
+using NSubstitute;
+using Shouldly;
+using Xunit;
+
+namespace Krautwatch.Application.Tests;
+
+///
+/// Who an identity provider is allowed to let in (#48), and what "first-run setup" means once an IdP
+/// owns identity. The protocol itself is framework middleware and not ours to test; these are the two
+/// rules that are.
+///
+public class OidcAuthorizationTests
+{
+ [Fact]
+ public void With_no_required_groups_anyone_the_idp_authenticated_is_admitted()
+ {
+ // The default, and it has to be: this must work against identity providers with no group model
+ // at all, so one homelab's access tiers cannot become a precondition for the feature.
+ var options = new OidcOptions();
+
+ options.Admits([]).ShouldBeTrue();
+ options.Admits(["Whoever"]).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void A_member_of_any_required_group_is_admitted()
+ {
+ var options = new OidcOptions { RequiredGroups = ["Homelab Admins", "Media Admins"] };
+
+ options.Admits(["Media Admins"]).ShouldBeTrue();
+ options.Admits(["Family", "Homelab Admins"]).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Someone_the_idp_knows_but_we_did_not_invite_is_refused()
+ {
+ // The case that matters: an IdP that auto-enrols (a Plex source, say) authenticates plenty of
+ // people who have no business here. Authenticated is not authorised.
+ var options = new OidcOptions { RequiredGroups = ["Homelab Admins", "Media Admins"] };
+
+ options.Admits(["Family"]).ShouldBeFalse();
+ options.Admits([]).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Group_names_are_matched_without_regard_to_casing()
+ {
+ // Typed by a human in two places — the IdP's UI and our config. A casing mismatch failing
+ // closed would be indistinguishable from a permissions problem.
+ var options = new OidcOptions { RequiredGroups = ["media admins"] };
+
+ options.Admits(["Media Admins"]).ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData("Homelab Admins,Media Admins", 2)]
+ [InlineData(" Homelab Admins , Media Admins ", 2)]
+ [InlineData("Media Admins", 1)]
+ [InlineData("", 0)]
+ [InlineData(null, 0)]
+ public void A_comma_separated_group_list_is_accepted_for_env_configured_deployments(
+ string? configured, int expected)
+ {
+ // Configuration binds a list only from indexed keys, which a compose .env cannot express
+ // comfortably — so the string form is parsed rather than silently ignored, which would leave
+ // an operator believing they had restricted access when they had not.
+ OidcOptions.ParseGroups(configured).Count.ShouldBe(expected);
+ }
+
+ [Fact]
+ public async Task Setup_is_not_required_when_an_identity_provider_owns_identity()
+ {
+ var store = Substitute.For();
+ store.ExistsAsync(Arg.Any()).Returns(false); // no local admin, ever
+
+ var sut = new SetupStateHandler(store, new LocalAdminRequirement(Required: false));
+
+ // Otherwise the wizard demands an administrator nobody can create, the sign-in page claims the
+ // instance is unconfigured, and the startup log advertises a dead-end link.
+ (await sut.IsSetupRequiredAsync(TestContext.Current.CancellationToken)).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Setup_is_still_required_for_the_local_provider_with_no_administrator()
+ {
+ var store = Substitute.For();
+ store.ExistsAsync(Arg.Any()).Returns(false);
+
+ var sut = new SetupStateHandler(store, new LocalAdminRequirement(Required: true));
+
+ (await sut.IsSetupRequiredAsync(TestContext.Current.CancellationToken)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_host_that_never_wired_the_requirement_behaves_as_before()
+ {
+ var store = Substitute.For();
+ store.ExistsAsync(Arg.Any()).Returns(false);
+
+ (await new SetupStateHandler(store).IsSetupRequiredAsync(TestContext.Current.CancellationToken))
+ .ShouldBeTrue();
+ }
+}