Merge remote-tracking branch 'origin/main' into ci/303-api-docs-blocking
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

# Conflicts:
#	docs/decisions.md
This commit is contained in:
2026-07-12 17:34:48 +02:00
57 changed files with 2342 additions and 271 deletions
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Husky pre-push backstop for ersatztv#303 H6 — the fast-forward-to-main path the Claude merge
# hook (pretooluse-merge-consent.sh) can't see. Reads git's pre-push ref lines on stdin; for a push
# to main it scans the pushed commits for a Gitea close-keyword (`fixes #N`), and if the linked
# issue's "## Done-when" checklist still has unticked boxes it BLOCKS the push.
#
# A git hook has no interactive "ask", so this is deliberately fail-OPEN: it only blocks when it can
# positively prove an unticked box (creds present, issue fetched, non-docs change). No creds, Gitea
# unreachable, docs-only diff, or no linked issue -> allow (a loud warning at most). The authoritative
# gate is the merge hook; this just catches a direct `git push origin main`.
#
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
set -euo pipefail
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
refs=$(cat || true)
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
exit 0 # can't verify -> fail-open (the merge hook is the real gate)
fi
gq() {
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$1" 2>/dev/null || true
else
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$1" 2>/dev/null || true
fi
}
zero=0000000000000000000000000000000000000000
blocked=""
while read -r localref localsha remoteref remotesha; do
[ "$remoteref" = "refs/heads/main" ] || continue
[ "$localsha" = "$zero" ] && continue # branch deletion
# Commit range being pushed. New branch (remotesha all-zero) -> just the tip, don't rescan history.
if [ "$remotesha" = "$zero" ]; then range="$localsha -1"; else range="$remotesha..$localsha"; fi
msgs=$(git log --format='%B' $range 2>/dev/null || true)
issues=$(printf '%s' "$msgs" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || continue
# Docs-only exemption over the pushed range.
changed=$(git diff --name-only $range 2>/dev/null || true)
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
continue
fi
for n in $issues; do
ibody=$(gq "repos/timothy/ersatztv/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
[ -n "$ibody" ] || continue # can't fetch -> fail-open
unchecked=$(printf '%s\n' "$ibody" | awk '
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
grab && /^##[[:space:]]/ {grab=0}
grab {print}' | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
if [ "${unchecked:-0}" -gt 0 ]; then
blocked="${blocked} - issue #$n has $unchecked unticked ## Done-when box(es)\n"
fi
done
done <<EOF
$refs
EOF
if [ -n "$blocked" ]; then
printf 'husky - H6 merge-consent (ersatztv#303): push to main BLOCKED\n' >&2
printf '%b' "$blocked" >&2
printf 'Finish/tick every Done-when criterion (incl. adversarial review) first, or push a docs-only change.\n' >&2
exit 1
fi
exit 0
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# PreToolUse / mcp__gitea__pull_request_write — derive merge consent from STATE instead of
# trusting the agent's judgment (ersatztv#303 H6). A PR merge is the one irreversible op; allow it
# only when BOTH are true:
# (a) the PR's CI combined status is green, AND
# (b) every checkbox in the linked issue's "## Done-when" section is ticked.
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
#
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
# - state derivable and NOT satisfied -> deny (actionable reason)
# - state derivable and satisfied -> allow
# - state NOT derivable (no creds, Gitea down,
# no linked issue, no Done-when section) -> ask (surface to a human/session judgment)
# Only a real merge is gated; every other pull_request_write method is allowed untouched.
#
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
set -euo pipefail
input=$(cat)
decide() { # $1=allow|deny|ask $2=reason
case "$1" in
allow) exit 0 ;;
deny) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'; exit 0 ;;
ask) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'; exit 0 ;;
esac
}
method=$(printf '%s' "$input" | jq -r '.tool_input.method // ""' 2>/dev/null || true)
[ "$method" = "merge" ] || decide allow ""
owner=$(printf '%s' "$input" | jq -r '.tool_input.owner // ""' 2>/dev/null || true)
repo=$(printf '%s' "$input" | jq -r '.tool_input.repo // ""' 2>/dev/null || true)
pr=$(printf '%s' "$input" | jq -r '.tool_input.pull_number // ""' 2>/dev/null || true)
mwcs=$(printf '%s' "$input" | jq -r '.tool_input.merge_when_checks_succeed // false' 2>/dev/null || true)
[ -n "$owner" ] && [ -n "$repo" ] && [ -n "$pr" ] || decide ask "H6 merge gate: could not read owner/repo/pull_number from the merge call; confirm manually that CI is green and the issue's Done-when boxes are ticked."
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
# curl wrapper carrying whichever auth is configured; empty output on any failure.
gq() {
local path="$1"
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
else
return 1
fi
}
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
decide ask "H6 merge gate: no Gitea credentials in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH), so CI/Done-when state can't be verified. Confirm manually that CI is green and the linked issue's Done-when boxes are all ticked, then approve."
fi
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
[ -n "$prjson" ] || decide ask "H6 merge gate: could not fetch PR #$pr from Gitea (unreachable or auth rejected). Verify CI-green + Done-when manually before merging."
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
decide allow "" # all changed files are docs/process-only
fi
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
# --- (b) Done-when checkboxes: every linked issue must have an all-ticked section. ---
for n in $issues; do
ibody=$(gq "repos/$owner/$repo/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
[ -n "$ibody" ] || decide ask "H6 merge gate: could not fetch linked issue #$n. Verify its Done-when checklist manually before merging."
# Slice the "## Done-when" section: from that header to the next "## " (or EOF).
section=$(printf '%s\n' "$ibody" | awk '
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
grab && /^##[[:space:]]/ {grab=0}
grab {print}')
if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
decide ask "H6 merge gate: linked issue #$n has no '## Done-when' checklist section (the merge-consent convention — see CLAUDE.md Task Completion Protocol). Add one, or confirm completion manually and approve."
fi
unchecked=$(printf '%s\n' "$section" | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
if [ "${unchecked:-0}" -gt 0 ]; then
decide deny "H6 merge gate: BLOCKED — linked issue #$n has $unchecked unticked box(es) in its ## Done-when checklist. Finish (or explicitly tick) every completion criterion — including the adversarial-review box — before merging PR #$pr."
fi
done
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
if [ "$mwcs" != "true" ]; then
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
state=$(gq "repos/$owner/$repo/commits/$sha/status" | jq -r '.state // ""' 2>/dev/null || true)
case "$state" in
success) : ;;
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
esac
fi
# Both derivable and satisfied -> allow.
decide allow ""
+10
View File
@@ -36,6 +36,16 @@
"timeout": 10
}
]
},
{
"matcher": "mcp__gitea__pull_request_write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-merge-consent.sh\"",
"timeout": 15
}
]
}
],
"PostToolUse": [
+6
View File
@@ -1,3 +1,9 @@
# H6 merge-consent backstop (ersatztv#303): gate a direct push to main on the linked issue's
# ## Done-when checklist. Read git's pre-push ref lines FIRST (before the web checks below, which
# may consume stdin) and forward them. Fail-open: no creds / not main / docs-only -> allow.
_prepush_refs="$(cat)"
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-donewhen.sh || exit 1
# Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE while running hooks. In a worktree
# (or any subdir), an explicit GIT_DIR makes nested `git` commands mislocate the working
# tree — notably `check:api`'s `git diff --exit-code` (run from web/) silently reports "no
+6
View File
@@ -82,6 +82,12 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **allows** a merge only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked; **denies** on an unticked box or red CI; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no creds, Gitea down).
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
+1
View File
@@ -32,6 +32,7 @@
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
@@ -0,0 +1,28 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Shared constants for the browser-SPA session authentication (issue #295): the cookie scheme name,
/// the custom claim types the local-login path stamps onto the principal, and the auth-method marker
/// values. The web host (cookie <c>OnValidatePrincipal</c>, <c>AuthController</c>) and the Application
/// handlers both reference these so the claim contract has a single definition.
/// </summary>
public static class AuthConstants
{
/// <summary>The cookie authentication scheme name shared by local login and the OIDC callback.</summary>
public const string CookieScheme = "cookie";
/// <summary>The OIDC challenge scheme name.</summary>
public const string OidcScheme = "oidc";
/// <summary>Claim type recording how the principal signed in (<see cref="MethodLocal" /> / <see cref="MethodOidc" />).</summary>
public const string AuthMethodClaim = "etv:auth_method";
/// <summary>Claim type carrying the local admin's security stamp (checked on every request to revoke sessions).</summary>
public const string SecurityStampClaim = "etv:security_stamp";
public const string MethodLocal = "local";
public const string MethodOidc = "oidc";
/// <summary>Minimum length for a local admin password.</summary>
public const int MinPasswordLength = 8;
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Changes the local admin password after verifying the current one. Rotates the security stamp so all
/// other sessions are revoked. <see cref="Username" /> is the signed-in principal's name.
/// </summary>
public record ChangeLocalAdminPassword(string Username, string CurrentPassword, string NewPassword)
: IRequest<Either<BaseError, LocalAdminPrincipal>>;
@@ -0,0 +1,68 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class ChangeLocalAdminPasswordHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalPasswordHasher passwordHasher)
: IRequestHandler<ChangeLocalAdminPassword, Either<BaseError, LocalAdminPrincipal>>
{
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
ChangeLocalAdminPassword request,
CancellationToken cancellationToken)
{
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.NewPassword))
{
return error;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ConfigElement> rows = await dbContext.ConfigElements
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
.ToListAsync(cancellationToken);
ConfigElement userRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminUsername.Key);
ConfigElement hashRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key);
ConfigElement stampRow = rows.Find(r => r.Key == ConfigElementKey.AuthSecurityStamp.Key);
if (hashRow is null)
{
return BaseError.New("No local administrator is configured");
}
string username = (request.Username ?? string.Empty).Trim();
bool userMatches = userRow is not null
&& string.Equals(userRow.Value, username, StringComparison.OrdinalIgnoreCase);
LocalPasswordVerification result =
passwordHasher.Verify(hashRow.Value, request.CurrentPassword ?? string.Empty);
if (!userMatches || result == LocalPasswordVerification.Failed)
{
return BaseError.New("Current password is incorrect");
}
// Atomic: the new hash and rotated stamp commit together, so a crash can't leave the new password
// active with the old stamp still authorizing revoked sessions.
string stamp = LocalAdminHelpers.NewSecurityStamp();
hashRow.Value = passwordHasher.Hash(request.NewPassword);
if (stampRow is null)
{
dbContext.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
}
else
{
stampRow.Value = stamp;
}
await dbContext.SaveChangesAsync(cancellationToken);
return new LocalAdminPrincipal(userRow.Value, stamp);
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// First-run setup-claim: creates the single local administrator. Fails if one already exists
/// (first-claim-wins), so a later anonymous call cannot take over the account.
/// </summary>
public record ClaimLocalAdmin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
@@ -0,0 +1,68 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class ClaimLocalAdminHandler(IDbContextFactory<TvContext> dbContextFactory, ILocalPasswordHasher passwordHasher)
: IRequestHandler<ClaimLocalAdmin, Either<BaseError, LocalAdminPrincipal>>
{
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
ClaimLocalAdmin request,
CancellationToken cancellationToken)
{
foreach (BaseError error in LocalAdminHelpers.ValidateNewCredentials(request.Username, request.Password))
{
return error;
}
string username = request.Username.Trim();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// Fast path for the common already-configured case (clean 409). The real first-claim-wins guard is
// the unique index on ConfigElement.Key + the single atomic SaveChanges below: two concurrent claims
// both pass this check, but only one INSERT of the three credential rows commits — the loser's
// SaveChanges violates the unique Key index and rolls back wholesale (no mixed-state credential).
bool alreadyConfigured = await dbContext.ConfigElements
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
if (alreadyConfigured)
{
return BaseError.New("A local administrator has already been configured");
}
string stamp = LocalAdminHelpers.NewSecurityStamp();
dbContext.ConfigElements.AddRange(
new ConfigElement { Key = ConfigElementKey.AuthLocalAdminUsername.Key, Value = username },
new ConfigElement
{
Key = ConfigElementKey.AuthLocalAdminPasswordHash.Key,
Value = passwordHasher.Hash(request.Password)
},
new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// A write conflict here is (almost always) a lost first-claim race — a concurrent claim inserted
// these keys first (unique Key index). Confirm the row now exists on a fresh context before
// reporting "already configured"; otherwise this was a genuine/transient DB error → rethrow rather
// than mask it.
await using TvContext verifyContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
bool nowConfigured = await verifyContext.ConfigElements
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
if (nowConfigured)
{
return BaseError.New("A local administrator has already been configured");
}
throw;
}
return new LocalAdminPrincipal(username, stamp);
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// The current local-admin security stamp, or <c>None</c> if no local admin is configured. The cookie
/// <c>OnValidatePrincipal</c> compares this to the principal's stamp claim on every request; a mismatch
/// (i.e. the password was changed) rejects the session.
/// </summary>
public record GetLocalAdminSecurityStamp : IRequest<Option<string>>;
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Auth;
public class GetLocalAdminSecurityStampHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetLocalAdminSecurityStamp, Option<string>>
{
public async Task<Option<string>> Handle(GetLocalAdminSecurityStamp request, CancellationToken cancellationToken) =>
await configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, cancellationToken);
}
@@ -0,0 +1,27 @@
namespace ErsatzTV.Application.Auth;
public enum LocalPasswordVerification
{
Failed,
Success,
SuccessRehashNeeded
}
/// <summary>
/// Wraps ASP.NET Core Identity's <c>PasswordHasher</c> (PBKDF2) behind a minimal, framework-agnostic
/// surface so the Auth handlers don't depend on Identity types directly.
/// </summary>
public interface ILocalPasswordHasher
{
/// <summary>Hashes a password for storage (random per-hash salt embedded in the returned string).</summary>
string Hash(string password);
/// <summary>Verifies a password against a stored hash in constant time (delegated to Identity).</summary>
LocalPasswordVerification Verify(string hash, string password);
/// <summary>
/// A stable, valid hash of a throwaway password. Verify against this when no real credential exists
/// so an unknown-username / unconfigured login costs the same as a real one (no user enumeration).
/// </summary>
string DummyHash { get; }
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Auth;
/// <summary>True once a local administrator credential has been set (first-run setup is complete).</summary>
public record IsLocalAdminConfigured : IRequest<bool>;
@@ -0,0 +1,15 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Auth;
public class IsLocalAdminConfiguredHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<IsLocalAdminConfigured, bool>
{
public async Task<bool> Handle(IsLocalAdminConfigured request, CancellationToken cancellationToken)
{
Option<ConfigElement> hash =
await configElementRepository.GetConfigElement(ConfigElementKey.AuthLocalAdminPasswordHash, cancellationToken);
return hash.IsSome;
}
}
@@ -0,0 +1,49 @@
using System.Security.Cryptography;
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
internal static class LocalAdminHelpers
{
public const int MaxUsernameLength = 256;
// Upper bound so an absurdly long password can't burn CPU in PBKDF2 (the request body is also capped
// by Kestrel, #283; this is defense-in-depth on the field itself).
public const int MaxPasswordLength = 1024;
/// <summary>128 bits of random, lowercase hex. Rotated on every password change to revoke sessions.</summary>
public static string NewSecurityStamp() =>
Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
/// <summary>Validates a new username + password. Returns the error, or None if valid.</summary>
public static Option<BaseError> ValidateNewCredentials(string username, string password)
{
string trimmed = (username ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
return BaseError.New("Username is required");
}
if (trimmed.Length > MaxUsernameLength)
{
return BaseError.New("Username is too long");
}
return ValidatePassword(password);
}
public static Option<BaseError> ValidatePassword(string password)
{
if (string.IsNullOrEmpty(password) || password.Length < AuthConstants.MinPasswordLength)
{
return BaseError.New($"Password must be at least {AuthConstants.MinPasswordLength} characters");
}
if (password.Length > MaxPasswordLength)
{
return BaseError.New($"Password must be at most {MaxPasswordLength} characters");
}
return Option<BaseError>.None;
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// The identity of the single local administrator, as returned by a successful claim / login / password
/// change. The web host turns this into a cookie principal: <see cref="Username" /> becomes the name claim
/// and <see cref="SecurityStamp" /> is stamped as <see cref="AuthConstants.SecurityStampClaim" /> so a later
/// password change (which rotates the stamp) revokes the session.
/// </summary>
public record LocalAdminPrincipal(string Username, string SecurityStamp);
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Identity;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// <see cref="ILocalPasswordHasher" /> backed by ASP.NET Core Identity's <see cref="PasswordHasher{TUser}" />
/// (PBKDF2-HMAC-SHA512, per-hash random salt, format-versioned so a future work-factor bump is a
/// transparent rehash-on-verify). Stateless and thread-safe → registered as a singleton.
/// </summary>
public sealed class LocalPasswordHasher : ILocalPasswordHasher
{
// The generic user parameter is unused by the hasher (it takes no per-user data), so a shared sentinel
// is fine.
private static readonly object Sentinel = new();
private readonly PasswordHasher<object> _hasher = new();
private readonly Lazy<string> _dummyHash;
public LocalPasswordHasher() =>
_dummyHash = new Lazy<string>(() => _hasher.HashPassword(Sentinel, "not-a-real-password"));
public string DummyHash => _dummyHash.Value;
public string Hash(string password) => _hasher.HashPassword(Sentinel, password);
public LocalPasswordVerification Verify(string hash, string password) =>
_hasher.VerifyHashedPassword(Sentinel, hash, password) switch
{
PasswordVerificationResult.Success => LocalPasswordVerification.Success,
PasswordVerificationResult.SuccessRehashNeeded => LocalPasswordVerification.SuccessRehashNeeded,
_ => LocalPasswordVerification.Failed
};
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Rotates the local admin security stamp, revoking every outstanding local session server-side (their
/// cookies carry the old stamp and fail <c>OnValidatePrincipal</c> on their next request). Used by logout
/// so signing out actually ends the session server-side, not just client-side. A no-op when no local
/// admin is configured. OIDC sessions are unaffected (they carry no stamp).
/// </summary>
public record RotateLocalAdminSecurityStamp : IRequest<Unit>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class RotateLocalAdminSecurityStampHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<RotateLocalAdminSecurityStamp, Unit>
{
public async Task<Unit> Handle(RotateLocalAdminSecurityStamp request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
ConfigElement stampRow = await dbContext.ConfigElements
.FirstOrDefaultAsync(c => c.Key == ConfigElementKey.AuthSecurityStamp.Key, cancellationToken);
// No local admin configured → nothing to revoke.
if (stampRow is null)
{
return Unit.Default;
}
stampRow.Value = LocalAdminHelpers.NewSecurityStamp();
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Recovery/bootstrap path: (re)sets the local admin from configuration (env
/// <c>Auth:LocalAdmin:Username</c>/<c>Password</c>). Overwrites any existing credential and rotates the
/// stamp (revoking sessions), so an operator who is locked out can reset by setting the env and
/// restarting. Runs at startup only when a password is configured.
/// </summary>
public record SeedLocalAdminFromEnvironment(string Username, string Password) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,63 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class SeedLocalAdminFromEnvironmentHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalPasswordHasher passwordHasher)
: IRequestHandler<SeedLocalAdminFromEnvironment, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
SeedLocalAdminFromEnvironment request,
CancellationToken cancellationToken)
{
string username = (request.Username ?? string.Empty).Trim();
if (username.Length == 0)
{
username = "admin";
}
if (username.Length > LocalAdminHelpers.MaxUsernameLength)
{
return BaseError.New("Seed username is too long");
}
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.Password))
{
return error;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ConfigElement> rows = await dbContext.ConfigElements
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
.ToListAsync(cancellationToken);
// Overwrite (recovery/bootstrap) atomically: username + new hash + rotated stamp commit together.
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminUsername.Key, username);
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminPasswordHash.Key, passwordHasher.Hash(request.Password));
Upsert(dbContext, rows, ConfigElementKey.AuthSecurityStamp.Key, LocalAdminHelpers.NewSecurityStamp());
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
private static void Upsert(TvContext dbContext, List<ConfigElement> existing, string key, string value)
{
ConfigElement row = existing.Find(r => r.Key == key);
if (row is null)
{
dbContext.ConfigElements.Add(new ConfigElement { Key = key, Value = value });
}
else
{
row.Value = value;
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Verifies a local-login username/password. On success returns the principal (username + current
/// security stamp) to sign into a cookie. A generic error (no username enumeration) on any failure.
/// </summary>
public record VerifyLocalAdminLogin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class VerifyLocalAdminLoginHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalPasswordHasher passwordHasher)
: IRequestHandler<VerifyLocalAdminLogin, Either<BaseError, LocalAdminPrincipal>>
{
private static readonly BaseError InvalidCredentials = BaseError.New("Invalid username or password");
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
VerifyLocalAdminLogin request,
CancellationToken cancellationToken)
{
string username = (request.Username ?? string.Empty).Trim();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// Read the hash and stamp in ONE snapshot so they are consistent (issue: a login racing a password
// change must not return a stamp newer than the hash it verified). A concurrent change is then either
// wholly before this read (the old password fails to verify) or wholly after it (we return the
// pre-change stamp, so the cookie AuthController issues is revoked on its very next request by
// CookieSecurityStampValidator). No writes happen here, so there is nothing to clobber.
Dictionary<string, string> config = await dbContext.ConfigElements
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
.ToDictionaryAsync(c => c.Key, c => c.Value, cancellationToken);
config.TryGetValue(ConfigElementKey.AuthLocalAdminUsername.Key, out string storedUser);
config.TryGetValue(ConfigElementKey.AuthLocalAdminPasswordHash.Key, out string storedHash);
config.TryGetValue(ConfigElementKey.AuthSecurityStamp.Key, out string stamp);
// Always run exactly one PBKDF2 verify — against a dummy hash when unconfigured/unknown — so response
// timing does not reveal whether the account exists (no user enumeration).
string candidateHash = storedHash ?? passwordHasher.DummyHash;
LocalPasswordVerification result = passwordHasher.Verify(candidateHash, request.Password ?? string.Empty);
bool userMatches = storedUser is not null
&& string.Equals(storedUser, username, StringComparison.OrdinalIgnoreCase);
if (storedHash is null || !userMatches || result == LocalPasswordVerification.Failed)
{
return InvalidCredentials;
}
return new LocalAdminPrincipal(storedUser, stamp ?? string.Empty);
}
}
@@ -15,6 +15,7 @@
<PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
<PackageReference Include="WebMarkupMin.Core" />
+7
View File
@@ -61,4 +61,11 @@ public class ConfigElementKey
public static ConfigElementKey XmltvTimeZone => new("xmltv.time_zone");
public static ConfigElementKey XmltvDaysToBuild => new("xmltv.days_to_build");
public static ConfigElementKey XmltvBlockBehavior => new("xmltv.block_behavior");
// Browser SPA authentication (issue #295). The single local-admin credential lives in ConfigElement
// rows (no DB migration): a username, a PBKDF2 password hash, and a security stamp that is rotated on
// every password change so a stamp mismatch in OnValidatePrincipal revokes all outstanding sessions.
public static ConfigElementKey AuthLocalAdminUsername => new("auth.local_admin.username");
public static ConfigElementKey AuthLocalAdminPasswordHash => new("auth.local_admin.password_hash");
public static ConfigElementKey AuthSecurityStamp => new("auth.security_stamp");
}
@@ -0,0 +1,122 @@
using ErsatzTV.Application.Auth;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Auth;
[TestFixture]
public class ChangeLocalAdminPasswordHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _configElementRepository = null!;
private ILocalPasswordHasher _passwordHasher = null!;
private const string Username = "Operator";
private const string CurrentPassword = "supersecret";
private const string NewPassword = "evenbettersecret";
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_configElementRepository = new ConfigElementRepository(_db.Factory);
_passwordHasher = new LocalPasswordHasher();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ChangeLocalAdminPasswordHandler MakeHandler() =>
new(_db.Factory, _passwordHasher);
private async Task SeedAdmin()
{
var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher);
(await claim.Handle(new ClaimLocalAdmin(Username, CurrentPassword), CancellationToken.None))
.IsRight.ShouldBeTrue();
}
private async Task<string> StoredHash() =>
(await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthLocalAdminPasswordHash,
CancellationToken.None)).IfNone("");
private async Task<string> StoredStamp() =>
(await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthSecurityStamp,
CancellationToken.None)).IfNone("");
[Test]
public async Task Handle_Should_Change_Password_And_Rotate_Stamp_On_Valid_Current_Password()
{
await SeedAdmin();
string originalStamp = await StoredStamp();
ChangeLocalAdminPasswordHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ChangeLocalAdminPassword(Username, CurrentPassword, NewPassword),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
string storedHash = await StoredHash();
_passwordHasher.Verify(storedHash, NewPassword).ShouldNotBe(LocalPasswordVerification.Failed);
_passwordHasher.Verify(storedHash, CurrentPassword).ShouldBe(LocalPasswordVerification.Failed);
string newStamp = await StoredStamp();
newStamp.ShouldNotBe(originalStamp);
LocalAdminPrincipal principal = result.Match(
Left: e => throw new ShouldAssertException(e.ToString()),
Right: p => p);
principal.SecurityStamp.ShouldBe(newStamp);
}
[Test]
public async Task Handle_Should_Fail_On_Wrong_Current_Password_And_Change_Nothing()
{
await SeedAdmin();
string originalHash = await StoredHash();
string originalStamp = await StoredStamp();
ChangeLocalAdminPasswordHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ChangeLocalAdminPassword(Username, "notthecurrentpassword", NewPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
(await StoredHash()).ShouldBe(originalHash);
(await StoredStamp()).ShouldBe(originalStamp);
}
[Test]
public async Task Handle_Should_Reject_Short_New_Password()
{
await SeedAdmin();
string shortPassword = new('a', AuthConstants.MinPasswordLength - 1);
ChangeLocalAdminPasswordHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ChangeLocalAdminPassword(Username, CurrentPassword, shortPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Fail_On_Unconfigured_Db()
{
ChangeLocalAdminPasswordHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ChangeLocalAdminPassword(Username, CurrentPassword, NewPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
}
}
@@ -0,0 +1,142 @@
using ErsatzTV.Application.Auth;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Auth;
[TestFixture]
public class ClaimLocalAdminHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _configElementRepository = null!;
private ILocalPasswordHasher _passwordHasher = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_configElementRepository = new ConfigElementRepository(_db.Factory);
_passwordHasher = new LocalPasswordHasher();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ClaimLocalAdminHandler MakeHandler() =>
new(_db.Factory, _passwordHasher);
[Test]
public async Task Handle_Should_Claim_Fresh_Admin_And_Persist_All_Config_Elements()
{
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Operator", "supersecret"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
LocalAdminPrincipal principal = result.Match(
Left: e => throw new ShouldAssertException(e.ToString()),
Right: p => p);
principal.Username.ShouldBe("Operator");
principal.SecurityStamp.ShouldNotBeNullOrEmpty();
Option<string> storedUser = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthLocalAdminUsername,
CancellationToken.None);
storedUser.IfNone("").ShouldBe("Operator");
Option<string> storedStamp = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthSecurityStamp,
CancellationToken.None);
storedStamp.IfNone("").ShouldBe(principal.SecurityStamp);
Option<string> storedHash = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthLocalAdminPasswordHash,
CancellationToken.None);
storedHash.IsSome.ShouldBeTrue();
_passwordHasher.Verify(storedHash.IfNone(""), "supersecret")
.ShouldNotBe(LocalPasswordVerification.Failed);
}
[Test]
public async Task Handle_Should_Refuse_Second_Claim_When_Admin_Already_Configured()
{
ClaimLocalAdminHandler handler = MakeHandler();
(await handler.Handle(new ClaimLocalAdmin("First", "supersecret"), CancellationToken.None))
.IsRight.ShouldBeTrue();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Second", "anothersecret"),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
// The original credential is untouched.
Option<string> storedUser = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthLocalAdminUsername,
CancellationToken.None);
storedUser.IfNone("").ShouldBe("First");
}
[Test]
public async Task Handle_Should_Reject_Whitespace_Username_And_Persist_Nothing()
{
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin(" ", "supersecret"),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await AssertNothingPersisted();
}
[Test]
public async Task Handle_Should_Reject_Short_Password_And_Persist_Nothing()
{
string shortPassword = new('a', AuthConstants.MinPasswordLength - 1);
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Operator", shortPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await AssertNothingPersisted();
}
[Test]
public async Task Handle_Should_Reject_Over_Long_Password_And_Persist_Nothing()
{
// LocalAdminHelpers.MaxPasswordLength (1024) is internal; use the documented bound directly.
string longPassword = new('a', 1024 + 1);
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Operator", longPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await AssertNothingPersisted();
}
private async Task AssertNothingPersisted()
{
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthLocalAdminUsername,
CancellationToken.None)).IsNone.ShouldBeTrue();
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthLocalAdminPasswordHash,
CancellationToken.None)).IsNone.ShouldBeTrue();
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthSecurityStamp,
CancellationToken.None)).IsNone.ShouldBeTrue();
}
}
@@ -0,0 +1,65 @@
using ErsatzTV.Application.Auth;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Auth;
[TestFixture]
public class RotateLocalAdminSecurityStampHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _configElementRepository = null!;
private ILocalPasswordHasher _passwordHasher = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_configElementRepository = new ConfigElementRepository(_db.Factory);
_passwordHasher = new LocalPasswordHasher();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private RotateLocalAdminSecurityStampHandler MakeHandler() => new(_db.Factory);
[Test]
public async Task Handle_Should_Rotate_The_Stamp_When_Configured()
{
// Arrange: claim an admin so a stamp exists.
Either<BaseError, LocalAdminPrincipal> claim = await new ClaimLocalAdminHandler(_db.Factory, _passwordHasher)
.Handle(new ClaimLocalAdmin("admin", "supersecret"), CancellationToken.None);
claim.IsRight.ShouldBeTrue();
Option<string> before =
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
string originalStamp = before.Match(s => s, () => throw new ShouldAssertException("expected a stamp"));
// Act
await MakeHandler().Handle(new RotateLocalAdminSecurityStamp(), CancellationToken.None);
// Assert: the stamp changed (all outstanding sessions carrying the old stamp are now stale).
Option<string> after =
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
string rotatedStamp = after.Match(s => s, () => throw new ShouldAssertException("expected a stamp"));
rotatedStamp.ShouldNotBe(originalStamp);
rotatedStamp.ShouldNotBeNullOrEmpty();
}
[Test]
public async Task Handle_Should_Be_A_No_Op_When_Unconfigured()
{
await MakeHandler().Handle(new RotateLocalAdminSecurityStamp(), CancellationToken.None);
Option<string> stamp =
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
stamp.IsNone.ShouldBeTrue();
}
}
@@ -0,0 +1,120 @@
using ErsatzTV.Application.Auth;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Auth;
[TestFixture]
public class SeedLocalAdminFromEnvironmentHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _configElementRepository = null!;
private ILocalPasswordHasher _passwordHasher = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_configElementRepository = new ConfigElementRepository(_db.Factory);
_passwordHasher = new LocalPasswordHasher();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private SeedLocalAdminFromEnvironmentHandler MakeHandler() =>
new(_db.Factory, _passwordHasher);
private async Task<string> StoredValue(ConfigElementKey key) =>
(await _configElementRepository.GetValue<string>(key, CancellationToken.None)).IfNone("");
[Test]
public async Task Handle_Should_Seed_Username_Hash_And_Stamp_On_Fresh_Db()
{
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
Either<BaseError, Unit> result = await handler.Handle(
new SeedLocalAdminFromEnvironment("Operator", "supersecret"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("Operator");
(await StoredValue(ConfigElementKey.AuthSecurityStamp)).ShouldNotBeNullOrEmpty();
_passwordHasher.Verify(await StoredValue(ConfigElementKey.AuthLocalAdminPasswordHash), "supersecret")
.ShouldNotBe(LocalPasswordVerification.Failed);
}
[Test]
public async Task Handle_Should_Overwrite_Existing_Credential_And_Rotate_Stamp()
{
var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher);
(await claim.Handle(new ClaimLocalAdmin("Original", "originalsecret"), CancellationToken.None))
.IsRight.ShouldBeTrue();
string originalStamp = await StoredValue(ConfigElementKey.AuthSecurityStamp);
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
Either<BaseError, Unit> result = await handler.Handle(
new SeedLocalAdminFromEnvironment("Replacement", "replacementsecret"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("Replacement");
(await StoredValue(ConfigElementKey.AuthSecurityStamp)).ShouldNotBe(originalStamp);
string storedHash = await StoredValue(ConfigElementKey.AuthLocalAdminPasswordHash);
_passwordHasher.Verify(storedHash, "replacementsecret").ShouldNotBe(LocalPasswordVerification.Failed);
_passwordHasher.Verify(storedHash, "originalsecret").ShouldBe(LocalPasswordVerification.Failed);
}
[Test]
public async Task Handle_Should_Default_Empty_Username_To_Admin()
{
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
Either<BaseError, Unit> result = await handler.Handle(
new SeedLocalAdminFromEnvironment(" ", "supersecret"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("admin");
}
[Test]
public async Task Handle_Should_Reject_Over_Long_Username()
{
// LocalAdminHelpers.MaxUsernameLength (256) is internal; use the documented bound directly.
string longUsername = new('u', 256 + 1);
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
Either<BaseError, Unit> result = await handler.Handle(
new SeedLocalAdminFromEnvironment(longUsername, "supersecret"),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthLocalAdminUsername,
CancellationToken.None)).IsNone.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Reject_Short_Password()
{
string shortPassword = new('a', AuthConstants.MinPasswordLength - 1);
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
Either<BaseError, Unit> result = await handler.Handle(
new SeedLocalAdminFromEnvironment("Operator", shortPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthLocalAdminPasswordHash,
CancellationToken.None)).IsNone.ShouldBeTrue();
}
}
@@ -0,0 +1,122 @@
using ErsatzTV.Application.Auth;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Auth;
[TestFixture]
public class VerifyLocalAdminLoginHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _configElementRepository = null!;
private ILocalPasswordHasher _passwordHasher = null!;
private const string Username = "Operator";
private const string Password = "supersecret";
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_configElementRepository = new ConfigElementRepository(_db.Factory);
_passwordHasher = new LocalPasswordHasher();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private VerifyLocalAdminLoginHandler MakeHandler() =>
new(_db.Factory, _passwordHasher);
private async Task SeedAdmin()
{
var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher);
(await claim.Handle(new ClaimLocalAdmin(Username, Password), CancellationToken.None))
.IsRight.ShouldBeTrue();
}
private async Task<string> StoredStamp() =>
(await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthSecurityStamp,
CancellationToken.None)).IfNone("");
[Test]
public async Task Handle_Should_Return_Principal_With_Stored_Stamp_On_Valid_Credentials()
{
await SeedAdmin();
VerifyLocalAdminLoginHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new VerifyLocalAdminLogin(Username, Password),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
LocalAdminPrincipal principal = result.Match(
Left: e => throw new ShouldAssertException(e.ToString()),
Right: p => p);
principal.Username.ShouldBe(Username);
principal.SecurityStamp.ShouldBe(await StoredStamp());
}
[Test]
public async Task Handle_Should_Fail_On_Wrong_Password()
{
await SeedAdmin();
VerifyLocalAdminLoginHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new VerifyLocalAdminLogin(Username, "wrongpassword"),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Fail_On_Wrong_Username()
{
await SeedAdmin();
VerifyLocalAdminLoginHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new VerifyLocalAdminLogin("SomebodyElse", Password),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Fail_On_Unconfigured_Db()
{
VerifyLocalAdminLoginHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new VerifyLocalAdminLogin(Username, Password),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Match_Username_Case_Insensitively()
{
await SeedAdmin();
VerifyLocalAdminLoginHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new VerifyLocalAdminLogin(Username.ToUpperInvariant(), Password),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
LocalAdminPrincipal principal = result.Match(
Left: e => throw new ShouldAssertException(e.ToString()),
Right: p => p);
// The stored (canonical) username is returned, not the differently-cased input.
principal.Username.ShouldBe(Username);
}
}
@@ -18,7 +18,7 @@ namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ApiControllerSecurityTests
{
private static readonly bool ApiKeyAuthorizationFilterIsGlobal = IsApiKeyAuthorizationFilterRegisteredGlobally();
private static readonly bool ApiAuthorizationFilterIsGlobal = IsApiAuthorizationFilterRegisteredGlobally();
[Test]
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
@@ -47,7 +47,7 @@ public class ApiControllerSecurityTests
foreach (Type controllerType in apiControllers)
{
bool controllerSkipsApiKey = controllerType
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
.GetCustomAttributes<SkipApiAuthorizationAttribute>(inherit: true)
.Any();
foreach (MethodInfo action in controllerType
@@ -64,7 +64,7 @@ public class ApiControllerSecurityTests
}
bool actionSkipsApiKey = action
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
.GetCustomAttributes<SkipApiAuthorizationAttribute>(inherit: true)
.Any();
(controllerSkipsApiKey || actionSkipsApiKey || IsGloballyProtected())
@@ -74,23 +74,27 @@ public class ApiControllerSecurityTests
}
[Test]
public void ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller()
public void Only_Scanner_And_Auth_Controllers_Should_Be_Auth_Exempt()
{
// ScannerController: internal loopback callback, gated by [LocalhostOnly] instead of a credential.
// AuthController: the /api/auth/* surface itself must be reachable before a caller is authenticated
// (config/session/login/setup) — its sensitive action (password change) self-checks the principal.
// Any OTHER [SkipApiAuthorization] controller is a fail-open hole and must be caught here.
Type[] exemptControllers = typeof(ScannerController)
.Assembly
.GetTypes()
.Where(t => t.Namespace == typeof(ScannerController).Namespace)
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
.Where(t => t.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true).Any())
.Where(t => t.GetCustomAttributes<SkipApiAuthorizationAttribute>(inherit: true).Any())
.ToArray();
exemptControllers.ShouldBe([typeof(ScannerController)]);
exemptControllers.ShouldBe([typeof(ScannerController), typeof(AuthController)], ignoreOrder: true);
}
[Test]
public void Startup_Should_Register_ApiKeyAuthorizationFilter_Globally()
public void Startup_Should_Register_ApiAuthorizationFilter_Globally()
{
ApiKeyAuthorizationFilterIsGlobal.ShouldBeTrue();
ApiAuthorizationFilterIsGlobal.ShouldBeTrue();
}
[Test]
@@ -109,8 +113,8 @@ public class ApiControllerSecurityTests
foreach (Type controllerType in sensitiveControllers)
{
controllerType.GetCustomAttributes<RequiresApiKeyAttribute>(inherit: true).Any()
.ShouldBeTrue($"{controllerType.Name} must carry [RequiresApiKey]");
controllerType.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any()
.ShouldBeTrue($"{controllerType.Name} must carry [RequiresAuthentication]");
}
}
@@ -125,9 +129,9 @@ public class ApiControllerSecurityTests
.ShouldBeTrue();
}
private static bool IsGloballyProtected() => ApiKeyAuthorizationFilterIsGlobal;
private static bool IsGloballyProtected() => ApiAuthorizationFilterIsGlobal;
private static bool IsApiKeyAuthorizationFilterRegisteredGlobally()
private static bool IsApiAuthorizationFilterRegisteredGlobally()
{
var settings = new Dictionary<string, string?>
{
@@ -155,6 +159,6 @@ public class ApiControllerSecurityTests
return options.Filters
.OfType<ServiceFilterAttribute>()
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
.Any(a => a.ServiceType == typeof(ApiAuthorizationFilter));
}
}
@@ -0,0 +1,71 @@
using System.Collections.Generic;
using ErsatzTV.Application.Auth;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class AuthControllerTests
{
private static IConfiguration Config(bool envSeed) =>
new ConfigurationBuilder()
.AddInMemoryCollection(
envSeed
? new Dictionary<string, string?> { ["Auth:LocalAdmin:Password"] = "seed-password" }
: new Dictionary<string, string?>())
.Build();
[Test]
public async Task Config_Reports_Setup_Not_Required_When_Env_Seed_Configured()
{
var mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
var controller = new AuthController(mediator, Config(envSeed: true));
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
// Env seed owns the credential → the SPA must not offer the browser setup-claim.
body.SetupRequired.ShouldBeFalse();
}
[Test]
public async Task Config_Reports_Setup_Required_When_Unconfigured_And_No_Env_Seed()
{
var mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
var controller = new AuthController(mediator, Config(envSeed: false));
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
body.SetupRequired.ShouldBeTrue();
}
[Test]
public async Task Setup_Is_Closed_With_409_When_Env_Seed_Configured()
{
var mediator = Substitute.For<IMediator>();
var controller = new AuthController(mediator, Config(envSeed: true))
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Setup(new SetupRequest("admin", "hunter2pw"), CancellationToken.None);
var problem = result.ShouldBeOfType<ConflictObjectResult>();
problem.StatusCode.ShouldBe(StatusCodes.Status409Conflict);
// The claim must never be attempted while the env seed owns the credential.
await mediator.DidNotReceive().Send(Arg.Any<ClaimLocalAdmin>(), Arg.Any<CancellationToken>());
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Security.Claims;
using ErsatzTV.Filters;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Http;
@@ -12,7 +13,7 @@ using Shouldly;
namespace ErsatzTV.Tests.Filters;
[TestFixture]
public class ApiKeyAuthorizationFilterTests
public class ApiAuthorizationFilterTests
{
private const string Key = "secret";
@@ -27,25 +28,38 @@ public class ApiKeyAuthorizationFilterTests
string? apiKeyHeader,
string path = "/api/channels",
bool skipApiKeyAuthorization = false,
bool requiresApiKey = false)
bool requiresApiKey = false,
bool authenticatedSession = false,
bool csrfHeader = false)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
httpContext.Request.Path = path;
if (apiKeyHeader is not null)
{
httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader;
httpContext.Request.Headers[ApiAuthorizationFilter.HeaderName] = apiKeyHeader;
}
if (csrfHeader)
{
httpContext.Request.Headers[ApiAuthorizationFilter.CsrfHeaderName] = "1";
}
if (authenticatedSession)
{
// A ClaimsIdentity with an authentication type reports IsAuthenticated == true.
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(authenticationType: "cookie"));
}
var metadata = new List<object>();
if (skipApiKeyAuthorization)
{
metadata.Add(new SkipApiKeyAuthorizationAttribute());
metadata.Add(new SkipApiAuthorizationAttribute());
}
if (requiresApiKey)
{
metadata.Add(new RequiresApiKeyAttribute());
metadata.Add(new RequiresAuthenticationAttribute());
}
var actionDescriptor = new ActionDescriptor { EndpointMetadata = metadata };
@@ -53,7 +67,7 @@ public class ApiKeyAuthorizationFilterTests
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
}
private static ApiKeyAuthorizationFilter MakeFilter(bool requireKeyForReads = true) =>
private static ApiAuthorizationFilter MakeFilter(bool requireKeyForReads = true) =>
new(new FakeApiKeyProvider(Key, requireKeyForReads));
private static void ShouldBeUnauthorized(AuthorizationFilterContext context)
@@ -124,10 +138,10 @@ public class ApiKeyAuthorizationFilterTests
context.Result.ShouldBeNull();
}
// ---- the sensitive-read tier stays gated even with reads open ([RequiresApiKey]) ----
// ---- the sensitive-read tier stays gated even with reads open ([RequiresAuthentication]) ----
[Test]
public void Should_Reject_Read_On_RequiresApiKey_Endpoint_Even_When_Reads_Not_Required()
public void Should_Reject_Read_On_RequiresAuthentication_Endpoint_Even_When_Reads_Not_Required()
{
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null, requiresApiKey: true);
MakeFilter(requireKeyForReads: false).OnAuthorization(context);
@@ -135,7 +149,7 @@ public class ApiKeyAuthorizationFilterTests
}
[Test]
public void Should_Allow_Read_On_RequiresApiKey_Endpoint_When_Key_Correct()
public void Should_Allow_Read_On_RequiresAuthentication_Endpoint_When_Key_Correct()
{
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: Key, requiresApiKey: true);
MakeFilter(requireKeyForReads: false).OnAuthorization(context);
@@ -172,4 +186,54 @@ public class ApiKeyAuthorizationFilterTests
MakeFilter(requireKeyForReads: true).OnAuthorization(context);
context.Result.ShouldBeNull();
}
// ---- session (cookie) authentication accepted as an alternative credential (issue #295) ----
[Test]
public void Should_Allow_Read_When_Session_Authenticated_Without_Key()
{
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null, authenticatedSession: true);
MakeFilter(requireKeyForReads: true).OnAuthorization(context);
context.Result.ShouldBeNull();
}
[Test]
public void Should_Allow_Session_Mutation_When_Csrf_Header_Present()
{
AuthorizationFilterContext context =
MakeContext("POST", apiKeyHeader: null, authenticatedSession: true, csrfHeader: true);
MakeFilter().OnAuthorization(context);
context.Result.ShouldBeNull();
}
[Test]
public void Should_Reject_Session_Mutation_When_Csrf_Header_Missing()
{
AuthorizationFilterContext context =
MakeContext("POST", apiKeyHeader: null, authenticatedSession: true, csrfHeader: false);
MakeFilter().OnAuthorization(context);
var result = context.Result.ShouldBeOfType<ObjectResult>();
result.StatusCode.ShouldBe(StatusCodes.Status403Forbidden);
var problemDetails = result.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(StatusCodes.Status403Forbidden);
}
[Test]
public void Should_Reject_When_Neither_Key_Nor_Session_Present()
{
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null, authenticatedSession: false);
MakeFilter(requireKeyForReads: true).OnAuthorization(context);
ShouldBeUnauthorized(context);
}
[Test]
public void Should_Prefer_Machine_Key_Over_Session_And_Skip_Csrf()
{
// A valid X-Api-Key is CSRF-immune: a session cookie riding along must not force a CSRF check.
AuthorizationFilterContext context =
MakeContext("POST", apiKeyHeader: Key, authenticatedSession: true, csrfHeader: false);
MakeFilter().OnAuthorization(context);
context.Result.ShouldBeNull();
}
}
@@ -12,7 +12,7 @@ using Shouldly;
namespace ErsatzTV.Tests.Filters;
/// <summary>
/// Proves the shared <see cref="ApiKeyAuthorizationFilter.EndpointRequiresKey" /> predicate — the one
/// Proves the shared <see cref="ApiAuthorizationFilter.EndpointRequiresKey" /> predicate — the one
/// the OpenAPI security/401 transformer consumes — agrees with what the filter actually enforces at
/// runtime, across a representative matrix. If the two ever diverged, the spec could claim an endpoint
/// is open while the filter gates it (or vice-versa); this test is the anti-drift guard (#287).
@@ -37,10 +37,10 @@ public class ApiKeyEndpointRequiresKeyTests
new object[] { "DELETE", false, false, false, true },
new object[] { "GET", false, false, true, true }, // read gated when reads-required
new object[] { "GET", false, false, false, false }, // read open when reads-not-required
new object[] { "GET", true, false, false, true }, // [RequiresApiKey] gates read even so
new object[] { "GET", true, false, false, true }, // [RequiresAuthentication] gates read even so
new object[] { "HEAD", false, false, false, false }, // read verb, open
new object[] { "OPTIONS", false, false, true, false },// preflight always exempt
new object[] { "POST", false, true, false, false } // [SkipApiKeyAuthorization] exempt
new object[] { "POST", false, true, false, false } // [SkipApiAuthorization] exempt
];
[TestCaseSource(nameof(Matrix))]
@@ -54,15 +54,15 @@ public class ApiKeyEndpointRequiresKeyTests
var metadata = new List<object>();
if (requiresApiKey)
{
metadata.Add(new RequiresApiKeyAttribute());
metadata.Add(new RequiresAuthenticationAttribute());
}
if (skip)
{
metadata.Add(new SkipApiKeyAuthorizationAttribute());
metadata.Add(new SkipApiAuthorizationAttribute());
}
ApiKeyAuthorizationFilter.EndpointRequiresKey(method, metadata, requireKeyForReads)
ApiAuthorizationFilter.EndpointRequiresKey(method, metadata, requireKeyForReads)
.ShouldBe(expected);
}
@@ -77,12 +77,12 @@ public class ApiKeyEndpointRequiresKeyTests
// The filter, on an /api path with the header MISSING, produces a 401 exactly when the predicate
// says the endpoint requires a key. Drive the real filter and compare its decision to the predicate.
AuthorizationFilterContext context = MakeContext(method, requiresApiKey, skip);
new ApiKeyAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads)).OnAuthorization(context);
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads)).OnAuthorization(context);
bool filterGated = context.Result is not null;
filterGated.ShouldBe(expected);
filterGated.ShouldBe(
ApiKeyAuthorizationFilter.EndpointRequiresKey(method, Metadata(requiresApiKey, skip), requireKeyForReads));
ApiAuthorizationFilter.EndpointRequiresKey(method, Metadata(requiresApiKey, skip), requireKeyForReads));
}
[Test]
@@ -90,11 +90,11 @@ public class ApiKeyEndpointRequiresKeyTests
{
// The predicate assumes an /api endpoint; the filter's path scoping precedes it. A mutating request
// outside /api must pass untouched even though the predicate (given the same method) returns true.
ApiKeyAuthorizationFilter.EndpointRequiresKey("POST", new List<object>(), requireKeyForReads: true)
ApiAuthorizationFilter.EndpointRequiresKey("POST", new List<object>(), requireKeyForReads: true)
.ShouldBeTrue();
AuthorizationFilterContext context = MakeContext("POST", requiresApiKey: false, skip: false, path: "/iptv/x.m3u");
new ApiKeyAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: true)).OnAuthorization(context);
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: true)).OnAuthorization(context);
context.Result.ShouldBeNull();
}
@@ -104,12 +104,12 @@ public class ApiKeyEndpointRequiresKeyTests
var metadata = new List<object>();
if (requiresApiKey)
{
metadata.Add(new RequiresApiKeyAttribute());
metadata.Add(new RequiresAuthenticationAttribute());
}
if (skip)
{
metadata.Add(new SkipApiKeyAuthorizationAttribute());
metadata.Add(new SkipApiAuthorizationAttribute());
}
return metadata;
@@ -0,0 +1,38 @@
using System.Security.Claims;
using ErsatzTV.Application.Auth;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace ErsatzTV.Auth;
/// <summary>
/// Cookie <c>OnValidatePrincipal</c> handler enforcing local-admin security-stamp revocation (#295):
/// a local-login session carries the admin's security stamp as a claim; a password change rotates the
/// stored stamp, so a mismatch here rejects the (now stale) session on its next request. OIDC sessions
/// carry no stamp claim and are governed by the IdP, so they are skipped.
/// </summary>
public static class CookieSecurityStampValidator
{
public static async Task ValidateAsync(CookieValidatePrincipalContext context)
{
ClaimsPrincipal principal = context.Principal;
string method = principal?.FindFirst(AuthConstants.AuthMethodClaim)?.Value;
if (!string.Equals(method, AuthConstants.MethodLocal, StringComparison.Ordinal))
{
return;
}
string presented = principal.FindFirst(AuthConstants.SecurityStampClaim)?.Value;
IMediator mediator = context.HttpContext.RequestServices.GetRequiredService<IMediator>();
Option<string> stored = await mediator.Send(new GetLocalAdminSecurityStamp());
string current = stored.IfNone(string.Empty);
if (string.IsNullOrEmpty(presented) || !string.Equals(presented, current, StringComparison.Ordinal))
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(AuthConstants.CookieScheme);
}
}
}
+234
View File
@@ -0,0 +1,234 @@
using System.Security.Claims;
using ErsatzTV.Application.Auth;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace ErsatzTV.Controllers.Api;
/// <summary>
/// Browser SPA session authentication (issue #295). Excluded from the OpenAPI document — the spec's
/// audience is machine <c>X-Api-Key</c> clients, and a browser-interactive cookie login is not something a
/// generated client drives — and exempt from the global <see cref="ApiAuthorizationFilter" /> (this surface
/// must be reachable before a caller has a session). Sensitive operations self-check the principal.
/// </summary>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[SkipApiAuthorization]
public class AuthController(IMediator mediator, IConfiguration configuration) : ControllerBase
{
// When the operator has set an env seed, the local admin is managed via configuration — the browser
// setup-claim is closed. This also eliminates the startup race where an attacker could claim admin in the
// window before LocalAdminSeedService runs (the seed would then lose and its insert be swallowed).
private bool EnvSeedConfigured => !string.IsNullOrWhiteSpace(configuration["Auth:LocalAdmin:Password"]);
/// <summary>Public: what auth options exist + whether first-run setup is still needed (drives the SPA boot gate).</summary>
[HttpGet("/api/auth/config")]
public async Task<IActionResult> Config(CancellationToken cancellationToken)
{
bool configured = await mediator.Send(new IsLocalAdminConfigured(), cancellationToken);
return Ok(new AuthConfigResponse(OidcHelper.IsEnabled, true, !configured && !EnvSeedConfigured));
}
/// <summary>The current session (anonymous is a 200 with authenticated=false, never a 401).</summary>
[HttpGet("/api/auth/session")]
public IActionResult Session()
{
if (User.Identity?.IsAuthenticated != true)
{
return Ok(new AuthSessionResponse(false, null, null));
}
return Ok(new AuthSessionResponse(true, User.Identity?.Name, User.FindFirst(AuthConstants.AuthMethodClaim)?.Value));
}
/// <summary>First-run setup-claim: create the local admin. Fails 409 if one already exists.</summary>
[HttpPost("/api/auth/setup")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> Setup([FromBody] SetupRequest request, CancellationToken cancellationToken)
{
if (EnvSeedConfigured)
{
return ApiResults.ConflictProblem(
"Managed via configuration",
"The local administrator is provisioned from Auth:LocalAdmin:* configuration; browser setup is disabled.");
}
if (await mediator.Send(new IsLocalAdminConfigured(), cancellationToken))
{
return ApiResults.ConflictProblem("Already configured", "A local administrator already exists.");
}
Either<BaseError, LocalAdminPrincipal> result =
await mediator.Send(new ClaimLocalAdmin(request.Username, request.Password), cancellationToken);
return await result.Match(
Left: async error =>
{
// A concurrent claim that lost the race reports 409 (the record now exists), not the 422 a
// validation error gets.
if (await mediator.Send(new IsLocalAdminConfigured(), cancellationToken))
{
return ApiResults.ConflictProblem("Already configured", "A local administrator already exists.");
}
return error.ToErrorResult();
},
Right: async principal =>
{
await IssueLocalCookieAsync(principal);
return (IActionResult)Ok(new AuthSessionResponse(true, principal.Username, AuthConstants.MethodLocal));
});
}
/// <summary>Local username/password login. A generic 401 on any failure (no username enumeration).</summary>
[HttpPost("/api/auth/login")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
Either<BaseError, LocalAdminPrincipal> result =
await mediator.Send(new VerifyLocalAdminLogin(request.Username, request.Password), cancellationToken);
return await result.Match(
Left: _ => Task.FromResult((IActionResult)Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "Invalid username or password."
})),
Right: async principal =>
{
await IssueLocalCookieAsync(principal);
return (IActionResult)Ok(new AuthSessionResponse(true, principal.Username, AuthConstants.MethodLocal));
});
}
/// <summary>
/// Sign out of the cookie session. Requires the CSRF header (this controller is filter-exempt). For a
/// local session this rotates the security stamp, ending the session <b>server-side</b> (a captured
/// cookie can't be replayed after logout) — which, for the single local admin, revokes all local
/// sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated caller
/// can't force-revoke the admin.
/// </summary>
[HttpPost("/api/auth/logout")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
// Prevent forced-logout CSRF: a same-site form POST carries the Lax cookie but can't set a custom
// header. The whole controller is [SkipApiAuthorization], so the filter's CSRF check doesn't apply.
if (RequiresCsrf(out IActionResult csrfError))
{
return csrfError;
}
if (User.Identity?.IsAuthenticated == true &&
string.Equals(
User.FindFirst(AuthConstants.AuthMethodClaim)?.Value,
AuthConstants.MethodLocal,
StringComparison.Ordinal))
{
await mediator.Send(new RotateLocalAdminSecurityStamp(), cancellationToken);
}
await HttpContext.SignOutAsync(AuthConstants.CookieScheme);
return NoContent();
}
/// <summary>Change the local admin password (requires a local-login session); rotates the stamp, revoking other sessions.</summary>
[HttpPost("/api/auth/password")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
{
// Filter-exempt controller, so apply the session-mutation CSRF check here explicitly.
if (RequiresCsrf(out IActionResult csrfError))
{
return csrfError;
}
// A machine key must not be able to rotate the local admin's password — require a local session.
if (User.Identity?.IsAuthenticated != true ||
!string.Equals(
User.FindFirst(AuthConstants.AuthMethodClaim)?.Value,
AuthConstants.MethodLocal,
StringComparison.Ordinal))
{
return Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "A local-login session is required to change the password."
});
}
Either<BaseError, LocalAdminPrincipal> result = await mediator.Send(
new ChangeLocalAdminPassword(User.Identity?.Name, request.CurrentPassword, request.NewPassword),
cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async principal =>
{
// Re-issue the cookie with the rotated stamp so THIS session survives while others are revoked.
await IssueLocalCookieAsync(principal);
return (IActionResult)NoContent();
});
}
/// <summary>Browser-navigation OIDC challenge. Outside <c>/api</c> (a top-level GET redirect). 404 when OIDC is off.</summary>
[HttpGet("/auth/oidc/login")]
public IActionResult OidcLogin()
{
if (!OidcHelper.IsEnabled)
{
return NotFound();
}
return Challenge(
new AuthenticationProperties { RedirectUri = $"{Request.PathBase}/app" },
AuthConstants.OidcScheme);
}
// Session-mutation CSRF gate for the [SkipApiAuthorization] auth surface (the global filter doesn't see
// it). Presence-only: a custom header can't be set by a cross-site form/navigation. Returns true (with a
// 403 result) when the header is missing.
private bool RequiresCsrf(out IActionResult error)
{
if (Request.Headers.ContainsKey(ApiAuthorizationFilter.CsrfHeaderName))
{
error = null;
return false;
}
error = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Forbidden",
Detail = $"This request requires the '{ApiAuthorizationFilter.CsrfHeaderName}' header."
})
{
StatusCode = StatusCodes.Status403Forbidden
};
return true;
}
private Task IssueLocalCookieAsync(LocalAdminPrincipal principal)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, principal.Username),
new(AuthConstants.AuthMethodClaim, AuthConstants.MethodLocal),
new(AuthConstants.SecurityStampClaim, principal.SecurityStamp)
};
var identity = new ClaimsIdentity(claims, AuthConstants.CookieScheme);
return HttpContext.SignInAsync(AuthConstants.CookieScheme, new ClaimsPrincipal(identity));
}
}
public record AuthConfigResponse(bool OidcEnabled, bool LocalLoginEnabled, bool SetupRequired);
public record AuthSessionResponse(bool Authenticated, string Username, string Method);
+1 -1
View File
@@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[RequiresApiKey]
[RequiresAuthentication]
public class LogsController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
@@ -11,7 +11,7 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
[RequiresApiKey]
[RequiresAuthentication]
public class MaintenanceController(IMediator mediator, ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
[HttpPost("/api/maintenance/gc")]
@@ -0,0 +1,7 @@
namespace ErsatzTV.Controllers.Api.Requests;
public record LoginRequest(string Username, string Password);
public record SetupRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
@@ -9,7 +9,7 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[SkipApiKeyAuthorization]
[SkipApiAuthorization]
[LocalhostOnly]
[Route("api/scan/{scanId:guid}")]
public class ScannerController(
@@ -19,7 +19,7 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
[RequiresApiKey]
[RequiresAuthentication]
public class SettingsController(IMediator mediator) : ControllerBase
{
// FFmpeg settings
@@ -27,7 +27,7 @@ using Serilog.Context;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[RequiresApiKey]
[RequiresAuthentication]
public class TroubleshootController(
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
IFileSystem fileSystem,
@@ -92,7 +92,8 @@ public class TroubleshootController(
[ProducesResponseType(typeof(ValidateSequentialScheduleResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ValidateSchedule(
[Required] [FromBody] ValidateSequentialScheduleRequest request,
[Required] [FromBody]
ValidateSequentialScheduleRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Yaml))
+144
View File
@@ -0,0 +1,144 @@
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Filters;
/// <summary>
/// Authorization for the JSON API. A request under <c>/api/*</c> that requires authentication is
/// accepted on either of two credentials (issue #295):
/// <list type="number">
/// <item>a matching machine <c>X-Api-Key</c> header (MCP / external clients), or</item>
/// <item>an authenticated session principal (browser cookie, from OIDC or local login).</item>
/// </list>
/// Which endpoints require authentication is decided by <see cref="EndpointRequiresKey" /> — the
/// single predicate shared with OpenAPI generation — so the spec can never drift from enforcement:
/// mutating verbs are always fail-closed; reads are gated when <c>Api:RequireKeyForReads</c> is set
/// (the default) or the endpoint carries <see cref="RequiresAuthenticationAttribute" />. Endpoints
/// marked <see cref="SkipApiAuthorizationAttribute" /> and anything outside <c>/api</c> (e.g.
/// <c>/iptv/*</c>, <c>/artwork/*</c>, the SPA) are never affected.
/// <para>
/// The machine key is CSRF-immune (a browser cannot set a custom header cross-origin without a
/// credentialed CORS grant, which is never issued). A cookie session is not: session-authenticated
/// <b>mutations</b> must additionally carry the <see cref="CsrfHeaderName" /> header, which — being a
/// custom header — forces a CORS preflight that a cross-site attacker page cannot satisfy.
/// </para>
/// </summary>
public class ApiAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthorizationFilter
{
public const string HeaderName = "X-Api-Key";
/// <summary>
/// Required on session-authenticated mutating requests as a CSRF defense. Presence is the whole
/// check — a custom request header cannot be set by a cross-site form/navigation and forces a CORS
/// preflight for cross-origin XHR, so only same-origin (the SPA) can send it.
/// </summary>
public const string CsrfHeaderName = "X-CSRF";
public void OnAuthorization(AuthorizationFilterContext context)
{
HttpRequest request = context.HttpContext.Request;
// Never gate anything outside the JSON API surface.
if (!request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!EndpointRequiresKey(request.Method, context.ActionDescriptor.EndpointMetadata, apiKeyProvider.RequireKeyForReads))
{
return;
}
// 1. Machine key wins and is CSRF-immune. Check it first so a browser that happens to hold both
// a cookie and a key is still treated as a machine caller (no CSRF header required).
if (request.Headers.TryGetValue(HeaderName, out StringValues provided)
&& KeysMatch(provided.ToString(), apiKeyProvider.ApiKey))
{
return;
}
// 2. Authenticated session (cookie principal from OIDC or local login).
if (context.HttpContext.User?.Identity?.IsAuthenticated == true)
{
// Session-authenticated mutations require the CSRF header. Reads are safe (SameSite=Lax +
// no credentialed CORS means a cross-site read can't be issued with the cookie either).
if (IsMutating(request.Method) && !request.Headers.ContainsKey(CsrfHeaderName))
{
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Forbidden",
Detail = $"Session-authenticated writes require the '{CsrfHeaderName}' header."
})
{
StatusCode = StatusCodes.Status403Forbidden
};
}
return;
}
// 3. Neither credential presented.
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = $"Authentication is required. Provide a valid '{HeaderName}' header (machine clients) "
+ "or sign in to obtain a session (browser)."
});
}
/// <summary>
/// The single decision shared between runtime enforcement (this filter) and the OpenAPI document
/// generation (<c>ApiSecurityOperationTransformer</c>), so the spec's declared
/// <c>security</c>/<c>401</c> can never drift from what is actually enforced. Assumes the endpoint
/// is already known to be under <c>/api</c> (the caller's responsibility). Returns
/// <see langword="true" /> when the endpoint requires authentication: any mutating verb
/// (POST/PUT/PATCH/DELETE) is always fail-closed; reads are gated when
/// <paramref name="requireKeyForReads" /> is set or the endpoint carries
/// <see cref="RequiresAuthenticationAttribute" />. <c>OPTIONS</c> preflight and endpoints marked
/// <see cref="SkipApiAuthorizationAttribute" /> are exempt. (The name is retained from the
/// API-key-only era for spec-generation stability; "key" here means "credential".)
/// </summary>
public static bool EndpointRequiresKey(
string httpMethod,
IEnumerable<object> endpointMetadata,
bool requireKeyForReads)
{
// CORS preflight carries no custom headers and is handled by the CORS middleware.
if (HttpMethods.IsOptions(httpMethod))
{
return false;
}
// Explicit opt-out for internal, separately-guarded endpoints (scanner callback, /api/auth/*).
if (endpointMetadata.OfType<SkipApiAuthorizationAttribute>().Any())
{
return false;
}
// Writes are always fail-closed; reads are gated by the global flag or a per-endpoint opt-in.
return IsMutating(httpMethod)
|| requireKeyForReads
|| endpointMetadata.OfType<RequiresAuthenticationAttribute>().Any();
}
private static bool IsMutating(string httpMethod) =>
HttpMethods.IsPost(httpMethod)
|| HttpMethods.IsPut(httpMethod)
|| HttpMethods.IsPatch(httpMethod)
|| HttpMethods.IsDelete(httpMethod);
// Compare in constant time so a remote attacker cannot use response-timing to recover the
// key prefix-by-prefix. FixedTimeEquals also short-circuits length differences without
// leaking anything beyond "lengths differ" (still not the matching-prefix length).
private static bool KeysMatch(string provided, string configured) =>
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided),
Encoding.UTF8.GetBytes(configured));
}
@@ -1,98 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Filters;
/// <summary>
/// API-key authorization for the JSON API. The effective key (from <see cref="IApiKeyProvider" />)
/// is never empty, so this is fail-closed by construction (issue #280): every mutating request
/// under <c>/api/*</c> must present a matching <c>X-Api-Key</c> header. Read (GET/HEAD) requests
/// under <c>/api/*</c> are also gated when <c>Api:RequireKeyForReads</c> is enabled (the default)
/// or the endpoint carries <see cref="RequiresApiKeyAttribute" />. Endpoints marked
/// <see cref="SkipApiKeyAuthorizationAttribute" /> and anything outside <c>/api</c> (e.g.
/// <c>/iptv/*</c>, <c>/artwork/*</c>, the SPA) are never affected. Independent of
/// <see cref="JwtHelper" />.
/// </summary>
public class ApiKeyAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthorizationFilter
{
public const string HeaderName = "X-Api-Key";
public void OnAuthorization(AuthorizationFilterContext context)
{
HttpRequest request = context.HttpContext.Request;
// Never gate anything outside the JSON API surface.
if (!request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!EndpointRequiresKey(request.Method, context.ActionDescriptor.EndpointMetadata, apiKeyProvider.RequireKeyForReads))
{
return;
}
if (!request.Headers.TryGetValue(HeaderName, out StringValues provided)
|| !KeysMatch(provided.ToString(), apiKeyProvider.ApiKey))
{
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = $"A valid API key is required. Provide it via the '{HeaderName}' header."
});
}
}
/// <summary>
/// The single decision shared between runtime enforcement (this filter) and the OpenAPI
/// document generation (<c>ApiSecurityOperationTransformer</c>), so the spec's declared
/// <c>security</c>/<c>401</c> can never drift from what is actually enforced. Assumes the
/// endpoint is already known to be under <c>/api</c> (the caller's responsibility). Returns
/// <see langword="true" /> when the endpoint requires a valid <c>X-Api-Key</c>: any mutating
/// verb (POST/PUT/PATCH/DELETE) is always fail-closed; reads are gated when
/// <paramref name="requireKeyForReads" /> is set or the endpoint carries
/// <see cref="RequiresApiKeyAttribute" />. <c>OPTIONS</c> preflight and endpoints marked
/// <see cref="SkipApiKeyAuthorizationAttribute" /> are exempt.
/// </summary>
public static bool EndpointRequiresKey(
string httpMethod,
IEnumerable<object> endpointMetadata,
bool requireKeyForReads)
{
// CORS preflight carries no custom headers and is handled by the CORS middleware.
if (HttpMethods.IsOptions(httpMethod))
{
return false;
}
// Explicit opt-out for internal, separately-guarded endpoints (e.g. the scanner callback).
if (endpointMetadata.OfType<SkipApiKeyAuthorizationAttribute>().Any())
{
return false;
}
bool isMutating = HttpMethods.IsPost(httpMethod)
|| HttpMethods.IsPut(httpMethod)
|| HttpMethods.IsPatch(httpMethod)
|| HttpMethods.IsDelete(httpMethod);
// Writes are always fail-closed; reads are gated by the global flag or a per-endpoint opt-in.
return isMutating
|| requireKeyForReads
|| endpointMetadata.OfType<RequiresApiKeyAttribute>().Any();
}
// Compare in constant time so a remote attacker cannot use response-timing to recover the
// key prefix-by-prefix. FixedTimeEquals also short-circuits length differences without
// leaking anything beyond "lengths differ" (still not the matching-prefix length).
private static bool KeysMatch(string provided, string configured) =>
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided),
Encoding.UTF8.GetBytes(configured));
}
@@ -1,12 +0,0 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
/// <summary>
/// Marks a read (GET/HEAD) API endpoint as always requiring the <c>X-Api-Key</c> header, even
/// when <c>Api:RequireKeyForReads</c> is disabled. Applied to the sensitive-read tier
/// (troubleshoot, logs, settings, maintenance) that discloses secrets/paths or triggers work.
/// The mirror of <see cref="SkipApiKeyAuthorizationAttribute" />. See issue #282.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class RequiresApiKeyAttribute : Attribute, IFilterMetadata;
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
/// <summary>
/// Marks a read (GET/HEAD) API endpoint as always requiring authentication (a valid machine
/// <c>X-Api-Key</c> header or an authenticated session), even when <c>Api:RequireKeyForReads</c>
/// is disabled. Applied to the sensitive-read tier (troubleshoot, logs, settings, maintenance)
/// that discloses secrets/paths or triggers work. An authenticated session satisfies this tier
/// just as the machine key does. The mirror of <see cref="SkipApiAuthorizationAttribute" />.
/// See issues #282, #295.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class RequiresAuthenticationAttribute : Attribute, IFilterMetadata;
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
/// <summary>
/// Marks an internal API endpoint as exempt from the global <see cref="ApiAuthorizationFilter" />
/// (neither a machine key nor a session is required). Used for endpoints that are guarded by a
/// different mechanism (e.g. the scanner callback's localhost-only check) and for the
/// <c>/api/auth/*</c> surface itself, which must be reachable before a caller is authenticated.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class SkipApiAuthorizationAttribute : Attribute, IFilterMetadata;
@@ -1,9 +0,0 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
/// <summary>
/// Marks an internal API endpoint as exempt from global API-key write authorization.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class SkipApiKeyAuthorizationAttribute : Attribute, IFilterMetadata;
@@ -11,7 +11,7 @@ namespace ErsatzTV.Serialization;
/// every operation that actually requires the <c>X-Api-Key</c> header it injects the
/// <c>ApiKey</c> security requirement and a documented <c>401</c> response. The "requires a key"
/// decision is the exact same predicate the runtime filter enforces
/// (<see cref="ApiKeyAuthorizationFilter.EndpointRequiresKey" />), so the spec can never claim an
/// (<see cref="ApiAuthorizationFilter.EndpointRequiresKey" />), so the spec can never claim an
/// endpoint is open when it is gated (or vice-versa). The <c>ApiKey</c> scheme itself and the
/// <c>ProblemDetails</c> schema the <c>401</c> references are declared by
/// <see cref="ApiSecuritySchemeDocumentTransformer" />. See issues #286/#287.
@@ -28,7 +28,7 @@ public sealed class ApiSecurityOperationTransformer(IApiKeyProvider apiKeyProvid
string method = context.Description.HttpMethod ?? string.Empty;
IEnumerable<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
if (!ApiKeyAuthorizationFilter.EndpointRequiresKey(method, metadata, apiKeyProvider.RequireKeyForReads))
if (!ApiAuthorizationFilter.EndpointRequiresKey(method, metadata, apiKeyProvider.RequireKeyForReads))
{
return Task.CompletedTask;
}
@@ -28,7 +28,7 @@ public static class ApiSecuritySchemeDocumentTransformer
document.Components.SecuritySchemes[ApiSecurityOperationTransformer.SchemeName] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
Name = ApiKeyAuthorizationFilter.HeaderName,
Name = ApiAuthorizationFilter.HeaderName,
In = ParameterLocation.Header,
Description =
"API key sent in the 'X-Api-Key' request header. Required for all mutating requests and, " +
+2 -2
View File
@@ -5,7 +5,7 @@ using ErsatzTV.Core;
namespace ErsatzTV.Services;
/// <summary>
/// Resolves the effective API key used by <see cref="Filters.ApiKeyAuthorizationFilter" /> and
/// Resolves the effective API key used by <see cref="Filters.ApiAuthorizationFilter" /> and
/// the read-gating policy. Resolved once at startup: the configured <c>Api:WriteKey</c> wins;
/// otherwise a previously-persisted key is loaded from the config volume; otherwise a fresh
/// 256-bit key is generated and persisted. The key is never empty, so write authorization is
@@ -136,7 +136,7 @@ public sealed class ApiKeyProvider : IApiKeyProvider
"Generated a new API key and saved it to {Path}. Send it via the '{HeaderName}' header " +
"(ChicoryTV: Settings → API Key). The key is required for API requests.",
path,
Filters.ApiKeyAuthorizationFilter.HeaderName);
Filters.ApiAuthorizationFilter.HeaderName);
}
catch (Exception ex)
{
@@ -0,0 +1,60 @@
using ErsatzTV.Application.Auth;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Services.RunOnce;
/// <summary>
/// Recovery/bootstrap: when <c>Auth:LocalAdmin:Password</c> is configured, (re)seeds the single local
/// administrator at startup (issue #295). Overwrites any existing credential and rotates the security
/// stamp, so an operator locked out of the browser UI can reset by setting the env and restarting. A
/// no-op when unset. Follows the RunOnce pattern (waits for the database to be ready — the migrator is a
/// BackgroundService, so registration order alone does not guarantee the schema exists).
/// </summary>
public class LocalAdminSeedService(
IServiceScopeFactory serviceScopeFactory,
IConfiguration configuration,
SystemStartup systemStartup,
ILogger<LocalAdminSeedService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
string password = configuration["Auth:LocalAdmin:Password"];
if (string.IsNullOrWhiteSpace(password))
{
return;
}
await systemStartup.WaitForDatabase(stoppingToken);
if (stoppingToken.IsCancellationRequested)
{
return;
}
string username = configuration["Auth:LocalAdmin:Username"];
try
{
using IServiceScope scope = serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
Either<BaseError, Unit> result =
await mediator.Send(new SeedLocalAdminFromEnvironment(username, password), stoppingToken);
result.Match(
Right: _ => logger.LogWarning(
"Seeded the local administrator from Auth:LocalAdmin:* configuration (any existing "
+ "credential was overwritten and all sessions revoked). Unset Auth:LocalAdmin:Password "
+ "after signing in."),
Left: error => logger.LogError(
"Failed to seed the local administrator from configuration: {Error}",
error.Value));
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to seed the local administrator from configuration");
}
}
}
+168 -81
View File
@@ -7,11 +7,15 @@ using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Security.Claims;
using System.Threading.Channels;
using System.Threading.RateLimiting;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Auth;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Auth;
using ErsatzTV.Core;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Errors;
@@ -81,6 +85,7 @@ using ErsatzTV.Services.RunOnce;
using ErsatzTV.Services.Validators;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
@@ -88,6 +93,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
@@ -210,6 +216,13 @@ public class Startup
if (!trustRestricted)
{
// Kept as-is from #285 (trust any peer, warn) rather than flipped to ForwardedHeaders.None:
// 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.
// Setting ForwardedHeaders:KnownProxies/:KnownNetworks is still strongly recommended when
// exposing ErsatzTV beyond a trusted LAN — it also gives the #295 login rate limiter an
// unspoofable client IP and lets the session cookie be marked Secure behind TLS.
Log.Warning(
"ForwardedHeaders trusts X-Forwarded-* from any peer (spoofable). Set " +
"ForwardedHeaders:KnownProxies and/or ForwardedHeaders:KnownNetworks to restrict " +
@@ -277,64 +290,121 @@ public class Startup
JwtHelper.Init(Configuration);
SearchHelper.Init(Configuration);
// Browser SPA authentication (#295). A cookie session is ALWAYS registered — local username/password
// login and the OIDC callback both sign into it. OIDC is added only when configured; the /iptv JWT
// bearer is added only when configured. The /api surface accepts a session OR the machine X-Api-Key
// (see ApiAuthorizationFilter); real enforcement is that filter, not a DefaultPolicy.
AuthenticationBuilder authenticationBuilder = services.AddAuthentication(options =>
{
options.DefaultScheme = AuthConstants.CookieScheme;
if (OidcHelper.IsEnabled)
{
options.DefaultChallengeScheme = AuthConstants.OidcScheme;
}
})
.AddCookie(
AuthConstants.CookieScheme,
options =>
{
options.CookieManager = new ChunkingCookieManager();
options.Cookie.Name = "ctv-session";
options.Cookie.HttpOnly = true;
// Lax + no credentialed CORS keeps the cookie same-origin (the SPA is served from /app);
// this is a core CSRF defense alongside the required X-CSRF header on session mutations.
options.Cookie.SameSite = SameSiteMode.Lax;
// SameAsRequest (not Always) so a plain-HTTP LAN deployment is not locked out; behind a
// TLS-terminating proxy the app sees https once ForwardedHeaders:KnownProxies is set.
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromDays(14);
options.SlidingExpiration = true;
options.Events = new CookieAuthenticationEvents
{
// /api is an XHR surface — answer 401/403 rather than redirecting to a login page.
OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
},
OnRedirectToAccessDenied = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
},
OnValidatePrincipal = CookieSecurityStampValidator.ValidateAsync
};
});
if (OidcHelper.IsEnabled)
{
services.AddAuthentication(options =>
authenticationBuilder.AddOpenIdConnect(
AuthConstants.OidcScheme,
options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie(
"cookie",
options =>
options.Authority = OidcHelper.Authority;
options.ClientId = OidcHelper.ClientId;
options.ClientSecret = OidcHelper.ClientSecret;
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce = true;
options.ResponseMode = OpenIdConnectResponseMode.Query;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.GetClaimsFromUserInfoEndpoint = true;
options.CallbackPath = new PathString("/callback");
options.SaveTokens = true;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Events = new OpenIdConnectEvents
{
options.CookieManager = new ChunkingCookieManager();
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
.AddOpenIdConnect(
"oidc",
options =>
{
options.Authority = OidcHelper.Authority;
options.ClientId = OidcHelper.ClientId;
options.ClientSecret = OidcHelper.ClientSecret;
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce = true;
options.ResponseMode = OpenIdConnectResponseMode.Query;
options.Scope.Clear();
options.Scope.Add("openid");
options.CallbackPath = new PathString("/callback");
options.SaveTokens = true;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri))
// Mark the session as OIDC so the cookie stamp validator skips it (the stamp is a
// local-login concept; OIDC sessions are governed by the IdP).
OnTokenValidated = context =>
{
options.Events = new OpenIdConnectEvents
if (context.Principal?.Identity is ClaimsIdentity identity)
{
OnRedirectToIdentityProviderForSignOut = context =>
{
context.Response.Redirect(OidcHelper.LogoutUri);
context.HandleResponse();
identity.AddClaim(new Claim(AuthConstants.AuthMethodClaim, AuthConstants.MethodOidc));
}
return Task.CompletedTask;
}
};
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = context =>
{
if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri))
{
context.Response.Redirect(OidcHelper.LogoutUri);
context.HandleResponse();
}
return Task.CompletedTask;
}
});
};
});
}
if (JwtHelper.IsEnabled)
{
services.AddAuthentication().AddJwtBearer(
authenticationBuilder.AddJwtBearer(
"jwt",
options =>
{
@@ -362,34 +432,38 @@ public class Startup
});
}
if (OidcHelper.IsEnabled || JwtHelper.IsEnabled)
// Authorization is always registered now that the pipeline always runs UseAuthorization (the cookie
// scheme is always present). No DefaultPolicy: /api is gated by ApiAuthorizationFilter, and no
// endpoint carries [Authorize]. The JWT-only policy stays for /iptv's ConditionalIptvAuthorizeFilter.
services.AddAuthorization(options =>
{
services.AddAuthorization(options =>
{
if (OidcHelper.IsEnabled)
if (JwtHelper.IsEnabled)
{
options.AddPolicy(
"JwtOnlyScheme",
new AuthorizationPolicyBuilder("jwt")
.RequireAuthenticatedUser()
.Build());
}
});
// Per-IP rate limit for the unauthenticated auth surface (login/setup/password) — blunts local
// password brute-force. Keyed on the connection remote IP (accurate only when
// ForwardedHeaders:KnownProxies is configured behind a proxy — see UseForwardedHeaders below).
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(
"auth",
httpContext => RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
"cookie",
"oidc");
defaultAuthorizationPolicyBuilder =
defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
}
if (JwtHelper.IsEnabled)
{
var onlyJwtSchemePolicyBuilder = new AuthorizationPolicyBuilder("jwt");
options.AddPolicy(
"JwtOnlyScheme",
onlyJwtSchemePolicyBuilder
.RequireAuthenticatedUser()
.Build());
}
}
);
}
PermitLimit = 10,
Window = TimeSpan.FromMinutes(5),
QueueLimit = 0
}));
});
services.AddCors(o => o.AddPolicy(
"ApiCors",
@@ -406,9 +480,12 @@ public class Startup
"Accept",
"Content-Type",
"Authorization",
ApiKeyAuthorizationFilter.HeaderName,
ApiAuthorizationFilter.HeaderName,
ApiAuthorizationFilter.CsrfHeaderName,
"If-Match")
.WithExposedHeaders("ETag");
// Note: AllowCredentials is deliberately NOT set — cross-origin cookie auth is impossible
// by design (a CSRF defense). Cross-origin clients authenticate with X-Api-Key.
}
// No configured origins => no cross-origin access (the SPA is same-origin from /app).
@@ -423,7 +500,7 @@ public class Startup
options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter());
options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter());
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
options.Filters.AddService<ApiKeyAuthorizationFilter>();
options.Filters.AddService<ApiAuthorizationFilter>();
})
.AddNewtonsoftJson(opt =>
{
@@ -438,7 +515,10 @@ public class Startup
// API-key authorization for the JSON API (independent of JWT/OIDC). The provider resolves the
// effective key once (config, else persisted, else generated) so writes are fail-closed.
services.AddSingleton<IApiKeyProvider, ApiKeyProvider>();
services.AddScoped<ApiKeyAuthorizationFilter>();
services.AddScoped<ApiAuthorizationFilter>();
// Local-admin password hashing (browser SPA session auth, #295). Stateless → singleton.
services.AddSingleton<ILocalPasswordHasher, LocalPasswordHasher>();
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>();
@@ -820,12 +900,16 @@ public class Startup
legacy.UseRouting();
// Blazor removal (#91 phase b, #206): the OIDC challenge's only attachment point
// was the now-deleted Razor Pages / Blazor UI (AuthorizeFolder("/") + the Blazor
// hub). The OIDC/JWT *service* wiring stays registered (inert unless configured);
// real SPA/API auth is #197. /iptv keeps its own ConditionalIptvAuthorizeFilter
// and mutating /api/* its ApiKeyAuthorizationFilter — both independent of this
// middleware.
// Browser SPA / API authentication (#295). This branch hosts /api, the OIDC /callback, and
// /docs. UseAuthentication populates HttpContext.User from the cookie (default scheme) the
// credential ApiAuthorizationFilter accepts alongside the machine X-Api-Key — and lets the
// OIDC middleware intercept /callback. UseAuthorization is required for the middleware to run
// (no [Authorize] endpoints; /api is gated by ApiAuthorizationFilter, /iptv by its own
// ConditionalIptvAuthorizeFilter). UseRateLimiter enforces the "auth" per-IP policy on the
// login/setup/password endpoints.
legacy.UseAuthentication();
legacy.UseAuthorization();
legacy.UseRateLimiter();
legacy.UseEndpoints(endpoints =>
{
@@ -1061,6 +1145,9 @@ public class Startup
// run-once/blocking startup services
services.AddHostedService<EndpointValidatorService>();
services.AddHostedService<DatabaseMigratorService>();
// Waits on SystemStartup.WaitForDatabase before seeding (#295 env seed) — the migrator is a
// BackgroundService, so registration order alone does not guarantee the schema exists.
services.AddHostedService<LocalAdminSeedService>();
services.AddHostedService<DatabaseCleanerService>();
services.AddHostedService<LoadLoggingLevelService>();
services.AddHostedService<CacheCleanerService>();
+70 -26
View File
@@ -521,38 +521,82 @@ for items that have no group, so the SPA can render an "ungrouped" bucket
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
reliable real-entity id for those synthetic rows.
## 9. Authentication — API key posture (fail-closed)
## 9. Authentication — session-or-key posture (fail-closed)
The whole `/api` surface is gated by the `X-Api-Key` header via the global `ApiKeyAuthorizationFilter`
(issue #197 Bundle A). When you add an endpoint:
The whole `/api` surface is gated by the global `ApiAuthorizationFilter` (renamed from
`ApiKeyAuthorizationFilter` in #295). A request that requires authentication is accepted on **either**
credential:
- **Do nothing** for the common case. Writes (POST/PUT/PATCH/DELETE) always require the key
(fail-closed — there is no "open" mode). Reads (GET/HEAD) require the key when
`Api:RequireKeyForReads` is enabled, which is the **default** (`true`). `OPTIONS` preflight is exempt.
- The effective key comes from `IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`): `Api:WriteKey`
if configured, else a key persisted at `FileSystemLayout.ApiKeyPath` (`/config/api.key`, `0600`), else
a freshly generated 256-bit key. It is never empty.
- **Sensitive-read GETs** that disclose secrets/paths or trigger work must carry `[RequiresApiKey]` so
they stay gated even if an operator sets `Api:RequireKeyForReads=false`. Current tier:
`Troubleshoot`/`Logs`/`Settings`/`Maintenance`. `ApiControllerSecurityTests` asserts this reflectively.
- **Internal loopback callbacks** (the scanner's `/api/scan/*`) use `[SkipApiKeyAuthorization]` +
`[LocalhostOnly]` — the API key is a poor fit for a co-located child process, so the gate is the
loopback check (sound only because `ForwardedHeaders` trust is restricted via
`ForwardedHeaders:KnownProxies`/`KnownNetworks`).
1. a matching machine **`X-Api-Key`** header (MCP / external clients — issue #197 Bundle A), or
2. an **authenticated session** principal (browser cookie `ctv-session`, from local login or OIDC — #295).
**Which endpoints require authentication is unchanged** and still decided by the single shared predicate
`ApiAuthorizationFilter.EndpointRequiresKey(httpMethod, endpointMetadata, requireKeyForReads)` (also used by
OpenAPI generation, so the spec can't drift). When you add an endpoint:
- **Do nothing** for the common case. Writes (POST/PUT/PATCH/DELETE) always require a credential
(fail-closed — there is no "open" mode). Reads (GET/HEAD) require one when `Api:RequireKeyForReads` is
enabled, the **default** (`true`). `OPTIONS` preflight is exempt.
- The effective machine key comes from `IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`):
`Api:WriteKey` if configured, else a key persisted at `FileSystemLayout.ApiKeyPath` (`/config/api.key`,
`0600`), else a freshly generated 256-bit key. It is never empty.
- **CSRF (session credential only).** The machine key is CSRF-immune (a browser can't set a custom header
cross-origin without a credentialed CORS grant, which is never issued). A cookie session is not: a
**session-authenticated mutation** must additionally carry the `X-CSRF` header (`ApiAuthorizationFilter.CsrfHeaderName`)
or it is rejected **403** — presence is the whole check (a custom header forces a CORS preflight a
cross-site page can't satisfy; reinforced by `SameSite=Lax` + CORS without `AllowCredentials`). Key-authed
requests are exempt. When you add a SPA mutation, send `X-CSRF: 1` (the SPA client does this centrally).
- **Sensitive-read GETs** that disclose secrets/paths or trigger work must carry `[RequiresAuthentication]`
(renamed from `[RequiresApiKey]`) so they stay gated even if an operator sets `Api:RequireKeyForReads=false`.
A valid session satisfies this tier just as the key does. Current tier: `Troubleshoot`/`Logs`/`Settings`/
`Maintenance`. `ApiControllerSecurityTests` asserts this reflectively.
- **Internal loopback callbacks** (the scanner's `/api/scan/*`) and the **`/api/auth/*` surface itself** use
`[SkipApiAuthorization]` (renamed from `[SkipApiKeyAuthorization]`). The scanner adds `[LocalhostOnly]`; the
auth surface must be reachable before a caller is authenticated, and its one sensitive action
(`POST /api/auth/password`) self-checks the principal. `ApiControllerSecurityTests` asserts these two are the
**only** auth-exempt controllers.
- **The `/api/auth/*` surface** (`AuthController`, `[ApiExplorerSettings(IgnoreApi = true)]` → excluded from the
OpenAPI doc, whose audience is machine clients): `GET config` (what auth options exist + `setupRequired`),
`GET session`, `POST setup` (first-run claim), `POST login`, `POST logout`, `POST password`. The browser-nav
OIDC challenge is `GET /auth/oidc/login` (outside `/api`). `login`/`setup`/`password` carry a per-IP rate
limit (`[EnableRateLimiting("auth")]`, 10 / 5 min). The local admin is a single credential in `ConfigElement`
rows (`AuthLocalAdminUsername`/`AuthLocalAdminPasswordHash` (PBKDF2) / `AuthSecurityStamp`) — **no DB
migration**; a password change rotates the stamp, revoking sessions via the cookie `OnValidatePrincipal`.
Recovery/bootstrap without the browser: set `Auth:LocalAdmin:Password` (+ optional `…:Username`, default
`admin`) and restart (`LocalAdminSeedService` reseeds + rotates the stamp). While that env is set the
**browser setup-claim is disabled** (409) — the env seed owns the credential, which also removes the
startup setup-vs-seed race. Logout rotates the security stamp for a local session (ends it server-side,
"log out everywhere"), gated on an authenticated session.
- **CORS** is opt-in: no cross-origin access by default (the SPA is same-origin from `/app`); set
`Api:CorsAllowedOrigins` (semicolon-separated exact origins) to allow specific browser origins — the
policy already permits `X-Api-Key`/`If-Match` and exposes `ETag`.
- The SPA sends the stored key (`ctv-api-key`) on **every** request; users enter it on the keyless
**API Key** screen (`web/src/screens/ApiKeyScreen.tsx`, route `/app/api-key`). See spa-conventions §5e.
`Api:CorsAllowedOrigins` (semicolon-separated exact origins). `AllowCredentials` is deliberately **not**
set — cross-origin cookie auth is impossible by design (a CSRF defense); cross-origin machine clients use
`X-Api-Key`. The policy permits `X-Api-Key`/`X-CSRF`/`If-Match` and exposes `ETag`.
- **ForwardedHeaders trust is unchanged from #285** (trust `X-Forwarded-*` from any peer by default, with a
warning; restrict via `ForwardedHeaders:KnownProxies`/`:KnownNetworks`). A stricter "ignore unless a proxy is
configured" default was considered for #295 but **reverted** — it would regress `/iptv` M3U/XMLTV/HLS
absolute-URL generation (which reads `Request.Scheme`/`Host`) for a proxied deployment that hasn't set
`KnownProxies`. **Strongly set `KnownProxies`/`:KnownNetworks`** when exposing ErsatzTV behind a proxy — it
also gives the #295 login rate limiter an unspoofable client IP and lets the session cookie be marked
`Secure` behind TLS.
- **Side-effecting GET endpoints are not CSRF-covered by the filter** (it only checks mutating verbs). A few
`[RequiresAuthentication]` GETs have side effects (e.g. `GET /api/troubleshoot/playback.m3u8` starts playback).
Once a session cookie is a normal SPA credential (PR2), those need POST-ification or an explicit `X-CSRF` gate
— tracked as **#301**, which gates PR2 (latent in PR1: the SPA still uses the machine key, so no session
reaches them in normal use).
- **PR1 scope note (this change is server-only).** The SPA still authenticates with the stored key
(`ctv-api-key`, `X-Api-Key`) until the SPA login flow lands (PR2) — PR1 is backward compatible. The
`ApiKeyScreen` → machine-key-management repurpose, the SPA login/setup screens, and `spa-conventions §5e`
update all land in PR2.
**The OpenAPI "v1" document now declares this posture by construction (#287).** An `ApiKey` security
**The OpenAPI "v1" document declares the machine posture by construction (#287).** An `ApiKey` security
scheme (`X-Api-Key`, `in: header`) is declared in `components.securitySchemes`, and
`ApiSecurityOperationTransformer` injects a per-operation `security` requirement + a documented `401`
for exactly the operations that require the key — using the **same** shared predicate
`ApiKeyAuthorizationFilter.EndpointRequiresKey(httpMethod, endpointMetadata, requireKeyForReads)` that
the runtime filter enforces, so the spec can never drift from enforcement. The document is generated
against the effective default (`Api:RequireKeyForReads=true`), under which every documented operation
requires the key. Two companion transformers run on the "v1" document only:
for exactly the operations that require a credential — using the **same** shared predicate
`ApiAuthorizationFilter.EndpointRequiresKey(...)` the runtime filter enforces, so the spec can never drift from
enforcement. The document is generated against the effective default (`Api:RequireKeyForReads=true`), under
which every documented operation requires the credential; the browser-session path is an additional accepted
credential the spec (machine audience) needn't express. Two companion transformers run on the "v1" document
only:
`OperationIdOpenApiTransformer` synthesizes a stable `operationId` (from controller+action) for the ~90
operations that lacked a `Name=` — and disambiguates the HEAD/GET pairs that share a controller+action
**structurally, independent of ApiExplorer visitation order** (#197 Bundle C): when 2+ synthesized ops
+126
View File
@@ -53,6 +53,9 @@ below (or that establishes a new convention worth recording).**
- [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss)
- [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292)
- [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty)
- [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only)
- [2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)](#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6)
- [2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)](#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h45)
---
@@ -1134,6 +1137,129 @@ headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLT
number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST
contract absent a federation requirement.
## 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)
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).** `ApiKeyAuthorizationFilter` → `ApiAuthorizationFilter`,
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.
---
## 2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)
**An issue's `## Done-when` checklist (in the issue body) is the machine-readable source of truth for whether
its PR may merge; consent is *derived*, not asserted.** Rationale: DONE/OPEN status used to live in
append-only prose that lags live Gitea state (the queue-drift #303 fixes) — so the completion gate moves out
of memory and into a checklist two hooks read. Convention: the issue body carries a `## Done-when` section
(always an "adversarial review passed" box, plus per-issue criteria); a merge is allowed only when the PR's CI
is green **and** every box on the linked issue (`fixes #N`) is ticked.
Enforcement (both fail *safe*, never a silent pass):
- `pretooluse-merge-consent.sh` — Claude PreToolUse on `mcp__gitea__pull_request_write` merge: **deny** on an
unticked box or non-green CI; **allow** when both satisfied; **ask** (human prompt) when state isn't
derivable (no linked issue, no `## Done-when`, no creds, Gitea unreachable). Docs-only PRs exempt.
- `.husky/pre-push` → `prepush-donewhen.sh` — backstop for a direct `git push origin main`; fail-*open* (a
git hook has no "ask"), blocks only on a positively-proven unticked box.
Both authenticate to Gitea from env only (`ETV_GITEA_BASICAUTH` / `ETV_GITEA_TOKEN`, `ETV_GITEA_URL`) — no
creds committed; without them the gate degrades to today's manual confirmation. Rollout is non-breaking: until
issues adopt `## Done-when`, the merge hook simply *asks* rather than auto-allowing. See CLAUDE.md → Task
Completion Protocol. (H6 lives with H1/H2/H8 in `.claude/settings.json`; H7 worktree-owner guard is its
sibling Wave-2 hook.)
---
## 2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)