diff --git a/Directory.Packages.props b/Directory.Packages.props index 545c34c..d9743fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,6 +31,8 @@ + + diff --git a/README.md b/README.md index aa3c96b..3e352e8 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,8 @@ Built on .NET 10, Postgres, Wolverine and .NET Aspire. > **The full round trip is proven** — grab → download → Sonarr import → file in the library, against a > real Sonarr 4.0.19, for both dated and numbered shows. > -> **What is *not* there yet:** **OIDC** -> ([#48](../../issues/48) — local login works, `oidc` is a stub); and **`*arr` reach-back** to pre-warm -> the crawl list ([#6](../../issues/6) — optional by design, see DR-011). +> Identity is either the built-in local login or **your own OIDC provider** — see +> [Authentication](#authentication). > > Credentials you enter can be kept out of the database entirely as > [secret references](#keeping-secrets-out-of-the-database); stored literally, they are plain text. @@ -265,10 +264,25 @@ The UI requires a sign-in. `Auth:Provider` selects how: | Value | Behaviour | |---|---| | `local` (default) | Built-in single administrator, created on first run | -| `oidc` | Delegate to your own identity provider — Authentik, Keycloak, Authelia, Entra *(not yet implemented)* | +| `oidc` | Delegate to your own identity provider — Authentik, Keycloak, Authelia, Entra | | `none` | No authentication — only for deployments already behind reverse-proxy forward-auth | -**First run:** there is no administrator yet, so the `web` host logs a one-time setup link. Fetch it from +**With an identity provider:** + +```dotenv +AUTH_PROVIDER=oidc +OIDC_AUTHORITY=https://identity.example.com/application/o/krautwatch/ +OIDC_CLIENT_ID=… +OIDC_CLIENT_SECRET=… # may be env:/file: +OIDC_REQUIRED_GROUPS=Homelab Admins,Media Admins # optional second check; empty admits anyone +``` + +Register `https:///signin-oidc` as the redirect URI and request `openid profile email`. Setting +`OIDC_REQUIRED_GROUPS` even though your provider already gates the application is deliberate: two +independent checks mean neither has to be the only thing that is right. Sign-out is federated, and +`/setup` stops asking for an administrator — your provider owns identity. + +**First run (local provider):** there is no administrator yet, so the `web` host logs a one-time setup link. Fetch it from the logs and open it: ```bash diff --git a/docs/plans/2026-08-23 - oidc authentication.md b/docs/plans/2026-08-23 - oidc authentication.md new file mode 100644 index 0000000..0974633 --- /dev/null +++ b/docs/plans/2026-08-23 - oidc authentication.md @@ -0,0 +1,100 @@ +# OIDC authentication (#48) + +**Date:** 2026-08-23 +**Status:** agreed, being implemented + +## What is already true + +Most of #48 shipped with local auth. `Auth:Provider` already selects the scheme in the Web host's +composition root, `local` and `none` work, every routable page carries an authorization decision +(asserted by `PageAuthorizationSpecs`), and both paths land on the same cookie and `ClaimsPrincipal`. +`oidc` is the one value that does nothing. + +The machine surface is out of scope by protocol: Sonarr can only send an `apikey` query parameter, so +`Api/NewznabIndexerApi` stays keyed however the human surface is configured. + +## The shape, and what it is not + +**No `IAuthenticationProvider` port.** The issue proposed one; the shipped design deliberately did not +build it, and that reasoning holds. Local credentials are a verification concern that fits a port +(`ILocalCredentialStore` + `IPasswordHasher`). OIDC is a redirect/token protocol owned end to end by +framework middleware — there is no behaviour left for a Domain interface to abstract, and inventing +one would mean wrapping `AddOpenIdConnect` in a port that only ever has one implementation. + +So: `Auth:Provider = oidc` adds the OpenID Connect handler beside the existing cookie handler, and +everything downstream — pages, `[Authorize]`, the cascading auth state — is untouched. + +``` +Auth:Provider = local → cookie + local credential store (unchanged) +Auth:Provider = oidc → cookie + OpenIdConnect challenge (this plan) +Auth:Provider = none → AnonymousAccess middleware (unchanged) +``` + +## Configuration + +```jsonc +{ + "Auth": { + "Provider": "oidc", + "Oidc": { + "Authority": "https://identity.chrison.dev/application/o/krautwatch/", + "ClientId": "…", + "ClientSecret": "…", // may be a secret reference (env:/file:) + "Scopes": ["openid", "profile", "email"], + "RequiredGroups": ["Homelab Admins", "Media Admins"], + "GroupsClaim": "groups" + } + } +} +``` + +`RequiredGroups` empty (the default) means anyone the IdP authenticates gets in — other people's +identity providers must not be forced into this homelab's group model. + +## Two layers of authorisation, deliberately + +The homelab's identity blueprint states the principle already: *authenticated must never imply +authorised*, because the Plex source auto-enrols friends. This adopts it rather than restating it: + +1. **Authentik** decides who may obtain a token for Krautwatch at all — application policy bindings + for `Homelab Admins` and `Media Admins`. +2. **Krautwatch** re-checks the `groups` claim against `RequiredGroups` on sign-in, and refuses + otherwise. + +Either layer alone would be enough on a good day. The point is that neither depends on the other +being right — a binding deleted by a careless converge should not silently open the UI. + +## Work + +1. **`OidcOptions`** bound from `Auth:Oidc`, with the client secret resolved through `ISecretResolver` + like every other stored credential. +2. **Wire `AddOpenIdConnect`** in the Web host when the provider is `oidc`: PKCE on, tokens not + persisted (nothing here calls the IdP's APIs), `SaveTokens = false`, scopes from config, and the + `groups` claim mapped so `RequiredGroups` and the UI can read it. +3. **Group enforcement** in `OnTokenValidated` — reject with a clear failure rather than signing in a + principal that later fails every page. +4. **`/login` under OIDC challenges instead of showing a form.** The password form is meaningless + there, and a page that looks like it accepts credentials but cannot is worse than a redirect. +5. **`/logout` signs out federated.** Cookie-only sign-out leaves the IdP session intact, so the next + visit silently signs straight back in — which reads as "logout is broken". +6. **First-run setup skips the administrator step** when the provider is `oidc`: there is no local + admin to create, and `SetupStateHandler` currently answers "setup required" forever without one. + The wizard's remaining steps (downloads, egress, `*arr`) still apply. +7. **Tests**: options binding and secret resolution, the group check (member, non-member, claim + absent, no requirement configured), the setup-state change, and `PageAuthorizationSpecs` staying + green. +8. **Docs**: README + self-hosting, with the Authentik registration spelled out since that is the + provider this was built against. + +## Authentik registration (separate PR, Homelab repo) + +Following the Pangolin entry in `stacks/Core/authentik/assets/blueprints/00-homelab-identity.yaml`: +an `oauth2provider` (confidential, `client_id`/`client_secret` as `!Env` from Bitwarden Secrets +Manager, `include_claims_in_id_token: true`, explicit `grant_types`), an `application`, and policy +bindings for the two admin groups. Opened as a PR for converge rather than applied from here. + +## Not in scope + +- Changing the `*arr` API key model (#48's last checkbox) — a separate concern with its own trade-offs. +- Local and OIDC side by side. One provider at a time keeps "who can sign in" answerable from one + setting; a deployment that wants both has a reverse proxy for it. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 216e114..8cd895d 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -127,7 +127,32 @@ Once an administrator exists, `/setup` never reopens. |---|---| | `local` *(default)* | built-in single administrator, as above | | `none` | no authentication — **only** behind reverse-proxy forward-auth | -| `oidc` | **not implemented yet** ([#48](https://github.com/Chrison-dev/Krautwatch/issues/48)) | +| `oidc` | hand identity to your own provider — see below | + +### Signing in with your own identity provider + +```dotenv +AUTH_PROVIDER=oidc +OIDC_AUTHORITY=https://identity.example.com/application/o/krautwatch/ +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= # may be env:/file: like any stored credential +OIDC_REQUIRED_GROUPS=Homelab Admins,Media Admins # optional; empty admits anyone it authenticates +``` + +The redirect URI to register is **`https:///signin-oidc`**, and the sign-out +URI is `https:///signout-callback-oidc`. Request the `openid profile email` +scopes; `profile` is what carries group membership on Authentik. + +**`OIDC_REQUIRED_GROUPS` is worth setting even though your provider already decides who gets a +token.** Two independent checks means a policy deleted by a careless change on either side does not +silently open the UI. Leave it empty if your provider has no group model — Krautwatch then admits +whoever completes the flow. + +`/setup` stops asking for an administrator under `oidc`: your provider owns identity and no local +account is ever created. The rest of the wizard — downloads, egress, `*arr` — still applies. + +Signing out is federated: it clears the Krautwatch cookie *and* ends the provider session, because +dropping only ours means the next visit signs straight back in and looks broken. --- diff --git a/src/Application/Auth/SignIn.cs b/src/Application/Auth/SignIn.cs index 4722ecd..6575115 100644 --- a/src/Application/Auth/SignIn.cs +++ b/src/Application/Auth/SignIn.cs @@ -101,8 +101,25 @@ await store.CreateAsync(new AdminAccount } /// Whether first-run setup is still pending — drives both the /setup gate and the startup log. -public class SetupStateHandler(ILocalCredentialStore store) +/// +/// "Setup" here means creating the local administrator, so it only ever applies to the local +/// provider. Under oidc the IdP owns identity and no local account will ever exist; without this +/// the wizard would demand one forever, the sign-in page would claim the instance is unconfigured, and +/// the startup log would print a setup link that leads to a step nobody can complete (#48). +/// +public class SetupStateHandler(ILocalCredentialStore store, LocalAdminRequirement? requirement = null) { public async Task IsSetupRequiredAsync(CancellationToken ct = default) => - !await store.ExistsAsync(ct); + (requirement?.Required ?? true) && !await store.ExistsAsync(ct); } + +/// +/// Whether this deployment needs a local administrator at all — registered by the host that knows +/// which authentication scheme is configured. +/// +/// +/// A one-field type rather than reading configuration in the Application layer: the slice stays +/// testable without an IConfiguration, and the composition root keeps its job of deciding what +/// the scheme is. Absent means required, so hosts that never wired it behave exactly as before. +/// +public sealed record LocalAdminRequirement(bool Required); diff --git a/src/Domain/Options/OidcOptions.cs b/src/Domain/Options/OidcOptions.cs new file mode 100644 index 0000000..6d948d2 --- /dev/null +++ b/src/Domain/Options/OidcOptions.cs @@ -0,0 +1,67 @@ +namespace Krautwatch.Domain.Options; + +/// +/// OpenID Connect settings for the human-facing UI, bound from Auth:Oidc (#48). +/// +/// +/// Domain-side because the group check is a rule about who may use Krautwatch, not a detail of the +/// protocol handler — the wiring lives in the Web host, the policy lives here and is testable without +/// an identity provider. +/// +public sealed class OidcOptions +{ + public const string SectionName = "Auth:Oidc"; + + /// The IdP's issuer, e.g. https://identity.example.com/application/o/krautwatch/. + public string Authority { get; set; } = ""; + + public string ClientId { get; set; } = ""; + + /// May be a secret reference (env: / file:) like any other stored credential. + public string ClientSecret { get; set; } = ""; + + /// Requested scopes. profile is what carries the groups claim on Authentik. + public List Scopes { get; set; } = ["openid", "profile", "email"]; + + /// + /// Groups a user must be in — any one of them — to be admitted. + /// + /// + /// Empty by default, meaning anyone the IdP authenticated gets in. That default matters: this has + /// to work against identity providers that carry no group model at all, so a homelab's own access + /// tiers cannot be a precondition for the feature. + /// + /// Where it is set, it is a second layer rather than the only one — the IdP should also be + /// refusing to issue a token for this application. Neither layer relies on the other being right, + /// which is the point: a policy binding deleted by a careless deploy must not silently open the UI. + /// + /// + public List RequiredGroups { get; set; } = []; + + /// The claim carrying group membership. Authentik emits groups in profile. + public string GroupsClaim { get; set; } = "groups"; + + /// + /// Splits a comma-separated group list, for deployments configured through environment variables. + /// + /// + /// Configuration binds a list from indexed keys (…RequiredGroups__0), which a compose + /// .env has no comfortable way to express. Accepting "Homelab Admins,Media Admins" + /// as well means the setting can be written the way every other one in that file is. + /// + public static List ParseGroups(string? commaSeparated) => + string.IsNullOrWhiteSpace(commaSeparated) + ? [] + : commaSeparated + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + /// Whether a principal is allowed in, given the groups their token carries. + /// + /// Case-insensitive: group names are human-typed in two places (an IdP's UI and our config), and a + /// casing mismatch failing closed would look exactly like a permissions problem. + /// + public bool Admits(IEnumerable groups) => + RequiredGroups.Count == 0 + || groups.Any(g => RequiredGroups.Contains(g, StringComparer.OrdinalIgnoreCase)); +} diff --git a/src/Presentation/AppHost/Program.cs b/src/Presentation/AppHost/Program.cs index cff5d2a..61731ac 100644 --- a/src/Presentation/AppHost/Program.cs +++ b/src/Presentation/AppHost/Program.cs @@ -72,6 +72,15 @@ // Optional: TheTVDB matching. Absent is fine — matching degrades to titles rather than failing. var tvdbApiKey = builder.AddParameter("tvdb-apikey", secret: true); +// Optional: hand identity to an OIDC provider instead of the built-in local login (#48). Blank means +// local, which is what a fresh install should be — an instance that comes up asking for an IdP nobody +// configured is an instance nobody can sign in to. +var authProvider = builder.AddParameter("auth-provider", secret: false); +var oidcAuthority = builder.AddParameter("oidc-authority", secret: false); +var oidcClientId = builder.AddParameter("oidc-client-id", secret: false); +var oidcClientSecret = builder.AddParameter("oidc-client-secret", secret: true); +var oidcRequiredGroups = builder.AddParameter("oidc-required-groups", secret: false); + // Optional: a first Sonarr/Radarr instance, so a compose deployment can arrive already wired up // instead of requiring a trip through Settings (#5). Applied by the Web host only while no instance // exists — after that the UI owns them, and these are ignored. @@ -102,6 +111,13 @@ .WithEnvironment("SONARR_API_KEY", sonarrApiKey) .WithEnvironment("RADARR_URL", radarrUrl) .WithEnvironment("RADARR_API_KEY", radarrApiKey) + .WithEnvironment("Auth__Provider", authProvider) + .WithEnvironment("Auth__Oidc__Authority", oidcAuthority) + .WithEnvironment("Auth__Oidc__ClientId", oidcClientId) + .WithEnvironment("Auth__Oidc__ClientSecret", oidcClientSecret) + // Comma-separated, parsed by the Web host: a .env file has nowhere to put an array, and + // configuration alone only binds indexed keys. + .WithEnvironment("Auth__Oidc__RequiredGroups", oidcRequiredGroups) .WithExternalHttpEndpoints(); // ────────────────────────────────────────────────────────────── diff --git a/src/Presentation/Web/Components/Pages/Login.razor b/src/Presentation/Web/Components/Pages/Login.razor index 70d8fc5..f1d40d8 100644 --- a/src/Presentation/Web/Components/Pages/Login.razor +++ b/src/Presentation/Web/Components/Pages/Login.razor @@ -4,10 +4,12 @@ @using Krautwatch.Application.Auth @using Microsoft.AspNetCore.Authentication @using Microsoft.AspNetCore.Authentication.Cookies +@using Microsoft.AspNetCore.Authentication.OpenIdConnect @inject SignInHandler SignIn @inject SetupStateHandler SetupState @inject NavigationManager Nav @inject IHttpContextAccessor HttpContextAccessor +@inject IConfiguration Config @* Static SSR deliberately (no @rendermode): writing the auth cookie needs HttpContext before the response has started, which an interactive circuit cannot do. *@ @@ -17,7 +19,14 @@

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(); + } +}