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