diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index d6bf4da07..43ba808d0 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -21,6 +21,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@@ -79,7 +80,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
- router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
+ quickConnect := quickconnect.GetInstance()
+ router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
return router
}
@@ -135,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
- router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
+ quickConnect := quickconnect.GetInstance()
+ router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
return router
}
diff --git a/conf/configuration.go b/conf/configuration.go
index 4812f2515..6f295ebeb 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -236,6 +236,7 @@ type jellyfinOptions struct {
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
ExposedPublicUsers string
AutoDiscovery bool
+ QuickConnect bool
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
@@ -1089,6 +1090,7 @@ func setViperDefaults() {
viper.SetDefault("jellyfin.enabled", false)
viper.SetDefault("jellyfin.servername", "")
viper.SetDefault("jellyfin.autodiscovery", false)
+ viper.SetDefault("jellyfin.quickconnect", true)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
diff --git a/core/quickconnect/quickconnect.go b/core/quickconnect/quickconnect.go
new file mode 100644
index 000000000..9b69bc904
--- /dev/null
+++ b/core/quickconnect/quickconnect.go
@@ -0,0 +1,203 @@
+// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and
+// an already signed-in user approves it, so the client can sign in without a password.
+package quickconnect
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/random"
+ "github.com/navidrome/navidrome/utils/singleton"
+)
+
+const timeout = 10 * time.Minute
+
+// Initiate is unauthenticated, so the number of live requests must be bounded.
+const maxPending = 1000
+
+var (
+ ErrAlreadyAuthorized = errors.New("quick connect request already authorized")
+ ErrTooManyRequests = errors.New("too many pending quick connect requests")
+)
+
+type Device struct {
+ ID string
+ Name string
+ App string
+ AppVersion string
+}
+
+type Request struct {
+ Device Device
+ Secret string
+ Code string
+ DateAdded time.Time
+ UserID string
+}
+
+func (r Request) Authorized() bool {
+ return r.UserID != ""
+}
+
+type QuickConnect interface {
+ Initiate(device Device) (Request, error)
+ Status(secret string) (Request, error)
+ // Lookup returns a request still waiting for approval.
+ Lookup(code string) (Request, error)
+ Authorize(code, userID string) (Request, error)
+ // Redeem returns the id of the user who approved the request. It succeeds only once per secret.
+ Redeem(secret string) (string, error)
+}
+
+type entry struct {
+ Request
+ expiresAt time.Time
+}
+
+type quickConnect struct {
+ mu sync.Mutex
+ bySecret map[string]*entry
+ byCode map[string]*entry
+}
+
+// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests.
+func GetInstance() QuickConnect {
+ return singleton.GetInstance(newStore)
+}
+
+func New() QuickConnect {
+ return newStore()
+}
+
+func newStore() *quickConnect {
+ return &quickConnect{
+ bySecret: map[string]*entry{},
+ byCode: map[string]*entry{},
+ }
+}
+
+// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API.
+func Enabled() bool {
+ return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect
+}
+
+func (qc *quickConnect) Initiate(device Device) (Request, error) {
+ qc.mu.Lock()
+ defer qc.mu.Unlock()
+ qc.expire()
+ if len(qc.bySecret) >= maxPending {
+ return Request{}, ErrTooManyRequests
+ }
+ // The fields may be substrings of a much larger header; copy them so the header isn't retained.
+ device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name),
+ App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)}
+ now := time.Now()
+ e := &entry{
+ Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now},
+ expiresAt: now.Add(timeout),
+ }
+ qc.bySecret[e.Secret] = e
+ qc.byCode[e.Code] = e
+ return e.Request, nil
+}
+
+func (qc *quickConnect) Status(secret string) (Request, error) {
+ qc.mu.Lock()
+ defer qc.mu.Unlock()
+ e, err := qc.find(qc.bySecret, secret)
+ if err != nil {
+ return Request{}, err
+ }
+ return e.Request, nil
+}
+
+func (qc *quickConnect) Lookup(code string) (Request, error) {
+ qc.mu.Lock()
+ defer qc.mu.Unlock()
+ e, err := qc.findPending(code)
+ if err != nil {
+ return Request{}, err
+ }
+ return e.Request, nil
+}
+
+func (qc *quickConnect) Authorize(code, userID string) (Request, error) {
+ qc.mu.Lock()
+ defer qc.mu.Unlock()
+ e, err := qc.findPending(code)
+ if err != nil {
+ return Request{}, err
+ }
+ e.UserID = userID
+ e.expiresAt = time.Now().Add(timeout)
+ return e.Request, nil
+}
+
+func (qc *quickConnect) Redeem(secret string) (string, error) {
+ qc.mu.Lock()
+ defer qc.mu.Unlock()
+ e, err := qc.find(qc.bySecret, secret)
+ if err != nil || !e.Authorized() {
+ return "", model.ErrNotFound
+ }
+ qc.remove(e)
+ return e.UserID, nil
+}
+
+func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) {
+ qc.expire()
+ e, ok := index[key]
+ if !ok {
+ return nil, model.ErrNotFound
+ }
+ return e, nil
+}
+
+func (qc *quickConnect) findPending(code string) (*entry, error) {
+ e, err := qc.find(qc.byCode, normalizeCode(code))
+ if err == nil && e.Authorized() {
+ return nil, ErrAlreadyAuthorized
+ }
+ return e, err
+}
+
+func (qc *quickConnect) expire() {
+ now := time.Now()
+ for _, e := range qc.bySecret {
+ if !now.Before(e.expiresAt) {
+ qc.remove(e)
+ }
+ }
+}
+
+func (qc *quickConnect) remove(e *entry) {
+ delete(qc.bySecret, e.Secret)
+ delete(qc.byCode, e.Code)
+}
+
+func (qc *quickConnect) newCode() string {
+ for {
+ code := fmt.Sprintf("%06d", random.Int64N(900000)+100000)
+ if _, taken := qc.byCode[code]; !taken {
+ return code
+ }
+ }
+}
+
+func newSecret() string {
+ b := make([]byte, 32)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// Users often type the code in groups ("123 456").
+func normalizeCode(code string) string {
+ return strings.Join(strings.Fields(code), "")
+}
diff --git a/core/quickconnect/quickconnect_suite_test.go b/core/quickconnect/quickconnect_suite_test.go
new file mode 100644
index 000000000..029f4e333
--- /dev/null
+++ b/core/quickconnect/quickconnect_suite_test.go
@@ -0,0 +1,17 @@
+package quickconnect
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestQuickConnect(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "QuickConnect Suite")
+}
diff --git a/core/quickconnect/quickconnect_test.go b/core/quickconnect/quickconnect_test.go
new file mode 100644
index 000000000..b03186807
--- /dev/null
+++ b/core/quickconnect/quickconnect_test.go
@@ -0,0 +1,190 @@
+package quickconnect
+
+import (
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}
+
+var _ = Describe("QuickConnect", func() {
+ var qc QuickConnect
+
+ BeforeEach(func() {
+ qc = New()
+ })
+
+ Describe("Initiate", func() {
+ It("creates a pending request with a 6-digit code and a random secret", func() {
+ req, err := qc.Initiate(device)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`))
+ Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`))
+ Expect(req.Device).To(Equal(device))
+ Expect(req.DateAdded).ToNot(BeZero())
+ Expect(req.Authorized()).To(BeFalse())
+ })
+
+ It("never gives two live requests the same code or secret", func() {
+ codes := map[string]bool{}
+ secrets := map[string]bool{}
+ for range 500 {
+ req, err := qc.Initiate(device)
+ Expect(err).ToNot(HaveOccurred())
+ codes[req.Code] = true
+ secrets[req.Secret] = true
+ }
+ Expect(codes).To(HaveLen(500))
+ Expect(secrets).To(HaveLen(500))
+ })
+
+ It("refuses new requests when too many are pending", func() {
+ for range maxPending {
+ _, err := qc.Initiate(device)
+ Expect(err).ToNot(HaveOccurred())
+ }
+ _, err := qc.Initiate(device)
+ Expect(err).To(MatchError(ErrTooManyRequests))
+ })
+ })
+
+ Describe("Status", func() {
+ It("returns the request for a known secret", func() {
+ req, _ := qc.Initiate(device)
+ got, err := qc.Status(req.Secret)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got).To(Equal(req))
+ })
+
+ It("returns ErrNotFound for an unknown secret", func() {
+ _, err := qc.Status("nope")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+ })
+
+ Describe("Lookup", func() {
+ It("finds a request by code, ignoring spaces", func() {
+ req, _ := qc.Initiate(device)
+ got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:])
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got).To(Equal(req))
+ })
+
+ It("returns ErrNotFound for an unknown code", func() {
+ _, err := qc.Lookup("000000")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("refuses a request that is already authorized", func() {
+ req, _ := qc.Initiate(device)
+ _, _ = qc.Authorize(req.Code, "user-1")
+ _, err := qc.Lookup(req.Code)
+ Expect(err).To(MatchError(ErrAlreadyAuthorized))
+ })
+ })
+
+ Describe("Authorize", func() {
+ It("marks the request as authorized by the user", func() {
+ req, _ := qc.Initiate(device)
+ got, err := qc.Authorize(" "+req.Code+" ", "user-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.Authorized()).To(BeTrue())
+ Expect(got.UserID).To(Equal("user-1"))
+
+ status, _ := qc.Status(req.Secret)
+ Expect(status.Authorized()).To(BeTrue())
+ })
+
+ It("refuses a request that is already authorized", func() {
+ req, _ := qc.Initiate(device)
+ _, _ = qc.Authorize(req.Code, "user-1")
+ _, err := qc.Authorize(req.Code, "user-2")
+ Expect(err).To(MatchError(ErrAlreadyAuthorized))
+ })
+
+ It("returns ErrNotFound for an unknown code", func() {
+ _, err := qc.Authorize("000000", "user-1")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+ })
+
+ Describe("Redeem", func() {
+ It("returns the approving user once, then forgets the request", func() {
+ req, _ := qc.Initiate(device)
+ _, _ = qc.Authorize(req.Code, "user-1")
+
+ userID, err := qc.Redeem(req.Secret)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(userID).To(Equal("user-1"))
+
+ _, err = qc.Redeem(req.Secret)
+ Expect(err).To(MatchError(model.ErrNotFound))
+ _, err = qc.Status(req.Secret)
+ Expect(err).To(MatchError(model.ErrNotFound))
+ _, err = qc.Lookup(req.Code)
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("refuses a request that is not authorized yet", func() {
+ req, _ := qc.Initiate(device)
+ _, err := qc.Redeem(req.Secret)
+ Expect(err).To(MatchError(model.ErrNotFound))
+ _, err = qc.Status(req.Secret)
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+})
+
+// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give.
+func TestPendingRequestExpires(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ g := NewWithT(t)
+ qc := New()
+ req, _ := qc.Initiate(device)
+
+ time.Sleep(timeout - time.Second)
+ _, err := qc.Status(req.Secret)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ time.Sleep(2 * time.Second)
+ _, err = qc.Status(req.Secret)
+ g.Expect(err).To(MatchError(model.ErrNotFound))
+ _, err = qc.Authorize(req.Code, "user-1")
+ g.Expect(err).To(MatchError(model.ErrNotFound))
+ })
+}
+
+func TestAuthorizeExtendsExpiry(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ g := NewWithT(t)
+ qc := New()
+ req, _ := qc.Initiate(device)
+
+ time.Sleep(timeout - time.Second)
+ _, err := qc.Authorize(req.Code, "user-1")
+ g.Expect(err).ToNot(HaveOccurred())
+
+ time.Sleep(timeout - time.Second)
+ userID, err := qc.Redeem(req.Secret)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(userID).To(Equal("user-1"))
+ })
+}
+
+func TestExpiredRequestsFreeCapacity(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ g := NewWithT(t)
+ qc := New()
+ for range maxPending {
+ _, _ = qc.Initiate(device)
+ }
+ time.Sleep(timeout + time.Second)
+ _, err := qc.Initiate(device)
+ g.Expect(err).ToNot(HaveOccurred())
+ })
+}
diff --git a/core/wire_providers.go b/core/wire_providers.go
index a09fcc108..b3df9b2dc 100644
--- a/core/wire_providers.go
+++ b/core/wire_providers.go
@@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
)
@@ -32,6 +33,7 @@ var Set = wire.NewSet(
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,
+ quickconnect.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
)
diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md
index 893642cdd..c0c5e6ca1 100644
--- a/server/jellyfin/README.md
+++ b/server/jellyfin/README.md
@@ -24,6 +24,8 @@ ServerName = "My Music Server"
ExposedPublicUsers = "alice, bob"
# Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery".
AutoDiscovery = true
+# Optional: let users sign in new devices with a 6-digit code (default: true). See "Quick Connect".
+QuickConnect = false
# Optional: max collection responses streaming at once (default: half the DB connection pool,
# min 2). Each streaming response holds a DB connection for its whole duration; excess requests
# queue rather than fail.
@@ -37,6 +39,7 @@ ND_JELLYFIN_ENABLED=true
ND_JELLYFIN_SERVERNAME="My Music Server"
ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob"
ND_JELLYFIN_AUTODISCOVERY=true
+ND_JELLYFIN_QUICKCONNECT=false
```
Once enabled, the API is mounted at:
@@ -82,6 +85,27 @@ surface.
Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch.
+### Quick Connect
+
+Quick Connect signs a new device in without typing a password. The client shows a 6-digit code,
+a signed-in user approves it, and the client gets its `AccessToken`. It is on by default
+(`Jellyfin.QuickConnect`); when off, `GET QuickConnect/Enabled` returns `false` and every other
+Quick Connect call returns 401.
+
+1. `POST QuickConnect/Initiate` (public; needs `Client`, `Device`, `DeviceId` and `Version` in the
+ auth header) returns the `Code` and a `Secret`.
+2. The user approves the code, either in the Navidrome web UI (user menu → **Quick Connect**, which
+ shows the app and device before approving) or from a signed-in Jellyfin client with
+ `POST QuickConnect/Authorize?Code=`. Admins may pass `UserId` to approve for another user.
+3. The client polls `GET QuickConnect/Connect?Secret=` until `Authenticated` is `true`.
+4. `POST Users/AuthenticateWithQuickConnect` with `{"Secret": "..."}` returns the same result as
+ `AuthenticateByName`.
+
+Pending codes live in memory and expire after 10 minutes (a server restart drops them). Unlike
+Jellyfin, a secret signs in only once. `Initiate`, `AuthenticateWithQuickConnect` and code approval
+(`Authorize` and the web UI) are rate-limited per IP like the login; `Connect` is not, since some
+clients poll it every second.
+
### Public user list (login picker)
`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the
@@ -140,7 +164,8 @@ returns direct children only (no tracks — no track is a library's direct child
| Area | Endpoints |
|---|---|
-| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` |
+| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated) |
+| Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` |
| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` |
| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` |
| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) |
diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go
index eddd14f66..4a471d1f0 100644
--- a/server/jellyfin/api.go
+++ b/server/jellyfin/api.go
@@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@@ -40,17 +41,18 @@ type Router struct {
broker events.Broker
lyricsCache cache.SimpleCache[string, model.LyricList]
similarFlight singleflight.Group
+ quickConnect quickconnect.QuickConnect
serverIDVal string
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
transcodeDecider stream.TranscodeDecider, players core.Players,
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
- sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router {
+ sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router {
r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
- sonic: sonicSvc, lyrics: lyricsSvc, broker: broker,
+ sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect,
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
SizeLimit: 1000,
DefaultTTL: 5 * time.Minute,
@@ -81,6 +83,11 @@ func (api *Router) routes() http.Handler {
login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
}
login.Post("/users/authenticatebyname", api.authenticateByName)
+ quickConnectLogin := login.With(requireQuickConnect)
+ quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate)
+ quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect)
+ // Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown.
+ inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect)
inner.Get("/users/public", api.getPublicUsers)
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
@@ -107,6 +114,12 @@ func (api *Router) routes() http.Handler {
r.Get("/users/{userId}/views", api.getUserViews)
r.Get("/users/me", api.getCurrentUser)
r.Get("/users/{userId}", api.getCurrentUser)
+ // Throttled like login so a signed-in user cannot enumerate other people's pending codes.
+ approve := r.With(requireQuickConnect)
+ if conf.Server.AuthRequestLimit > 0 {
+ approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
+ }
+ approve.Post("/quickconnect/authorize", api.quickConnectAuthorize)
// Cursor-backed collections: each streams straight from the DB, holding a connection for the
// whole client-paced response, so enough slow clients would take the entire pool and stall the
diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go
index 605303793..a64dcfe8f 100644
--- a/server/jellyfin/api_test.go
+++ b/server/jellyfin/api_test.go
@@ -10,6 +10,7 @@ import (
"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/tests"
. "github.com/onsi/ginkgo/v2"
@@ -19,7 +20,7 @@ import (
var _ = Describe("Router", func() {
It("serves the public handshake through the mounted handler", func() {
ds := &tests.MockDataStore{}
- api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@@ -27,7 +28,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for unknown routes", func() {
- api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
api.ServeHTTP(w, r)
@@ -37,7 +38,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for a known path with an unsupported method", func() {
- api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@@ -54,7 +55,7 @@ var _ = Describe("Router", func() {
Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{}
- api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil)
+ api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/Me", nil)
@@ -71,7 +72,7 @@ var _ = Describe("Router", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 2
conf.Server.AuthWindowLength = time.Minute
- api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
login := func() int {
w := httptest.NewRecorder()
@@ -86,11 +87,38 @@ var _ = Describe("Router", func() {
Expect(login()).To(Equal(http.StatusTooManyRequests))
})
+ It("rate-limits Quick Connect approval by IP when a login limit is configured", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.AuthRequestLimit = 2
+ conf.Server.AuthWindowLength = time.Minute
+ conf.Server.Jellyfin.QuickConnect = true
+ ds := &tests.MockDataStore{}
+ auth.Init(ds)
+ usr := model.User{ID: testID("alice"), UserName: "alice"}
+ Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed())
+ token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin)
+ Expect(err).ToNot(HaveOccurred())
+ api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New())
+
+ authorize := func() int {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/QuickConnect/Authorize?code=000000", nil)
+ r.RemoteAddr = "10.0.0.1:1234"
+ r.Header.Set("X-Emby-Token", token)
+ api.ServeHTTP(w, r)
+ return w.Code
+ }
+ // An unknown code is 404; the limiter cuts in on the 3rd attempt with 429.
+ Expect(authorize()).To(Equal(http.StatusNotFound))
+ Expect(authorize()).To(Equal(http.StatusNotFound))
+ Expect(authorize()).To(Equal(http.StatusTooManyRequests))
+ })
+
It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
conf.Server.AuthWindowLength = time.Minute
- api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Every request arrives on the same proxy connection, so only the resolved client IP
// can separate the buckets.
handler := middleware.ClientIPFromHeader("X-Real-IP")(api)
diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go
index ba4fe40f1..32b4a1432 100644
--- a/server/jellyfin/auth.go
+++ b/server/jellyfin/auth.go
@@ -30,10 +30,15 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
+ api.signIn(w, r, usr)
+}
+
+func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.User) {
+ ctx := r.Context()
// Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a
// never/stale "Last Login" in the admin UI.
if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil {
- log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err)
+ log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err)
}
token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go
index 21e3903f2..4a28eb313 100644
--- a/server/jellyfin/dto/dto.go
+++ b/server/jellyfin/dto/dto.go
@@ -233,6 +233,17 @@ type PlayerStateInfo struct {
PlaybackOrder string `json:"PlaybackOrder"`
}
+type QuickConnectResult struct {
+ Authenticated bool `json:"Authenticated"`
+ Secret string `json:"Secret"`
+ Code string `json:"Code"`
+ DeviceId string `json:"DeviceId"`
+ DeviceName string `json:"DeviceName"`
+ AppName string `json:"AppName"`
+ AppVersion string `json:"AppVersion"`
+ DateAdded string `json:"DateAdded"`
+}
+
type AuthenticationResult struct {
User *UserDto `json:"User"`
SessionInfo *SessionInfo `json:"SessionInfo"`
diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go
index bb17b40c7..fc7e329de 100644
--- a/server/jellyfin/dto/mappers.go
+++ b/server/jellyfin/dto/mappers.go
@@ -51,16 +51,16 @@ func premiereDate(date string, year int) *string {
if err != nil {
return nil
}
- s := jellyfinDate(&parsed)
+ s := JellyfinDate(&parsed)
return &s
}
// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339.
const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00"
-// jellyfinDate formats t as the date string clients expect, or "" for the zero time so the
+// JellyfinDate formats t as the date string clients expect, or "" for the zero time so the
// field is omitted rather than sent as a meaningless epoch.
-func jellyfinDate(t *time.Time) string {
+func JellyfinDate(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
@@ -135,7 +135,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
d.Rating = &r
}
- if s := jellyfinDate(a.PlayDate); s != "" {
+ if s := JellyfinDate(a.PlayDate); s != "" {
d.LastPlayedDate = &s
}
return d
@@ -159,7 +159,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
AlbumId: albumID,
AlbumArtist: mf.AlbumArtist,
RunTimeTicks: TicksFromSeconds(mf.Duration),
- DateCreated: jellyfinDate(&mf.CreatedAt),
+ DateCreated: JellyfinDate(&mf.CreatedAt),
Container: mf.Suffix,
CanDownload: true,
BackdropImageTags: []string{},
@@ -265,7 +265,7 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
ChildCount: new(al.SongCount),
SongCount: new(al.SongCount),
RunTimeTicks: TicksFromSeconds(al.Duration),
- DateCreated: jellyfinDate(&al.CreatedAt),
+ DateCreated: JellyfinDate(&al.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
@@ -318,7 +318,7 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto {
IsFolder: true,
AlbumCount: new(ar.AlbumCount),
SongCount: new(ar.SongCount),
- DateCreated: jellyfinDate(ar.CreatedAt),
+ DateCreated: JellyfinDate(ar.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
@@ -346,7 +346,7 @@ func LibraryToBaseItem(lib model.Library) BaseItemDto {
IsFolder: true,
Path: lib.Path,
LocationType: "FileSystem",
- DateCreated: jellyfinDate(&lib.CreatedAt),
+ DateCreated: JellyfinDate(&lib.CreatedAt),
ChildCount: new(lib.TotalAlbums),
UserData: &UserItemDataDto{Key: id, ItemId: id},
BackdropImageTags: []string{},
@@ -386,7 +386,7 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto {
MediaType: "Audio",
ChildCount: new(p.SongCount),
RunTimeTicks: TicksFromSeconds(p.Duration),
- DateCreated: jellyfinDate(&p.CreatedAt),
+ DateCreated: JellyfinDate(&p.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
@@ -460,7 +460,7 @@ func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, server
DeviceName: deviceName,
ApplicationVersion: version,
ServerId: serverID,
- LastActivityDate: jellyfinDate(&now),
+ LastActivityDate: JellyfinDate(&now),
IsActive: true,
PlayableMediaTypes: []string{"Audio"},
SupportedCommands: []string{},
diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go
index 6dc177363..259904673 100644
--- a/server/jellyfin/dto/mappers_test.go
+++ b/server/jellyfin/dto/mappers_test.go
@@ -17,10 +17,10 @@ var _ = Describe("mappers", func() {
ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
- Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
+ Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
+ PlayCount: 2,
+ Starred: true,
}
- mf.PlayCount = 2
- mf.Starred = true
item := SongToBaseItem(mf, nil)
Expect(item.Type).To(Equal("Audio"))
Expect(item.MediaType).To(Equal("Audio"))
diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go
index 1ee19ea01..aa93f7e38 100644
--- a/server/jellyfin/e2e/e2e_suite_test.go
+++ b/server/jellyfin/e2e/e2e_suite_test.go
@@ -44,6 +44,7 @@ import (
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/storage/storagetest"
@@ -338,6 +339,7 @@ func setupTestDB() {
sonicSvc,
lyrics.NewLyrics(ds, nil),
events.NoopBroker(),
+ quickconnect.New(),
)
}
diff --git a/server/jellyfin/e2e/quickconnect_test.go b/server/jellyfin/e2e/quickconnect_test.go
new file mode 100644
index 000000000..1beffc56f
--- /dev/null
+++ b/server/jellyfin/e2e/quickconnect_test.go
@@ -0,0 +1,91 @@
+package e2e
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/server/jellyfin/dto"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("QuickConnect", func() {
+ BeforeEach(func() {
+ setupTestDB()
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Jellyfin.QuickConnect = true
+ })
+
+ clientReq := func(deviceID, method, path, body string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(method, path, strings.NewReader(body))
+ r.Header.Set("Authorization", `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="`+deviceID+`", Version="1.0"`)
+ r.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, r)
+ return w
+ }
+ initiate := func() dto.QuickConnectResult {
+ var pending dto.QuickConnectResult
+ parseInto(clientReq("new-device", "POST", "/QuickConnect/Initiate", ""), &pending)
+ Expect(pending.Authenticated).To(BeFalse())
+ return pending
+ }
+ redeem := func(deviceID, secret string) *httptest.ResponseRecorder {
+ return clientReq(deviceID, "POST", "/Users/AuthenticateWithQuickConnect", `{"Secret":"`+secret+`"}`)
+ }
+
+ It("signs a new client in with a code approved by a signed-in user", func() {
+ Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("true"))
+ pending := initiate()
+
+ Expect(postAs(regularUser, "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusOK))
+
+ var status dto.QuickConnectResult
+ parseInto(rawReq("GET", "/QuickConnect/Connect?Secret="+pending.Secret, ""), &status)
+ Expect(status.Authenticated).To(BeTrue())
+
+ // Android TV redeems with a different DeviceId than it initiated with.
+ w := redeem("other-device", pending.Secret)
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var res dto.AuthenticationResult
+ parseInto(w, &res)
+ Expect(res.User.Name).To(Equal(regularUser.UserName))
+ Expect(res.SessionInfo).ToNot(BeNil())
+ Expect(res.SessionInfo.DeviceId).To(Equal("other-device"))
+
+ r := httptest.NewRequest("GET", "/Users/Me", nil)
+ r.Header.Set("X-Emby-Token", res.AccessToken)
+ me := httptest.NewRecorder()
+ router.ServeHTTP(me, r)
+ Expect(me.Code).To(Equal(http.StatusOK))
+
+ Expect(redeem("new-device", pending.Secret).Code).To(Equal(http.StatusNotFound))
+ })
+
+ It("lets an admin approve a code for another user", func() {
+ pending := initiate()
+ Expect(post("/QuickConnect/Authorize?Code="+pending.Code+"&UserId="+enc(regularUser.ID), "").Code).
+ To(Equal(http.StatusOK))
+
+ var res dto.AuthenticationResult
+ parseInto(redeem("new-device", pending.Secret), &res)
+ Expect(res.User.Name).To(Equal(regularUser.UserName))
+ })
+
+ It("requires authentication to approve a code", func() {
+ pending := initiate()
+ Expect(rawReq("POST", "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("answers 401 on every Quick Connect call when disabled", func() {
+ conf.Server.Jellyfin.QuickConnect = false
+ Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("false"))
+ Expect(clientReq("new-device", "POST", "/QuickConnect/Initiate", "").Code).To(Equal(http.StatusUnauthorized))
+ Expect(rawReq("GET", "/QuickConnect/Connect?Secret=x", "").Code).To(Equal(http.StatusUnauthorized))
+ Expect(post("/QuickConnect/Authorize?Code=123456", "").Code).To(Equal(http.StatusUnauthorized))
+ Expect(redeem("new-device", "x").Code).To(Equal(http.StatusUnauthorized))
+ })
+})
diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go
index ac8c350c4..51443bdc9 100644
--- a/server/jellyfin/e2e/system_test.go
+++ b/server/jellyfin/e2e/system_test.go
@@ -81,12 +81,4 @@ var _ = Describe("System", func() {
Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome"))
})
})
-
- Describe("GET /QuickConnect/Enabled", func() {
- It("reports QuickConnect disabled", func() {
- w := rawReq("GET", "/QuickConnect/Enabled", "")
- Expect(w.Code).To(Equal(http.StatusOK))
- Expect(strings.TrimSpace(w.Body.String())).To(Equal("false"))
- })
- })
})
diff --git a/server/jellyfin/quickconnect.go b/server/jellyfin/quickconnect.go
new file mode 100644
index 000000000..c478b1894
--- /dev/null
+++ b/server/jellyfin/quickconnect.go
@@ -0,0 +1,145 @@
+package jellyfin
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/core/quickconnect"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server/jellyfin/dto"
+)
+
+func requireQuickConnect(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !conf.Server.Jellyfin.QuickConnect {
+ http.Error(w, "Quick connect is disabled", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
+ api.ok(w, r, conf.Server.Jellyfin.QuickConnect)
+}
+
+// Initiate is unauthenticated and its fields are kept for minutes, so their size must be bounded.
+const maxQuickConnectField = 512
+
+func (api *Router) quickConnectInitiate(w http.ResponseWriter, r *http.Request) {
+ a := parseMediaBrowserAuth(r)
+ if a.Client == "" || a.Device == "" || a.DeviceId == "" || a.Version == "" ||
+ max(len(a.Client), len(a.Device), len(a.DeviceId), len(a.Version)) > maxQuickConnectField {
+ http.Error(w, "Client, Device, DeviceId and Version are required", http.StatusBadRequest)
+ return
+ }
+ req, err := api.quickConnect.Initiate(quickconnect.Device{
+ ID: a.DeviceId, Name: a.Device, App: a.Client, AppVersion: a.Version,
+ })
+ if errors.Is(err, quickconnect.ErrTooManyRequests) {
+ http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
+ return
+ }
+ if err != nil {
+ api.internalError(w, r, err)
+ return
+ }
+ api.ok(w, r, quickConnectResult(req))
+}
+
+func (api *Router) quickConnectConnect(w http.ResponseWriter, r *http.Request) {
+ req, err := api.quickConnect.Status(r.URL.Query().Get("secret"))
+ if err != nil {
+ http.Error(w, "Unknown secret", http.StatusNotFound)
+ return
+ }
+ api.ok(w, r, quickConnectResult(req))
+}
+
+func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ caller, _ := request.UserFrom(ctx)
+ userID := caller.ID
+ if encoded := r.URL.Query().Get("userid"); encoded != "" {
+ var ok bool
+ if userID, ok = dto.DecodeID(encoded); !ok {
+ http.Error(w, "Invalid userId", http.StatusBadRequest)
+ return
+ }
+ }
+ target := caller
+ if userID != caller.ID {
+ if !caller.IsAdmin {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ usr, err := api.ds.User(ctx).Get(userID)
+ if errors.Is(err, model.ErrNotFound) {
+ http.Error(w, "Unknown user", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ api.internalError(w, r, err)
+ return
+ }
+ target = *usr
+ }
+
+ req, err := api.quickConnect.Authorize(r.URL.Query().Get("code"), target.ID)
+ switch {
+ case errors.Is(err, model.ErrNotFound):
+ http.Error(w, "Unknown code", http.StatusNotFound)
+ case errors.Is(err, quickconnect.ErrAlreadyAuthorized):
+ http.Error(w, "Code already used", http.StatusConflict)
+ case err != nil:
+ api.internalError(w, r, err)
+ default:
+ log.Info(ctx, "Jellyfin API: Quick Connect sign-in approved", "username", target.UserName,
+ "approvedBy", caller.UserName, "client", req.Device.App, "device", req.Device.Name)
+ api.ok(w, r, true)
+ }
+}
+
+func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ var body struct {
+ Secret string `json:"Secret"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Secret == "" {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ return
+ }
+ userID, err := api.quickConnect.Redeem(body.Secret)
+ if err != nil {
+ http.Error(w, "Unknown secret", http.StatusNotFound)
+ return
+ }
+ usr, err := api.ds.User(ctx).Get(userID)
+ if errors.Is(err, model.ErrNotFound) {
+ log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if err != nil {
+ api.internalError(w, r, err)
+ return
+ }
+ api.signIn(w, r, usr)
+}
+
+func quickConnectResult(req quickconnect.Request) dto.QuickConnectResult {
+ return dto.QuickConnectResult{
+ Authenticated: req.Authorized(),
+ Secret: req.Secret,
+ Code: req.Code,
+ DeviceId: req.Device.ID,
+ DeviceName: req.Device.Name,
+ AppName: req.Device.App,
+ AppVersion: req.Device.AppVersion,
+ DateAdded: dto.JellyfinDate(&req.DateAdded),
+ }
+}
diff --git a/server/jellyfin/quickconnect_test.go b/server/jellyfin/quickconnect_test.go
new file mode 100644
index 000000000..0478a356a
--- /dev/null
+++ b/server/jellyfin/quickconnect_test.go
@@ -0,0 +1,301 @@
+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))
+ })
+ })
+})
diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go
index 62b52cfff..70da5b1eb 100644
--- a/server/jellyfin/routing_test.go
+++ b/server/jellyfin/routing_test.go
@@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() {
var api *Router
BeforeEach(func() {
- api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("serves a fully lowercase path directly", func() {
diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go
index 401c791fd..b4be0e244 100644
--- a/server/jellyfin/socket_test.go
+++ b/server/jellyfin/socket_test.go
@@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() {
Expect(err).ToNot(HaveOccurred())
token = t
- api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("upgrades when authenticated via the api_key query parameter", func() {
diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go
index 35ade5ff1..49e41470c 100644
--- a/server/jellyfin/system.go
+++ b/server/jellyfin/system.go
@@ -153,7 +153,3 @@ func parseIP(addr string) netip.Addr {
}
return ip.Unmap()
}
-
-func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
- api.ok(w, r, false)
-}
diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go
index c9368f3b5..2350a7342 100644
--- a/server/jellyfin/system_test.go
+++ b/server/jellyfin/system_test.go
@@ -145,17 +145,6 @@ var _ = Describe("System", func() {
Expect(info.IsInNetwork).To(BeTrue())
})
- It("reports quick connect as disabled", func() {
- w := httptest.NewRecorder()
- r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)
- api.quickConnectEnabled(w, r)
-
- Expect(w.Code).To(Equal(http.StatusOK))
- var enabled bool
- Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed())
- Expect(enabled).To(BeFalse())
- })
-
Context("serverID with a real DataStore", func() {
var ctx context.Context
var ds *tests.MockDataStore
diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go
index d1007f457..7c4f00fdd 100644
--- a/server/nativeapi/config_test.go
+++ b/server/nativeapi/config_test.go
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
- nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
+ nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go
index 13b33c238..cef2e06ad 100644
--- a/server/nativeapi/library_test.go
+++ b/server/nativeapi/library_test.go
@@ -31,7 +31,7 @@ var _ = Describe("Library API", func() {
conf.Server.EnableSharing = false
ds = &tests.MockDataStore{}
auth.Init(ds)
- nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
+ nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go
index ebc9aeb28..ae6e301a8 100644
--- a/server/nativeapi/metadata_test.go
+++ b/server/nativeapi/metadata_test.go
@@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() {
}
auth.Init(ds)
provider = &fakeProvider{}
- nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider)
+ nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil)
router = server.JWTVerifier(nativeRouter)
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go
index 4da550c0d..9d7575a0c 100644
--- a/server/nativeapi/missing_test.go
+++ b/server/nativeapi/missing_test.go
@@ -40,7 +40,7 @@ var _ = Describe("Missing Files Endpoint", func() {
token, err = auth.CreateToken(&user)
Expect(err).ToNot(HaveOccurred())
- router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil))
+ router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil))
})
DescribeTable("GET /missing/{id}",
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index 57a712a20..f931834c6 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
playlistsvc "github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -48,10 +49,11 @@ type Router struct {
pluginManager PluginManager
imgUpload artwork.Uploader
provider external.Provider
+ quickConnect quickconnect.QuickConnect
}
-func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router {
- r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider}
+func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router {
+ r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect}
r.Handler = r.routes()
return r
}
@@ -88,6 +90,7 @@ func (api *Router) routes() http.Handler {
api.addMissingFilesRoute(r)
api.addKeepAliveRoute(r)
api.addInsightsRoute(r)
+ api.addQuickConnectRoute(r)
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
api.addInspectRoute(r)
diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go
index 203fcd4cf..954c872a7 100644
--- a/server/nativeapi/native_api_song_test.go
+++ b/server/nativeapi/native_api_song_test.go
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
- nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
+ nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go
index 4a6f5d03d..2f6c578f9 100644
--- a/server/nativeapi/playlists_test.go
+++ b/server/nativeapi/playlists_test.go
@@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
- nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
+ nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go
index 1683885e7..4e45ddb92 100644
--- a/server/nativeapi/plugin_test.go
+++ b/server/nativeapi/plugin_test.go
@@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() {
ds = &tests.MockDataStore{}
mockManager = &tests.MockPluginManager{}
auth.Init(ds)
- nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil)
+ nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
diff --git a/server/nativeapi/quickconnect.go b/server/nativeapi/quickconnect.go
new file mode 100644
index 000000000..61d402cc5
--- /dev/null
+++ b/server/nativeapi/quickconnect.go
@@ -0,0 +1,76 @@
+package nativeapi
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/core/quickconnect"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server"
+)
+
+type quickConnectDevice struct {
+ AppName string `json:"appName"`
+ AppVersion string `json:"appVersion"`
+ DeviceName string `json:"deviceName"`
+}
+
+func (api *Router) addQuickConnectRoute(r chi.Router) {
+ if !quickconnect.Enabled() {
+ return
+ }
+ r.Route("/quickconnect", func(r chi.Router) {
+ // Throttled like login so a signed-in user cannot enumerate other people's pending codes.
+ if conf.Server.AuthRequestLimit > 0 {
+ r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
+ }
+ r.Get("/", lookupQuickConnect(api.quickConnect))
+ r.Post("/authorize", authorizeQuickConnect(api.quickConnect))
+ })
+}
+
+func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ req, err := qc.Lookup(r.URL.Query().Get("code"))
+ writeQuickConnectResult(w, r, req, err)
+ }
+}
+
+func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Code string `json:"code"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ user, _ := request.UserFrom(ctx)
+ req, err := qc.Authorize(body.Code, user.ID)
+ if err == nil {
+ log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name)
+ }
+ writeQuickConnectResult(w, r, req, err)
+ }
+}
+
+func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) {
+ switch {
+ case errors.Is(err, model.ErrNotFound):
+ http.Error(w, "Unknown code", http.StatusNotFound)
+ case errors.Is(err, quickconnect.ErrAlreadyAuthorized):
+ http.Error(w, "Code already used", http.StatusConflict)
+ case err != nil:
+ log.Error(r.Context(), "Quick Connect failed", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ default:
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name})
+ }
+}
diff --git a/server/nativeapi/quickconnect_test.go b/server/nativeapi/quickconnect_test.go
new file mode 100644
index 000000000..f2f06f0f3
--- /dev/null
+++ b/server/nativeapi/quickconnect_test.go
@@ -0,0 +1,148 @@
+package nativeapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/quickconnect"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Quick Connect endpoints", func() {
+ var (
+ qc quickconnect.QuickConnect
+ pending quickconnect.Request
+ user = model.User{ID: "u1", UserName: "alice"}
+ )
+
+ BeforeEach(func() {
+ qc = quickconnect.New()
+ var err error
+ pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"})
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ asUser := func(r *http.Request) *http.Request {
+ return r.WithContext(request.WithUser(r.Context(), user))
+ }
+ decode := func(w *httptest.ResponseRecorder) quickConnectDevice {
+ var res quickConnectDevice
+ Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
+ return res
+ }
+ finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"}
+
+ Describe("GET /quickconnect", func() {
+ lookup := func(code string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil)))
+ return w
+ }
+
+ It("describes the device waiting for the code", func() {
+ w := lookup(pending.Code)
+ Expect(w.Code).To(Equal(http.StatusOK))
+ Expect(decode(w)).To(Equal(finamp))
+ })
+
+ It("does not approve the code", func() {
+ lookup(pending.Code)
+ got, _ := qc.Status(pending.Secret)
+ Expect(got.Authorized()).To(BeFalse())
+ })
+
+ It("returns 404 for an unknown code", func() {
+ Expect(lookup("000000").Code).To(Equal(http.StatusNotFound))
+ })
+
+ It("returns 409 for a code that is already approved", func() {
+ _, _ = qc.Authorize(pending.Code, "someone")
+ Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict))
+ })
+ })
+
+ Describe("POST /quickconnect/authorize", func() {
+ authorize := func(body string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body))))
+ return w
+ }
+
+ It("approves the code for the signed-in user", func() {
+ w := authorize(`{"code":"` + pending.Code + `"}`)
+ Expect(w.Code).To(Equal(http.StatusOK))
+ Expect(decode(w)).To(Equal(finamp))
+ got, _ := qc.Status(pending.Secret)
+ Expect(got.UserID).To(Equal(user.ID))
+ })
+
+ It("returns 404 for an unknown code", func() {
+ Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound))
+ })
+
+ It("returns 409 for a code that is already approved", func() {
+ _, _ = qc.Authorize(pending.Code, "someone")
+ Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict))
+ })
+
+ It("returns 400 for a bad body", func() {
+ Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest))
+ })
+ })
+
+ Describe("route registration", func() {
+ serve := func() int {
+ r := chi.NewRouter()
+ (&Router{quickConnect: qc}).addQuickConnectRoute(r)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil)))
+ return w.Code
+ }
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Jellyfin.Enabled = true
+ conf.Server.Jellyfin.QuickConnect = true
+ })
+
+ It("mounts the routes when Jellyfin Quick Connect is on", func() {
+ Expect(serve()).To(Equal(http.StatusOK))
+ })
+
+ It("rate-limits code attempts when a login limit is configured", func() {
+ conf.Server.AuthRequestLimit = 2
+ conf.Server.AuthWindowLength = time.Minute
+ r := chi.NewRouter()
+ (&Router{quickConnect: qc}).addQuickConnectRoute(r)
+ attempt := func() int {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil)
+ req.RemoteAddr = "10.0.0.1:1234"
+ r.ServeHTTP(w, asUser(req))
+ return w.Code
+ }
+ Expect(attempt()).To(Equal(http.StatusNotFound))
+ Expect(attempt()).To(Equal(http.StatusNotFound))
+ Expect(attempt()).To(Equal(http.StatusTooManyRequests))
+ })
+
+ It("skips the routes when the Jellyfin API is off", func() {
+ conf.Server.Jellyfin.Enabled = false
+ Expect(serve()).To(Equal(http.StatusNotFound))
+ })
+
+ It("skips the routes when Quick Connect is off", func() {
+ conf.Server.Jellyfin.QuickConnect = false
+ Expect(serve()).To(Equal(http.StatusNotFound))
+ })
+ })
+})
diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go
index 2a363980f..27454fc7d 100644
--- a/server/nativeapi/user_password_token_refresh_test.go
+++ b/server/nativeapi/user_password_token_refresh_test.go
@@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
auth.Init(ds)
userService := core.NewUser(ds, noopPluginUnloader{})
- nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil)
+ nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
})
diff --git a/server/serve_index.go b/server/serve_index.go
index a538daf1a..651c8c907 100644
--- a/server/serve_index.go
+++ b/server/serve_index.go
@@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
@@ -75,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
"enableExternalServices": conf.Server.EnableExternalServices,
"enableReplayGain": conf.Server.EnableReplayGain,
+ "enableQuickConnect": quickconnect.Enabled(),
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
"separator": string(os.PathSeparator),
"enableInspect": conf.Server.Inspect.Enabled,
diff --git a/server/serve_index_test.go b/server/serve_index_test.go
index 78f3873b8..23215a5ef 100644
--- a/server/serve_index_test.go
+++ b/server/serve_index_test.go
@@ -97,6 +97,8 @@ var _ = Describe("serveIndex", func() {
Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true),
Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true),
Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true),
+ Entry("enableQuickConnect", func() { conf.Server.Jellyfin.Enabled = true; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", true),
+ Entry("enableQuickConnect without the Jellyfin API", func() { conf.Server.Jellyfin.Enabled = false; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", false),
Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true),
Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true),
Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"),
diff --git a/ui/src/config.js b/ui/src/config.js
index 39f0cd467..e406e47cf 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -38,6 +38,7 @@ const defaultConfig = {
devUIShowConfig: true,
devNewEventStream: false,
enableReplayGain: true,
+ enableQuickConnect: false,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',
separator: '/',
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index e79beb787..f0e44ce1d 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -218,6 +218,15 @@ const wrapperDataProvider = {
({ json }) => ({ data: json }),
)
},
+ lookupQuickConnect: (code) =>
+ httpClient(
+ `${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`,
+ ).then(({ json }) => ({ data: json })),
+ authorizeQuickConnect: (code) =>
+ httpClient(`${REST_URL}/quickconnect/authorize`, {
+ method: 'POST',
+ body: JSON.stringify({ code }),
+ }).then(({ json }) => ({ data: json })),
inspect: (songId) => {
return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({
data: json,
diff --git a/ui/src/dialogs/QuickConnectDialog.jsx b/ui/src/dialogs/QuickConnectDialog.jsx
new file mode 100644
index 000000000..cde7e9eaf
--- /dev/null
+++ b/ui/src/dialogs/QuickConnectDialog.jsx
@@ -0,0 +1,128 @@
+import { useState } from 'react'
+import PropTypes from 'prop-types'
+import { useDataProvider, useNotify, useTranslate } from 'react-admin'
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ TextField,
+} from '@material-ui/core'
+
+export const QuickConnectDialog = ({ open, onClose }) => {
+ const translate = useTranslate()
+ const notify = useNotify()
+ const dataProvider = useDataProvider()
+ const [code, setCode] = useState('')
+ const [pending, setPending] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const handleClose = () => {
+ setCode('')
+ setPending(null)
+ onClose()
+ }
+
+ const handleError = (error) => {
+ setPending(null)
+ const invalid = error?.status === 404 || error?.status === 409
+ notify(
+ invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError',
+ 'warning',
+ )
+ }
+
+ const run = (request, onSuccess) => {
+ setLoading(true)
+ request
+ .then(({ data }) => onSuccess(data))
+ .catch(handleError)
+ .finally(() => setLoading(false))
+ }
+
+ const lookup = (event) => {
+ event.preventDefault()
+ run(dataProvider.lookupQuickConnect(code), setPending)
+ }
+
+ const approve = () =>
+ run(dataProvider.authorizeQuickConnect(code), (data) => {
+ notify('message.quickConnectApproved', 'success', {
+ app: data.appName,
+ device: data.deviceName,
+ })
+ handleClose()
+ })
+
+ return (
+
+ )
+}
+
+QuickConnectDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+}
diff --git a/ui/src/dialogs/QuickConnectDialog.test.jsx b/ui/src/dialogs/QuickConnectDialog.test.jsx
new file mode 100644
index 000000000..5dda1cbdf
--- /dev/null
+++ b/ui/src/dialogs/QuickConnectDialog.test.jsx
@@ -0,0 +1,103 @@
+import * as React from 'react'
+import { TestContext } from 'ra-test'
+import { DataProviderContext } from 'react-admin'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { describe, afterEach, it, expect, vi } from 'vitest'
+import { QuickConnectDialog } from './QuickConnectDialog'
+
+const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' }
+
+const renderDialog = (dataProvider, onClose = vi.fn()) => {
+ render(
+