Files
silo-server/internal/api/middleware/auth.go
T
CoffeeKnyte e5bf0155ad fix(playback): make revocation state converge and cutoffs credential-accurate
Five defects in revocation state and credential semantics. Lands after the
tracker-lifecycle batch on purpose: raising the over-cap TTL is only safe once
the count feeding it is trustworthy.

#13 -- a longer old revocation suppressed a newer cutoff. applyLocal kept or
replaced the WHOLE record by expiry, so when the existing revocation expired
later the new one was dropped entirely, including its newer RevokedAt. The
durable upsert did the same, with a comment documenting it as intentional.
RevokedAt is the user-kill CUTOFF, so this left a credential issued between the
two cutoffs valid -- a second admin kill after a user re-authenticates silently
failed to cut them. The two fields now merge independently: ExpiresAt stays
monotonic, RevokedAt advances to the later value, and reason follows the newer
cutoff. Both superseded comments are replaced rather than left contradicting the
code. Session-kind revocation still ignores RevokedAt, so the enforcer's
re-revoke cannot weaken a session kill.

Also fixed while here: Redis received the merged record but pub/sub published the
raw input one, so under pub/sub-only delivery (Redis down) an edge got the newer
short record without the older long expiry and lost monotonicity. Both now carry
the merged record.

#7 + M1 -- Postgres could indefinitely block the urgent Redis kill.
RevokeWithWarnings held the global opMu across all propagation, stripped the
caller's deadline with WithoutCancel, and did the durable Postgres upsert BEFORE
Redis, on a pool with no statement timeout. The local kill still applied, so
playback on that process was fine -- but edge propagation, pub/sub, the admin
response and every later revoke/unrevoke stalled behind the lock. Redis and
pub/sub now go first, and the detached context is bounded. WithoutCancel is kept
deliberately: propagation must outlive an aborted admin request.

opMu scope is deliberately NOT narrowed. mirrorToRedis is an unconditional SET
with no atomic merge, so same-process serialization is what stops an older value
overwriting a newer one; narrowing the lock would also let an unrevoke interleave
with a revoke's propagation. Bounding the context caps how long the lock can be
held, which is the actual reported harm. The remaining cross-replica race -- two
central replicas racing the same SET -- is documented, not half-fixed; it needs
A6's shared picture.

A2 / #6 -- a missed unrevoke got resurrected. In-memory tombstones already
existed, but being process-local they did not survive a restart or reach a
replica that missed the pub/sub event, so maintain's durable self-heal
re-Upserted the surviving entry and the ban returned. Tombstones are now durable,
via two nullable columns on stream_revocations rather than a second table: a
tombstone is a state of the same key, and it needs its own expiry horizon
separate from the revocation's. The upsert rejects a stale replica's write while
a tombstone is live but lets a genuinely newer revocation clear it, and warm
paths apply tombstones BEFORE revocations so an un-banned key cannot be restored
as a live kill. Tombstones are pruned on the same sweep, so the table cannot grow
without bound.

A1 / #3 -- over-cap kills reopened after 5 minutes while the token stayed
reconstructable for 24h. The TTL now derives from playback.MaxTokenTTL rather
than duplicating 24h, behind a validated setting.

Critically, the enforcer uses a revoke-if-absent path rather than re-revoking.
Expiry is monotonic and the enforcer re-evaluates every 30s, so a plain long TTL
would slide expiry forward by another full lifetime on every pass -- making a
wrong kill effectively permanent for as long as any stale record persisted, with
only an explicit unrevoke to recover it. Admin Revoke keeps its monotonic
behaviour; only the enforcer's own repeat kill is non-extending. The setting is
documented as affecting future revocations only, since monotonic expiry means it
cannot shorten one already issued.

A3 / #5 -- the user cutoff compared against a fresh time.Now() taken at request
entry, so a request from a pre-cutoff login could look post-cutoff and escape the
kill. The credential time is now the access token's iat.

Two deliberate choices worth stating. API-key credentials carry no issue time, so
they pass the zero time and, per IsRevoked's documented contract, are never
matched by a user cutoff: a user kill provably cannot cut an API-key-owned pour.
That is an accepted, logged, documented hole -- and strictly better than
time.Now(), which actively defeats the cutoff. And jellycompat uses the compat
session's CreatedAt rather than the bridged Silo token's iat, because that token
refreshes without a new Jellyfin login, so its iat would advance on refresh and
let a refreshed credential slip past a cutoff.

Stream tokens are now bound to their route: a token whose SessionID does not match
the URL's session_id is rejected with 403 instead of being silently ignored,
matching the reconstruction helper that already refused a different session.

Per-login logout cuts remain out of scope -- they need per-login identity in the
stream credential. S5 (the (sessionID, userID, startedAt) clump) is rejected as
ceremony now that A3 is the iat option rather than the generation model.

