From 4f8ac1e01f5f9debfea59d21d56c4a393b10262b Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 6 Jul 2026 11:42:14 -0400 Subject: [PATCH] fix(jellyfin): accept a bare Authorization token for audio streaming Jellify's native player (react-native-nitro-player) authenticates the audio stream by setting a bare Authorization header carrying the raw access token ({ AUTHORIZATION: api.accessToken }), not the "MediaBrowser ... Token=" scheme parseEmbyAuth understands. tokenFromRequest didn't recognize it, so every /Audio/{id}/stream request from the native player 401'd and no audio played (the JS client still optimistically posted playback progress, masking it). Accept a bare (or "Bearer ") Authorization header, as real Jellyfin does. Verified live: the native player's exact request now streams 200. --- server/jellyfin/e2e/e2e_suite_test.go | 13 +++++++++++++ server/jellyfin/e2e/streaming_test.go | 7 +++++++ server/jellyfin/middlewares.go | 18 ++++++++++++++++++ server/jellyfin/middlewares_test.go | 18 ++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index f33eb3b2a..a2d28bd16 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -156,6 +156,19 @@ func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder return w } +// getWithBareToken performs a GET authenticated only by a bare Authorization header carrying the +// raw token, as react-native-nitro-player (Jellify's native audio player) does — no X-Emby-Token, +// no MediaBrowser scheme. +func getWithBareToken(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", path, nil) + token, err := auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("Authorization", token) + router.ServeHTTP(w, r) + return w +} + // rawReq performs a request with no authentication (for public routes). func rawReq(method, path, body string) *httptest.ResponseRecorder { w := httptest.NewRecorder() diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index 0dbb96acc..7774b355b 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -43,6 +43,13 @@ var _ = Describe("Streaming", func() { It("returns 404 for an unknown track", func() { Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) }) + + It("streams when authenticated only by a bare Authorization token (Jellify native player)", func() { + id := songID("Come Together") + w := getWithBareToken("/Audio/" + enc(id) + "/stream?playSessionId=x&static=true") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) }) Describe("direct-file endpoints", func() { diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 81a503c5b..621f8db13 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -84,6 +84,12 @@ func tokenFromRequest(r *http.Request) string { if t := parseEmbyAuth(r).Token; t != "" { return t } + // Jellify's native audio player (react-native-nitro-player) sends the raw access token in a bare + // Authorization header (optionally "Bearer "), not the MediaBrowser scheme parseEmbyAuth + // understands. Accept that form too, as real Jellyfin does; otherwise its streams 401. + if t := bareAuthToken(r); t != "" { + return t + } // Both api_key and ApiKey are used in the wild (Finamp's just_audio engine fetches direct-file // URLs with ?ApiKey=); normalizeQueryKeys has already folded key case, but the two spellings // differ by an underscore, not case, so both are still checked. @@ -93,6 +99,18 @@ func tokenFromRequest(r *http.Request) string { return r.URL.Query().Get("apikey") } +// bareAuthToken extracts a raw token from an Authorization header that is not the MediaBrowser/Emby +// scheme (handled by parseEmbyAuth) — i.e. "Bearer " or a bare "". A JWT carries no spaces +// or quotes, so a value containing either is a schemed header, not a bare token, and is ignored. +func bareAuthToken(r *http.Request) string { + h := strings.TrimSpace(r.Header.Get("Authorization")) + h = strings.TrimPrefix(h, "Bearer ") + if h == "" || strings.ContainsAny(h, " \"") { + return "" + } + return h +} + func (api *Router) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index 7c4a61976..41b7e14f3 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -91,6 +91,24 @@ var _ = Describe("tokenFromRequest", func() { invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r) Expect(got).To(Equal("tok123")) }) + + It("accepts a bare token in the Authorization header (Jellify's native player)", func() { + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) + r.Header.Set("Authorization", "tok123") + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("accepts a Bearer token in the Authorization header", func() { + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) + r.Header.Set("Authorization", "Bearer tok123") + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("does not treat a tokenless MediaBrowser Authorization header as a bare token", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Client="Jellify", DeviceId="x"`) + Expect(tokenFromRequest(r)).To(BeEmpty()) + }) }) var _ = Describe("normalizeQueryKeys", func() {