mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
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:
parent
be8ec15168
commit
b76ae14286
43 changed files with 1626 additions and 88 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
203
core/quickconnect/quickconnect.go
Normal file
203
core/quickconnect/quickconnect.go
Normal 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), "")
|
||||
}
|
||||
17
core/quickconnect/quickconnect_suite_test.go
Normal file
17
core/quickconnect/quickconnect_suite_test.go
Normal 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")
|
||||
}
|
||||
190
core/quickconnect/quickconnect_test.go
Normal file
190
core/quickconnect/quickconnect_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
91
server/jellyfin/e2e/quickconnect_test.go
Normal file
91
server/jellyfin/e2e/quickconnect_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
145
server/jellyfin/quickconnect.go
Normal file
145
server/jellyfin/quickconnect.go
Normal 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),
|
||||
}
|
||||
}
|
||||
301
server/jellyfin/quickconnect_test.go
Normal file
301
server/jellyfin/quickconnect_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
76
server/nativeapi/quickconnect.go
Normal file
76
server/nativeapi/quickconnect.go
Normal 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})
|
||||
}
|
||||
}
|
||||
148
server/nativeapi/quickconnect_test.go
Normal file
148
server/nativeapi/quickconnect_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const defaultConfig = {
|
|||
devUIShowConfig: true,
|
||||
devNewEventStream: false,
|
||||
enableReplayGain: true,
|
||||
enableQuickConnect: false,
|
||||
defaultDownsamplingFormat: 'opus',
|
||||
publicBaseUrl: '/share',
|
||||
separator: '/',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
128
ui/src/dialogs/QuickConnectDialog.jsx
Normal file
128
ui/src/dialogs/QuickConnectDialog.jsx
Normal 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,
|
||||
}
|
||||
103
ui/src/dialogs/QuickConnectDialog.test.jsx
Normal file
103
ui/src/dialogs/QuickConnectDialog.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -2,4 +2,5 @@ export * from './AboutDialog'
|
|||
export * from './SelectPlaylistInput'
|
||||
export * from './ListenBrainzTokenDialog'
|
||||
export * from './SaveQueueDialog'
|
||||
export * from './QuickConnectDialog'
|
||||
export * from './Dialogs'
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue