feat(jellycompat): accept admin api keys for autoscan

This commit is contained in:
Silo Server Migration
2026-05-25 11:18:02 -04:00
parent bf0aefa050
commit 6ceb75e717
4 changed files with 198 additions and 0 deletions
+3
View File
@@ -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,
+120
View File
@@ -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"
}
+69
View File
@@ -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
}
+6
View File
@@ -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