feat(api): #295 PR1 — browser SPA session auth (session-OR-key gate, server-only) #300

Merged
timothy merged 5 commits from feat/295-auth-pr1 into main 2026-07-12 17:15:00 +02:00
Owner

Implements PR1 (server-only) of the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Arc item 5 (#197) Phase-1 remote-exposure hardening — revises Bundle A's human-facing "paste the machine key" gap.

What

The /api surface now accepts a valid X-Api-Key (machine) OR an authenticated session (browser cookie, local login or OIDC). PR1 is backward compatible — machine/key behavior is byte-identical and the SPA keeps riding its stored key; the SPA login flow is PR2.

  • ApiKeyAuthorizationFilter → ApiAuthorizationFilter: same fail-closed EndpointRequiresKey predicate (still the single source shared with OpenAPI); accepts key-first (CSRF-immune) then session. Session-authed mutations require the X-CSRF header (403 otherwise). Attributes renamed [RequiresApiKey]→[RequiresAuthentication], [SkipApiKeyAuthorization]→[SkipApiAuthorization].
  • Cookie ctv-session always registered (Lax/SameAsRequest/14d sliding; 401 not redirect for /api); OIDC handler revived when OIDC:* configured (profile scope, userinfo, auth-method claim). UseAuthentication/UseAuthorization/UseRateLimiter revived in the legacy MapWhen branch only.
  • Local admin = single credential in ConfigElement rows (AuthLocalAdminUsername / AuthLocalAdminPasswordHash PBKDF2 via Microsoft.Extensions.Identity.Core / rotating AuthSecurityStamp) — NO DB migration. Password change rotates the stamp; CookieSecurityStampValidator revokes stale local sessions.
  • AuthController /api/auth/{config,session,setup,login,logout,password} + browser-nav GET /auth/oidc/login; excluded from OpenAPI (machine-audience spec). Per-IP rate limit on login/setup/password; dummy-hash verify (no user enumeration). Env-seed recovery (Auth:LocalAdmin:*) via LocalAdminSeedService.
  • ForwardedHeaders now strict opt-in: X-Forwarded-* ignored unless KnownProxies/KnownNetworks configured (protects the login rate-limiter IP + cookie Secure scheme detection).

⚠️ Deployment coordination

Operators exposing ErsatzTV behind a reverse proxy must set ForwardedHeaders:KnownProxies/:KnownNetworks for correct client-IP + HTTPS detection (part of the #295 rollout checklist). This only cosmetically affects the current key-only prod (logs the proxy IP); the host/port guard keys on Host + LocalPort, not the remote IP.

Verification

  • dotnet test ErsatzTV.Tests green: 1499/1499 — incl. 6 new session/CSRF filter cases + 17 Application/Auth handler tests (real repo + real PBKDF2 hasher over the in-memory DB).
  • Live E2E: 11/11 against a real running instance (curl): setup-claim + 409 re-claim, session satisfies [RequiresAuthentication], CSRF gate (403 no header / passes with), machine-key unchanged, case-insensitive login, security-stamp rotation revokes other sessions while re-issuing the acting one, logout revocation.
  • No OpenAPI / generated-artifact drift (v1.json / v1.d.ts / endpoint-index unchanged — AuthController excluded, renames don't touch the doc).

Docs (same PR)

  • docs/api-conventions.md §9 rewritten (session-or-key posture, CSRF, /api/auth/*, forwarded-headers opt-in).
  • docs/decisions.md entry (supersedes the #206 "inert OIDC wiring" note).

Phasing / not in this PR

  • PR2 — SPA: drop the key header for browser calls + 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).

Refs #295 #197 #206 #58

🤖 Generated with Claude Code

Implements **PR1 (server-only)** of the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Arc item 5 (#197) Phase-1 remote-exposure hardening — revises Bundle A's human-facing "paste the machine key" gap. ## What The `/api` surface now accepts **a valid `X-Api-Key` (machine) OR an authenticated session (browser cookie, local login or OIDC)**. **PR1 is backward compatible** — machine/key behavior is byte-identical and the SPA keeps riding its stored key; the SPA login flow is **PR2**. - **`ApiKeyAuthorizationFilter` → `ApiAuthorizationFilter`**: same fail-closed `EndpointRequiresKey` predicate (still the single source shared with OpenAPI); accepts key-first (CSRF-immune) then session. Session-authed **mutations require the `X-CSRF` header** (403 otherwise). Attributes renamed `[RequiresApiKey]`→`[RequiresAuthentication]`, `[SkipApiKeyAuthorization]`→`[SkipApiAuthorization]`. - **Cookie `ctv-session` always registered** (`Lax`/`SameAsRequest`/14d sliding; **401 not redirect** for `/api`); OIDC handler revived when `OIDC:*` configured (`profile` scope, userinfo, auth-method claim). `UseAuthentication`/`UseAuthorization`/`UseRateLimiter` revived in the **legacy `MapWhen` branch only**. - **Local admin = single credential in `ConfigElement` rows** (`AuthLocalAdminUsername` / `AuthLocalAdminPasswordHash` PBKDF2 via `Microsoft.Extensions.Identity.Core` / rotating `AuthSecurityStamp`) — **NO DB migration**. Password change rotates the stamp; `CookieSecurityStampValidator` revokes stale local sessions. - **`AuthController` `/api/auth/{config,session,setup,login,logout,password}`** + browser-nav `GET /auth/oidc/login`; **excluded from OpenAPI** (machine-audience spec). Per-IP rate limit on login/setup/password; dummy-hash verify (no user enumeration). Env-seed recovery (`Auth:LocalAdmin:*`) via `LocalAdminSeedService`. - **`ForwardedHeaders` now strict opt-in**: `X-Forwarded-*` ignored unless `KnownProxies`/`KnownNetworks` configured (protects the login rate-limiter IP + cookie `Secure` scheme detection). ## ⚠️ Deployment coordination Operators exposing ErsatzTV behind a reverse proxy **must set `ForwardedHeaders:KnownProxies`/`:KnownNetworks`** for correct client-IP + HTTPS detection (part of the #295 rollout checklist). This only cosmetically affects the current key-only prod (logs the proxy IP); the host/port guard keys on `Host` + `LocalPort`, not the remote IP. ## Verification - **`dotnet test ErsatzTV.Tests` green: 1499/1499** — incl. 6 new session/CSRF filter cases + 17 `Application/Auth` handler tests (real repo + real PBKDF2 hasher over the in-memory DB). - **Live E2E: 11/11** against a real running instance (curl): setup-claim + 409 re-claim, session satisfies `[RequiresAuthentication]`, CSRF gate (403 no header / passes with), machine-key unchanged, case-insensitive login, **security-stamp rotation revokes other sessions while re-issuing the acting one**, logout revocation. - **No OpenAPI / generated-artifact drift** (`v1.json` / `v1.d.ts` / `endpoint-index` unchanged — `AuthController` excluded, renames don't touch the doc). ## Docs (same PR) - `docs/api-conventions.md` §9 rewritten (session-or-key posture, CSRF, `/api/auth/*`, forwarded-headers opt-in). - `docs/decisions.md` entry (supersedes the #206 "inert OIDC wiring" note). ## Phasing / not in this PR - **PR2** — SPA: drop the key header for browser calls + `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). Refs #295 #197 #206 #58 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-07-12 16:19:38 +02:00
feat(api): #295 PR1 — browser SPA session auth (session-OR-key gate, server-only)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
0b23d4b6b1
Implements the ratified #295 design (PR1, server-only, backward compatible). The
/api surface now accepts a valid X-Api-Key (machine) OR an authenticated session
(browser cookie, local login or OIDC), gated by the evolved ApiAuthorizationFilter
(renamed from ApiKeyAuthorizationFilter; same fail-closed EndpointRequiresKey
predicate). Machine/key behavior is byte-identical and the SPA keeps working via
its stored key — the SPA login flow lands in PR2.

- ApiAuthorizationFilter: key-first (CSRF-immune) then session; session-authed
  mutations require the X-CSRF header (403 otherwise). Attributes renamed
  [RequiresApiKey]->[RequiresAuthentication], [SkipApiKeyAuthorization]->[SkipApiAuthorization].
- Cookie scheme ctv-session always registered (Lax/SameAsRequest/14d sliding, 401 not
  redirect for /api); OIDC handler revived when configured (profile scope, userinfo,
  auth-method claim); UseAuthentication/UseAuthorization/UseRateLimiter revived in the
  legacy MapWhen branch.
- Local admin = single credential in ConfigElement rows (username / PBKDF2 hash via
  Microsoft.Extensions.Identity.Core / rotating security stamp) — NO DB migration.
  Password change rotates the stamp; CookieSecurityStampValidator revokes stale local
  sessions. Env-seed recovery (Auth:LocalAdmin:*) via LocalAdminSeedService.
- AuthController /api/auth/{config,session,setup,login,logout,password} + browser-nav
  GET /auth/oidc/login; excluded from OpenAPI (machine-audience spec). Per-IP rate limit
  on login/setup/password; dummy-hash verify (no user enumeration).
- ForwardedHeaders now strict opt-in: X-Forwarded-* ignored unless KnownProxies/Networks
  configured (rate-limiter IP + cookie-Secure integrity). Deployment: operators behind a
  proxy must set ForwardedHeaders:KnownProxies.
- Tests: session/CSRF filter cases + 17 Application/Auth handler tests; full ErsatzTV.Tests
  green (1499). No OpenAPI/generated-artifact drift.
- Docs: api-conventions section 9 rewritten; decisions.md entry (supersedes #206 inert-OIDC note).

Refs #295 #197 #206 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 16:41:14 +02:00
fix(api): #295 PR1 — fold in cold-fork + Codex review findings
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
6ac5150fd0
Independent review (cold fork = MERGEABLE-WITH-NITS; Codex = BLOCKED, caught
concurrency defects the fork missed). All actionable findings folded in:

- HIGH (Codex) atomic first-claim-wins: ClaimLocalAdmin now writes the three
  credential rows in ONE transaction guarded by the unique ConfigElement.Key
  index (lost race -> DbUpdateException -> 409), so concurrent claims can't
  produce a mixed-state credential.
- HIGH (Codex) consistent login snapshot: VerifyLocalAdminLogin reads hash+stamp
  in one query and drops rehash-on-verify, so a login racing a password change
  can't capture a stamp newer than the hash it verified (concurrent change ->
  old password fails, or the issued cookie carries the pre-change stamp ->
  revoked next request).
- MEDIUM (Codex) env-seed migration race: LocalAdminSeedService is now a RunOnce
  BackgroundService that awaits SystemStartup.WaitForDatabase (the migrator is a
  BackgroundService; registration order didn't guarantee the schema) + try/catch.
- MEDIUM (fork M1) ForwardedHeaders: reverted the strict-opt-in flip — it would
  regress /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme) behind a
  proxy without KnownProxies. Kept #285 behavior; KnownProxies still recommended.
- LOW (Codex/fork) require X-CSRF on /api/auth/logout + /password (the
  [SkipApiAuthorization] surface isn't covered by the filter's CSRF check;
  closes forced-logout CSRF).
- ChangeLocalAdminPassword also writes hash+stamp atomically. Input length caps
  on username/password.

Deferred with a tracked gate: MEDIUM (Codex) side-effecting [RequiresAuthentication]
GETs (troubleshoot playback/archive) aren't CSRF-covered -> #301, gates PR2
(latent in PR1: the SPA still uses the machine key).

Verify: full ErsatzTV.Tests green (1501); no OpenAPI/generated drift. Docs updated
(api-conventions §9, decisions.md).

Refs #295 #301

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 16:47:27 +02:00
fix(api): #295 PR1 — logout ends the session server-side (E2E-caught)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
9a9aaf0740
Live E2E found that replaying a pre-logout cookie still authenticated (200, not
401): SignOutAsync only clears the CLIENT cookie, but the stateless encrypted
cookie ticket stays valid server-side because its security stamp is unchanged —
a captured cookie was replayable after logout until ticket expiry.

Fix: logout now rotates the local-admin security stamp (RotateLocalAdminSecurityStamp),
so every outstanding local session (old stamp) fails OnValidatePrincipal on its
next request. For the single admin this is "log out everywhere". Gated on an
authenticated local session so an unauthenticated caller can't force-revoke the
admin. OIDC sessions (no stamp) are unaffected; SignOutAsync still clears the
client cookie for UX.

+2 handler tests (rotate-when-configured / no-op-when-unconfigured). Auth suite
green (21). Docs: decisions.md note updated.

Refs #295

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 16:53:14 +02:00
fix(api): #295 PR1 — fold in fix-commit re-review (2nd Codex round)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
e8c3481ea5
Fix-commit re-review confirmed the 1st-round fixes resolved and caught a 2nd round:

- 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 credential persists, defeating env recovery). Fixed
  structurally: the setup-claim endpoint is CLOSED (409) whenever
  Auth:LocalAdmin:Password is configured — the env seed owns the credential, so
  there's no claim to race (also strengthens the setup-claim TOFU posture).
  Config.setupRequired reflects it.
- LOW — a concurrent setup race-loser now returns 409 (not 422); ClaimLocalAdmin's
  DbUpdateException catch re-checks existence and rethrows genuine/transient DB
  errors instead of masking them as "already configured".
- MEDIUM (accepted, documented) — two simultaneous authenticated password changes
  are a non-serializable lost-update; accepted for a single-admin system
  (self-healing via re-login, implausible timing).

+3 AuthController tests (env-seed closes setup / setupRequired gating). Full
ErsatzTV.Tests green (1506); no generated drift. Docs updated.

Refs #295

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 17:08:19 +02:00
Merge origin/main into feat/295-auth-pr1
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m11s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m36s
48d256f83b
Brings in the #303 process/rigor hooks + docs. Only conflict-free overlap was
docs/decisions.md (main added the ## Index TOC); reconciled by adding the #295
auth entry to the index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy merged commit 48d256f83b into main 2026-07-12 17:15:00 +02:00
timothy deleted branch feat/295-auth-pr1 2026-07-12 17:15:01 +02:00
Sign in to join this conversation.