#12 (closing an RSS feed does not cut its current pour) is deferred: it needs a
namespaced revocation id that cannot collide with real session ids, that id
threaded onto public feed requests, and protection against a new feed inheriting
an old tombstone.

Part of #305.
2026-07-30 12:27:05 +00:00

368 lines
12 KiB
Go

// Package middleware provides HTTP middleware for the Silo API,
// including authentication and authorization.
package middleware
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// contextKey is an unexported type for context keys in this package.
type contextKey string
// claimsKey is the context key for storing JWT claims.
const claimsKey contextKey = "claims"
// SessionValidator checks whether a session is still valid (not revoked/expired).
type SessionValidator interface {
IsValid(ctx context.Context, sessionID string) (bool, error)
}
// TokenValidator validates a JWT token string and returns the parsed claims.
type TokenValidator interface {
ValidateToken(tokenStr string) (*auth.Claims, error)
}
// APIKeyValidator looks up an API key by its full key string.
type APIKeyValidator interface {
GetByKey(ctx context.Context, key string) (*models.APIKey, error)
UpdateLastUsed(ctx context.Context, id int64) error
}
// APIKeyUserLoader loads a user by ID for API key authentication.
type APIKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// AuthMiddleware provides HTTP middleware for JWT-based authentication with
// session validity caching.
type AuthMiddleware struct {
tokenValidator TokenValidator
sessionValidator SessionValidator
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
apiKeyLastUsed *auth.APIKeyLastUsedTracker
}
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
// and session validator.
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
return &AuthMiddleware{
tokenValidator: tv,
sessionValidator: sv,
apiKeyValidator: akv,
apiKeyUserLoader: akul,
apiKeyLastUsed: auth.NewAPIKeyLastUsedTracker(akv, nil),
}
}
// RequireAuth is an HTTP middleware that enforces JWT authentication.
// It extracts the Bearer token from the Authorization header, validates the
// JWT, checks session validity (with an in-memory cache), and sets the
// parsed claims in the request context for downstream handlers.
func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearerToken(r)
if !ok {
writeUnauthorized(w, "Missing or malformed authorization header")
return
}
var claims *auth.Claims
if strings.HasPrefix(token, "sa_") {
// API key authentication.
if am.apiKeyValidator == nil {
writeUnauthorized(w, "API key authentication not available")
return
}
apiKey, err := am.apiKeyValidator.GetByKey(r.Context(), token)
if err != nil {
writeUnauthorized(w, "Invalid API key")
return
}
user, err := am.apiKeyUserLoader.GetByID(r.Context(), apiKey.UserID)
if err != nil {
writeUnauthorized(w, "Invalid API key")
return
}
if !user.Enabled {
writeUnauthorized(w, "User account is disabled")
return
}
am.apiKeyLastUsed.Touch(apiKey.ID)
claims = &auth.Claims{
UserID: user.ID,
Role: user.Role,
SessionID: "",
TokenType: auth.TokenTypeAPIKey,
APIKeyID: apiKey.ID,
RateTier: apiKey.RateTier,
}
} else {
// JWT authentication (existing flow).
var err error
claims, err = am.tokenValidator.ValidateToken(token)
if err != nil {
writeUnauthorized(w, "Invalid or expired token")
return
}
if claims.TokenType != auth.TokenTypeAccess {
writeUnauthorized(w, "Invalid or expired token")
return
}
valid, err := am.checkSession(r.Context(), claims.SessionID)
if err != nil || !valid {
writeUnauthorized(w, "Session is no longer valid")
return
}
}
// Populate activity log context if present (set by activitylog middleware upstream)
if lc := activitylog.GetLogContext(r.Context()); lc != nil {
uid := claims.UserID
lc.UserID = &uid
lc.ImpersonatorUserID = claims.ImpersonatorUserID
lc.SessionID = claims.SessionID
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireAdmin is a standalone HTTP middleware that checks if the authenticated
// user has the "admin" role. It expects RequireAuth to have already placed
// claims in the request context.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role != "admin" {
writeForbidden(w, "Admin access required")
return
}
next.ServeHTTP(w, r)
})
}
// PrimaryProfileChecker reports whether profileID belongs to userID and, if
// so, whether it is the household primary profile. found must be false when
// the profile does not exist or belongs to a different account.
type PrimaryProfileChecker func(ctx context.Context, userID int, profileID string) (isPrimary bool, found bool, err error)
// RequireActingAdmin enforces the admin role plus the household policy that
// admin powers are only exercised through the account's primary profile.
// When the request declares an active profile (X-Profile-Id) that belongs to
// the admin account but is not the primary profile, the request is refused;
// requests with no declared profile keep working (clients that haven't
// selected a profile yet). With a nil checker it behaves exactly like
// RequireAdmin.
//
// Note this enforces the declared profile, not an authenticated one: all
// profiles on an account share the login session, so this is a policy
// boundary for well-behaved clients, not a defense against the account
// holder themselves.
func RequireActingAdmin(checkPrimary PrimaryProfileChecker) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role != "admin" {
writeForbidden(w, "Admin access required")
return
}
allowed, err := actingAdminAllowed(r, claims.UserID, checkPrimary)
if err != nil {
writeInternalError(w, "Failed to verify active profile")
return
}
if !allowed {
writeForbidden(w, "Admin access requires the account's primary profile")
return
}
next.ServeHTTP(w, r)
})
}
}
// actingAdminAllowed reports whether an admin request may exercise admin
// powers given the profile it declares. Allowed when no checker is
// configured, no profile is declared, or the declared profile is the
// account's primary profile. A declared profile that cannot be resolved to
// one of the caller's profiles fails closed: otherwise a non-primary session
// could regain admin powers by sending a bogus X-Profile-Id.
func actingAdminAllowed(r *http.Request, userID int, checkPrimary PrimaryProfileChecker) (bool, error) {
if checkPrimary == nil {
return true, nil
}
profileID := declaredProfileID(r)
if profileID == "" {
return true, nil
}
isPrimary, found, err := checkPrimary(r.Context(), userID, profileID)
if err != nil {
return false, err
}
return found && isPrimary, nil
}
// declaredProfileID returns the active profile the request declares: the
// profile context when RequireProfile ran earlier in the chain, otherwise
// the raw X-Profile-Id header.
func declaredProfileID(r *http.Request) string {
if id := GetProfileID(r.Context()); id != "" {
return id
}
return r.Header.Get("X-Profile-Id")
}
// SetClaims stores JWT claims in the context. This is useful for testing
// handlers that depend on authentication without going through the full
// middleware chain.
func SetClaims(ctx context.Context, claims *auth.Claims) context.Context {
return context.WithValue(ctx, claimsKey, claims)
}
// GetClaims retrieves the JWT claims from the context. Returns nil if no
// claims are present (caller should handle this case).
func GetClaims(ctx context.Context) *auth.Claims {
claims, ok := ctx.Value(claimsKey).(*auth.Claims)
if !ok {
return nil
}
return claims
}
// CredentialIssuedAt returns the authenticated access credential's iat. API
// keys and legacy JWTs without iat deliberately return zero: streamrevoke's
// documented fail-open contract means a user cutoff cannot cut pours owned by
// those credentials. In particular, substituting time.Now would incorrectly
// make every old API key appear newer than the cutoff.
func CredentialIssuedAt(ctx context.Context) time.Time {
claims := GetClaims(ctx)
if claims == nil {
slog.DebugContext(ctx, "stream credential has no claims; user revocation cutoff fails open")
return time.Time{}
}
if claims.TokenType == auth.TokenTypeAPIKey {
slog.DebugContext(ctx, "API-key stream credential has no issue time; user revocation cutoff fails open",
"user_id", claims.UserID, "api_key_id", claims.APIKeyID)
return time.Time{}
}
if claims.IssuedAt == nil {
slog.DebugContext(ctx, "stream access credential has no iat; user revocation cutoff fails open",
"user_id", claims.UserID, "session_id", claims.SessionID)
return time.Time{}
}
return claims.IssuedAt.Time
}
// IsAdmin reports whether the context's authenticated user account has the
// admin role. Returns false when no claims are present. Note this is the
// account-level role; it says nothing about which household profile is active.
func IsAdmin(ctx context.Context) bool {
claims := GetClaims(ctx)
return claims != nil && claims.Role == "admin"
}
// GetUserID retrieves the user ID from the JWT claims in the context.
// Returns 0 if no claims are present.
func GetUserID(ctx context.Context) int {
claims := GetClaims(ctx)
if claims == nil {
return 0
}
return claims.UserID
}
// checkSession checks whether the session is valid, using the in-memory cache
// first and falling back to the session validator on cache miss.
func (am *AuthMiddleware) checkSession(ctx context.Context, sessionID string) (bool, error) {
return am.sessionValidator.IsValid(ctx, sessionID)
}
// extractBearerToken extracts a JWT from the request. It checks (in order):
// 1. Authorization: Bearer <token> header
// 2. ?token=<token> query parameter (for native media elements that can't set headers)
func extractBearerToken(r *http.Request) (string, bool) {
// Try Authorization header first.
if header := r.Header.Get("Authorization"); header != "" {
parts := strings.SplitN(header, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
if token := strings.TrimSpace(parts[1]); token != "" {
return token, true
}
}
}
// Fall back to query parameter (used by <video> / <audio> src URLs).
if token := r.URL.Query().Get("token"); token != "" {
return token, true
}
return "", false
}
// errorResponse is the JSON structure for error responses.
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// writeUnauthorized writes a 401 JSON error response.
func writeUnauthorized(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "unauthorized",
Message: message,
})
}
// writeInternalError writes a 500 JSON error response.
func writeInternalError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "internal_error",
Message: message,
})
}
// writeForbidden writes a 403 JSON error response.
func writeForbidden(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "forbidden",
Message: message,
})
}