navidrome/server/jellyfin/middlewares_test.go
Deluan Quintão 59810c3d59
feat(jellyfin): non-expiring, audience-scoped tokens revocable by password change (#6013)
* feat(auth): add per-user token_epoch column and bump method

* feat(auth): add aud and ep claims, omitted when zero

* feat(auth): add CreateAPIToken for non-expiring, audience-scoped tokens

* feat(auth): add CheckClaims for epoch and audience validation

* feat(jellyfin): issue non-expiring, jellyfin-scoped access tokens

* fix(subsonic): reject API-scoped and revoked tokens on the jwt path

* fix(server): reject API-scoped and revoked tokens on the native API

* fix(server): pin the token-subject guard and stop leaking test config

Adds a regression spec for the DevAutoLogin/ExtAuth guard in
tokenAllowed, switches its comparison to case-insensitive to match
the user lookup's own COLLATE NOCASE semantics, and restores Subsonic
JWT test config after each spec instead of leaking SessionTimeout.

* feat(request): add a token epoch holder for handler-to-middleware signalling

* refactor(server): write the refreshed JWT header after the handler runs

* feat(auth): revoke all tokens for a user when their password changes

* fix(server): restore Unwrap on the JWT refresh writer so SSE write deadlines apply

* test(auth): pin that non-session tokens reject API access tokens

* test(jellyfin): pin token scoping and epoch revocation end to end

Exercises auth.CreateAPIToken and CheckClaims against the real Jellyfin
router and SQLite DB: the minted token has no exp and is aud-scoped to
jellyfin, and bumping token_epoch through the real UserRepository revokes
an already-issued token on the next protected request.

* test(nativeapi): pin the token-epoch handoff through a real password-change request

Drive a self password change through the real Authenticator/JWTRefresher
chain and a real SQLite-backed userRepository, so the epoch handoff between
Put and the refreshed-token writer is verified end to end, not as two
separately-tested halves. Also fix tokenAllowed to read the enriched ctx it
was given instead of r.Context(), so its warning log carries the username.

* refactor(server): drop tokenAllowed's now-unused request parameter

Finding-2 already moved every use to ctx; r was dead weight. Also note
in the new nativeapi test why it must stay the package's only real-DB
spec: db.Db() is a process-wide singleton its cleanup closes for good.

* refactor(auth): remove duplication in claim decoding and token minting

* refactor(auth): group aud with the standard JWT claims

* refactor(auth): read aud with the standard-claim accessor pattern

* fix(log): redact every api_key spelling the Jellyfin API accepts

* fix(auth): bind session tokens to the user id, not just the username

* fix(auth): return the token epoch from the same atomic increment

* fix(auth): bump the token epoch in the same statement as the password write

* chore(auth): trim comments to the why-only budget
2026-08-22 20:36:24 -04:00

429 lines
15 KiB
Go

package jellyfin
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("authenticate middleware", func() {
var api *Router
var ds *tests.MockDataStore
BeforeEach(func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
api = &Router{ds: ds}
})
tokenFor := func(name string) string {
t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name})
Expect(err).ToNot(HaveOccurred())
return t
}
It("passes with a valid X-Emby-Token and injects the user", func() {
var gotUser model.User
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser, _ = request.UserFrom(r.Context())
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("X-Emby-Token", tokenFor("alice"))
api.authenticate(next).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gotUser.UserName).To(Equal("alice"))
})
It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() {
var gotUser model.User
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser, _ = request.UserFrom(r.Context())
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`)
api.authenticate(next).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gotUser.UserName).To(Equal("alice"))
})
It("rejects a missing token with 401", func() {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
api.authenticate(next).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a garbage token with 401 and does not call next", func() {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("X-Emby-Token", "not-a-jwt")
api.authenticate(next).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(nextCalled).To(BeFalse())
})
It("rejects a valid token whose subject user does not exist with 401", func() {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"})
Expect(err).ToNot(HaveOccurred())
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("X-Emby-Token", t)
api.authenticate(next).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
Context("token scoping and revocation", func() {
var usr *model.User
BeforeEach(func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3}
Expect(ur.Put(usr)).To(Succeed())
})
serve := func(token string) *httptest.ResponseRecorder {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("X-Emby-Token", token)
api.authenticate(next).ServeHTTP(w, r)
return w
}
It("accepts a jellyfin-scoped token with the current epoch", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
It("rejects a token whose epoch is stale", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 4
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a token minted for another API", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("still accepts an unscoped session token", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
})
})
var _ = Describe("withPlayer middleware", func() {
var api *Router
var players *fakePlayers
BeforeEach(func() {
players = &fakePlayers{}
api = &Router{ds: &tests.MockDataStore{}, players: players}
})
callWith := func() (model.Player, model.Transcoding, bool) {
var gotPlayer model.Player
var gotTrc model.Transcoding
var hasTrc bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPlayer, _ = request.PlayerFrom(r.Context())
gotTrc, hasTrc = request.TranscodingFrom(r.Context())
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/s1/stream", nil)
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`)
api.withPlayer(next).ServeHTTP(w, r)
return gotPlayer, gotTrc, hasTrc
}
It("injects the registered player into the context", func() {
player, _, hasTrc := callWith()
Expect(player.ID).To(Equal("dev1"))
Expect(hasTrc).To(BeFalse())
})
It("injects the player's server-forced transcoding into the context", func() {
players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"}
_, trc, hasTrc := callWith()
Expect(hasTrc).To(BeTrue())
Expect(trc.TargetFormat).To(Equal("opus"))
})
})
var _ = Describe("tokenFromRequest", func() {
It("accepts the recommended Authorization: MediaBrowser scheme", func() {
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`)
Expect(tokenFromRequest(r)).To(Equal("tok123"))
})
It("prefers the Authorization scheme token over deprecated token headers", func() {
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`)
r.Header.Set("X-Emby-Token", "legacy-token")
Expect(tokenFromRequest(r)).To(Equal("scheme-token"))
})
It("accepts the lowercase api_key query param", func() {
r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil)
Expect(tokenFromRequest(r)).To(Equal("tok123"))
})
It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() {
r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil)
var got string
invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r)
Expect(got).To(Equal("tok123"))
})
})
var _ = Describe("parseMediaBrowserAuth", func() {
authFor := func(header string) mediaBrowserAuth {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Emby-Authorization", header)
return parseMediaBrowserAuth(r)
}
It("reads Finamp's raw (unencoded) field values", func() {
a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`)
Expect(a.Client).To(Equal("Finamp"))
Expect(a.Device).To(Equal("Pixel 8 Pro"))
Expect(a.DeviceId).To(Equal("dev1"))
})
It("percent-decodes Jellify's URL-encoded field values", func() {
a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`)
Expect(a.Client).To(Equal("Jellify"))
Expect(a.Device).To(Equal("Pixel 8 Pro"))
})
It("keeps a literal '%' that isn't valid percent-encoding", func() {
a := authFor(`MediaBrowser Client="100% Player", Device="d"`)
Expect(a.Client).To(Equal("100% Player"))
})
It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`)
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`)
a := parseMediaBrowserAuth(r)
Expect(a.Client).To(Equal("New"))
Expect(a.DeviceId).To(Equal("dev-new"))
})
It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() {
// A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must
// still be honored.
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", `Digest username="proxy", realm="site"`)
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`)
a := parseMediaBrowserAuth(r)
Expect(a.Client).To(Equal("Finamp"))
Expect(a.Token).To(Equal("tok"))
})
It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", `Custom Token="not-for-us"`)
Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty())
})
It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() {
a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`)
Expect(a.Client).To(Equal("OldClient"))
Expect(a.Token).To(Equal("tok"))
})
It("matches the scheme case-insensitively (HTTP auth schemes are)", func() {
a := authFor(`mediabrowser Token="tok"`)
Expect(a.Token).To(Equal("tok"))
})
})
var _ = Describe("normalizeQueryKeys", func() {
// keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for
// the given (lowercase) key — i.e. what a case-insensitive read would find.
keyFor := func(rawQuery, key string) string {
r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil)
var got string
invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r)
return got
}
It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() {
Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc"))
Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc"))
})
It("leaves values untouched", func() {
Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio"))
})
It("passes already-lowercase keys through unchanged", func() {
Expect(keyFor("container=mp3", "container")).To(Equal("mp3"))
})
It("merges values when two keys fold to the same name instead of dropping one", func() {
r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil)
var got []string
invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r)
Expect(got).To(ConsistOf("aaa", "bbb"))
})
})
var _ = Describe("throttleStreams", func() {
// serve fires n concurrent requests through the middleware and reports the highest number that
// were ever inside the handler at once.
serve := func(limit, n int) int32 {
var inFlight, peak int32
release := make(chan struct{})
h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
for {
old := atomic.LoadInt32(&peak)
if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) {
break
}
}
<-release // hold the slot until every request has had a chance to enter
atomic.AddInt32(&inFlight, -1)
}))
var wg sync.WaitGroup
for range n {
wg.Add(1)
go func() {
defer wg.Done()
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil))
}()
}
// Give the admitted requests time to pile up before letting them finish.
time.Sleep(100 * time.Millisecond)
close(release)
wg.Wait()
return atomic.LoadInt32(&peak)
}
It("admits no more than the limit at once", func() {
Expect(serve(2, 8)).To(Equal(int32(2)))
})
It("queues the excess rather than rejecting it", func() {
// All 8 still complete — they wait for a slot instead of getting a 429.
var served int32
release := make(chan struct{})
h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
atomic.AddInt32(&served, 1)
}))
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil))
}()
}
close(release)
wg.Wait()
Expect(served).To(Equal(int32(8)))
})
// chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not
// crash the server at startup.
It("is disabled, not panicking, when the limit is zero", func() {
Expect(func() { serve(0, 4) }).ToNot(Panic())
Expect(serve(0, 4)).To(BeNumerically(">", int32(1)))
})
})
var _ = Describe("caseInsensitivePaths", func() {
var handler http.Handler
var gotID, gotContainer string
BeforeEach(func() {
gotID, gotContainer = "", ""
r := chi.NewRouter()
// Routes are registered lowercase, mirroring the real router.
r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) {
gotID = chi.URLParam(req, "id")
w.WriteHeader(http.StatusOK)
})
r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) {
gotContainer = chi.URLParam(req, "container")
w.WriteHeader(http.StatusOK)
})
// A second route reusing the "bar" segment name at a different position.
r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler = caseInsensitivePaths(r)
})
serve := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
return w
}
It("routes a mixed-case request to its lowercase-registered route", func() {
Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK))
})
It("routes both routes that share a segment name, regardless of casing", func() {
Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK))
Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK))
})
It("lowercases the mixed literal.extension segment so the route and container match", func() {
w := serve("/Audio/abc/STREAM.MP3")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gotContainer).To(Equal("mp3"))
})
It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() {
serve("/foo/DEADBEEF/bar")
Expect(gotID).To(Equal("deadbeef"))
})
It("normalizes the RoutePath branch when mounted under a parent", func() {
parent := chi.NewRouter()
parent.Mount("/jellyfin", handler)
w := httptest.NewRecorder()
parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil))
Expect(w.Code).To(Equal(http.StatusOK))
})
})