navidrome/server/jellyfin/quickconnect_test.go
Deluan Quintão b76ae14286
feat(jellyfin): add Quick Connect sign-in (#6174)
* feat(jellyfin): add Quick Connect sign-in

Jellyfin clients can now sign in without a password: the client shows a
6-digit code, a signed-in user approves it, and the client redeems a secret
for its access token.

- core/quickconnect: in-memory store shared by both routers through wire.
  Codes expire after 10 minutes; a secret redeems only once (Jellyfin
  allows repeats for 10 minutes); at most 1000 pending requests.
- Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect.
  Admins may approve for another user via UserId, like Swiftfin's admin page.
  Initiate and redeem share the login rate limiter; Connect does not, since
  Finamp and Streamyfin poll it every second.
- Web UI: a Quick Connect item in the user menu looks up the code and shows
  the app and device before approving, so a user can't be tricked into
  approving an unknown device blindly.
- Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters
  when the Jellyfin API is enabled.

* refactor(jellyfin): tidy Quick Connect naming and route guards

Group the Quick Connect routes under one requireQuickConnect guard, make the
request's device a named field so req.Device.ID can't be mistaken for a
request id, and rename the web API response type to quickConnectDevice.

* refactor(jellyfin): remove duplicated Jellyfin date formatting function

* test(jellyfin): set play count and starred in the song fixture literal

* refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper

* fix(jellyfin): bound the client fields Quick Connect keeps in memory

Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version
header fields for up to ten minutes. With no header size limit, each pending
request could hold about 1 MB, and even a short field kept the whole header
alive because the parsed values are substrings of it. Reject fields over 512
bytes and copy the stored values.

Also answer 500 instead of 401 when the redeem user lookup fails for a reason
other than the user being gone.

* fix(jellyfin): rate-limit Quick Connect code approval

Any signed-in user could try codes without limit on the Jellyfin Authorize
endpoint and the web UI lookup/authorize endpoints, and so could approve
another person's pending device for their own account. Apply the same per-IP
limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
2026-09-19 14:57:01 -04:00

301 lines
10 KiB
Go

package jellyfin
import (
"context"
"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(context.Background()).(*tests.MockedUserRepo)
for _, u := range []model.User{alice, bob, admin} {
Expect(ur.Put(&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(context.Background()).(*tests.MockedUserRepo).Error = errors.New("db down")
Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError))
})
})
})