feat(jellyfin): authentication middleware and AuthenticateByName login

This commit is contained in:
Deluan 2026-07-05 14:51:46 -04:00
commit 535d252d17
5 changed files with 243 additions and 0 deletions

View file

@ -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
}

55
server/jellyfin/auth.go Normal file
View file

@ -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,
}
}

View file

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

View file

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

View file

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