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 <token>") Authorization header, as real Jellyfin
does. Verified live: the native player's exact request now streams 200.
This commit is contained in:
Deluan
2026-07-06 11:42:14 -04:00
parent 263eeda6fb
commit 4f8ac1e01f
4 changed files with 56 additions and 0 deletions

View File

@@ -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()

View File

@@ -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() {

View File

@@ -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 <token>"), 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 <jwt>" or a bare "<jwt>". 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()

View File

@@ -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() {