package subsonic import ( "context" "crypto/md5" "encoding/hex" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "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" ) func newGetRequest(queryParams ...string) *http.Request { r := httptest.NewRequest("GET", "/ping?"+strings.Join(queryParams, "&"), nil) ctx := r.Context() return r.WithContext(log.NewContext(ctx)) } func newPostRequest(queryParam string, formFields ...string) *http.Request { r, err := http.NewRequest("POST", "/ping?"+queryParam, strings.NewReader(strings.Join(formFields, "&"))) if err != nil { panic(err) } r.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") ctx := r.Context() return r.WithContext(log.NewContext(ctx)) } var _ = Describe("Middlewares", func() { var ctx context.Context var next *mockHandler var w *httptest.ResponseRecorder var ds model.DataStore BeforeEach(func() { ctx = GinkgoT().Context() next = &mockHandler{} w = httptest.NewRecorder() ds = &tests.MockDataStore{} }) Describe("ParsePostForm", func() { It("converts any filed in a x-www-form-urlencoded POST into query params", func() { r := newPostRequest("a=abc", "u=user", "v=1.15", "c=test") cp := postFormToQueryParams(next) cp.ServeHTTP(w, r) Expect(next.req.URL.Query().Get("a")).To(Equal("abc")) Expect(next.req.URL.Query().Get("u")).To(Equal("user")) Expect(next.req.URL.Query().Get("v")).To(Equal("1.15")) Expect(next.req.URL.Query().Get("c")).To(Equal("test")) }) It("adds repeated params", func() { r := newPostRequest("a=abc", "id=1", "id=2") cp := postFormToQueryParams(next) cp.ServeHTTP(w, r) Expect(next.req.URL.Query().Get("a")).To(Equal("abc")) Expect(next.req.URL.Query()["id"]).To(ConsistOf("1", "2")) }) It("overrides query params with same key", func() { r := newPostRequest("a=query", "a=body") cp := postFormToQueryParams(next) cp.ServeHTTP(w, r) Expect(next.req.URL.Query().Get("a")).To(Equal("body")) }) }) Describe("CheckParams", func() { It("passes when all required params are available (subsonicauth case)", func() { r := newGetRequest("u=user", "v=1.15", "c=test") cp := checkRequiredParameters(next) cp.ServeHTTP(w, r) username, _ := request.UsernameFrom(next.req.Context()) Expect(username).To(Equal("user")) version, _ := request.VersionFrom(next.req.Context()) Expect(version).To(Equal("1.15")) client, _ := request.ClientFrom(next.req.Context()) Expect(client).To(Equal("test")) Expect(next.called).To(BeTrue()) }) It("passes when all required params are available (reverse-proxy case)", func() { conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32" conf.Server.ExtAuth.UserHeader = "Remote-User" r := newGetRequest("v=1.15", "c=test") r.Header.Add("Remote-User", "user") r = r.WithContext(request.WithReverseProxyIp(r.Context(), "127.0.0.234")) cp := checkRequiredParameters(next) cp.ServeHTTP(w, r) username, _ := request.UsernameFrom(next.req.Context()) Expect(username).To(Equal("user")) version, _ := request.VersionFrom(next.req.Context()) Expect(version).To(Equal("1.15")) client, _ := request.ClientFrom(next.req.Context()) Expect(client).To(Equal("test")) Expect(next.called).To(BeTrue()) }) It("does not require u when apiKey is present", func() { r := newGetRequest("apiKey=nds_abc", "v=1.15", "c=test") cp := checkRequiredParameters(next) cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) }) It("fails when user is missing", func() { r := newGetRequest("v=1.15", "c=test") cp := checkRequiredParameters(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="10"`)) Expect(next.called).To(BeFalse()) }) It("fails when version is missing", func() { r := newGetRequest("u=user", "c=test") cp := checkRequiredParameters(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="10"`)) Expect(next.called).To(BeFalse()) }) It("fails when client is missing", func() { r := newGetRequest("u=user", "v=1.15") cp := checkRequiredParameters(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="10"`)) Expect(next.called).To(BeFalse()) }) }) Describe("Authenticate", func() { BeforeEach(func() { ur := ds.User() _ = ur.Put(ctx, &model.User{ UserName: "admin", NewPassword: "wordpass", }) }) When("using password authentication", func() { It("passes authentication with correct credentials", func() { r := newGetRequest("u=admin", "p=wordpass") cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) user, _ := request.UserFrom(next.req.Context()) Expect(user.UserName).To(Equal("admin")) }) It("fails authentication with invalid user", func() { r := newGetRequest("u=invalid", "p=wordpass") cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) It("fails authentication with invalid password", func() { r := newGetRequest("u=admin", "p=INVALID") cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) When("using token authentication", func() { var salt = "12345" It("passes authentication with correct token", func() { token := fmt.Sprintf("%x", md5.Sum([]byte("wordpass"+salt))) r := newGetRequest("u=admin", "t="+token, "s="+salt) cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) user, _ := request.UserFrom(next.req.Context()) Expect(user.UserName).To(Equal("admin")) }) It("fails authentication with invalid token", func() { r := newGetRequest("u=admin", "t=INVALID", "s="+salt) cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) It("fails authentication with empty password", func() { // Token generated with random Salt, empty password token := fmt.Sprintf("%x", md5.Sum([]byte(""+salt))) r := newGetRequest("u=NON_EXISTENT_USER", "t="+token, "s="+salt) cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) When("using JWT authentication", func() { var validToken string BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) }) It("passes authentication with correct token", func() { usr := &model.User{UserName: "admin"} var err error validToken, err = auth.CreateToken(usr) Expect(err).NotTo(HaveOccurred()) r := newGetRequest("u=admin", "jwt="+validToken) cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) user, _ := request.UserFrom(next.req.Context()) Expect(user.UserName).To(Equal("admin")) }) It("fails authentication with invalid token", func() { r := newGetRequest("u=admin", "jwt=INVALID_TOKEN") cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) When("using reverse proxy authentication", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" conf.Server.ExtAuth.UserHeader = "Remote-User" }) It("passes authentication with correct IP and header", func() { r := newGetRequest("u=admin") r.Header.Add("Remote-User", "admin") r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) user, _ := request.UserFrom(next.req.Context()) Expect(user.UserName).To(Equal("admin")) }) It("fails authentication with wrong IP", func() { r := newGetRequest("u=admin") r.Header.Add("Remote-User", "admin") r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.2.1")) cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) When("using internal authentication", func() { It("passes authentication with correct internal credentials", func() { // Simulate internal authentication by setting the context with WithInternalAuth r := newGetRequest() r = r.WithContext(request.WithInternalAuth(r.Context(), "admin")) cp := authenticate(ds)(next) cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) user, _ := request.UserFrom(next.req.Context()) Expect(user.UserName).To(Equal("admin")) }) It("fails authentication with missing internal context", func() { r := newGetRequest("u=admin") // Do not set the internal auth context cp := authenticate(ds)(next) cp.ServeHTTP(w, r) // Internal auth requires the context, so this should fail Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) When("using API key authentication", func() { var key string serve := func(params ...string) { authenticate(ds)(next).ServeHTTP(w, newGetRequest(params...)) } BeforeEach(func() { usr, err := ds.User().FindByUsername(ctx, "admin") Expect(err).ToNot(HaveOccurred()) Expect(ds.Player().Put(ctx, &model.Player{ID: "player-1", Name: "My Phone", UserId: usr.ID, Client: "Symfonium"})).To(Succeed()) key = "nds_0123456789abcdefghijkl" Expect(ds.Player().SetAPIKey(ctx, "player-1", key)).To(Succeed()) }) It("authenticates the owner and binds the key's player", func() { serve("apiKey=" + key) Expect(next.called).To(BeTrue()) user, _ := request.UserFrom(next.req.Context()) Expect(user.UserName).To(Equal("admin")) username, _ := request.UsernameFrom(next.req.Context()) Expect(username).To(Equal("admin")) player, ok := request.PlayerFrom(next.req.Context()) Expect(ok).To(BeTrue()) Expect(player.ID).To(Equal("player-1")) }) It("accepts the key in a POST form body", func() { r := newPostRequest("", "apiKey="+key) cp := postFormToQueryParams(authenticate(ds)(next)) cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) player, _ := request.PlayerFrom(next.req.Context()) Expect(player.ID).To(Equal("player-1")) }) It("rejects an unknown key with error 44", func() { serve("apiKey=nds_unknown") Expect(w.Body.String()).To(ContainSubstring(`code="44"`)) Expect(next.called).To(BeFalse()) }) DescribeTable("rejects apiKey mixed with other credentials with error 43", func(extra string) { serve("apiKey="+key, extra) Expect(w.Body.String()).To(ContainSubstring(`code="43"`)) Expect(next.called).To(BeFalse()) }, Entry("u", "u=admin"), Entry("p", "p=wordpass"), Entry("t", "t=abc"), Entry("s", "s=abc"), Entry("jwt", "jwt=abc"), Entry("empty u", "u="), Entry("empty p", "p="), ) Context("key sent as the password", func() { It("authenticates and binds the key's player", func() { serve("u=admin", "p="+key) Expect(next.called).To(BeTrue()) player, ok := request.PlayerFrom(next.req.Context()) Expect(ok).To(BeTrue()) Expect(player.ID).To(Equal("player-1")) }) It("accepts the hex-encoded form", func() { serve("u=admin", "p=enc:"+hex.EncodeToString([]byte(key))) Expect(next.called).To(BeTrue()) }) It("still accepts a real password that starts with the key prefix", func() { Expect(ds.User().Put(ctx, &model.User{UserName: "prefixed", NewPassword: "nds_secret"})).To(Succeed()) serve("u=prefixed", "p=nds_secret") Expect(next.called).To(BeTrue()) _, ok := request.PlayerFrom(next.req.Context()) Expect(ok).To(BeFalse()) }) It("rejects another user's key with error 40", func() { Expect(ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "pw"})).To(Succeed()) serve("u=other", "p="+key) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) }) When("failed attempts reach AuthRequestLimit", func() { var cp http.Handler BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 3 conf.Server.AuthWindowLength = time.Minute cp = authenticate(ds)(next) }) serve := func(r *http.Request) *httptest.ResponseRecorder { next.called = false rec := httptest.NewRecorder() cp.ServeHTTP(rec, r) return rec } failTimes := func(n int, params ...string) { for range n { Expect(serve(newGetRequest(params...)).Body.String()).To(ContainSubstring(`code="40"`)) } } It("rejects the correct password exactly like a wrong one", func() { failTimes(3, "u=admin", "p=WRONG") rec := serve(newGetRequest("u=admin", "p=wordpass")) Expect(next.called).To(BeFalse()) Expect(rec.Code).To(Equal(http.StatusOK)) Expect(rec.Body.String()).To(ContainSubstring(`code="40"`)) Expect(rec.Header().Get("Retry-After")).To(BeEmpty()) }) It("counts attempts against unknown usernames", func() { failTimes(3, "u=newuser", "p=secret") _ = ds.User().Put(ctx, &model.User{UserName: "newuser", NewPassword: "secret"}) serve(newGetRequest("u=newuser", "p=secret")) Expect(next.called).To(BeFalse()) }) It("treats usernames case-insensitively", func() { failTimes(3, "u=ADMIN", "p=WRONG") serve(newGetRequest("u=admin", "p=wordpass")) Expect(next.called).To(BeFalse()) }) It("does not count successful logins", func() { for range 10 { serve(newGetRequest("u=admin", "p=wordpass")) Expect(next.called).To(BeTrue()) } }) It("does not count server errors", func() { userRepo := ds.User().(*tests.MockedUserRepo) userRepo.Error = errors.New("db down") failTimes(5, "u=admin", "p=wordpass") userRepo.Error = nil serve(newGetRequest("u=admin", "p=wordpass")) Expect(next.called).To(BeTrue()) }) It("does not count server errors when a key is sent as the password", func() { usr, _ := ds.User().FindByUsername(ctx, "admin") playerRepo := ds.Player().(*tests.MockPlayerRepo) Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) key := "nds_0123456789abcdefghijkl" Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) playerRepo.Error = errors.New("db down") failTimes(5, "u=admin", "p="+key) playerRepo.Error = nil serve(newGetRequest("u=admin", "p="+key)) Expect(next.called).To(BeTrue()) }) It("does not block other usernames from the same IP", func() { _ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"}) failTimes(3, "u=admin", "p=WRONG") serve(newGetRequest("u=other", "p=otherpass")) Expect(next.called).To(BeTrue()) }) It("does not block the same username from another IP", func() { failTimes(3, "u=admin", "p=WRONG") r := newGetRequest("u=admin", "p=wordpass") r.RemoteAddr = "198.51.100.7:1234" serve(r) Expect(next.called).To(BeTrue()) }) It("does not limit reverse proxy authentication", func() { conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" conf.Server.ExtAuth.UserHeader = "Remote-User" failTimes(3, "u=admin", "p=WRONG") r := newGetRequest() r.Header.Add("Remote-User", "admin") r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) serve(r) Expect(next.called).To(BeTrue()) }) It("throttles a repeated bad key without locking out valid keys from the same IP", func() { usr, _ := ds.User().FindByUsername(ctx, "admin") playerRepo := ds.Player().(*tests.MockPlayerRepo) Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) key := "nds_0123456789abcdefghijkl" Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) for range 3 { Expect(serve(newGetRequest("apiKey=nds_bad")).Body.String()).To(ContainSubstring(`code="44"`)) } playerRepo.APIKeys["nds_bad"] = "player-1" rec := serve(newGetRequest("apiKey=nds_bad")) Expect(next.called).To(BeFalse()) Expect(rec.Body.String()).To(ContainSubstring(`code="44"`)) serve(newGetRequest("apiKey=" + key)) Expect(next.called).To(BeTrue()) }) It("is disabled when AuthRequestLimit is 0", func() { conf.Server.AuthRequestLimit = 0 cp = authenticate(ds)(next) failTimes(10, "u=admin", "p=WRONG") serve(newGetRequest("u=admin", "p=wordpass")) Expect(next.called).To(BeTrue()) }) }) When("valid requests overlap", func() { var gate *gatedUserRepo var gatedDS model.DataStore BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 5 conf.Server.AuthWindowLength = time.Minute gate = &gatedUserRepo{ UserRepository: ds.User(), entered: make(chan struct{}, 64), proceed: make(chan struct{}), } gatedDS = &gatedDataStore{DataStore: ds, users: gate} }) It("lets every valid request through while checks are in flight", func() { const burst = 6 cp := authenticate(gatedDS)(&countingHandler{}) var passed atomic.Int32 var wg sync.WaitGroup for range burst { wg.Go(func() { rec := httptest.NewRecorder() cp.ServeHTTP(rec, newGetRequest("u=admin", "p=wordpass")) if !strings.Contains(rec.Body.String(), `code="40"`) { passed.Add(1) } }) } for range conf.Server.AuthRequestLimit { Eventually(gate.entered).Should(Receive()) } close(gate.proceed) wg.Wait() Expect(passed.Load()).To(Equal(int32(burst))) }) It("caps concurrent credential checks for wrong passwords", func() { cp := authenticate(gatedDS)(&countingHandler{}) var wg sync.WaitGroup for i := range 100 { wg.Go(func() { cp.ServeHTTP(httptest.NewRecorder(), newGetRequest("u=admin", fmt.Sprintf("p=wrong%d", i))) }) } limit := int32(conf.Server.AuthRequestLimit) Eventually(gate.lookups.Load).Should(Equal(limit)) Consistently(gate.lookups.Load, 100*time.Millisecond).Should(Equal(limit)) close(gate.proceed) wg.Wait() }) }) }) Describe("AdminOnly", func() { It("passes admin users", func() { r := newGetRequest() r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true})) adminOnly(next).ServeHTTP(w, r) Expect(next.called).To(BeTrue()) }) It("rejects non-admin users", func() { r := newGetRequest() r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false})) adminOnly(next).ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="50"`)) Expect(next.called).To(BeFalse()) }) It("returns an internal error when user is missing from context", func() { r := newGetRequest() adminOnly(next).ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="0"`)) Expect(next.called).To(BeFalse()) }) }) Describe("GetPlayer", func() { var mockedPlayers *mockPlayers var r *http.Request BeforeEach(func() { mockedPlayers = &mockPlayers{} r = newGetRequest() ctx := request.WithUsername(r.Context(), "someone") ctx = request.WithClient(ctx, "client") r = r.WithContext(ctx) }) It("returns a new player in the cookies when none is specified", func() { gp := getPlayer(mockedPlayers)(next) gp.ServeHTTP(w, r) cookieStr := w.Header().Get("Set-Cookie") Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone"))) }) It("does not add the cookie if there was an error", func() { ctx := request.WithClient(r.Context(), "error") r = r.WithContext(ctx) gp := getPlayer(mockedPlayers)(next) gp.ServeHTTP(w, r) cookieStr := w.Header().Get("Set-Cookie") Expect(cookieStr).To(BeEmpty()) }) Context("player bound by an API key", func() { BeforeEach(func() { r = r.WithContext(request.WithPlayer(r.Context(), model.Player{ID: "keyed"})) gp := getPlayer(mockedPlayers)(next) gp.ServeHTTP(w, r) }) It("uses the key's player", func() { Expect(mockedPlayers.touched).To(BeTrue()) player, _ := request.PlayerFrom(next.req.Context()) Expect(player.ID).To(Equal("keyed")) }) It("does not set the player cookie", func() { Expect(w.Header().Get("Set-Cookie")).To(BeEmpty()) }) }) Context("PlayerId specified in Cookies", func() { BeforeEach(func() { cookie := &http.Cookie{ Name: playerIDCookieName("someone"), Value: "123", MaxAge: consts.CookieExpiry, } r.AddCookie(cookie) gp := getPlayer(mockedPlayers)(next) gp.ServeHTTP(w, r) }) It("stores the player in the context", func() { Expect(next.called).To(BeTrue()) player, _ := request.PlayerFrom(next.req.Context()) Expect(player.ID).To(Equal("123")) _, ok := request.TranscodingFrom(next.req.Context()) Expect(ok).To(BeFalse()) }) It("returns the playerId in the cookie", func() { cookieStr := w.Header().Get("Set-Cookie") Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123")) }) }) Context("Player has transcoding configured", func() { BeforeEach(func() { cookie := &http.Cookie{ Name: playerIDCookieName("someone"), Value: "123", MaxAge: consts.CookieExpiry, } r.AddCookie(cookie) mockedPlayers.transcoding = &model.Transcoding{ID: "12"} gp := getPlayer(mockedPlayers)(next) gp.ServeHTTP(w, r) }) It("stores the player in the context", func() { player, _ := request.PlayerFrom(next.req.Context()) Expect(player.ID).To(Equal("123")) transcoding, _ := request.TranscodingFrom(next.req.Context()) Expect(transcoding.ID).To(Equal("12")) }) }) }) Describe("validateCredentials", func() { var usr *model.User BeforeEach(func() { ur := ds.User() _ = ur.Put(ctx, &model.User{ UserName: "admin", NewPassword: "wordpass", }) var err error usr, err = ur.FindByUsernameWithPassword(ctx, "admin") if err != nil { panic(err) } }) Context("Plaintext password", func() { It("authenticates with plaintext password ", func() { err := validateCredentials(usr, "wordpass", "", "", "") Expect(err).NotTo(HaveOccurred()) }) It("fails authentication with wrong password", func() { err := validateCredentials(usr, "INVALID", "", "", "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) Context("Encoded password", func() { It("authenticates with simple encoded password ", func() { err := validateCredentials(usr, "enc:776f726470617373", "", "", "") Expect(err).NotTo(HaveOccurred()) }) }) Context("Token based authentication", func() { It("authenticates with token based authentication", func() { err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "") Expect(err).NotTo(HaveOccurred()) }) It("fails if salt is missing", func() { err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "", "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) Context("JWT based authentication", func() { var usr *model.User var validToken string BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) usr = &model.User{UserName: "admin"} var err error validToken, err = auth.CreateToken(usr) if err != nil { panic(err) } }) It("authenticates with JWT token based authentication", func() { err := validateCredentials(usr, "", "", "", validToken) Expect(err).NotTo(HaveOccurred()) }) It("fails if JWT token is invalid", func() { err := validateCredentials(usr, "", "", "", "invalid.token") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) It("fails if JWT token sub is different than username", func() { u := &model.User{UserName: "hacker"} validToken, _ = auth.CreateToken(u) err := validateCredentials(usr, "", "", "", validToken) Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) Context("JWT credentials", func() { var usr *model.User BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} }) It("accepts an unscoped session token", func() { tokenStr, err := auth.CreateToken(usr) Expect(err).ToNot(HaveOccurred()) Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed()) }) It("rejects a jellyfin-scoped token", func() { tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) Expect(err).ToNot(HaveOccurred()) Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) }) It("rejects a token with a stale epoch", func() { tokenStr, err := auth.CreateToken(usr) Expect(err).ToNot(HaveOccurred()) usr.TokenEpoch = 2 Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) }) }) }) }) type mockHandler struct { req *http.Request called bool } func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { mh.req = r mh.called = true w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } type mockPlayers struct { core.Players transcoding *model.Transcoding touched bool } func (mp *mockPlayers) Touch(_ context.Context, plr model.Player, _, _, _ string) (*model.Player, *model.Transcoding, error) { mp.touched = true return &plr, mp.transcoding, nil } func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) { return &model.Player{ID: playerId}, nil } func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) { if client == "error" { return nil, nil, errors.New(client) } return &model.Player{ID: id}, mp.transcoding, nil } type gatedDataStore struct { model.DataStore users model.UserRepository } func (g *gatedDataStore) User() model.UserRepository { return g.users } type gatedUserRepo struct { model.UserRepository entered chan struct{} proceed chan struct{} lookups atomic.Int32 } func (g *gatedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { g.lookups.Add(1) g.entered <- struct{}{} <-g.proceed return g.UserRepository.FindByUsernameWithPassword(ctx, username) } type countingHandler struct{ calls atomic.Int32 } func (c *countingHandler) ServeHTTP(http.ResponseWriter, *http.Request) { c.calls.Add(1) }