diff --git a/cmd/silo/main.go b/cmd/silo/main.go index e470ed37..c36dcddf 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -1577,6 +1577,9 @@ func main() { // Construct auth service for jellycompat login. userRepo := auth.NewUserRepository(deps.DB) + compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB) + compatDeps.APIKeyUserLoader = userRepo + compatDeps.ScanQueue = deps.LibraryScanQueue sessionRepo := auth.NewSessionRepository(deps.DB) jwtService := auth.NewJWTService( cfg.Auth.JWTSecret, diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go new file mode 100644 index 00000000..3220e8d7 --- /dev/null +++ b/internal/jellycompat/auth_api_key.go @@ -0,0 +1,120 @@ +package jellycompat + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type adminAPIKeyContextKey string + +const adminAPIKeyKey adminAPIKeyContextKey = "jellycompat_admin_api_key" + +type apiKeyValidator interface { + GetByKey(ctx context.Context, key string) (*models.APIKey, error) + UpdateLastUsed(ctx context.Context, id int64) error +} + +type apiKeyUserLoader interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +type AdminAPIKeyAuthenticator struct { + keys apiKeyValidator + users apiKeyUserLoader +} + +type adminAPIKeyAuthResult struct { + ctx context.Context + status int + ok bool +} + +func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator { + if keys == nil || users == nil { + return nil + } + return &AdminAPIKeyAuthenticator{keys: keys, users: users} +} + +func AdminAPIKeyFromContext(ctx context.Context) bool { + ok, _ := ctx.Value(adminAPIKeyKey).(bool) + return ok +} + +func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + result := a.authenticate(r) + if !result.ok { + writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + return + } + next.ServeHTTP(w, r.WithContext(result.ctx)) + }) +} + +func RequireSessionOrAdminAPIKey(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) { + token, ok := ExtractToken(r) + if ok && strings.HasPrefix(token, "sa_") { + result := keyAuth.authenticate(r) + if !result.ok { + writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + return + } + next.ServeHTTP(w, r.WithContext(result.ctx)) + return + } + sessionAuth.RequireSession(next).ServeHTTP(w, r) + }) + } +} + +func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult { + if a == nil || a.keys == nil || a.users == nil { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + token, ok := ExtractToken(r) + if !ok || !strings.HasPrefix(token, "sa_") { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + apiKey, err := a.keys.GetByKey(r.Context(), token) + if err != nil { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + user, err := a.users.GetByID(r.Context(), apiKey.UserID) + if err != nil || user == nil || !user.Enabled { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + if user.Role != "admin" { + return adminAPIKeyAuthResult{ctx: context.WithValue(r.Context(), adminAPIKeyKey, false), status: http.StatusForbidden} + } + go func(id int64) { + if err := a.keys.UpdateLastUsed(context.Background(), id); err != nil { + slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err) + } + }(apiKey.ID) + return adminAPIKeyAuthResult{ + ctx: context.WithValue(r.Context(), adminAPIKeyKey, true), + status: http.StatusOK, + ok: true, + } +} + +func authErrorCode(status int) string { + if status == http.StatusForbidden { + return "Forbidden" + } + return "Unauthorized" +} + +func authErrorMessage(status int) string { + if status == http.StatusForbidden { + return "Admin access required" + } + return "Invalid API key" +} diff --git a/internal/jellycompat/auth_test.go b/internal/jellycompat/auth_test.go index 4827ce48..89e8ae7a 100644 --- a/internal/jellycompat/auth_test.go +++ b/internal/jellycompat/auth_test.go @@ -1,10 +1,14 @@ package jellycompat import ( + "context" "net/http" "net/http/httptest" "testing" "time" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" ) func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) { @@ -114,3 +118,68 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test t.Errorf("expected 200 (no authService = skip refresh), got %d", rec.Code) } } + +func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) { + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, + ) + req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !AdminAPIKeyFromContext(r.Context()) { + t.Fatal("expected admin API key marker in context") + } + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) { + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}}, + ) + req := httptest.NewRequest("POST", "/Library/Media/Updated", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not run") + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String()) + } +} + +type fakeAPIKeyValidator struct { + key *models.APIKey +} + +func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) { + if f.key != nil && f.key.Key == key { + return f.key, nil + } + return nil, auth.ErrAPIKeyNotFound +} + +func (f *fakeAPIKeyValidator) UpdateLastUsed(context.Context, int64) error { + return nil +} + +type fakeAPIKeyUserLoader struct { + user *models.User +} + +func (f *fakeAPIKeyUserLoader) GetByID(_ context.Context, id int) (*models.User, error) { + if f.user != nil && f.user.ID == id { + return f.user, nil + } + return nil, auth.ErrNotFound +} diff --git a/internal/jellycompat/server.go b/internal/jellycompat/server.go index af4f6230..02d683e8 100644 --- a/internal/jellycompat/server.go +++ b/internal/jellycompat/server.go @@ -15,6 +15,7 @@ import ( "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/scantrigger" "github.com/Silo-Server/silo-server/internal/subtitles" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -41,6 +42,11 @@ type Dependencies struct { UserDataService UserDataService AuthService *auth.Service + // Autoscan / admin compatibility support. + APIKeyValidator apiKeyValidator + APIKeyUserLoader apiKeyUserLoader + ScanQueue scantrigger.Queuer + // Catalog repos (for ContentService construction) BrowseRepo *catalog.BrowseRepository ItemRepo *catalog.ItemRepository