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.
This commit is contained in:
Deluan Quintão 2026-09-19 14:57:01 -04:00 • committed by GitHub
commit b76ae14286
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1626 additions and 88 deletions

View file

@ -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
}

View file

@ -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", "")

View file

@ -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), "")
}

View file

@ -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")
}

View file

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

View file

@ -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,
)

View file

@ -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) |

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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"`

View file

@ -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{},

View file

@ -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"))

View file

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

View file

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

View file

@ -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"))
})
})
})

View file

@ -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),
}
}

View file

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

View file

@ -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() {

View file

@ -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() {

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"}

View file

@ -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}",

View file

@ -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)

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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"),

View file

@ -38,6 +38,7 @@ const defaultConfig = {
devUIShowConfig: true,
devNewEventStream: false,
enableReplayGain: true,
enableQuickConnect: false,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',
separator: '/',

View file

@ -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,

View file

@ -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 (
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="quick-connect-dialog"
fullWidth
maxWidth="xs"
>
<DialogTitle id="quick-connect-dialog">
{translate('menu.quickConnect.name')}
</DialogTitle>
{pending ? (
<>
<DialogContent>
<DialogContentText>
{translate('menu.quickConnect.confirm', {
app: pending.appName,
version: pending.appVersion,
device: pending.deviceName,
})}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setPending(null)} color="primary">
{translate('ra.action.back')}
</Button>
<Button onClick={approve} color="primary" disabled={loading}>
{translate('menu.quickConnect.approve')}
</Button>
</DialogActions>
</>
) : (
<form onSubmit={lookup}>
<DialogContent>
<DialogContentText>
{translate('menu.quickConnect.help')}
</DialogContentText>
<TextField
id="quickConnectCode"
variant="outlined"
fullWidth
autoFocus
label={translate('menu.quickConnect.code')}
value={code}
onChange={(event) => setCode(event.target.value)}
inputProps={{ inputMode: 'numeric', autoComplete: 'off' }}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
{translate('ra.action.cancel')}
</Button>
<Button
type="submit"
color="primary"
disabled={!code.trim() || loading}
>
{translate('menu.quickConnect.continue')}
</Button>
</DialogActions>
</form>
)}
</Dialog>
)
}
QuickConnectDialog.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
}

View file

@ -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(
<DataProviderContext.Provider value={dataProvider}>
<TestContext initialState={{ admin: { ui: { optimistic: false } } }}>
<QuickConnectDialog open={true} onClose={onClose} />
</TestContext>
</DataProviderContext.Provider>,
)
return onClose
}
const enterCode = (code) => {
fireEvent.change(screen.getByRole('textbox'), { target: { value: code } })
fireEvent.click(screen.getByText('menu.quickConnect.continue'))
}
describe('QuickConnectDialog', () => {
afterEach(cleanup)
it('shows the device before approving the code', async () => {
const dataProvider = {
lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
}
const onClose = renderDialog(dataProvider)
enterCode('123 456')
await screen.findByText('menu.quickConnect.approve')
expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456')
expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('menu.quickConnect.approve'))
await waitFor(() => expect(onClose).toHaveBeenCalled())
expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456')
})
it('goes back to the code input', async () => {
const dataProvider = {
lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
authorizeQuickConnect: vi.fn(),
}
renderDialog(dataProvider)
enterCode('123456')
await screen.findByText('menu.quickConnect.approve')
fireEvent.click(screen.getByText('ra.action.back'))
expect(screen.getByRole('textbox')).toHaveValue('123456')
expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled()
})
it('stays on the code input when the code is unknown', async () => {
const dataProvider = {
lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }),
authorizeQuickConnect: vi.fn(),
}
const onClose = renderDialog(dataProvider)
enterCode('000000')
await waitFor(() =>
expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(),
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByText('menu.quickConnect.approve')).toBeNull()
expect(onClose).not.toHaveBeenCalled()
})
it('returns to the code input when approval fails', async () => {
const dataProvider = {
lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }),
}
const onClose = renderDialog(dataProvider)
enterCode('123456')
fireEvent.click(await screen.findByText('menu.quickConnect.approve'))
await screen.findByRole('textbox')
expect(onClose).not.toHaveBeenCalled()
})
it('disables Continue until a code is typed', () => {
renderDialog({ lookupQuickConnect: vi.fn() })
expect(
screen.getByText('menu.quickConnect.continue').closest('button'),
).toBeDisabled()
})
})

View file

@ -2,4 +2,5 @@ export * from './AboutDialog'
export * from './SelectPlaylistInput'
export * from './ListenBrainzTokenDialog'
export * from './SaveQueueDialog'
export * from './QuickConnectDialog'
export * from './Dialogs'

