feat(auth): local provider, token-gated first-run setup, and lock down the Web UI (#48) - #56
Merged
Merged
Conversation
#48) Presentation/Web had no authentication whatsoever. That became the blocker for #4 (the Sonarr/Radarr config UI), which stores *arr API keys — putting credentials behind an open UI was not acceptable, so auth lands first. ## The abstraction is the scheme, not one interface The obvious design — one IAuthenticationProvider with `local` and `oidc` behind it — does not work, and building it would have produced a leaky abstraction. Local credentials are a *verification* concern that fits a Domain port. OIDC is a *protocol* concern: redirects, code exchange, token validation, all owned by ASP.NET Core's OpenIdConnect middleware, which never sees a password. There is nothing left for AuthenticateAsync(username, password) to abstract. So the pluggable point is scheme selection in the Web composition root (`Auth:Provider = local | oidc | none`), and both schemes converge on the same cookie and the same ClaimsPrincipal. Everything downstream depends on *an authenticated principal*, not on how it was obtained. OIDC itself is PR 2. ## What is here Domain: AdminAccount (singleton row, never seeded — no default credentials ship), ILocalCredentialStore, IPasswordHasher + PasswordVerification. No ASP.NET types. Application/Auth: SignInHandler, CreateAdminHandler, SetupStateHandler, SetupToken, CreateAdminRequestValidator (12-char minimum; length rather than character-class theatre). Infrastructure/Auth: EF-backed LocalCredentialStore, and IdentityPasswordHasher over ASP.NET Core Identity's PasswordHasher<T> — PBKDF2-HMAC-SHA256, per-password salt, versioned format with rehash-on-verify. The hasher only, not the Identity user stack. Migration AddAdminAccount. Web: cookie auth, /login, /logout, /setup, sign-out and current user in the nav. ## Decisions worth knowing Default is `local`, so a fresh install lands on first-run setup instead of sitting open. `none` remains for operators fronting Krautwatch with reverse-proxy forward-auth. First-run setup is gated by a token generated per process and written to the host log (the pattern Aspire's own dashboard uses). Leaving /setup open until claimed is what most self-hosted apps do and it is an admin-takeover window — on a shared network the first visitor owns the instance. The auth pages are static SSR (no @rendermode) because writing the cookie needs HttpContext before the response starts, which an interactive circuit cannot do. That meant moving interactivity from a global @rendermode on <Routes> to per-page. Every routable page now carries [Authorize] or [AllowAnonymous]. Blazor has no fallback policy for components, so "someone adds a page and forgets" is a silent way to expose the UI — PageAuthorizationSpecs makes the decision mandatory and asserts that only Login/Logout/Setup are anonymous. Cookie SecurePolicy is SameAsRequest, not Always: homelab deployments commonly sit behind a proxy terminating TLS and reach this host over plain HTTP, where Always silently drops the cookie and login appears to do nothing. ## Two bugs found by running it, not by compiling it Rate limiting was defined but never applied — 13 login POSTs all succeeded. It also could not be a named policy on MapRazorComponents, because Blazor routes every page through that single endpoint and it would have throttled the whole UI to 10 requests a minute. Now a global limiter with an explicit no-limiter partition for everything that is not a login POST. `Auth:Provider = none` did not work: every page still 302'd to a login that cannot succeed. Substituting AuthenticationStateProvider only affected one of the two render modes. Fixed with middleware assigning HttpContext.User, which is the one place both static SSR and the interactive circuit agree on. ## Verified end to end against real Postgres - /setup with no token and with a wrong token -> "Setup token required"; correct token -> the form. - POST creates the admin; stored hash starts AQAAAAIAAYag (Identity PBKDF2 v3), not plaintext. - Replaying setup with the valid token -> "Already set up", username unchanged. - Wrong password -> no auth cookie issued. Correct password -> 302 + cookie. - /activity anonymous -> 302; with the cookie -> 200 showing the signed-in user. - Logout clears the session; /activity 302s again. - GET /login x13 -> all 200 (browsing unthrottled); POST /login x13 -> 429 after 10; /health unaffected. - Auth:Provider=none -> /activity 200 without any session. - PageAuthorizationSpecs was checked for false-passing by removing [Authorize] from Search.razor: it failed and named the offending page. Tests: App 67 / Arch 11 / Infra 75 green, 0 warnings. Plan: docs/plans/2026-07-27 - authentication.md Follow-ups: OIDC (PR 2), the *arr inbound key (PR 3), then #4 unblocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the first part of #48. This is a prerequisite for #4 (the Sonarr/Radarr config UI): that page stores
*arrAPI keys, andPresentation/Webhad no authentication at all, so credentials-behind-an-open-UI had to be fixed first.The abstraction is the scheme, not one interface
The obvious design — one
IAuthenticationProviderwithlocalandoidcbehind it — doesn't work, and building it would have produced a leaky abstraction:OpenIdConnectmiddleware, which never sees a password. Nothing is left forAuthenticateAsync(username, password)to abstract.So the pluggable point is scheme selection in the composition root:
Both converge on the same cookie and the same
ClaimsPrincipal, so everything downstream depends on an authenticated principal, not on how it was obtained. That's the real abstraction. OIDC itself is PR 2.What's here
AdminAccount(singleton row, never seeded — no default credentials ship),ILocalCredentialStore,IPasswordHasher. No ASP.NET types.Application/Auth—SignInHandler,CreateAdminHandler,SetupStateHandler,SetupToken, validator (12-char minimum; length over character-class theatre)LocalCredentialStore;IdentityPasswordHasherover Identity'sPasswordHasher<T>(PBKDF2-HMAC-SHA256, salted, versioned, rehash-on-verify) — the hasher only, not the Identity user stack. MigrationAddAdminAccount./login,/logout,/setup, current user + sign-out in the navDecisions worth a look
Default is
local, so a fresh install lands on first-run setup rather than sitting open.nonestays for operators fronting Krautwatch with reverse-proxy forward-auth.First-run setup is token-gated. Leaving
/setupopen until claimed is what most self-hosted apps do, and it's an admin-takeover window — on a shared network the first visitor owns the instance. The token is generated per process and written to the host log (the pattern Aspire's own dashboard uses).Auth pages are static SSR (no
@rendermode): writing the cookie needsHttpContextbefore the response starts, which an interactive circuit can't do. That meant moving interactivity from a global@rendermodeon<Routes>to per-page — hence the one-line change to Home/Search/Activity.Every routable page now carries
[Authorize]or[AllowAnonymous]. Blazor has no fallback policy for components, so "someone adds a page and forgets" is a silent way to expose the UI.PageAuthorizationSpecsmakes the decision mandatory and asserts that only Login/Logout/Setup are anonymous.Cookie
SecurePolicyisSameAsRequest, notAlways— homelab deployments commonly sit behind a proxy terminating TLS and reach this host over plain HTTP, whereAlwayssilently drops the cookie and login appears to do nothing at all.Two bugs that compiled and unit-tested clean
Both found by actually running the host:
MapRazorComponents: Blazor routes every page through that single endpoint, so it would have throttled the entire UI to 10 requests/minute. Now a global limiter with an explicit no-limiter partition for everything that isn't a login POST.Auth:Provider = nonedidn't work — every page still 302'd to a login that can never succeed. SubstitutingAuthenticationStateProvideronly affects one of the two render modes. Fixed with middleware assigningHttpContext.User, the one place static SSR and the interactive circuit agree.Verified end to end against real Postgres
/setupno token / wrong token/setupcorrect tokenAQAAAAIAAYag(PBKDF2 v3) — not plaintext/activityanonymous → with cookie/activity302s againGET /login×13POST /login×13/healthunaffectedAuth:Provider=none/activity200 without a sessionPageAuthorizationSpecswas itself checked for false-passing: removing[Authorize]fromSearch.razormade it fail and name the offending page.Tests: App 67 / Arch 11 / Infra 75 green, 0 warnings.
Not in this PR
t=capsstaying anonymous so Prowlarr can probe).*arrkeys at rest — needs a key-management story; worth its own issue.webhost log.Plan:
docs/plans/2026-07-27 - authentication.md· config-UI plan updated to record the sequencing.🤖 Generated with Claude Code