* fix(jellycompat): authenticate Android TV token-less direct play
Stock Jellyfin Android TV ignores the api_key-bearing DirectStreamUrl
returned from PlaybackInfo and builds its own direct-play URL with no
auth header, no api_key/ApiKey query param, and no PlaySessionId. The
media request arrives via the player's HTTP stack (okhttp) with
auth_kind=none, so PlaybackSessionAuth 401s it — the client retries,
falls back to a transcode that stalls, and surfaces "player error".
Add a third fallback in PlaybackSessionAuth, scoped strictly to the
direct-play video stream routes (/Videos/{id}/stream[.{container}]) via
the chi route pattern so /Items/{id}/Download stays protected: anchor
auth on the PlaybackSession negotiated during the preceding (already
authenticated) PlaybackInfo, looked up by mediaSourceId when present
(else the route item id), and resolve its CompatToken.
Covered by tests: token-less direct play with a matching session
succeeds, no matching session 401s, and Download is not loosened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(jellycompat): require session item match + expand direct-play tests
Address CodeRabbit review on PR #200:
- Require the matched PlaybackSession's RouteItemID to equal the requested
route item before authorizing, so a mediaSourceId cannot authorize a
stream for a different item.
- Seed the compat session in the 401 tests so they fail on route/session
scoping rather than a missing session.
- Table-drive the positive test across /Videos/{id}/stream and
/Videos/{id}/stream.{container}, plus the route-item lookup branch when
mediaSourceId is absent; add a cross-item denial test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// directPlayRouter wraps the probe handler in PlaybackSessionAuth and mounts it
|
|
// on a chi router so RoutePattern() and URLParam("id") are populated exactly as
|
|
// they are in production. The same handler also fronts /Items/{id}/Download to
|
|
// prove the new token-less fallback does NOT leak into that route.
|
|
func directPlayRouter(t *testing.T, sessions *SessionStore, playback *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator, reached *bool) *chi.Mux {
|
|
t.Helper()
|
|
probe := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
*reached = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
r := chi.NewRouter()
|
|
r.Handle("/Videos/{id}/stream", probe)
|
|
r.Handle("/Videos/{id}/stream.{container}", probe)
|
|
r.Handle("/Items/{id}/Download", probe)
|
|
return r
|
|
}
|
|
|
|
// seedDirectPlaySession registers a resolvable compat session plus the
|
|
// PlaybackSession that PlaybackInfo would have negotiated for the item.
|
|
func seedDirectPlaySession(t *testing.T, clock func() time.Time) (*SessionStore, *PlaybackSessionStore) {
|
|
t.Helper()
|
|
sessions := NewSessionStore(time.Hour, clock)
|
|
if err := sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 7}); err != nil {
|
|
t.Fatalf("put session: %v", err)
|
|
}
|
|
playback := NewPlaybackSessionStore(time.Hour, clock)
|
|
playback.Put(PlaybackSession{
|
|
ID: "ps1",
|
|
CompatToken: "compat-tok",
|
|
RouteItemID: "item123",
|
|
MediaSources: []PlaybackMediaSource{{ID: "src9"}},
|
|
})
|
|
return sessions, playback
|
|
}
|
|
|
|
// TestPlaybackSessionAuth_DirectPlayNoToken: stock Jellyfin Android TV requests
|
|
// the stream with no auth header, no api_key/ApiKey, and no PlaySessionId. The
|
|
// negotiated PlaybackSession for the item authorizes it — across both stream
|
|
// route patterns and both lookup branches (by mediaSourceId, and by route item
|
|
// id when mediaSourceId is absent).
|
|
func TestPlaybackSessionAuth_DirectPlayNoToken(t *testing.T) {
|
|
now := fixedNow()
|
|
clock := func() time.Time { return now }
|
|
|
|
cases := []struct {
|
|
name string
|
|
url string
|
|
}{
|
|
{"stream + mediaSourceId", "/Videos/item123/stream?static=true&mediaSourceId=src9&container=mkv"},
|
|
{"stream.{container} + mediaSourceId", "/Videos/item123/stream.mkv?static=true&mediaSourceId=src9"},
|
|
{"stream, route-item lookup (no mediaSourceId)", "/Videos/item123/stream?static=true&container=mkv"},
|
|
{"stream.{container}, route-item lookup", "/Videos/item123/stream.mkv?static=true"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sessions, playback := seedDirectPlaySession(t, clock)
|
|
var reached bool
|
|
router := directPlayRouter(t, sessions, playback, nil, &reached)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !reached {
|
|
t.Fatal("expected inner handler to be reached via PlaybackSession fallback")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPlaybackSessionAuth_DirectPlayNoMatchingSession: with a resolvable compat
|
|
// session present but NO PlaybackSession negotiated for the requested item, the
|
|
// token-less request stays a 401 (proves the playback-session match gates it,
|
|
// not a missing compat session).
|
|
func TestPlaybackSessionAuth_DirectPlayNoMatchingSession(t *testing.T) {
|
|
now := fixedNow()
|
|
clock := func() time.Time { return now }
|
|
sessions := NewSessionStore(time.Hour, clock)
|
|
if err := sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 7}); err != nil {
|
|
t.Fatalf("put session: %v", err)
|
|
}
|
|
playback := NewPlaybackSessionStore(time.Hour, clock)
|
|
// Session exists, but for a different item id and source id.
|
|
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "compat-tok", RouteItemID: "other"})
|
|
|
|
var reached bool
|
|
router := directPlayRouter(t, sessions, playback, nil, &reached)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Videos/item123/stream?static=true&mediaSourceId=src9", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if reached {
|
|
t.Fatal("inner handler must not run without a matching PlaybackSession")
|
|
}
|
|
}
|
|
|
|
// TestPlaybackSessionAuth_DirectPlayCrossItemDenied: a mediaSourceId that resolves
|
|
// to a session for a DIFFERENT route item must not authorize the request — the
|
|
// session's RouteItemID must match the requested item.
|
|
func TestPlaybackSessionAuth_DirectPlayCrossItemDenied(t *testing.T) {
|
|
now := fixedNow()
|
|
clock := func() time.Time { return now }
|
|
sessions := NewSessionStore(time.Hour, clock)
|
|
if err := sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 7}); err != nil {
|
|
t.Fatalf("put session: %v", err)
|
|
}
|
|
playback := NewPlaybackSessionStore(time.Hour, clock)
|
|
// src9 belongs to itemB's session; the request targets itemA.
|
|
playback.Put(PlaybackSession{
|
|
ID: "psB",
|
|
CompatToken: "compat-tok",
|
|
RouteItemID: "itemB",
|
|
MediaSources: []PlaybackMediaSource{{ID: "src9"}},
|
|
})
|
|
|
|
var reached bool
|
|
router := directPlayRouter(t, sessions, playback, nil, &reached)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Videos/itemA/stream?static=true&mediaSourceId=src9", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if reached {
|
|
t.Fatal("inner handler must not run when the matched session belongs to another item")
|
|
}
|
|
}
|
|
|
|
// TestPlaybackSessionAuth_DownloadNotLoosened: the new direct-play fallback must
|
|
// not apply to /Items/{id}/Download — a token-less download stays 401 even when a
|
|
// resolvable compat session AND a PlaybackSession exist for the same item id, so
|
|
// the 401 proves route scoping rather than a missing session.
|
|
func TestPlaybackSessionAuth_DownloadNotLoosened(t *testing.T) {
|
|
now := fixedNow()
|
|
clock := func() time.Time { return now }
|
|
sessions, playback := seedDirectPlaySession(t, clock)
|
|
|
|
var reached bool
|
|
router := directPlayRouter(t, sessions, playback, nil, &reached)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Items/item123/Download", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if reached {
|
|
t.Fatal("download route must not be served via the direct-play fallback")
|
|
}
|
|
}
|