Files
ersatztv/docs/decisions/records/security/session-auth-dual-credential.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

9.5 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
security.session-auth-dual-credential 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only) active 2026-07-12 none none `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. session auth, dual credential, CSRF, OIDC revival, local admin · paths: `ApiAuthorizationFilter`, `ErsatzTV/Startup.cs` · issues: #295, #206, #197 `docs/spa-conventions.md` §5e (PR2); `RootWriterForceVersionTests`-adjacent auth tests

Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206 "OIDC wiring stays inert until #197" note: the retained OIDC service registration is now revived, and a cookie session becomes a first-class /api credential alongside the machine X-Api-Key. PR1 is server-only and backward compatible — the SPA keeps sending its stored key; the SPA login flow, the ApiKeyScreen→machine-key repurpose, and spa-conventions §5e land in PR2.

One gate, evolved (not [Authorize]-per-controller). ApiKeyAuthorizationFilterApiAuthorizationFilter, same fail-closed-by-omission logic (a forgotten [Authorize] fails open — the #280 failure mode — so the global filter stays the gate). It now accepts a request when a valid X-Api-Key matches OR the principal is an authenticated session; the "does this endpoint need auth?" decision is still the single shared EndpointRequiresKey(...) predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to match the widened meaning: [RequiresApiKey][RequiresAuthentication], [SkipApiKeyAuthorization][SkipApiAuthorization]. IApiKeyProvider, the X-Api-Key header, and Api:WriteKey/Api:RequireKeyForReads are unchanged — machine/key behavior is byte-identical (verified: no OpenAPI drift, existing filter tests still green).

CSRF (session only). The machine key is CSRF-immune (a browser can't set a custom header cross-origin without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated mutation must carry the X-CSRF header (presence-only — a custom header forces a CORS preflight a cross-site page can't satisfy) or is rejected 403. Reinforced by SameSite=Lax + CORS without AllowCredentials (cross-origin cookie auth is impossible by design). No antiforgery-token machinery.

Cookie ctv-session. Always registered (local login works with no IdP); OIDC handler added only when OIDC:* is configured. HttpOnly, SameSite=Lax, SecurePolicy=SameAsRequest (so a plain-HTTP LAN isn't bricked), 14-day sliding. /api XHR gets 401/403, not a redirect (OnRedirectToLogin/AccessDenied). The UseAuthentication/UseAuthorization middleware — deleted with Blazor in #91b — is revived in the legacy MapWhen branch only (hosts /api + OIDC /callback + /docs; /iptv and /app untouched).

Local store = ConfigElement rows, single admin, NO migration (owner ruling F2): AuthLocalAdminUsername, AuthLocalAdminPasswordHash (ASP.NET PasswordHasher, PBKDF2, via Microsoft.Extensions.Identity.Core), AuthSecurityStamp. A password change rotates the stamp; the cookie OnValidatePrincipal (CookieSecurityStampValidator) compares the claim to the stored stamp and rejects a stale session (revocation). OIDC sessions carry an etv:auth_method=oidc claim and skip the stamp check (governed by the IdP).

Fail-closed out of the box + recovery. An unconfigured instance keeps /api gated (the key still works); first-run is a setup-claim (POST /api/auth/setup, first-claim-wins, only valid while unconfigured — owner ruling F1). Recovery without the browser: Auth:LocalAdmin:Password env seed (LocalAdminSeedService, overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit ([EnableRateLimiting("auth")], 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured user (no enumeration).

Authelia = app-owned OIDC session; never trust proxy identity headers (owner ruling F3): the container is LAN-reachable bypassing the proxy, so Remote-User/Remote-Email header trust is spoofable. OIDC→Authelia gives SSO without a double login. ForwardedHeaders behaviour is kept unchanged from #285 (trust any peer with a warning; restrict via KnownProxies/:KnownNetworks). A stricter "ignore X-Forwarded-* unless a proxy is configured" default was implemented and then reverted after review (cold fork M1): the forwarded scheme/host feed /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme in GetChannelGuideHandler/ IptvController), so ignoring them would regress stream URLs to http/internal-host for a proxied deployment that hasn't set KnownProxies. Deployment coordination: operators behind a proxy should set ForwardedHeaders:KnownProxies/:KnownNetworks — it gives the login rate limiter an unspoofable client IP and marks the session cookie Secure behind TLS. The residual (a direct LAN peer can spoof X-Forwarded-For to evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 + no-enumeration.

Review hardening (fork + independent Codex pass, folded into PR1). Codex caught concurrency defects the fork missed — folded in: (a) atomic first-claim-wins — setup writes the three credential rows in one transaction guarded by the unique ConfigElement.Key index (a lost race → DbUpdateException → 409), so a concurrent claim can't produce a mixed-state credential; (b) consistent login snapshot — login reads the hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued cookie carrying the pre-change stamp → revoked next request); (c) env-seed waits on SystemStartup.WaitForDatabase (the migrator is a BackgroundService, so registration order alone didn't guarantee the schema existed) — moved to Services/RunOnce/. Also: logout + password require X-CSRF (the [SkipApiAuthorization] auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and input length caps on username/password. Logout rotates the security stamp when called from a local session (E2E-caught: SignOutAsync alone only clears the client cookie, leaving the stateless encrypted ticket replayable server-side) — so signing out actually ends the session server-side; for the single admin this revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated caller can't force-revoke the admin. Deferred with a tracked gate: side-effecting [RequiresAuthentication] GETs (troubleshoot playback/archive) aren't CSRF-covered — #301, gating PR2 (latent in PR1: the SPA still uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.

A fix-commit re-review (Codex, #242 discipline) then confirmed the above resolved and caught a second round: (a) HIGH — env-seed vs. setup race: an attacker could claim admin in the startup window before LocalAdminSeedService runs, and the seed's insert would then be swallowed (attacker's credential persists, defeating the env recovery path). Fixed structurally: the setup-claim endpoint is closed whenever Auth:LocalAdmin:Password is configured — the env seed owns the credential, so there is no claim to race (this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password and browser setup is disabled). (b) LOW: a concurrent setup race-loser now returns 409 (not 422), and ClaimLocalAdmin's DbUpdateException catch re-checks existence and rethrows genuine/transient DB errors rather than masking them as "already configured". (c) MEDIUM — accepted: two simultaneous authenticated password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately revoked). Accepted for a single-admin system: it needs two concurrent authenticated sessions both submitting the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF optimistic concurrency to the credential rows is disproportionate here.

OpenAPI = ApiKey-only; /api/auth/* excluded (owner ruling F4): the spec's audience is machine/MCP clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path is an additional accepted credential the doc needn't express. AuthController is [ApiExplorerSettings(IgnoreApi = true)]. Verified: no v1.json/v1.d.ts/endpoint-index drift from this PR.

Phasing. PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add X-CSRF, AuthContext + boot gate, login/setup screens, ApiKeyScreen→machine-key management, E2E, spa-conventions §5e). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2 same release, then a manual Authelia round-trip checklist before the prod pin bump.