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).
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>
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>
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>
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>
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 main2026-07-12 17:15:00 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
/apisurface now accepts a validX-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-closedEndpointRequiresKeypredicate (still the single source shared with OpenAPI); accepts key-first (CSRF-immune) then session. Session-authed mutations require theX-CSRFheader (403 otherwise). Attributes renamed[RequiresApiKey]→[RequiresAuthentication],[SkipApiKeyAuthorization]→[SkipApiAuthorization].ctv-sessionalways registered (Lax/SameAsRequest/14d sliding; 401 not redirect for/api); OIDC handler revived whenOIDC:*configured (profilescope, userinfo, auth-method claim).UseAuthentication/UseAuthorization/UseRateLimiterrevived in the legacyMapWhenbranch only.ConfigElementrows (AuthLocalAdminUsername/AuthLocalAdminPasswordHashPBKDF2 viaMicrosoft.Extensions.Identity.Core/ rotatingAuthSecurityStamp) — NO DB migration. Password change rotates the stamp;CookieSecurityStampValidatorrevokes stale local sessions.AuthController/api/auth/{config,session,setup,login,logout,password}+ browser-navGET /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:*) viaLocalAdminSeedService.ForwardedHeadersnow strict opt-in:X-Forwarded-*ignored unlessKnownProxies/KnownNetworksconfigured (protects the login rate-limiter IP + cookieSecurescheme detection).⚠️ Deployment coordination
Operators exposing ErsatzTV behind a reverse proxy must set
ForwardedHeaders:KnownProxies/:KnownNetworksfor 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 onHost+LocalPort, not the remote IP.Verification
dotnet test ErsatzTV.Testsgreen: 1499/1499 — incl. 6 new session/CSRF filter cases + 17Application/Authhandler tests (real repo + real PBKDF2 hasher over the in-memory DB).[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.v1.json/v1.d.ts/endpoint-indexunchanged —AuthControllerexcluded, 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.mdentry (supersedes the #206 "inert OIDC wiring" note).Phasing / not in this PR
X-CSRF,AuthContext+ boot gate, login/setup screens,ApiKeyScreen→machine-key management, E2E,spa-conventions §5e.Refs #295 #197 #206 #58
🤖 Generated with Claude Code