From 535d252d17487ce0e05346ca3b86edbe570004d1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 14:51:46 -0400 Subject: [PATCH] feat(jellyfin): authentication middleware and AuthenticateByName login --- server/jellyfin/api.go | 6 +++ server/jellyfin/auth.go | 55 ++++++++++++++++++++ server/jellyfin/auth_test.go | 51 +++++++++++++++++++ server/jellyfin/middlewares.go | 78 +++++++++++++++++++++++++++++ server/jellyfin/middlewares_test.go | 53 ++++++++++++++++++++ 5 files changed, 243 insertions(+) create mode 100644 server/jellyfin/auth.go create mode 100644 server/jellyfin/auth_test.go create mode 100644 server/jellyfin/middlewares.go create mode 100644 server/jellyfin/middlewares_test.go diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index ae78b93c4..5e9e2ecf9 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -47,6 +47,12 @@ func (api *Router) routes() http.Handler { r.Get("/System/Ping", api.ping) r.Post("/System/Ping", api.ping) r.Get("/QuickConnect/Enabled", api.quickConnectEnabled) + r.Post("/Users/AuthenticateByName", api.authenticateByName) + + r.Group(func(r chi.Router) { + r.Use(api.authenticate) + // authenticated endpoints are registered by later tasks + }) return r } diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go new file mode 100644 index 000000000..15d332f3e --- /dev/null +++ b/server/jellyfin/auth.go @@ -0,0 +1,55 @@ +package jellyfin + +import ( + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type authenticateByNameRequest struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body authenticateByNameRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. + usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username) + if err != nil || usr.Password != body.Pw { + log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + token, err := auth.CreateToken(usr) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + api.ok(w, r, dto.AuthenticationResult{ + User: userToDto(usr, api.serverName()), + AccessToken: token, + ServerId: api.publicInfo(ctx).Id, + SessionInfo: &dto.SessionInfo{Id: parseEmbyAuth(r).DeviceId, UserId: usr.ID}, + }) +} + +func userToDto(u *model.User, serverName string) *dto.UserDto { + return &dto.UserDto{ + Name: u.UserName, + Id: u.ID, + HasPassword: true, + HasConfiguredPassword: true, + } +} diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go new file mode 100644 index 000000000..6170c0d59 --- /dev/null +++ b/server/jellyfin/auth_test.go @@ -0,0 +1,51 @@ +package jellyfin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AuthenticateByName", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(nil).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + It("issues a token for valid credentials", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User.Name).To(Equal("alice")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + }) + + It("rejects invalid credentials with 401", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"wrong"}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go new file mode 100644 index 000000000..086eb0983 --- /dev/null +++ b/server/jellyfin/middlewares.go @@ -0,0 +1,78 @@ +package jellyfin + +import ( + "net/http" + "regexp" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" +) + +type embyAuth struct { + Client, Device, DeviceId, Version, Token string +} + +var embyAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`) + +// parseEmbyAuth reads the MediaBrowser/Emby authorization header, e.g. +// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`. +func parseEmbyAuth(r *http.Request) embyAuth { + var a embyAuth + h := r.Header.Get("X-Emby-Authorization") + if h == "" { + h = r.Header.Get("Authorization") + } + for _, m := range embyAuthField.FindAllStringSubmatch(h, -1) { + switch m[1] { + case "Client": + a.Client = m[2] + case "Device": + a.Device = m[2] + case "DeviceId": + a.DeviceId = m[2] + case "Version": + a.Version = m[2] + case "Token": + a.Token = m[2] + } + } + return a +} + +func tokenFromRequest(r *http.Request) string { + if t := r.Header.Get("X-Emby-Token"); t != "" { + return t + } + if t := r.Header.Get("X-MediaBrowser-Token"); t != "" { + return t + } + if t := parseEmbyAuth(r).Token; t != "" { + return t + } + return r.URL.Query().Get("api_key") +} + +func (api *Router) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + token := tokenFromRequest(r) + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + claims, err := auth.Validate(token) + if err != nil || claims.Subject == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + usr, err := api.ds.User(ctx).FindByUsername(claims.Subject) + if err != nil { + log.Warn(ctx, "Jellyfin API: token subject not found", "user", claims.Subject, err) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx = request.WithUser(ctx, *usr) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go new file mode 100644 index 000000000..585743b9b --- /dev/null +++ b/server/jellyfin/middlewares_test.go @@ -0,0 +1,53 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "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(nil).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + tokenFor := func(name string) string { + t, err := auth.CreateToken(&model.User{ID: "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("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)) + }) +})