feat(jellycompat): accept admin api keys for autoscan
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user