From 4c3dd9323edb2143c0eb29bb4031af35c7cc31e7 Mon Sep 17 00:00:00 2001 From: JonahMMay Date: Wed, 30 Sep 2026 16:58:58 +0000 Subject: [PATCH] fix(stream): let a stream token authorize direct/remux delivery HandleStream required a user before anything else, so a request that StreamTokenAuth had already authorized with a session-bound ?st= grant (and no bearer, by design) was rejected 401 "Authentication required". The HLS manifest/segment handlers accept that case; the progressive one did not. Native TV players (Tizen videohole) play the plan URL verbatim, so every direct-play and progressive-remux start 401'd, the client's failure recovery fell through to an encode, and HDR sources then ended on "no validated HDR-preserving or tone-map recipe". Reproduced against the live server with a real plan token: 206 on the root /stream/{id} route, 401 on /api/v1 and /api/v2/stream/{id}. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/api/handlers/stream.go | 7 +- .../handlers/stream_token_delivery_test.go | 70 +++++++++++++++++++ scripts/prairie-invariants.txt | 1 + 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/stream_token_delivery_test.go diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index 35ab26f9a6..6d905a614c 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -108,7 +108,12 @@ func NewStreamHandler(sessionMgr SessionManagerInterface, fileResolver FilePathR // For transcode: returns 400 (transcode uses manifest/segment endpoints). func (h *StreamHandler) HandleStream(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) - if userID == 0 { + // A verified, session-bound stream token (StreamTokenAuth) authorizes + // delivery of this session's bytes without a user, exactly as it does for + // the HLS manifest and segments: native TV players fetch the plan URL and + // cannot send a bearer. The session load below still enforces the session's + // own media-authorization mode, and a present identity must still own it. + if userID == 0 && !apimw.IsStreamTokenAuthorized(r.Context()) { writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") return } diff --git a/internal/api/handlers/stream_token_delivery_test.go b/internal/api/handlers/stream_token_delivery_test.go new file mode 100644 index 0000000000..0b86ce623c --- /dev/null +++ b/internal/api/handlers/stream_token_delivery_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/prairie-server/prairie-server/internal/api/middleware" + "github.com/prairie-server/prairie-server/internal/models" + "github.com/prairie-server/prairie-server/internal/playback" + "github.com/prairie-server/prairie-server/internal/streamtoken" +) + +// A native TV player fetches the plan's /api/v2/stream/{id}?st=… URL with no +// bearer. The session-bound stream token must authorize those bytes exactly as +// it does the HLS manifest and segments; before this, every direct-play and +// progressive-remux start on Tizen 401'd and fell back to an encode. +func TestHandleStream_StreamTokenAuthorizesAnonymousDelivery(t *testing.T) { + const secret = "test-stream-token-secret" + filePath := writePlaybackTestMediaFile(t, "movie.mp4") + file := &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: filePath, Duration: 3600} + sessionMgr := playback.NewSessionManager(0, 0) + session, err := sessionMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + handler := NewStreamHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.JWTSecret = secret + gated := (&apimw.AuthMiddleware{}).StreamTokenAuth(secret)(http.HandlerFunc(handler.HandleStream)) + + serve := func(token string) *httptest.ResponseRecorder { + t.Helper() + target := "/api/v2/stream/" + session.ID + if token != "" { + target += "?st=" + url.QueryEscape(token) + } + req := httptest.NewRequest(http.MethodGet, target, nil) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("session_id", session.ID) + req = req.WithContext(context.WithValue(context.Background(), chi.RouteCtxKey, routeCtx)) + rr := httptest.NewRecorder() + gated.ServeHTTP(rr, req) + return rr + } + + if rr := serve(""); rr.Code != http.StatusUnauthorized { + t.Fatalf("no credential: status = %d, want 401", rr.Code) + } + + other, err := streamtoken.Sign(streamtoken.Claims{SessionID: "another-session"}, secret, time.Hour) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if rr := serve(other); rr.Code != http.StatusUnauthorized { + t.Fatalf("token for another session: status = %d, want 401", rr.Code) + } + + token, err := streamtoken.Sign(streamtoken.Claims{SessionID: session.ID}, secret, time.Hour) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if rr := serve(token); rr.Code != http.StatusOK { + t.Fatalf("session-bound token: status = %d, body = %s", rr.Code, rr.Body.String()) + } +} diff --git a/scripts/prairie-invariants.txt b/scripts/prairie-invariants.txt index f18645b853..73aba7a74e 100644 --- a/scripts/prairie-invariants.txt +++ b/scripts/prairie-invariants.txt @@ -41,3 +41,4 @@ web/src/api/client.ts 1 X-Prairie- client identity headers # --- Stats for nerds (event log + planner detail). web/src/player/components/PlaybackInfoOverlay.tsx 1 Recent events web stats overlay shows the player event log web/src/player/components/VideoPlayer.tsx 1 new PlaybackEventLog\( web player records events for the stats overlay +internal/api/handlers/stream.go 1 userID == 0 && !apimw\.IsStreamTokenAuthorized\( v2 progressive/direct delivery honors st (Tizen direct play)