package jellyfin import ( "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) type fullQuickConnect struct{ quickconnect.QuickConnect } func (fullQuickConnect) Initiate(quickconnect.Device) (quickconnect.Request, error) { return quickconnect.Request{}, quickconnect.ErrTooManyRequests } var _ = Describe("QuickConnect", func() { const finamp = `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="dev-1", Version="1.0.0"` var ( api *Router ds *tests.MockDataStore qc quickconnect.QuickConnect alice = model.User{ID: testID("alice"), UserName: "alice"} bob = model.User{ID: testID("bob"), UserName: "bob"} admin = model.User{ID: testID("admin"), UserName: "admin", IsAdmin: true} ) BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Jellyfin.QuickConnect = true ds = &tests.MockDataStore{} auth.Init(ds) ur := ds.User().(*tests.MockedUserRepo) for _, u := range []model.User{alice, bob, admin} { Expect(ur.Put(GinkgoT().Context(), &u)).To(Succeed()) } qc = quickconnect.New() api = &Router{ds: ds, quickConnect: qc} }) as := func(r *http.Request, u model.User) *http.Request { return r.WithContext(request.WithUser(r.Context(), u)) } initiate := func() quickconnect.Request { req, err := qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) Expect(err).ToNot(HaveOccurred()) return req } Describe("GET /QuickConnect/Enabled", func() { DescribeTable("reports the config value", func(enabled bool, expected string) { conf.Server.Jellyfin.QuickConnect = enabled w := httptest.NewRecorder() api.quickConnectEnabled(w, httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(MatchJSON(expected)) }, Entry("enabled", true, "true"), Entry("disabled", false, "false"), ) }) Describe("requireQuickConnect", func() { next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) }) It("rejects requests with 401 when Quick Connect is disabled", func() { conf.Server.Jellyfin.QuickConnect = false w := httptest.NewRecorder() requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) It("passes requests through when Quick Connect is enabled", func() { w := httptest.NewRecorder() requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) Expect(w.Code).To(Equal(http.StatusTeapot)) }) }) Describe("POST /QuickConnect/Initiate", func() { initiateWith := func(authHeader string) *httptest.ResponseRecorder { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/QuickConnect/Initiate", nil) r.Header.Set("Authorization", authHeader) api.quickConnectInitiate(w, r) return w } It("returns all QuickConnectResult fields, with the device from the auth header", func() { w := initiateWith(finamp) Expect(w.Code).To(Equal(http.StatusOK)) var raw map[string]any Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) // sdk-kotlin declares every field non-null. Expect(raw).To(HaveKeyWithValue("Authenticated", false)) Expect(raw).To(HaveKeyWithValue("Secret", MatchRegexp(`^[0-9a-f]{64}$`))) Expect(raw).To(HaveKeyWithValue("Code", MatchRegexp(`^\d{6}$`))) Expect(raw).To(HaveKeyWithValue("DeviceId", "dev-1")) Expect(raw).To(HaveKeyWithValue("DeviceName", "Pixel 7")) Expect(raw).To(HaveKeyWithValue("AppName", "Finamp")) Expect(raw).To(HaveKeyWithValue("AppVersion", "1.0.0")) Expect(raw).To(HaveKeyWithValue("DateAdded", Not(BeEmpty()))) _, err := qc.Status(raw["Secret"].(string)) Expect(err).ToNot(HaveOccurred()) }) It("returns 400 when the auth header does not identify the client", func() { w := initiateWith(`MediaBrowser Client="Finamp", Device="Pixel 7", Version="1.0.0"`) Expect(w.Code).To(Equal(http.StatusBadRequest)) }) It("returns 400 when a client field is oversized", func() { long := strings.Repeat("x", maxQuickConnectField+1) api.quickConnect = fullQuickConnect{qc} w := initiateWith(`MediaBrowser Client="Finamp", Device="` + long + `", DeviceId="dev-1", Version="1.0.0"`) Expect(w.Code).To(Equal(http.StatusBadRequest)) }) It("returns 429 when too many requests are pending", func() { api.quickConnect = fullQuickConnect{qc} Expect(initiateWith(finamp).Code).To(Equal(http.StatusTooManyRequests)) }) }) Describe("GET /QuickConnect/Connect", func() { connect := func(secret string) (int, dto.QuickConnectResult) { w := httptest.NewRecorder() invoke(api.quickConnectConnect, w, httptest.NewRequest("GET", "/QuickConnect/Connect?Secret="+secret, nil)) var res dto.QuickConnectResult if w.Code == http.StatusOK { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) } return w.Code, res } It("reports whether the request has been authorized", func() { req := initiate() code, res := connect(req.Secret) Expect(code).To(Equal(http.StatusOK)) Expect(res.Authenticated).To(BeFalse()) Expect(res.Code).To(Equal(req.Code)) _, err := qc.Authorize(req.Code, alice.ID) Expect(err).ToNot(HaveOccurred()) code, res = connect(req.Secret) Expect(code).To(Equal(http.StatusOK)) Expect(res.Authenticated).To(BeTrue()) }) It("returns 404 for an unknown secret", func() { code, _ := connect("unknown") Expect(code).To(Equal(http.StatusNotFound)) }) }) Describe("POST /QuickConnect/Authorize", func() { authorize := func(query string, u model.User) *httptest.ResponseRecorder { w := httptest.NewRecorder() invoke(api.quickConnectAuthorize, w, as(httptest.NewRequest("POST", "/QuickConnect/Authorize?"+query, nil), u)) return w } approver := func(req quickconnect.Request) string { got, err := qc.Status(req.Secret) Expect(err).ToNot(HaveOccurred()) return got.UserID } It("approves the code for the caller when no userId is given", func() { req := initiate() w := authorize("code="+req.Code, alice) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(MatchJSON("true")) Expect(approver(req)).To(Equal(alice.ID)) }) It("accepts the caller's own userId", func() { req := initiate() w := authorize("Code="+req.Code+"&UserId="+dto.EncodeID(alice.ID), alice) Expect(w.Code).To(Equal(http.StatusOK)) Expect(approver(req)).To(Equal(alice.ID)) }) It("forbids a non-admin from approving for another user", func() { req := initiate() w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), alice) Expect(w.Code).To(Equal(http.StatusForbidden)) Expect(approver(req)).To(BeEmpty()) }) It("lets an admin approve for another user", func() { req := initiate() w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), admin) Expect(w.Code).To(Equal(http.StatusOK)) Expect(approver(req)).To(Equal(bob.ID)) }) It("returns 404 when an admin approves for an unknown user", func() { req := initiate() w := authorize("code="+req.Code+"&userId="+dto.EncodeID(testID("ghost")), admin) Expect(w.Code).To(Equal(http.StatusNotFound)) Expect(approver(req)).To(BeEmpty()) }) It("returns 400 for a malformed userId", func() { req := initiate() w := authorize("code="+req.Code+"&userId=not-a-guid", admin) Expect(w.Code).To(Equal(http.StatusBadRequest)) }) It("returns 404 for an unknown code", func() { w := authorize("code=000000", alice) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 409 for a code that is already approved", func() { req := initiate() Expect(authorize("code="+req.Code, alice).Code).To(Equal(http.StatusOK)) Expect(authorize("code="+req.Code, bob).Code).To(Equal(http.StatusConflict)) Expect(approver(req)).To(Equal(alice.ID)) }) }) Describe("POST /Users/AuthenticateWithQuickConnect", func() { redeem := func(body string) *httptest.ResponseRecorder { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/AuthenticateWithQuickConnect", strings.NewReader(body)) r.Header.Set("Authorization", finamp) api.authenticateWithQuickConnect(w, r) return w } redeemSecret := func(secret string) *httptest.ResponseRecorder { return redeem(`{"Secret":"` + secret + `"}`) } It("signs in the approving user once", func() { req := initiate() _, _ = qc.Authorize(req.Code, alice.ID) w := redeemSecret(req.Secret) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.AuthenticationResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.User.Name).To(Equal("alice")) Expect(res.ServerId).ToNot(BeEmpty()) Expect(res.SessionInfo).ToNot(BeNil()) Expect(res.SessionInfo.DeviceId).To(Equal("dev-1")) claims, err := auth.Validate(res.AccessToken) Expect(err).ToNot(HaveOccurred()) Expect(claims.Subject).To(Equal("alice")) Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) }) It("accepts a camelCase body", func() { req := initiate() _, _ = qc.Authorize(req.Code, alice.ID) Expect(redeem(`{"secret":"` + req.Secret + `"}`).Code).To(Equal(http.StatusOK)) }) It("returns 404 while the request is not approved", func() { req := initiate() Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) }) DescribeTable("returns 400 for a bad body", func(body string) { Expect(redeem(body).Code).To(Equal(http.StatusBadRequest)) }, Entry("not JSON", `nope`), Entry("no secret", `{}`), ) It("returns 401 when the approving user no longer exists", func() { req := initiate() _, _ = qc.Authorize(req.Code, testID("ghost")) Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusUnauthorized)) }) It("returns 500 when the user lookup fails", func() { req := initiate() _, _ = qc.Authorize(req.Code, alice.ID) ds.User().(*tests.MockedUserRepo).Error = errors.New("db down") Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError)) }) }) })