diff --git a/internal/jellycompat/auth.go b/internal/jellycompat/auth.go index 4c284784..10b99237 100644 --- a/internal/jellycompat/auth.go +++ b/internal/jellycompat/auth.go @@ -128,8 +128,7 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler { } } - ctx := context.WithValue(r.Context(), compatSessionKey, session) - next.ServeHTTP(w, r.WithContext(ctx)) + serveWithSession(next, w, r, session) }) } @@ -141,28 +140,55 @@ func safeTokenPrefix(token string) string { return token[:8] + "..." } +// serveWithSession injects the resolved compat session into the request context +// and continues the handler chain. +func serveWithSession(next http.Handler, w http.ResponseWriter, r *http.Request, session *Session) { + ctx := context.WithValue(r.Context(), compatSessionKey, session) + next.ServeHTTP(w, r.WithContext(ctx)) +} + +// resolveCompatToken resolves a token to a compat session: a session-store token +// (normal login) or, matching Jellyfin, an sa_ admin API key (synthesized +// session bound to the key user's primary profile). Returns false when the token +// matches neither. keyAuth may be nil (resolveSession handles a nil receiver). +func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, token string) (*Session, bool) { + if token == "" { + return nil, false + } + if session, ok := sessions.Get(token); ok { + return session, true + } + if strings.HasPrefix(token, "sa_") { + if session, _, _ := keyAuth.resolveSession(ctx, token); session != nil { + return session, true + } + } + return nil, false +} + // PlaybackSessionAuth creates middleware that falls back to playback session // authentication for media stream endpoints where external players (e.g. libmpv) // don't forward auth headers or query parameters. -func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore) func(next http.Handler) http.Handler { +func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Try standard token auth first. + // Try standard token auth first — a compat session token or an sa_ + // admin key (synthesized session). if token, ok := ExtractToken(r); ok { - if session, sessionOK := sessions.Get(token); sessionOK { - ctx := context.WithValue(r.Context(), compatSessionKey, session) - next.ServeHTTP(w, r.WithContext(ctx)) + if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, token); ok { + serveWithSession(next, w, r, session) return } } - // Prefer the exact negotiated playback session when the client - // includes PlaySessionId on follow-up stream requests. + // Follow-up HLS requests (master/segment) carry only PlaySessionId, + // no auth header or api_key. Resolve the negotiated session's + // CompatToken — which for an API-key stream is itself the sa_ key, + // so it must go through the same session-or-API-key resolution. if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" { if playSession, found := playbackStore.Get(playSessionID); found { - if session, ok := sessions.Get(playSession.CompatToken); ok { - ctx := context.WithValue(r.Context(), compatSessionKey, session) - next.ServeHTTP(w, r.WithContext(ctx)) + if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok { + serveWithSession(next, w, r, session) return } writeError(w, http.StatusUnauthorized, "Unauthorized", "Session expired") diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go index 8841b00d..885075d5 100644 --- a/internal/jellycompat/auth_api_key.go +++ b/internal/jellycompat/auth_api_key.go @@ -2,12 +2,15 @@ package jellycompat import ( "context" + "fmt" "log/slog" "net/http" "strings" + "sync" "time" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" ) type adminAPIKeyContextKey string @@ -24,8 +27,17 @@ type apiKeyUserLoader interface { } type AdminAPIKeyAuthenticator struct { - keys apiKeyValidator - users apiKeyUserLoader + keys apiKeyValidator + users apiKeyUserLoader + provider userstore.UserStoreProvider + now func() time.Time + + // profiles caches only the primary-profile lookup per user — never an + // authorization decision, which is re-checked on every request. + profiles *apiKeyProfileCache + + lastUsedMu sync.Mutex + lastUsedAt map[int64]time.Time } type adminAPIKeyAuthResult struct { @@ -36,11 +48,26 @@ type adminAPIKeyAuthResult struct { ok bool } -func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator { +// NewAdminAPIKeyAuthenticator builds the authenticator. keys and users are +// required (a nil return disables API-key auth entirely). provider is optional: +// when present, an admin key can synthesize a compat session (see +// resolveSession); when nil, only the admin-bool path (RequireAdminAPIKey / +// RequireSessionOrAdminAPIKey) is available. +func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator { if keys == nil || users == nil { return nil } - return &AdminAPIKeyAuthenticator{keys: keys, users: users} + if now == nil { + now = time.Now + } + return &AdminAPIKeyAuthenticator{ + keys: keys, + users: users, + provider: provider, + now: now, + profiles: newAPIKeyProfileCache(), + lastUsedAt: make(map[int64]time.Time), + } } func AdminAPIKeyFromContext(ctx context.Context) bool { @@ -77,46 +104,234 @@ func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKe } } +// RequireSessionOrAPIKeySession authenticates either a compat session token or +// an sa_ admin API key. For an API key it injects a session synthesized for the +// key user's primary profile, so ordinary handlers (which read +// SessionFromContext) work unchanged for both auth modes — matching Jellyfin, +// where an API key authorizes the same endpoints a user token does. When +// API-key auth is unavailable (nil authenticator or no UserStoreProvider) an +// sa_ token falls through to session-token auth and is rejected there. +func RequireSessionOrAPIKeySession(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token, ok := ExtractToken(r); ok && strings.HasPrefix(token, "sa_") { + if session, result, handled := keyAuth.resolveSession(r.Context(), token); handled { + if !result.ok { + writeError(w, result.status, result.code, result.message) + return + } + serveWithSession(next, w, r, session) + return + } + } + sessionAuth.RequireSession(next).ServeHTTP(w, r) + }) + } +} + +// authenticate validates an admin API key and, on success, returns a result +// carrying the admin-API-key marker in context. Used by the server-to-server +// admin routes that act on the admin identity rather than a compat session. func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult { - unauthorized := adminAPIKeyAuthResult{ - ctx: r.Context(), - status: http.StatusUnauthorized, - code: "Unauthorized", - message: "Invalid API key", + token, _ := ExtractToken(r) + apiKey, _, res := a.validate(r.Context(), token) + if !res.ok { + return res } - if a == nil || a.keys == nil || a.users == nil { - return unauthorized - } - token, ok := ExtractToken(r) - if !ok || !strings.HasPrefix(token, "sa_") { - return unauthorized - } - apiKey, err := a.keys.GetByKey(r.Context(), token) - if err != nil || apiKey == nil { - return unauthorized - } - user, err := a.users.GetByID(r.Context(), apiKey.UserID) - if err != nil || user == nil || !user.Enabled { - return unauthorized - } - if user.Role != "admin" { - return adminAPIKeyAuthResult{ - ctx: r.Context(), - status: http.StatusForbidden, - code: "Forbidden", - message: "Admin access required", - } - } - go func(id int64) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := a.keys.UpdateLastUsed(ctx, id); err != nil { - slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err) - } - }(apiKey.ID) + a.touchLastUsed(apiKey.ID) return adminAPIKeyAuthResult{ ctx: context.WithValue(r.Context(), adminAPIKeyKey, true), status: http.StatusOK, ok: true, } } + +// validate authenticates an admin API key by token and returns the key plus its +// owning admin user. The full authorization check — key lookup, user load, +// enabled, admin role — runs on EVERY call, so revocation (key deletion, user +// disable, role change) takes effect on the very next request. On failure the +// result carries the HTTP status/code/message: 401 for a missing/unknown/ +// disabled key, 403 for a non-admin key. +func (a *AdminAPIKeyAuthenticator) validate(ctx context.Context, token string) (*models.APIKey, *models.User, adminAPIKeyAuthResult) { + unauthorized := adminAPIKeyAuthResult{ + status: http.StatusUnauthorized, + code: "Unauthorized", + message: "Invalid API key", + } + if a == nil || a.keys == nil || a.users == nil { + return nil, nil, unauthorized + } + if !strings.HasPrefix(token, "sa_") { + return nil, nil, unauthorized + } + apiKey, err := a.keys.GetByKey(ctx, token) + if err != nil || apiKey == nil { + return nil, nil, unauthorized + } + user, err := a.users.GetByID(ctx, apiKey.UserID) + if err != nil || user == nil || !user.Enabled { + return nil, nil, unauthorized + } + if user.Role != "admin" { + return nil, nil, adminAPIKeyAuthResult{ + status: http.StatusForbidden, + code: "Forbidden", + message: "Admin access required", + } + } + return apiKey, user, adminAPIKeyAuthResult{ok: true} +} + +// touchLastUsed records key usage without blocking the request, throttled to at +// most once per apiKeyLastUsedInterval per key so per-request validation on the +// hot path (e.g. HLS segments) does not issue a DB write each time. +func (a *AdminAPIKeyAuthenticator) touchLastUsed(id int64) { + now := a.now() + a.lastUsedMu.Lock() + if last, ok := a.lastUsedAt[id]; ok && now.Sub(last) < apiKeyLastUsedInterval { + a.lastUsedMu.Unlock() + return + } + a.lastUsedAt[id] = now + a.lastUsedMu.Unlock() + + go func(id int64) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := a.keys.UpdateLastUsed(ctx, id); err != nil { + slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err) + } + }(id) +} + +// resolveSession authenticates an sa_ API key and returns a compat session +// synthesized for the key user's primary profile, so ordinary browse/stream +// handlers (which read SessionFromContext) work unchanged for an API key. +// +// handled reports whether this authenticator can serve API-key session auth at +// all: false when the authenticator is nil or has no UserStoreProvider wired, in +// which case the caller must fall through to session-token auth. When handled is +// true, result.ok reports success; on failure result carries what to return. +// +// The key and owning user are re-validated on EVERY call (no cached +// authorization), so revocation is immediate. Only the primary-profile lookup is +// cached (see primaryProfile), sparing the hot path the ListProfiles round-trip. +func (a *AdminAPIKeyAuthenticator) resolveSession(ctx context.Context, token string) (*Session, adminAPIKeyAuthResult, bool) { + if a == nil || a.provider == nil { + return nil, adminAPIKeyAuthResult{}, false + } + apiKey, user, res := a.validate(ctx, token) + if !res.ok { + return nil, res, true + } + a.touchLastUsed(apiKey.ID) + + profile, err := a.primaryProfile(ctx, user.ID) + if err != nil { + slog.Warn("jellycompat api key session synthesis failed", "user_id", user.ID, "error", err) + return nil, adminAPIKeyAuthResult{ + status: http.StatusUnauthorized, + code: "Unauthorized", + message: "Unable to resolve a profile for this API key", + }, true + } + + // Upstream Silo token fields are left empty and the expiry zero: an API key + // has no refreshable Silo token, and a zero expiry makes RequireSession's + // refresh path a no-op. Downstream services scope by StreamAppUserID/ProfileID. + return &Session{ + Token: token, + Username: user.Username, + AccountUsername: user.Username, + ProfileID: profile.ID, + ProfileName: profile.Name, + PseudoUserID: PseudoUserID(user.ID, profile.ID), + StreamAppUserID: user.ID, + CreatedAt: a.now(), + }, adminAPIKeyAuthResult{ok: true}, true +} + +// primaryProfile returns the account's primary profile, caching the result per +// user for apiKeyProfileCacheTTL. This caches profile DATA only — never an +// authorization decision — so it is safe against revocation (validated +// separately on every request). +func (a *AdminAPIKeyAuthenticator) primaryProfile(ctx context.Context, userID int) (userstore.Profile, error) { + if profile, ok := a.profiles.get(userID, a.now()); ok { + return profile, nil + } + store, err := a.provider.ForUser(ctx, userID) + if err != nil { + return userstore.Profile{}, fmt.Errorf("open user store for user %d: %w", userID, err) + } + profiles, err := store.ListProfiles(ctx) + if err != nil { + return userstore.Profile{}, fmt.Errorf("list profiles for user %d: %w", userID, err) + } + profile, err := selectPrimaryProfile(userID, profiles) + if err != nil { + return userstore.Profile{}, err + } + a.profiles.put(userID, profile, a.now().Add(apiKeyProfileCacheTTL)) + return profile, nil +} + +// selectPrimaryProfile returns the account's primary profile, falling back to +// the first profile (with a warning) when none is flagged primary, and erroring +// when the account has no profiles. +func selectPrimaryProfile(userID int, profiles []userstore.Profile) (userstore.Profile, error) { + if len(profiles) == 0 { + return userstore.Profile{}, fmt.Errorf("user %d has no profiles", userID) + } + for _, p := range profiles { + if p.IsPrimary { + return p, nil + } + } + slog.Warn("jellycompat api key: no primary profile found, using first", + "user_id", userID, "profile_id", profiles[0].ID) + return profiles[0], nil +} + +const ( + // apiKeyProfileCacheTTL bounds how long a primary-profile lookup is reused. + // Profiles are stable, so this is generous; it only affects how quickly a + // reassigned/renamed primary profile is picked up, never authorization + // (the key and user are re-validated on every request). + apiKeyProfileCacheTTL = 5 * time.Minute + // apiKeyLastUsedInterval throttles api_keys.last_used_at writes so that + // per-request validation on the hot path does not write each time. + apiKeyLastUsedInterval = time.Minute +) + +type apiKeyProfileCacheEntry struct { + profile userstore.Profile + expiresAt time.Time +} + +// apiKeyProfileCache caches the primary-profile lookup per user so the hot path +// (e.g. HLS segment requests) skips the ListProfiles round-trip. It caches only +// profile data, never an authorization decision. +type apiKeyProfileCache struct { + mu sync.RWMutex + entries map[int]apiKeyProfileCacheEntry +} + +func newAPIKeyProfileCache() *apiKeyProfileCache { + return &apiKeyProfileCache{entries: make(map[int]apiKeyProfileCacheEntry)} +} + +func (c *apiKeyProfileCache) get(userID int, now time.Time) (userstore.Profile, bool) { + c.mu.RLock() + entry, ok := c.entries[userID] + c.mu.RUnlock() + if !ok || !entry.expiresAt.After(now) { + return userstore.Profile{}, false + } + return entry.profile, true +} + +func (c *apiKeyProfileCache) put(userID int, profile userstore.Profile, expiresAt time.Time) { + c.mu.Lock() + c.entries[userID] = apiKeyProfileCacheEntry{profile: profile, expiresAt: expiresAt} + c.mu.Unlock() +} diff --git a/internal/jellycompat/auth_api_key_session_test.go b/internal/jellycompat/auth_api_key_session_test.go new file mode 100644 index 00000000..83a5c75c --- /dev/null +++ b/internal/jellycompat/auth_api_key_session_test.go @@ -0,0 +1,365 @@ +package jellycompat + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// fakeUserStore embeds userstore.UserStore so only ListProfiles needs an +// implementation; any other method call panics (the synthesis path must not +// touch them). +type fakeUserStore struct { + userstore.UserStore + profiles []userstore.Profile + listErr error +} + +func (s *fakeUserStore) ListProfiles(context.Context) ([]userstore.Profile, error) { + return s.profiles, s.listErr +} + +type fakeUserStoreProvider struct { + store *fakeUserStore + forErr error + forCalls int +} + +func (p *fakeUserStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) { + p.forCalls++ + if p.forErr != nil { + return nil, p.forErr + } + return p.store, nil +} + +func (p *fakeUserStoreProvider) Close() error { return nil } + +// adminKeyAuthWithPrimary builds an authenticator backed by an admin key whose +// account has a non-primary and a primary profile (in that order, so the test +// proves IsPrimary selection is order-independent). +func adminKeyAuthWithPrimary(t *testing.T, clock func() time.Time) (*AdminAPIKeyAuthenticator, *fakeUserStoreProvider, *fakeAPIKeyValidator) { + t.Helper() + validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}} + users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", Role: "admin", Enabled: true}} + provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{ + {ID: "kids", Name: "Kids", IsPrimary: false}, + {ID: "p1", Name: "Parent", IsPrimary: true}, + }}} + keyAuth := NewAdminAPIKeyAuthenticator(validator, users, provider, clock) + if keyAuth == nil { + t.Fatal("expected non-nil authenticator") + } + return keyAuth, provider, validator +} + +func apiKeyRequest() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/Items", nil) + req.Header.Set("X-Emby-Token", "sa_test") + return req +} + +func TestRequireSessionOrAPIKeySession_SynthesizesPrimaryProfile(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + keyAuth, provider, _ := adminKeyAuthWithPrimary(t, clock) + sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)} + + var got *Session + h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = SessionFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, apiKeyRequest()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got == nil { + t.Fatal("expected synthesized session in context") + } + if got.StreamAppUserID != 2 { + t.Errorf("StreamAppUserID = %d, want 2", got.StreamAppUserID) + } + if got.ProfileID != "p1" || got.ProfileName != "Parent" { + t.Errorf("profile = %q/%q, want p1/Parent", got.ProfileID, got.ProfileName) + } + if want := PseudoUserID(2, "p1"); got.PseudoUserID != want { + t.Errorf("PseudoUserID = %s, want %s", got.PseudoUserID, want) + } + if got.Username != "admin" { + t.Errorf("Username = %q, want admin", got.Username) + } + // Refresh-skip invariants: no upstream Silo token, zero expiry. + if got.StreamAppAccessToken != "" || got.StreamAppRefreshToken != "" { + t.Errorf("expected empty upstream tokens, got access=%q refresh=%q", got.StreamAppAccessToken, got.StreamAppRefreshToken) + } + if !got.StreamAppTokenExpiry.IsZero() { + t.Error("expected zero StreamAppTokenExpiry so RequireSession skips refresh") + } + if provider.forCalls != 1 { + t.Errorf("ForUser calls = %d, want 1", provider.forCalls) + } +} + +func TestRequireSessionOrAPIKeySession_RejectsNonAdminKey(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}} + users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Role: "user", Enabled: true}} + provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{{ID: "p1", IsPrimary: true}}}} + keyAuth := NewAdminAPIKeyAuthenticator(validator, users, provider, clock) + sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)} + + h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler should not run for a non-admin key") + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, apiKeyRequest()) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + // The non-admin account's profiles must never be consulted. + if provider.forCalls != 0 { + t.Errorf("ForUser calls = %d, want 0 (rejected before synthesis)", provider.forCalls) + } +} + +func TestRequireSessionOrAPIKeySession_RejectsUnknownKey(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + keyAuth, _, _ := adminKeyAuthWithPrimary(t, clock) + sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)} + + h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler should not run for an unknown key") + })) + req := httptest.NewRequest(http.MethodGet, "/Items", nil) + req.Header.Set("X-Emby-Token", "sa_doesnotmatch") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +// TestRequireSessionOrAPIKeySession_FallsThroughWhenSynthesisUnavailable guards +// the regression where the middleware fronts the whole browse group: a nil +// authenticator or one without a UserStoreProvider must fall through to session +// auth (clean 401) rather than panic. +func TestRequireSessionOrAPIKeySession_FallsThroughWhenSynthesisUnavailable(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)} + + cases := map[string]*AdminAPIKeyAuthenticator{ + "nil authenticator": nil, + "nil provider": NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, + nil, clock, + ), + } + for name, keyAuth := range cases { + t.Run(name, func(t *testing.T) { + h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler should not run without a valid session") + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, apiKeyRequest()) // must not panic + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + }) + } +} + +// TestResolveSession_RevalidatesEachCallCachesProfile: the key + owning user are +// validated on every call (so revocation is immediate), while the primary-profile +// lookup is cached until apiKeyProfileCacheTTL. +func TestResolveSession_RevalidatesEachCallCachesProfile(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + keyAuth, provider, validator := adminKeyAuthWithPrimary(t, clock) + ctx := context.Background() + + for i := 1; i <= 3; i++ { + if s, _, handled := keyAuth.resolveSession(ctx, "sa_test"); !handled || s == nil { + t.Fatalf("resolve %d should succeed", i) + } + } + if validator.getCalls != 3 { + t.Errorf("GetByKey calls = %d, want 3 (re-validated every request)", validator.getCalls) + } + if provider.forCalls != 1 { + t.Errorf("ForUser calls = %d, want 1 (profile cached)", provider.forCalls) + } + + // After the profile TTL, the profile is re-fetched (auth still every call). + now = now.Add(apiKeyProfileCacheTTL + time.Second) + if s, _, handled := keyAuth.resolveSession(ctx, "sa_test"); !handled || s == nil { + t.Fatal("post-TTL resolve should succeed") + } + if validator.getCalls != 4 { + t.Errorf("GetByKey calls = %d, want 4", validator.getCalls) + } + if provider.forCalls != 2 { + t.Errorf("ForUser calls = %d, want 2 after profile TTL", provider.forCalls) + } +} + +// TestResolveSession_RevocationIsImmediate: once a key is revoked, the very next +// request fails — no cached authorization decision survives (Finding 2). +func TestResolveSession_RevocationIsImmediate(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + keyAuth, _, validator := adminKeyAuthWithPrimary(t, clock) + ctx := context.Background() + + if s, _, handled := keyAuth.resolveSession(ctx, "sa_test"); !handled || s == nil { + t.Fatal("initial resolve should succeed") + } + validator.key = nil // revoke: GetByKey now returns not-found + + s, res, handled := keyAuth.resolveSession(ctx, "sa_test") + if !handled { + t.Fatal("expected handled=true for an sa_ token") + } + if s != nil || res.ok { + t.Fatalf("revoked key must not resolve a session; got session=%v ok=%v", s != nil, res.ok) + } + if res.status != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", res.status) + } +} + +// TestPlaybackSessionAuth_APIKeyViaPlaySessionId: HLS follow-up requests carry +// only PlaySessionId. When the negotiated session's CompatToken is an sa_ key, +// PlaybackSessionAuth must still resolve it (Finding 1). +func TestPlaybackSessionAuth_APIKeyViaPlaySessionId(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + keyAuth, _, _ := adminKeyAuthWithPrimary(t, clock) + sessions := NewSessionStore(time.Hour, clock) + playback := NewPlaybackSessionStore(time.Hour, clock) + playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"}) + + var got *Session + h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = SessionFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + // No token header / no api_key — only PlaySessionId, like an HLS segment URL. + req := httptest.NewRequest(http.MethodGet, "/Videos/x/hls/p/seg0.ts?PlaySessionId=ps1", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got == nil || got.Token != "sa_test" || got.StreamAppUserID != 2 { + t.Fatalf("expected synthesized api-key session (token sa_test, user 2); got %+v", got) + } +} + +// TestPlaybackSessionAuth_RevokedAPIKeyViaPlaySessionId: a revoked key fails even +// through the PlaySessionId fallback. +func TestPlaybackSessionAuth_RevokedAPIKeyViaPlaySessionId(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + keyAuth, _, validator := adminKeyAuthWithPrimary(t, clock) + validator.key = nil // revoked + sessions := NewSessionStore(time.Hour, clock) + playback := NewPlaybackSessionStore(time.Hour, clock) + playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"}) + + h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler should not run for a revoked key") + })) + req := httptest.NewRequest(http.MethodGet, "/Videos/x/hls/p/seg0.ts?PlaySessionId=ps1", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +// TestValidatePseudoUser confirms the path-userId guard a synthesized session +// relies on: empty or matching ids pass; a mismatched id 404s. +func TestValidatePseudoUser(t *testing.T) { + session := &Session{PseudoUserID: PseudoUserID(2, "p1")} + + if !validatePseudoUser(httptest.NewRecorder(), "", session) { + t.Error("empty userID should pass") + } + if !validatePseudoUser(httptest.NewRecorder(), session.PseudoUserID.String(), session) { + t.Error("matching userID should pass") + } + rec := httptest.NewRecorder() + if validatePseudoUser(rec, PseudoUserID(2, "other").String(), session) { + t.Error("mismatched userID should fail") + } + if rec.Code != http.StatusNotFound { + t.Errorf("mismatch status = %d, want 404", rec.Code) + } +} + +func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) { + h := NewAuthHandler(&config.Config{}, nil, nil) + session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"} + + req := httptest.NewRequest(http.MethodGet, "/Users", nil) + req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session)) + rec := httptest.NewRecorder() + h.HandleUsers(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var users []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &users); err != nil { + t.Fatalf("unmarshal: %v; body=%s", err, rec.Body.String()) + } + if len(users) != 1 { + t.Fatalf("len = %d, want 1 (current profile only)", len(users)) + } + if users[0]["Id"] != session.PseudoUserID.String() { + t.Errorf("Id = %v, want %s", users[0]["Id"], session.PseudoUserID.String()) + } + if users[0]["Name"] != "admin" { + t.Errorf("Name = %v, want admin", users[0]["Name"]) + } +} + +func TestHandleUsers_RequiresSession(t *testing.T) { + h := NewAuthHandler(&config.Config{}, nil, nil) + rec := httptest.NewRecorder() + h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRouter_UsersRequiresAuth(t *testing.T) { + cfg, err := config.LoadFromDB(map[string]string{}) + if err != nil { + t.Fatalf("LoadFromDB: %v", err) + } + router := NewRouter(Dependencies{Config: cfg}) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/Users", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("GET /Users status = %d, want 401", rec.Code) + } +} diff --git a/internal/jellycompat/auth_test.go b/internal/jellycompat/auth_test.go index a5e7cb95..bef63ea0 100644 --- a/internal/jellycompat/auth_test.go +++ b/internal/jellycompat/auth_test.go @@ -120,7 +120,7 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test } func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) { - authn := NewAdminAPIKeyAuthenticator( + authn := newAdminAPIKeyAuthForTest( &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, ) @@ -141,7 +141,7 @@ func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) { } func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) { - authn := NewAdminAPIKeyAuthenticator( + authn := newAdminAPIKeyAuthForTest( &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}}, ) @@ -159,7 +159,7 @@ func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) { } func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) { - authn := NewAdminAPIKeyAuthenticator( + authn := newAdminAPIKeyAuthForTest( &fakeAPIKeyValidator{returnNilWithoutError: true}, &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, ) @@ -178,7 +178,7 @@ func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) { func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) { called := make(chan bool, 1) - authn := NewAdminAPIKeyAuthenticator( + authn := newAdminAPIKeyAuthForTest( &fakeAPIKeyValidator{ key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}, update: func(ctx context.Context, _ int64) error { @@ -210,13 +210,21 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) { } } +// newAdminAPIKeyAuthForTest builds an authenticator without a UserStoreProvider, +// exercising the admin-bool path only (no session synthesis). +func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator { + return NewAdminAPIKeyAuthenticator(keys, users, nil, nil) +} + type fakeAPIKeyValidator struct { key *models.APIKey returnNilWithoutError bool + getCalls int update func(context.Context, int64) error } func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) { + f.getCalls++ if f.returnNilWithoutError { return nil, nil } diff --git a/internal/jellycompat/handlers_auth.go b/internal/jellycompat/handlers_auth.go index 4f3b27c9..1b88b5b0 100644 --- a/internal/jellycompat/handlers_auth.go +++ b/internal/jellycompat/handlers_auth.go @@ -176,6 +176,23 @@ func (h *AuthHandler) HandleCurrentUser(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, h.userDTO(session)) } +// HandleUsers serves GET /Users. +// +// Jellyfin's user-list endpoint accepts any authenticated caller (including an +// API key) and is what Tunarr probes to verify an API-key connection (the key +// has no "me"). Silo separates login accounts from household profiles and is +// multi-account, so we return only the caller's own user as a single-element +// list rather than enumerating every account's profiles — listing all users +// would leak identities across households. +func (h *AuthHandler) HandleUsers(w http.ResponseWriter, r *http.Request) { + session := SessionFromContext(r.Context()) + if session == nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") + return + } + writeJSON(w, http.StatusOK, []userDTOResponse{h.userDTO(session)}) +} + // HandleUserByID serves GET /Users/{id}. func (h *AuthHandler) HandleUserByID(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index 97e9622e..2131950e 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -75,7 +75,7 @@ func NewRouter(deps Dependencies) chi.Router { itemsHandler := NewItemsHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.ImageCache, nextUpRepo, deps.BrowseRepo, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.EpisodeRepo, deps.AccessFilterFn, subtitleRepo) itemsHandler.recommender = deps.Recommender autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler) - adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader) + adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader, deps.UserStoreProvider, deps.Now) autoscanVirtualFoldersRegistered := false if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil { r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)). @@ -126,8 +126,9 @@ func NewRouter(deps Dependencies) chi.Router { if deps.Authenticator != nil { r.Group(func(r chi.Router) { - r.Use(deps.Authenticator.RequireSession) + r.Use(RequireSessionOrAPIKeySession(deps.Authenticator, adminAPIKeyAuth)) r.Get("/Users/Me", authHandler.HandleCurrentUser) + r.Get("/Users", authHandler.HandleUsers) r.Get("/Users/{id}", authHandler.HandleUserByID) r.Get("/UserViews", itemsHandler.HandleViews) r.Get("/UserViews/GroupingOptions", itemsHandler.HandleGroupingOptionsStub) @@ -205,7 +206,7 @@ func NewRouter(deps Dependencies) chi.Router { // Stream routes: use playback-session auth fallback for media players // (e.g. libmpv) that don't forward auth headers or query parameters. r.Group(func(r chi.Router) { - r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore)) + r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth)) r.Method(http.MethodHead, "/Videos/{id}/stream", http.HandlerFunc(playbackHandler.HandleVideoStream)) r.Get("/Videos/{id}/stream", playbackHandler.HandleVideoStream) r.Method(http.MethodHead, "/Videos/{id}/stream.{container}", http.HandlerFunc(playbackHandler.HandleVideoStream))