View file

@ -591,6 +591,9 @@
"remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.",
"notifications_blocked": "You have blocked Notifications for this site in your browser's settings",
"notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https",
"quickConnectApproved": "%{app} on %{device} is now signed in",
"quickConnectInvalidCode": "Invalid or expired code",
"quickConnectError": "Could not approve the code",
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
"lastfmLinkFailure": "Last.fm could not be linked",
"lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled",
@ -622,6 +625,14 @@
"none": "None"
},
"settings": "Settings",
"quickConnect": {
"name": "Quick Connect",
"code": "Code",
"help": "Enter the code shown by a Jellyfin app to sign it in to your account",
"confirm": "Sign in %{app} %{version} on %{device} to your account?",
"continue": "Continue",
"approve": "Approve"
},
"version": "Version",
"theme": "Theme",
"personal": {

View file

@ -6,12 +6,17 @@ import {
usePermissions,
getResources,
} from 'react-admin'
import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md'
import {
MdInfo,
MdPerson,
MdPhonelink,
MdSupervisorAccount,
} from 'react-icons/md'
import { useSelector } from 'react-redux'
import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core'
import ViewListIcon from '@material-ui/icons/ViewList'
import { Dialogs } from '../dialogs/Dialogs'
import { AboutDialog } from '../dialogs'
import { AboutDialog, QuickConnectDialog } from '../dialogs'
import PersonalMenu from './PersonalMenu'
import ActivityPanel from './ActivityPanel'
import NowPlayingPanel from './NowPlayingPanel'
@ -33,33 +38,34 @@ const useStyles = makeStyles(
},
)
const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => {
const classes = useStyles(rest)
const translate = useTranslate()
const [open, setOpen] = React.useState(false)
const DialogMenuItem = forwardRef(
({ onClick, label, icon, dialog, ...rest }, ref) => {
const classes = useStyles(rest)
const [open, setOpen] = React.useState(false)
const handleOpen = () => {
setOpen(true)
}
const handleClose = () => {
onClick && onClick()
setOpen(false)
}
const label = translate('menu.about')
return (
<>
<MenuItem ref={ref} onClick={handleOpen} className={classes.root}>
<ListItemIcon className={classes.icon}>
<MdInfo title={label} size={24} />
</ListItemIcon>
{label}
</MenuItem>
<AboutDialog onClose={handleClose} open={open} />
</>
)
})
const handleClose = () => {
onClick && onClick()
setOpen(false)
}
return (
<>
<MenuItem
ref={ref}
onClick={() => setOpen(true)}
className={classes.root}
>
<ListItemIcon className={classes.icon}>
{createElement(icon, { title: label, size: 24 })}
</ListItemIcon>
{label}
</MenuItem>
{createElement(dialog, { onClose: handleClose, open })}
</>
)
},
)
AboutMenuItem.displayName = 'AboutMenuItem'
DialogMenuItem.displayName = 'DialogMenuItem'
const settingsResources = (resource) =>
resource.name !== 'user' &&
@ -126,13 +132,24 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
<UserMenu {...rest}>
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
{config.enableQuickConnect && (
<DialogMenuItem
label={translate('menu.quickConnect.name')}
icon={MdPhonelink}
dialog={QuickConnectDialog}
/>
)}
<Divider />
{renderUserMenuItemLink()}
{resources
.filter(settingsResources)
.map((r) => renderSettingsMenuItemLink(r))}
<Divider />
<AboutMenuItem />
<DialogMenuItem
label={translate('menu.about')}
icon={MdInfo}
dialog={AboutDialog}
/>
</UserMenu>
<Dialogs />
</>

View file

@ -33,12 +33,14 @@ vi.mock('../dialogs/Dialogs', () => ({
}))
vi.mock('../dialogs', () => ({
AboutDialog: () => <div />,
QuickConnectDialog: () => <div />,
}))
describe('<AppBar />', () => {
beforeEach(() => {
config.devActivityPanel = true
config.enableNowPlaying = true
config.enableQuickConnect = false
store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 0 },
})
@ -62,4 +64,24 @@ describe('<AppBar />', () => {
)
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
})
it('shows the Quick Connect menu item when enabled', () => {
config.enableQuickConnect = true
render(
<Provider store={store}>
<AppBar />
</Provider>,
)
expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0)
})
it('hides the Quick Connect menu item when disabled', () => {
render(
<Provider store={store}>
<AppBar />
</Provider>,
)
expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0)
expect(screen.queryAllByText('menu.about')).not.toHaveLength(0)
})
})