Skip to content

feat(auth): local provider, token-gated first-run setup, and lock down the Web UI (#48) - #56

Merged
ChrisonSimtian merged 1 commit into
mainfrom
feat/auth-local
Jul 27, 2026
Merged

feat(auth): local provider, token-gated first-run setup, and lock down the Web UI (#48)#56
ChrisonSimtian merged 1 commit into
mainfrom
feat/auth-local

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Contributor

Closes the first part of #48. This is a prerequisite for #4 (the Sonarr/Radarr config UI): that page stores *arr API keys, and Presentation/Web had 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 IAuthenticationProvider with local and oidc behind it — doesn't work, and building it would have produced a leaky abstraction:

  • Local credentials are a verification concern: given a username and password, is this the admin? That fits a Domain port cleanly.
  • OIDC is a protocol concern — redirects, code exchange, token validation — all owned by ASP.NET Core's OpenIdConnect middleware, which never sees a password. Nothing is left for AuthenticateAsync(username, password) to abstract.

So the pluggable point is scheme selection in the composition root:

Auth:Provider = none | local | oidc
        ├─ local → cookie auth + ILocalCredentialStore (Domain port)
        └─ oidc  → cookie auth + OpenIdConnect middleware (no Domain port)

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

Layer Added
Domain AdminAccount (singleton row, never seeded — no default credentials ship), ILocalCredentialStore, IPasswordHasher. No ASP.NET types.
Application Application/AuthSignInHandler, CreateAdminHandler, SetupStateHandler, SetupToken, validator (12-char minimum; length over character-class theatre)
Infrastructure EF LocalCredentialStore; IdentityPasswordHasher over Identity's PasswordHasher<T> (PBKDF2-HMAC-SHA256, salted, versioned, rehash-on-verify) — the hasher only, not the Identity user stack. Migration AddAdminAccount.
Web Cookie auth, /login, /logout, /setup, current user + sign-out in the nav

Decisions worth a look

Default is local, so a fresh install lands on first-run setup rather than sitting open. none stays for operators fronting Krautwatch with reverse-proxy forward-auth.

First-run setup is token-gated. Leaving /setup open 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 needs HttpContext before the response starts, which an interactive circuit can't do. That meant moving interactivity from a global @rendermode on <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. 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 at all.

Two bugs that compiled and unit-tested clean

Both found by actually running the host:

  1. Rate limiting was defined but never applied — 13 login POSTs all went through. It also couldn't be a named policy on 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.
  2. Auth:Provider = none didn't work — every page still 302'd to a login that can never succeed. Substituting AuthenticationStateProvider only affects one of the two render modes. Fixed with middleware assigning HttpContext.User, the one place static SSR and the interactive circuit agree.

Verified end to end against real Postgres

Check Result
/setup no token / wrong token "Setup token required"
/setup correct token form renders
POST setup admin created, hash starts AQAAAAIAAYag (PBKDF2 v3) — not plaintext
Replay setup with valid token "Already set up", username unchanged — no takeover
Wrong password no auth cookie issued
Correct password 302 + cookie
/activity anonymous → with cookie 302 → 200, shows signed-in user
Logout session cleared, /activity 302s again
GET /login ×13 all 200 — browsing unthrottled
POST /login ×13 429 after 10; /health unaffected
Auth:Provider=none /activity 200 without a session

PageAuthorizationSpecs was itself checked for false-passing: removing [Authorize] from Search.razor made it fail and name the offending page.

Tests: App 67 / Arch 11 / Infra 75 green, 0 warnings.

Not in this PR

  • OIDC (PR 2) and the arr inbound API key (PR 3 — generated rather than unset-and-open, surfaced in the now-authenticated UI, with t=caps staying anonymous so Prowlarr can probe).
  • Encrypting *arr keys at rest — needs a key-management story; worth its own issue.
  • The First-time setup wizard: guided initial configuration (db, auth, downloads, geo egress, *arr) #54 setup wizard wraps a wider guided flow around this first-run step and must reuse this gate rather than adding a second one.

⚠️ Behaviour change: your existing dev instance will prompt for setup on next run. Grab the link from the web host log.

Plan: docs/plans/2026-07-27 - authentication.md · config-UI plan updated to record the sequencing.

🤖 Generated with Claude Code

#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>
@ChrisonSimtian
ChrisonSimtian merged commit e4e9bc0 into main Jul 27, 2026
1 check passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/auth-local branch July 27, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant