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