Files
silo-server/internal/jellycompat/auth_test.go
544a262924 fix(jellycompat): accept Jellyfin current ApiKey query param for stream auth (#186)
Silo's ExtractToken only honored the legacy 'api_key' query parameter and
the X-Emby-Token/X-Mediabrowser-Token headers. Real Jellyfin's
AuthorizationContext treats 'ApiKey' (PascalCase) as the current,
always-enabled query token and 'api_key' as legacy (gated behind
EnableLegacyAuthorization). Native Jellyfin clients that build their own
direct-play /Videos/{id}/stream URLs (incl. Jellyfin Android TV) send
'ApiKey', which Silo rejected — the request arrived with auth_kind=none and
the route returned 401, surfacing as a client 'playbackerror'.

Match both 'ApiKey' and 'api_key' case-insensitively in ExtractToken and in
the authKind log classifier. Strictly additive: existing header and
api_key paths are unchanged.

Verified against the live server: /Videos/{id}/stream?...&ApiKey=<tok>
returned 401 before and is accepted after; &api_key=<tok> and the
X-Emby-Token header continue to return 206.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:20:04 -04:00

312 lines
10 KiB
Go

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) {
now := fixedNow()
clock := func() time.Time { return now }
store := NewSessionStore(30*24*time.Hour, clock)
// Session with token expiring in 3 minutes (within 5min buffer).
// With no authService configured, refresh is skipped and the session
// is still returned (best-effort enhancement, not hard requirement).
_ = store.Put(Session{
Token: "valid-tok",
StreamAppUserID: 1,
StreamAppAccessToken: "old-access",
StreamAppRefreshToken: "refresh-tok",
StreamAppTokenExpiry: now.Add(3 * time.Minute),
})
authn := &Authenticator{sessions: store, authService: nil}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "valid-tok")
rec := httptest.NewRecorder()
handler := authn.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
t.Fatal("expected session in context")
}
if session.StreamAppAccessToken != "old-access" {
t.Errorf("expected access token unchanged, got %s", session.StreamAppAccessToken)
}
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
func TestRequireSession_PassesThroughNonExpiringToken(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
store := NewSessionStore(30*24*time.Hour, clock)
// Token not expiring for 30 minutes — no refresh needed.
_ = store.Put(Session{
Token: "fresh-tok",
StreamAppUserID: 1,
StreamAppAccessToken: "good-access",
StreamAppTokenExpiry: now.Add(30 * time.Minute),
})
authn := &Authenticator{sessions: store}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "fresh-tok")
rec := httptest.NewRecorder()
handler := authn.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
t.Fatal("expected session in context")
}
if session.StreamAppAccessToken != "good-access" {
t.Errorf("expected access token unchanged, got %s", session.StreamAppAccessToken)
}
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
store := NewSessionStore(30*24*time.Hour, clock)
// Session with already-expired StreamApp token and no authService to refresh.
_ = store.Put(Session{
Token: "expired-tok",
StreamAppUserID: 1,
StreamAppAccessToken: "dead-access",
StreamAppTokenExpiry: now.Add(-1 * time.Hour), // already expired
})
authn := &Authenticator{sessions: store, authService: nil}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "expired-tok")
rec := httptest.NewRecorder()
handler := authn.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// When authService is nil and token is expired, session should still
// be returned (the compat session itself is valid, only the StreamApp
// token is stale). The refresh logic is a best-effort enhancement.
session := SessionFromContext(r.Context())
if session == nil {
t.Fatal("expected session in context")
}
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 (no authService = skip refresh), got %d", rec.Code)
}
}
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(30*24*time.Hour, clock)
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
cases := []struct {
name string
rawQuery string
wantCode int
}{
// Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId,
// no api_key and no auth header. Previously 401'd (case-sensitive lookup).
{"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK},
{"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK},
{"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK},
{"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized},
{"no auth at all", "static=true", http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil)
rec := httptest.NewRecorder()
gotSession := false
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if SessionFromContext(r.Context()) != nil {
gotSession = true
}
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
}
if tc.wantCode == http.StatusOK && !gotSession {
t.Fatal("expected authenticated session in context")
}
})
}
}
func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
// "ApiKey" is Jellyfin's current query spelling (PascalCase); "api_key" is
// the legacy spelling. Native clients (incl. Jellyfin Android TV) build
// direct-play /Videos/{id}/stream URLs with "ApiKey", so rejecting it 401s
// the stream. Match both, plus the casing variants clients send.
for _, key := range []string{"ApiKey", "apikey", "APIKEY", "api_key", "Api_Key", "API_KEY"} {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil)
if got, ok := ExtractToken(req); !ok || got != "tok123" {
t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok)
}
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&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 := newAdminAPIKeyAuthForTest(
&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())
}
}
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{returnNilWithoutError: true},
&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) {
t.Fatal("handler should not run")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
called := make(chan bool, 1)
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{
key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"},
update: func(ctx context.Context, _ int64) error {
_, ok := ctx.Deadline()
called <- ok
return nil
},
},
&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) {
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
select {
case ok := <-called:
if !ok {
t.Fatal("expected last-used update context to have a deadline")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for last-used update")
}
}
// 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
}
if f.key != nil && f.key.Key == key {
return f.key, nil
}
return nil, auth.ErrAPIKeyNotFound
}
func (f *fakeAPIKeyValidator) UpdateLastUsed(ctx context.Context, id int64) error {
if f.update != nil {
return f.update(ctx, id)
}
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
}