mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-30 08:46:16 -04:00
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:
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user