feat(subsonic): OpenSubsonic API key authentication (#6219)

* feat(persistence): store hashed API keys on players

* feat(core): refresh key-bound players without renaming them

Add Players.Touch, which records usage for a player already identified by
an API key without guessing its identity or overwriting its name. Register
also stops renaming players that have an API key.

Register no longer returns player save errors (or a stale FindMatch
ErrNotFound when the save is rate-limited); save failures are only logged,
and only the transcoding lookup error is returned, same as Touch.

* feat(subsonic): authenticate with OpenSubsonic API keys

Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>

* feat(subsonic): add tokenInfo and advertise apiKeyAuthentication

* feat(server): add endpoints to generate and revoke player API keys

* feat(ui): manage player API keys

Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>

* fix(subsonic): throttle API keys per key and IP

A stale key on one device exhausted the shared per-IP bucket and locked out
every valid key from the same IP. The limiter only stores a hash of the bucket
string, so the key is not retained. Also adds e2e coverage of API key auth
through the real repository, and clarifies the player resolution log message.

* fix(ui): keep the new API key dialog open until closed

The key is shown only once, so Escape and backdrop clicks no longer dismiss
it. Also clarifies when the key can be used as a password.

* refactor: simplify API key code paths

Share the player refresh tail between Register and Touch, fold the
ownership-filtered write tail into execOwned, parse the query once for
apiKey conflicts, derive HasAPIKey in the player mock, share the player
form inputs between create and edit, and pick the delete button by key
state instead of spreading conditional props.

* feat(players): set API keys through the player record

The key is a write-only apiKey field applied on save: required and owner-only on create, optional on edit, empty to revoke. Replaces the generate/revoke endpoints.

* fix(players): reject API keys already in use

Creating or editing a player with a key another player already has now returns a validation error instead of a 500, and a create that loses the race no longer leaves a keyless player behind. Ownership is checked before the key on create.

* feat(ui): edit player API keys as a form field

Replaces the show-once dialog, whose icon-less Close button was invisible on mobile. The key is generated in the browser, required and pre-filled on create.

* fix(ui): keep new player API keys out of the record cache

The json-server create response echoes the request body, and undoable edits merge the payload into the cache, so the key could reappear on the edit page. Strip it from the create result and save player edits pessimistically. Also fall back to a prompt when the clipboard write fails.

* fix(ui): polish player API key field

Set userId on the created player record so owner actions show immediately, and show a neutral no-key message to non-owners.

* refactor: simplify player API key create and field

Write the key hash in the create INSERT so the unique index settles
races, re-read the created player instead of hand-building the cached
record, reuse isWritable for the revoke check, and collapse the key
field's derived state and generate/regenerate buttons.

* fix(ui): let the API key field size like other inputs

fullWidth is now opt-in instead of forced.

* fix(ui): align the API key field with other player inputs

Apply react-admin's input className, move the actions (now including Copy) below the field, and use a monospace font so the whole key fits.

* fix(ui): redirect to the player list after create

Matches the other create pages.

* refactor(persistence): name the write-access rule for owned rows

Owned-row writes now say which row they target and who may write it: ownedRow(rowID, ownerOrAdmin|ownerOnly) builds the WHERE, updateOwnedRow applies it, and SetAPIKey uses ownerOnly instead of a hand-built user_id filter. updateOwned/deleteOwned keep their signatures.

* fix(players): apply an edit's key change and fields atomically

Update now runs SetAPIKey and the column update in one transaction. Also shares the key format check, drops FindByAPIKey's unneeded empty-key guard, and sets the context username only on the apiKey path.

* fix(subsonic): treat any credential param sent with apiKey as a conflict

The spec requires error 43 when u, p, t or s is present with apiKey, even with an empty value.

* refactor(subsonic): leave the player cookie code unchanged for key-bound requests

Return early instead of wrapping the cookie block, so the diff (and CodeQL's view of it) matches master.

* fix(subsonic): don't count key lookup errors as failed logins

A database error while checking a key sent as the password now surfaces as a server error instead of a bad password, so it no longer feeds the failed-login limiter.

* feat(players): use nds_ as the API key prefix

Part of a Navidrome secret prefix family (nd + a letter for the kind), alongside ndg_ for API v1 grants.

* feat(ui): make player API keys easier to find

Label the Settings menu entry "Players & API keys", add an API key
filter to the player list, show the key icon in the mobile list, and
add Brazilian Portuguese translations for the new player strings.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): always show the player API key filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): hide the unset Last Seen date in the player list

Players created by hand have no last_seen yet, which showed as 12/31/1.

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
This commit is contained in:
Deluan Quintão 2026-09-27 21:56:58 -04:00 • committed by GitHub
commit 4cdffd5633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1582 additions and 100 deletions

View file

@ -49,6 +49,7 @@ const (
DefaultEncryptionKey = "just for obfuscation"
PasswordsEncryptedKey = "PasswordsEncryptedKey"
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
APIKeyPrefix = "nds_"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"

View file

@ -17,6 +17,7 @@ import (
type Players interface {
Get(ctx context.Context, playerId string) (*model.Player, error)
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
}
func NewPlayers(ds model.DataStore) Players {
@ -33,7 +34,6 @@ type players struct {
func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
var plr *model.Player
var trc *model.Transcoding
var err error
user, _ := request.UserFrom(ctx)
if playerID != "" {
@ -58,7 +58,21 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
}
}
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
if !plr.HasAPIKey {
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
}
return p.refresh(ctx, plr, userAgent, ip)
}
// Touch refreshes a player that the request already identified (by API key), without guessing or renaming it.
func (p *players) Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
if plr.Client == "" {
plr.Client = client
}
return p.refresh(ctx, &plr, userAgent, ip)
}
func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
plr.UserAgent = userAgent
plr.IP = ip
plr.LastSeen = time.Now()
@ -66,14 +80,14 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
err = p.ds.Player().Put(ctx, plr)
if err != nil {
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err)
if err := p.ds.Player().Put(ctx, plr); err != nil {
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", plr.Client, "username", userName(ctx), "type", plr.UserAgent, err)
}
})
if plr.TranscodingId != "" {
trc, err = p.ds.Transcoding().Get(ctx, plr.TranscodingId)
if plr.TranscodingId == "" {
return plr, nil, nil
}
trc, err := p.ds.Transcoding().Get(ctx, plr.TranscodingId)
return plr, trc, err
}

View file

@ -114,6 +114,15 @@ var _ = Describe("Players", func() {
Expect(trc.ID).To(Equal("1"))
})
It("does not rename a player that has an API key", func() {
plr := &model.Player{ID: "123", Name: "My Phone", Client: "client", UserId: "userid", HasAPIKey: true}
repo.add(plr)
p, _, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.Name).To(Equal("My Phone"))
})
Context("bad username casing", func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"})
@ -130,6 +139,34 @@ var _ = Describe("Players", func() {
})
})
})
Describe("Touch", func() {
It("records usage but keeps the name and client", func() {
plr := model.Player{ID: "123", Name: "My Phone", Client: "Symfonium", UserId: "userid", HasAPIKey: true}
p, trc, err := players.Touch(ctx, plr, "OtherClient", "android", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.Name).To(Equal("My Phone"))
Expect(p.Client).To(Equal("Symfonium"))
Expect(p.UserAgent).To(Equal("android"))
Expect(p.IP).To(Equal("1.2.3.4"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("fills in the client on first use", func() {
p, _, err := players.Touch(ctx, model.Player{ID: "123", Name: "Manual", UserId: "userid"}, "Symfonium", "android", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.Client).To(Equal("Symfonium"))
})
It("returns the player's transcoding", func() {
p, trc, err := players.Touch(ctx, model.Player{ID: "123", UserId: "userid", TranscodingId: "1"}, "c", "ua", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(trc.ID).To(Equal("1"))
})
})
})
type mockPlayerRepository struct {

View file

@ -0,0 +1,8 @@
-- +goose Up
-- +goose StatementBegin
alter table player add column api_key_hash varchar default null;
create unique index if not exists player_api_key_hash on player(api_key_hash);
-- +goose StatementEnd
-- +goose Down
SELECT 1;

View file

@ -21,6 +21,8 @@ type Player struct {
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"`
ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"`
HasAPIKey bool `structs:"-" db:"has_api_key" json:"hasApiKey"`
APIKey *string `structs:"-" json:"apiKey,omitempty"`
}
type Players []Player
@ -33,4 +35,6 @@ type PlayerRepository interface {
Put(ctx context.Context, p *Player) error
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error)
FindByAPIKey(ctx context.Context, key string) (*Player, error)
SetAPIKey(ctx context.Context, playerID, key string) error
}

View file

@ -2,10 +2,16 @@ package persistence
import (
"context"
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
@ -17,7 +23,8 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository {
r := &playerRepository{}
r.db = db
r.registerModel(&model.Player{}, map[string]filterFunc{
"name": containsFilter("player.name"),
"name": containsFilter("player.name"),
"hasapikey": hasAPIKeyFilter,
})
r.setSortMappings(map[string]string{
"user_name": "username", //TODO rename all user_name and userName to username
@ -25,6 +32,13 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository {
return r
}
func hasAPIKeyFilter(_ string, value any) Sqlizer {
if v, _ := value.(string); strings.EqualFold(v, "true") {
return NotEq{"player.api_key_hash": nil}
}
return Eq{"player.api_key_hash": nil}
}
func (r *playerRepository) Put(ctx context.Context, p *model.Player) error {
_, err := r.put(ctx, p.ID, p)
return err
@ -32,7 +46,7 @@ func (r *playerRepository) Put(ctx context.Context, p *model.Player) error {
func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
return r.newSelect(ctx, options...).
Columns("player.*").
Columns("player.*", "player.api_key_hash is not null as has_api_key").
Join("user ON player.user_id = user.id").
Columns("user.user_name username")
}
@ -103,32 +117,115 @@ func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOpt
return res, err
}
// isPermitted authorizes creating a new record, based on the owner declared in the request body.
// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a
// player they own. Updates must not use this (the body owner is attacker-controlled); they go
// through updateOwned, which authorizes against the persisted user_id in the WHERE clause.
func (r *playerRepository) isPermitted(ctx context.Context, p *model.Player) bool {
u := loggedUser(ctx)
return u.IsAdmin || p.UserId == u.ID
var apiKeyFormat = regexp.MustCompile(`^` + consts.APIKeyPrefix + `[0-9A-Za-z]{22}$`)
func apiKeyValidationError(msg string) error {
return &rest.ValidationError{Errors: map[string]string{"apiKey": msg}}
}
func validateAPIKey(key string) error {
if !apiKeyFormat.MatchString(key) {
return apiKeyValidationError("resources.player.validation.apiKeyFormat")
}
return nil
}
func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) {
if !r.isPermitted(ctx, t) {
u := loggedUser(ctx)
if t.UserId == "" && u.ID != invalidUserId {
t.UserId = u.ID
}
if t.UserId != u.ID {
return "", rest.ErrPermissionDenied
}
return r.put(ctx, "", t) // Save only creates; edits go through the owner-scoped Update
// Hand-made players are only reachable through a key, so one is required
if t.APIKey == nil || *t.APIKey == "" {
return "", apiKeyValidationError("ra.validation.required")
}
if err := validateAPIKey(*t.APIKey); err != nil {
return "", err
}
values, err := toSQLArgs(t)
if err != nil {
return "", err
}
// Save only creates, so the key hash goes in the same INSERT and the unique index settles races
values["id"] = id.NewRandom()
values["api_key_hash"] = hashAPIKey(*t.APIKey)
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
if isUniqueViolation(err) {
return "", apiKeyValidationError("ra.validation.unique")
}
if err != nil {
return "", err
}
return values["id"].(string), nil
}
func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error {
t := &entity
t.ID = id
return r.updateOwned(ctx, id, t, cols...)
if t.APIKey == nil {
return r.updateOwned(ctx, id, t, cols...)
}
// The key and the other columns are two writes; commit both or neither
return r.inTx(func(tx *playerRepository) error {
if err := tx.SetAPIKey(ctx, id, *t.APIKey); err != nil {
return err
}
return tx.updateOwned(ctx, id, t, cols...)
})
}
func (r *playerRepository) inTx(block func(tx *playerRepository) error) error {
conn, ok := r.db.(*dbx.DB)
if !ok {
return block(r) // already inside a transaction
}
return conn.Transactional(func(tx *dbx.Tx) error {
return block(NewPlayerRepository(tx).(*playerRepository))
})
}
func (r *playerRepository) Delete(ctx context.Context, ids ...string) error {
return r.deleteOwnedAll(ctx, ids...)
}
// Keys are long random strings, not user-chosen passwords, so a fast unsalted hash is enough and keeps lookups indexed.
func hashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func (r *playerRepository) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) {
sel := r.selectPlayer(ctx).Where(Eq{"player.api_key_hash": hashAPIKey(key)})
var res model.Player
if err := r.queryOne(ctx, sel, &res); err != nil {
return nil, err
}
return &res, nil
}
// SetAPIKey stores the key's hash, or revokes it when key is empty. Setting is owner-only, even for
// admins, so nobody can mint a login for someone else.
func (r *playerRepository) SetAPIKey(ctx context.Context, playerID, key string) error {
if key == "" {
return r.updateOwnedRow(ctx, playerID, ownerOrAdmin, map[string]any{"api_key_hash": nil})
}
if err := validateAPIKey(key); err != nil {
return err
}
err := r.updateOwnedRow(ctx, playerID, ownerOnly, map[string]any{"api_key_hash": hashAPIKey(key)})
if isUniqueViolation(err) {
return apiKeyValidationError("ra.validation.unique")
}
return err
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository[model.Player] = (*playerRepository)(nil)
var _ rest.Persistable[model.Player] = (*playerRepository)(nil)

View file

@ -2,6 +2,7 @@ package persistence
import (
"context"
"errors"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
@ -12,6 +13,14 @@ import (
"github.com/pocketbase/dbx"
)
const testAPIKey = "nds_0123456789abcdefghijkl"
func expectAPIKeyError(err error, msg string) {
var verr *rest.ValidationError
ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue())
ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg))
}
var _ = Describe("PlayerRepository", func() {
var adminRepo *playerRepository
var database *dbx.DB
@ -178,11 +187,12 @@ var _ = Describe("PlayerRepository", func() {
clone := player
clone.ID = ""
clone.IP = "192.168.1.1"
clone.APIKey = new(testAPIKey)
id, err := repo.Save(repoCtx, &clone)
if clone.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
} else if player.UserId != userPlayer.UserId {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.UserId = ""
} else {
@ -202,12 +212,13 @@ var _ = Describe("PlayerRepository", func() {
} else {
Expect(count).To(Equal(baseCount + 1))
Expect(err).To(BeNil())
clone.APIKey = nil
clone.HasAPIKey = true
Expect(*newItem).To(Equal(clone))
}
},
Entry("same user", userPlayer),
Entry("other item", otherPlayer),
Entry("fake item", model.Player{}),
)
})
@ -251,6 +262,259 @@ var _ = Describe("PlayerRepository", func() {
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
)
Describe("API keys", func() {
const key = testAPIKey
const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV"
var ownerCtx, otherCtx context.Context
BeforeEach(func() {
ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser)
otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser)
})
storedHash := func(id string) string {
var row struct {
Hash string `db:"api_key_hash"`
}
Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}").
Bind(dbx.Params{"id": id}).One(&row)).To(Succeed())
return row.Hash
}
Describe("SetAPIKey", func() {
It("stores only the hash and finds the player by the key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
plr, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).ToNot(HaveOccurred())
Expect(plr.ID).To(Equal(regularPlayer.ID))
Expect(plr.HasAPIKey).To(BeTrue())
Expect(plr.APIKey).To(BeNil())
})
It("replaces the previous key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed())
_, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = adminRepo.FindByAPIKey(ctx, otherKey)
Expect(err).ToNot(HaveOccurred())
})
DescribeTable("rejects malformed keys",
func(bad string) {
err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad)
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
},
Entry("no prefix", "0123456789abcdefghijklmn"),
Entry("too short", "nds_short"),
Entry("too long", key+"x"),
Entry("bad chars", "nds_0123456789abcdefghij-!"),
)
It("revokes with an empty key, by the owner or an admin", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("accepts revoking a player that has no key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
})
It("does not let an admin set a key on another user's player", func() {
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied))
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("does not let another user set or revoke", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied))
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied))
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
})
It("returns not found for a missing player", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound))
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound))
})
It("does not find unknown or empty keys", func() {
_, err := adminRepo.FindByAPIKey(ctx, otherKey)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = adminRepo.FindByAPIKey(ctx, "")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("drops the key with the player", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed())
_, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("hasApiKey filter", func() {
BeforeEach(func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
})
filtered := func(value string) []string {
res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}})
Expect(err).ToNot(HaveOccurred())
var ids []string
for _, p := range res {
ids = append(ids, p.ID)
}
return ids
}
It("lists only players with a key", func() {
Expect(filtered("true")).To(ConsistOf(regularPlayer.ID))
count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(1)))
})
It("lists only players without a key", func() {
Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID))
})
})
Describe("Save (create)", func() {
It("creates the player with the key, owned by the logged-in user", func() {
id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)})
Expect(err).ToNot(HaveOccurred())
plr, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).ToNot(HaveOccurred())
Expect(plr.ID).To(Equal(id))
Expect(plr.UserId).To(Equal(regularUser.ID))
})
It("requires a key", func() {
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"})
expectAPIKeyError(err, "ra.validation.required")
_, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")})
expectAPIKeyError(err, "ra.validation.required")
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
It("rejects a malformed key without creating the player", func() {
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")})
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
It("does not let an admin create a keyed player for another user", func() {
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)})
Expect(err).To(MatchError(rest.ErrPermissionDenied))
_, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID})
Expect(err).To(MatchError(rest.ErrPermissionDenied))
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
It("rejects a key already used by another player without creating the player", func() {
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)})
expectAPIKeyError(err, "ra.validation.unique")
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
})
Describe("Update (edit)", func() {
It("rolls back the key change when the rest of the edit fails", func() {
_, err := database.NewQuery(`create trigger fail_player_rename before update of name on player
when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute()
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute()
})
plr := regularPlayer
plr.Name = "boom"
plr.APIKey = new(key)
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("keeps the key when apiKey is absent (a normal edit)", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
plr := regularPlayer
plr.Name = "Renamed"
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed())
Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed())
found, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).ToNot(HaveOccurred())
Expect(found.Name).To(Equal("Renamed"))
})
It("sets a new key when apiKey has a value", func() {
plr := regularPlayer
plr.APIKey = new(key)
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
})
It("revokes the key when apiKey is empty", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
plr := regularPlayer
plr.APIKey = new("")
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("lets an admin edit another user's keyed player without touching the key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
plr := regularPlayer
plr.MaxBitRate = 192
Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
})
It("refuses an admin setting a key on another user's player and leaves other columns alone", func() {
plr := regularPlayer
plr.Name = "Hijacked"
plr.APIKey = new(key)
Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied))
got, err := adminRepo.Get(ctx, regularPlayer.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.Name).To(Equal(regularPlayer.Name))
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("refuses a key already used by another player and leaves other columns alone", func() {
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
plr := regularPlayer
plr.Name = "Renamed"
plr.APIKey = new(key)
err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")
expectAPIKeyError(err, "ra.validation.unique")
got, err := adminRepo.Get(ctx, regularPlayer.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.Name).To(Equal(regularPlayer.Name))
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key)))
})
})
})
Describe("Ownership enforcement (cross-tenant write protection)", func() {
var regularRepo *playerRepository
var regularCtx context.Context
@ -287,6 +551,7 @@ var _ = Describe("PlayerRepository", func() {
Name: "HIJACKED",
UserId: regularUser.ID,
ReportRealPath: true,
APIKey: new(testAPIKey),
}
id, err := regularRepo.Save(regularCtx, &spoofed)

View file

@ -85,6 +85,22 @@ func (r sqlRepository) addRestriction(ctx context.Context, sql ...Sqlizer) Sqliz
return s
}
// writeAccess says who may change a row in a table with a user_id column.
type writeAccess int
const (
ownerOrAdmin writeAccess = iota // admins may write any row
ownerOnly // even admins may only write their own rows
)
// ownedRow matches the row rowID only if the logged-in user may write it under access.
func (r sqlRepository) ownedRow(ctx context.Context, rowID string, access writeAccess) Sqlizer {
if access == ownerOnly {
return And{Eq{"id": rowID}, Eq{"user_id": loggedUser(ctx).ID}}
}
return r.addRestriction(ctx, Eq{"id": rowID})
}
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
if r.tableName == "" {
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
@ -494,15 +510,12 @@ func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsTo
}
updateValues := filterUpdateValues(values, id, colsToUpdate...)
delete(updateValues, "user_id") // ownership is immutable on update
update := Update(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})).SetMap(updateValues)
count, err := r.executeSQL(ctx, update)
if err != nil {
return err
}
if count == 0 {
return r.classifyOwnedWriteMiss(ctx, id)
}
return nil
return r.updateOwnedRow(ctx, id, ownerOrAdmin, updateValues)
}
// updateOwnedRow sets values on the row rowID if the logged-in user may write it under access.
func (r sqlRepository) updateOwnedRow(ctx context.Context, rowID string, access writeAccess, values map[string]any) error {
return r.runRowWrite(ctx, rowID, Update(r.tableName).SetMap(values).Where(r.ownedRow(ctx, rowID, access)))
}
// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for
@ -511,12 +524,17 @@ func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsTo
// does not match and is left untouched. The failure path mirrors updateOwned (see
// classifyOwnedWriteMiss), so there is no TOCTOU on the delete.
func (r sqlRepository) deleteOwned(ctx context.Context, id string) error {
count, err := r.executeSQL(ctx, Delete(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})))
return r.runRowWrite(ctx, id, Delete(r.tableName).Where(r.ownedRow(ctx, id, ownerOrAdmin)))
}
// runRowWrite executes q, a write already filtered by ownedRow(rowID, …), and classifies a miss.
func (r sqlRepository) runRowWrite(ctx context.Context, rowID string, q Sqlizer) error {
count, err := r.executeSQL(ctx, q)
if err != nil {
return err
}
if count == 0 {
return r.classifyOwnedWriteMiss(ctx, id)
return r.classifyOwnedWriteMiss(ctx, rowID)
}
return nil
}

View file

@ -19,6 +19,26 @@ var _ = Describe("sqlRepository", func() {
r.tableName = "table"
})
Describe("ownedRow", func() {
DescribeTable("matches the row, limited to what the logged-in user may write",
func(user model.User, access writeAccess, expectedSQL string, expectedArgs ...any) {
userCtx := request.WithUser(GinkgoT().Context(), user)
sql, args, err := r.ownedRow(userCtx, "row-1", access).ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
Expect(args).To(Equal(expectedArgs))
},
Entry("admin, ownerOrAdmin: any row", model.User{ID: "admin", IsAdmin: true}, ownerOrAdmin,
"(id = ?)", "row-1"),
Entry("regular, ownerOrAdmin: own rows", model.User{ID: "user"}, ownerOrAdmin,
"(id = ? AND user_id = ?)", "row-1", "user"),
Entry("admin, ownerOnly: own rows", model.User{ID: "admin", IsAdmin: true}, ownerOnly,
"(id = ? AND user_id = ?)", "row-1", "admin"),
Entry("regular, ownerOnly: own rows", model.User{ID: "user"}, ownerOnly,
"(id = ? AND user_id = ?)", "row-1", "user"),
)
})
Describe("applyOptions", func() {
var sq squirrel.SelectBuilder
BeforeEach(func() {

View file

@ -182,6 +182,7 @@
},
"player": {
"name": "Tocador |||| Tocadores",
"menuName": "Tocadores e chaves de API",
"fields": {
"name": "Nome",
"transcodingId": "Conversão",
@ -190,7 +191,29 @@
"userName": "Usuário",
"lastSeen": "Últ. acesso",
"reportRealPath": "Use paths reais",
"scrobbleEnabled": "Enviar scrobbles para serviços externos"
"scrobbleEnabled": "Enviar scrobbles para serviços externos",
"hasApiKey": "Chave de API"
},
"actions": {
"generateApiKey": "Gerar chave de API",
"regenerateApiKey": "Gerar nova",
"revokeApiKey": "Revogar",
"copyApiKey": "Copiar"
},
"message": {
"apiKeyActive": "Este tocador tem uma chave de API. Use-a no seu app como chave de API, ou como senha se o app não usar autenticação por token.",
"apiKeyNone": "Sem chave de API. Gere uma para conectar um app a este tocador.",
"apiKeyNoneOther": "Sem chave de API.",
"apiKeyPending": "Copie esta chave agora. Ela será salva quando você clicar em Salvar e não será exibida novamente.",
"apiKeyRevokePending": "A chave de API será removida quando você salvar.",
"deleteWithKeyTitle": "Excluir tocador",
"deleteWithKeyContent": "Este tocador tem uma chave de API. Os apps que a usam vão parar de funcionar."
},
"notifications": {
"apiKeyCopied": "Chave de API copiada para o clipboard"
},
"validation": {
"apiKeyFormat": "Formato de chave de API inválido"
}
},
"transcoding": {

View file

@ -107,6 +107,7 @@ func (api *Router) routes() http.Handler {
r.Use(getPlayer(api.players))
h(r, "ping", api.Ping)
h(r, "getLicense", api.GetLicense)
h(r, "tokenInfo", api.TokenInfo)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))

View file

@ -0,0 +1,54 @@
package e2e
import (
"net/http/httptest"
"net/url"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("API key authentication", func() {
var key string
BeforeEach(func() {
setupTestDB()
userCtx := request.WithUser(ctx, regularUser)
player := &model.Player{ID: "apikey-player", Name: "Phone", UserId: regularUser.ID, Client: "test-client"}
Expect(ds.Player().Put(userCtx, player)).To(Succeed())
key = "nds_0123456789abcdefghijkl"
Expect(ds.Player().SetAPIKey(userCtx, player.ID, key)).To(Succeed())
})
doKeyReq := func(endpoint, apiKey string) *responses.Subsonic {
q := url.Values{"apiKey": {apiKey}, "v": {"1.16.1"}, "c": {"test-client"}, "f": {"json"}}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil))
return parseJSONResponse(w)
}
It("authenticates ping with only the key", func() {
resp := doKeyReq("ping", key)
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("reports the key owner in tokenInfo", func() {
resp := doKeyReq("tokenInfo", key)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TokenInfo).ToNot(BeNil())
Expect(resp.TokenInfo.Username).To(Equal(regularUser.UserName))
})
It("rejects an unknown key with error 44", func() {
resp := doKeyReq("ping", "nds_unknown")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(int32(44)))
})
})

View file

@ -65,14 +65,15 @@ func checkRequiredParameters(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requiredParameters []string
p := req.Params(r)
username, _ := fromInternalOrProxyAuth(r)
if username != "" {
apiKey, _ := p.String("apiKey")
if username != "" || apiKey != "" {
requiredParameters = []string{"v", "c"}
} else {
requiredParameters = []string{"u", "v", "c"}
}
p := req.Params(r)
for _, param := range requiredParameters {
if _, err := p.String(param); err != nil {
log.Warn(r, err)
@ -104,10 +105,14 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
ctx := r.Context()
var usr *model.User
var keyPlayer *model.Player
var err error
p := req.Params(r)
apiKey, _ := p.String("apiKey")
username, isInternalAuth := fromInternalOrProxyAuth(r)
if username != "" {
switch {
case username != "":
authType := If(isInternalAuth, "internal", "reverse-proxy")
usr, err = ds.User().FindByUsername(ctx, username)
if errors.Is(err, context.Canceled) {
@ -119,8 +124,16 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
} else if err != nil {
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
}
} else {
p := req.Params(r)
case apiKey != "":
usr, keyPlayer, err = authenticateAPIKey(ctx, ds, limiter, r, apiKey)
if err != nil {
if ctx.Err() == nil {
sendError(w, r, err)
}
return
}
ctx = request.WithUsername(ctx, usr.UserName)
default:
username, _ := p.String("u")
pass, _ := p.String("p")
token, _ := p.String("t")
@ -142,6 +155,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
usr, err = ds.User().FindByUsernameWithPassword(ctx, username)
if err == nil {
err = validateCredentials(usr, pass, token, salt, jwt)
if errors.Is(err, model.ErrInvalidAuth) && pass != "" && jwt == "" {
keyPlayer, err = playerFromPasswordKey(ctx, ds, usr, pass)
}
}
invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth)
slot.release(invalidLogin)
@ -162,11 +178,77 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
}
ctx = request.WithUser(ctx, *usr)
if keyPlayer != nil {
ctx = request.WithPlayer(ctx, *keyPlayer)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
var apiKeyConflicts = []string{"u", "p", "t", "s", "jwt"}
func authenticateAPIKey(ctx context.Context, ds model.DataStore, limiter *authLimiter, r *http.Request, key string) (*model.User, *model.Player, error) {
query := r.URL.Query()
for _, param := range apiKeyConflicts {
if query.Has(param) {
log.Warn(ctx, "API: apiKey sent with other credentials", "auth", "apikey", "param", param, "remoteAddr", r.RemoteAddr)
return nil, nil, newError(responses.ErrorMultipleAuthMechanismsProvided)
}
}
// Per key, so a stale key on one device cannot lock out valid keys sharing the IP
slot, allowed := limiter.acquire(ctx, "apikey\x00"+server.ClientIP(r)+"\x00"+key)
if !allowed {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
log.Warn(ctx, "API: Too many failed API key attempts", "auth", "apikey", "remoteAddr", r.RemoteAddr)
return nil, nil, newError(responses.ErrorInvalidAPIKey)
}
player, err := ds.Player().FindByAPIKey(ctx, key)
var usr *model.User
if err == nil {
usr, err = ds.User().Get(ctx, player.UserId)
}
slot.release(errors.Is(err, model.ErrNotFound))
switch {
case errors.Is(err, context.Canceled):
return nil, nil, err
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid API key", "auth", "apikey", "remoteAddr", r.RemoteAddr)
return nil, nil, newError(responses.ErrorInvalidAPIKey)
case err != nil:
log.Error(ctx, "API: Error authenticating API key", "auth", "apikey", "remoteAddr", r.RemoteAddr, err)
return nil, nil, newError(responses.ErrorAuthenticationFail)
}
return usr, player, nil
}
// playerFromPasswordKey lets clients that only have a password field log in with an API key.
// It returns ErrInvalidAuth when pass is not a key of usr, so only real failures skip the limiter count.
func playerFromPasswordKey(ctx context.Context, ds model.DataStore, usr *model.User, pass string) (*model.Player, error) {
key := decodePassword(pass)
if !strings.HasPrefix(key, consts.APIKeyPrefix) {
return nil, model.ErrInvalidAuth
}
plr, err := ds.Player().FindByAPIKey(ctx, key)
if errors.Is(err, model.ErrNotFound) || (err == nil && plr.UserId != usr.ID) {
return nil, model.ErrInvalidAuth
}
return plr, err
}
func decodePassword(pass string) string {
if strings.HasPrefix(pass, "enc:") {
if dec, err := hex.DecodeString(pass[4:]); err == nil {
return string(dec)
}
}
return pass
}
func adminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loggedUser, ok := request.UserFrom(r.Context())
@ -194,12 +276,7 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error
claims.Subject == user.UserName &&
auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil
case pass != "":
if strings.HasPrefix(pass, "enc:") {
if dec, err := hex.DecodeString(pass[4:]); err == nil {
pass = string(dec)
}
}
valid = pass == user.Password
valid = decodePassword(pass) == user.Password
case token != "":
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
valid = t == token
@ -217,12 +294,20 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler {
ctx := r.Context()
userName, _ := request.UsernameFrom(ctx)
client, _ := request.ClientFrom(ctx)
playerId := playerIDFromCookie(r, userName)
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
userAgent := canonicalUserAgent(r)
player, trc, err := players.Register(ctx, playerId, client, userAgent, ip)
var player *model.Player
var trc *model.Transcoding
var err error
keyPlayer, boundByKey := request.PlayerFrom(ctx)
if boundByKey {
player, trc, err = players.Touch(ctx, keyPlayer, client, userAgent, ip)
} else {
player, trc, err = players.Register(ctx, playerIDFromCookie(r, userName), client, userAgent, ip)
}
if err != nil {
log.Error(ctx, "Could not register player", "username", userName, "client", client, err)
log.Error(ctx, "Could not resolve player", "username", userName, "client", client, err)
} else {
ctx = request.WithPlayer(ctx, *player)
if trc != nil {
@ -230,6 +315,11 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler {
}
r = r.WithContext(ctx)
// A key already identifies the player, so the cookie would only add a second, weaker signal
if boundByKey {
next.ServeHTTP(w, r)
return
}
cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP
Name: playerIDCookieName(userName),
Value: player.ID,

View file

@ -3,6 +3,7 @@ package subsonic
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"net/http"
@ -119,6 +120,14 @@ var _ = Describe("Middlewares", func() {
Expect(next.called).To(BeTrue())
})
It("does not require u when apiKey is present", func() {
r := newGetRequest("apiKey=nds_abc", "v=1.15", "c=test")
cp := checkRequiredParameters(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
})
It("fails when user is missing", func() {
r := newGetRequest("v=1.15", "c=test")
cp := checkRequiredParameters(next)
@ -311,6 +320,101 @@ var _ = Describe("Middlewares", func() {
})
})
When("using API key authentication", func() {
var key string
serve := func(params ...string) {
authenticate(ds)(next).ServeHTTP(w, newGetRequest(params...))
}
BeforeEach(func() {
usr, err := ds.User().FindByUsername(ctx, "admin")
Expect(err).ToNot(HaveOccurred())
Expect(ds.Player().Put(ctx, &model.Player{ID: "player-1", Name: "My Phone", UserId: usr.ID, Client: "Symfonium"})).To(Succeed())
key = "nds_0123456789abcdefghijkl"
Expect(ds.Player().SetAPIKey(ctx, "player-1", key)).To(Succeed())
})
It("authenticates the owner and binds the key's player", func() {
serve("apiKey=" + key)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("admin"))
username, _ := request.UsernameFrom(next.req.Context())
Expect(username).To(Equal("admin"))
player, ok := request.PlayerFrom(next.req.Context())
Expect(ok).To(BeTrue())
Expect(player.ID).To(Equal("player-1"))
})
It("accepts the key in a POST form body", func() {
r := newPostRequest("", "apiKey="+key)
cp := postFormToQueryParams(authenticate(ds)(next))
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
player, _ := request.PlayerFrom(next.req.Context())
Expect(player.ID).To(Equal("player-1"))
})
It("rejects an unknown key with error 44", func() {
serve("apiKey=nds_unknown")
Expect(w.Body.String()).To(ContainSubstring(`code="44"`))
Expect(next.called).To(BeFalse())
})
DescribeTable("rejects apiKey mixed with other credentials with error 43",
func(extra string) {
serve("apiKey="+key, extra)
Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
Expect(next.called).To(BeFalse())
},
Entry("u", "u=admin"),
Entry("p", "p=wordpass"),
Entry("t", "t=abc"),
Entry("s", "s=abc"),
Entry("jwt", "jwt=abc"),
Entry("empty u", "u="),
Entry("empty p", "p="),
)
Context("key sent as the password", func() {
It("authenticates and binds the key's player", func() {
serve("u=admin", "p="+key)
Expect(next.called).To(BeTrue())
player, ok := request.PlayerFrom(next.req.Context())
Expect(ok).To(BeTrue())
Expect(player.ID).To(Equal("player-1"))
})
It("accepts the hex-encoded form", func() {
serve("u=admin", "p=enc:"+hex.EncodeToString([]byte(key)))
Expect(next.called).To(BeTrue())
})
It("still accepts a real password that starts with the key prefix", func() {
Expect(ds.User().Put(ctx, &model.User{UserName: "prefixed", NewPassword: "nds_secret"})).To(Succeed())
serve("u=prefixed", "p=nds_secret")
Expect(next.called).To(BeTrue())
_, ok := request.PlayerFrom(next.req.Context())
Expect(ok).To(BeFalse())
})
It("rejects another user's key with error 40", func() {
Expect(ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "pw"})).To(Succeed())
serve("u=other", "p="+key)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
})
When("failed attempts reach AuthRequestLimit", func() {
var cp http.Handler
@ -376,6 +480,21 @@ var _ = Describe("Middlewares", func() {
Expect(next.called).To(BeTrue())
})
It("does not count server errors when a key is sent as the password", func() {
usr, _ := ds.User().FindByUsername(ctx, "admin")
playerRepo := ds.Player().(*tests.MockPlayerRepo)
Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed())
key := "nds_0123456789abcdefghijkl"
Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed())
playerRepo.Error = errors.New("db down")
failTimes(5, "u=admin", "p="+key)
playerRepo.Error = nil
serve(newGetRequest("u=admin", "p="+key))
Expect(next.called).To(BeTrue())
})
It("does not block other usernames from the same IP", func() {
_ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"})
failTimes(3, "u=admin", "p=WRONG")
@ -405,6 +524,25 @@ var _ = Describe("Middlewares", func() {
Expect(next.called).To(BeTrue())
})
It("throttles a repeated bad key without locking out valid keys from the same IP", func() {
usr, _ := ds.User().FindByUsername(ctx, "admin")
playerRepo := ds.Player().(*tests.MockPlayerRepo)
Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed())
key := "nds_0123456789abcdefghijkl"
Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed())
for range 3 {
Expect(serve(newGetRequest("apiKey=nds_bad")).Body.String()).To(ContainSubstring(`code="44"`))
}
playerRepo.APIKeys["nds_bad"] = "player-1"
rec := serve(newGetRequest("apiKey=nds_bad"))
Expect(next.called).To(BeFalse())
Expect(rec.Body.String()).To(ContainSubstring(`code="44"`))
serve(newGetRequest("apiKey=" + key))
Expect(next.called).To(BeTrue())
})
It("is disabled when AuthRequestLimit is 0", func() {
conf.Server.AuthRequestLimit = 0
cp = authenticate(ds)(next)
@ -532,6 +670,24 @@ var _ = Describe("Middlewares", func() {
Expect(cookieStr).To(BeEmpty())
})
Context("player bound by an API key", func() {
BeforeEach(func() {
r = r.WithContext(request.WithPlayer(r.Context(), model.Player{ID: "keyed"}))
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
})
It("uses the key's player", func() {
Expect(mockedPlayers.touched).To(BeTrue())
player, _ := request.PlayerFrom(next.req.Context())
Expect(player.ID).To(Equal("keyed"))
})
It("does not set the player cookie", func() {
Expect(w.Header().Get("Set-Cookie")).To(BeEmpty())
})
})
Context("PlayerId specified in Cookies", func() {
BeforeEach(func() {
cookie := &http.Cookie{
@ -712,6 +868,12 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
type mockPlayers struct {
core.Players
transcoding *model.Transcoding
touched bool
}
func (mp *mockPlayers) Touch(_ context.Context, plr model.Player, _, _, _ string) (*model.Player, *model.Transcoding, error) {
mp.touched = true
return &plr, mp.transcoding, nil
}
func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) {

View file

@ -16,6 +16,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "transcoding", Versions: []int32{1}},
{Name: "playbackReport", Versions: []int32{1}},
{Name: "topSongsByArtistId", Versions: []int32{1}},
{Name: "apiKeyAuthentication", Versions: []int32{1}},
}
if api.sonic != nil && api.sonic.HasProvider() {
extensions = append(extensions, responses.OpenSubsonicExtension{

View file

@ -44,44 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() {
It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() {
router.ServeHTTP(w, r)
// Make sure the endpoint is public, by not passing any authentication
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(7),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
))
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
)
})
})
Context("with sonic similarity plugin", func() {
BeforeEach(func() {
sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
})
It("should return 7 extensions including sonicSimilarity", func() {
router.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
@ -93,8 +62,41 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}),
))
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
)
})
})
Context("with sonic similarity plugin", func() {
BeforeEach(func() {
sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
})
It("should return 9 extensions including sonicSimilarity", func() {
router.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(9),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}),
))
})
})

View file

@ -0,0 +1,10 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"tokenInfo": {
"username": "deluan"
}
}

View file

@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<tokenInfo username="deluan"></tokenInfo>
</subsonic-response>

View file

@ -1,25 +1,29 @@
package responses
const (
ErrorGeneric int32 = 0
ErrorMissingParameter int32 = 10
ErrorClientTooOld int32 = 20
ErrorServerTooOld int32 = 30
ErrorAuthenticationFail int32 = 40
ErrorAuthorizationFail int32 = 50
ErrorTrialExpired int32 = 60
ErrorDataNotFound int32 = 70
ErrorGeneric int32 = 0
ErrorMissingParameter int32 = 10
ErrorClientTooOld int32 = 20
ErrorServerTooOld int32 = 30
ErrorAuthenticationFail int32 = 40
ErrorMultipleAuthMechanismsProvided int32 = 43
ErrorInvalidAPIKey int32 = 44
ErrorAuthorizationFail int32 = 50
ErrorTrialExpired int32 = 60
ErrorDataNotFound int32 = 70
)
var errors = map[int32]string{
ErrorGeneric: "A generic error",
ErrorMissingParameter: "Required parameter is missing",
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
ErrorAuthenticationFail: "Wrong username or password",
ErrorAuthorizationFail: "User is not authorized for the given operation",
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
ErrorDataNotFound: "The requested data was not found",
var errors = map[int32]string{ //nolint:gosec // G101 false positive: error messages, not credentials
ErrorGeneric: "A generic error",
ErrorMissingParameter: "Required parameter is missing",
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
ErrorAuthenticationFail: "Wrong username or password",
ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided",
ErrorInvalidAPIKey: "Invalid API key",
ErrorAuthorizationFail: "User is not authorized for the given operation",
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
ErrorDataNotFound: "The requested data was not found",
}
func ErrorMsg(code int32) string {

View file

@ -63,6 +63,7 @@ type Subsonic struct {
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"`
TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"`
}
const (
@ -596,6 +597,10 @@ type OpenSubsonicExtension struct {
type OpenSubsonicExtensions []OpenSubsonicExtension
type TokenInfo struct {
Username string `xml:"username,attr" json:"username"`
}
type ItemGenre struct {
Name string `xml:"name,attr" json:"name"`
}

View file

@ -1015,6 +1015,20 @@ var _ = Describe("Responses", func() {
})
})
Describe("TokenInfo", func() {
BeforeEach(func() {
response.OpenSubsonic = true
response.TokenInfo = &TokenInfo{Username: "deluan"}
})
It("should match .XML", func() {
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
})
Describe("InternetRadioStations", func() {
BeforeEach(func() {
response.InternetRadioStations = &InternetRadioStations{}

View file

@ -3,6 +3,7 @@ package subsonic
import (
"net/http"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
)
@ -15,3 +16,10 @@ func (api *Router) GetLicense(_ *http.Request) (*responses.Subsonic, error) {
response.License = &responses.License{Valid: true}
return response, nil
}
func (api *Router) TokenInfo(r *http.Request) (*responses.Subsonic, error) {
user, _ := request.UserFrom(r.Context())
response := newResponse()
response.TokenInfo = &responses.TokenInfo{Username: user.UserName}
return response, nil
}

View file

@ -0,0 +1,23 @@
package subsonic
import (
"net/http/httptest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TokenInfo", func() {
It("returns the authenticated username", func() {
api := &Router{}
r := httptest.NewRequest("GET", "/tokenInfo", nil)
r = r.WithContext(request.WithUser(r.Context(), model.User{UserName: "deluan"}))
resp, err := api.TokenInfo(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.TokenInfo.Username).To(Equal("deluan"))
})
})

View file

@ -228,7 +228,7 @@ func (db *MockDataStore) Player() model.PlayerRepository {
if db.RealDS != nil {
return db.RealDS.Player()
}
db.MockedPlayer = struct{ model.PlayerRepository }{}
db.MockedPlayer = CreateMockPlayerRepo()
return db.MockedPlayer
}

75
tests/mock_player_repo.go Normal file
View file

@ -0,0 +1,75 @@
package tests
import (
"context"
"maps"
"slices"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
func CreateMockPlayerRepo() *MockPlayerRepo {
return &MockPlayerRepo{Data: map[string]*model.Player{}, APIKeys: map[string]string{}}
}
// MockPlayerRepo keeps API keys in plaintext (key -> player ID); hashing belongs to the real repository.
type MockPlayerRepo struct {
model.PlayerRepository
Error error
Data map[string]*model.Player
APIKeys map[string]string
}
func (m *MockPlayerRepo) Get(_ context.Context, id string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
p, ok := m.Data[id]
if !ok {
return nil, model.ErrNotFound
}
cp := *p
cp.HasAPIKey = slices.Contains(slices.Collect(maps.Values(m.APIKeys)), id)
return &cp, nil
}
func (m *MockPlayerRepo) Put(_ context.Context, p *model.Player) error {
if m.Error != nil {
return m.Error
}
if p.ID == "" {
p.ID = id.NewRandom()
}
cp := *p
m.Data[p.ID] = &cp
return nil
}
func (m *MockPlayerRepo) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
if playerID, ok := m.APIKeys[key]; ok {
return m.Get(ctx, playerID)
}
return nil, model.ErrNotFound
}
func (m *MockPlayerRepo) SetAPIKey(_ context.Context, playerID, key string) error {
if m.Error != nil {
return m.Error
}
if _, ok := m.Data[playerID]; !ok {
return model.ErrNotFound
}
m.removeKeys(playerID)
if key != "" {
m.APIKeys[key] = playerID
}
return nil
}
func (m *MockPlayerRepo) removeKeys(playerID string) {
maps.DeleteFunc(m.APIKeys, func(_ string, v string) bool { return v == playerID })
}

View file

@ -141,7 +141,7 @@ const Admin = (props) => {
<Resource
name="player"
{...player}
options={{ subMenu: 'settings' }}
options={{ subMenu: 'settings', label: 'resources.player.menuName' }}
/>,
permissions === 'admin' ? (
<Resource

View file

@ -148,6 +148,12 @@ const updateUser = async (params) => {
return userResponse
}
// ra-data-json-server merges the request body into the result; re-read so the plaintext key is never cached
const createPlayer = async (resource, params) => {
const { data } = await dataProvider.create(resource, params)
return dataProvider.getOne(resource, { id: data.id })
}
const wrapperDataProvider = {
...dataProvider,
getList: (resource, params) => {
@ -194,6 +200,9 @@ const wrapperDataProvider = {
return createUser(params)
}
const [r, p] = mapResource(resource, params)
if (resource === 'player') {
return createPlayer(r, p)
}
return dataProvider.create(r, p)
},
delete: (resource, params) => {

View file

@ -88,6 +88,21 @@ describe('wrapperDataProvider', () => {
})
})
describe('create player', () => {
it('returns the server record, never the plaintext API key', async () => {
const data = { name: 'Phone', apiKey: 'nds_0123456789abcdefghijkl' }
const saved = { id: 'p1', name: 'Phone', hasApiKey: true, userId: 'u1' }
mockProvider.create.mockResolvedValue({ data: { ...data, id: 'p1' } })
mockProvider.getOne.mockResolvedValue({ data: saved })
const result = await wrapperDataProvider.create('player', { data })
expect(mockProvider.create).toHaveBeenCalledWith('player', { data })
expect(mockProvider.getOne).toHaveBeenCalledWith('player', { id: 'p1' })
expect(result.data).toEqual(saved)
})
})
describe('refreshMetadata', () => {
it('posts to the album metadata refresh endpoint', () => {
mockHttpClient.mockResolvedValue({ json: {} })

View file

@ -182,6 +182,7 @@
},
"player": {
"name": "Player |||| Players",
"menuName": "Players & API keys",
"fields": {
"name": "Name",
"transcodingId": "Transcoding",
@ -190,7 +191,29 @@
"userName": "Username",
"lastSeen": "Last Seen At",
"reportRealPath": "Report Real Path",
"scrobbleEnabled": "Send Scrobbles to external services"
"scrobbleEnabled": "Send Scrobbles to external services",
"hasApiKey": "API Key"
},
"actions": {
"generateApiKey": "Generate API key",
"regenerateApiKey": "Regenerate",
"revokeApiKey": "Revoke",
"copyApiKey": "Copy"
},
"message": {
"apiKeyActive": "This player has an API key. Use it in your app as the API key, or as the password if the app does not use token authentication.",
"apiKeyNone": "No API key. Generate one to connect an app to this player.",
"apiKeyNoneOther": "No API key.",
"apiKeyPending": "Copy this key now. It is saved when you click Save and will not be shown again.",
"apiKeyRevokePending": "The API key will be removed when you save.",
"deleteWithKeyTitle": "Delete player",
"deleteWithKeyContent": "This player has an API key. Apps using it will stop working."
},
"notifications": {
"apiKeyCopied": "API key copied to clipboard"
},
"validation": {
"apiKeyFormat": "Invalid API key format"
}
},
"transcoding": {

View file

@ -102,9 +102,11 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
}
const renderSettingsMenuItemLink = (resource, id) => {
const label = translate(`resources.${resource.name}.name`, {
smart_count: id ? 1 : 2,
})
const label = resource.options.label
? translate(resource.options.label)
: translate(`resources.${resource.name}.name`, {
smart_count: id ? 1 : 2,
})
const link = id ? `/${resource.name}/${id}` : `/${resource.name}`
return (
<MenuItemLink

View file

@ -9,11 +9,14 @@ import config from '../config'
let store
const mocks = vi.hoisted(() => ({ resources: [] }))
vi.mock('react-admin', () => ({
AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>,
MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>,
useTranslate: () => (x) => x,
usePermissions: () => ({ permissions: 'admin' }),
getResources: () => [],
getResources: () => mocks.resources,
}))
vi.mock('./NowPlayingPanel', () => ({
@ -41,6 +44,7 @@ describe('<AppBar />', () => {
config.devActivityPanel = true
config.enableNowPlaying = true
config.enableQuickConnect = false
mocks.resources = []
store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 0 },
})
@ -84,4 +88,22 @@ describe('<AppBar />', () => {
expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0)
expect(screen.queryAllByText('menu.about')).not.toHaveLength(0)
})
it('uses the resource label for settings items when set', () => {
mocks.resources = [
{
name: 'player',
hasList: true,
options: { subMenu: 'settings', label: 'resources.player.menuName' },
},
{ name: 'transcoding', hasList: true, options: { subMenu: 'settings' } },
]
render(
<Provider store={store}>
<AppBar />
</Provider>,
)
expect(screen.getByText('resources.player.menuName')).toBeInTheDocument()
expect(screen.getByText('resources.transcoding.name')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,107 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useInput, useNotify, useTranslate } from 'react-admin'
import { Button, TextField } from '@material-ui/core'
import { FaKey } from 'react-icons/fa'
import { MdContentCopy, MdDelete, MdRefresh } from 'react-icons/md'
import { isWritable } from '../common/playlistUtils'
import { generateApiKey } from './apiKey'
const identity = (v) => v
const MASK = '•'.repeat(26)
const ApiKeyInput = ({ record, isCreate, fullWidth, className, ...props }) => {
const translate = useTranslate()
const notify = useNotify()
// Identity format/parse keep "" (revoke) distinct from undefined (untouched)
const {
input: { value, onChange },
meta: { error, touched },
} = useInput({ ...props, format: identity, parse: identity })
const isOwner = isCreate || record?.userId === localStorage.getItem('userId')
const pending = !!value
const revoking = value === '' && !!record?.hasApiKey
const saved = value == null && !!record?.hasApiKey
const hasKey = pending || saved
const copy = () => {
const fallback = () =>
prompt(translate('message.shareCopyToClipboard'), value)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(value)
.then(
() => notify('resources.player.notifications.apiKeyCopied'),
fallback,
)
} else {
fallback()
}
}
const helperText = pending
? 'resources.player.message.apiKeyPending'
: revoking
? 'resources.player.message.apiKeyRevokePending'
: saved
? 'resources.player.message.apiKeyActive'
: isOwner
? 'resources.player.message.apiKeyNone'
: 'resources.player.message.apiKeyNoneOther'
return (
<div>
<TextField
label={translate('resources.player.fields.hasApiKey')}
value={pending ? value : saved ? MASK : ''}
variant="outlined"
margin="dense"
fullWidth={fullWidth}
className={className}
// Monospace keeps the whole key visible in a standard-width input
inputProps={{
style: { fontFamily: 'monospace', fontSize: '0.875rem' },
}}
InputProps={{ readOnly: true }}
error={!!(touched && error)}
helperText={translate(touched && error ? error : helperText)}
/>
<div>
{pending && (
<Button startIcon={<MdContentCopy />} onClick={copy}>
{translate('resources.player.actions.copyApiKey')}
</Button>
)}
{isOwner && (
<Button
startIcon={hasKey ? <MdRefresh /> : <FaKey />}
onClick={() => onChange(generateApiKey())}
>
{translate(
hasKey
? 'resources.player.actions.regenerateApiKey'
: 'resources.player.actions.generateApiKey',
)}
</Button>
)}
{saved && isWritable(record?.userId) && (
<Button startIcon={<MdDelete />} onClick={() => onChange('')}>
{translate('resources.player.actions.revokeApiKey')}
</Button>
)}
</div>
</div>
)
}
ApiKeyInput.propTypes = {
source: PropTypes.string.isRequired,
record: PropTypes.object,
isCreate: PropTypes.bool,
fullWidth: PropTypes.bool,
className: PropTypes.string,
validate: PropTypes.oneOfType([PropTypes.func, PropTypes.array]),
}
export default ApiKeyInput

View file

@ -0,0 +1,172 @@
import * as React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Form } from 'react-final-form'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import ApiKeyInput from './ApiKeyInput'
const hooks = vi.hoisted(() => ({ notify: vi.fn() }))
const KEY = 'nds_0123456789abcdefghijkl'
const KEY_FORMAT = /^nds_[0-9A-Za-z]{22}$/
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useNotify: () => hooks.notify,
useTranslate: () => (key) => key,
}
})
const renderInput = ({
record,
initialValues = {},
isCreate = false,
fullWidth,
}) => {
let values
const utils = render(
<Form
onSubmit={() => {}}
initialValues={initialValues}
render={({ values: v }) => {
values = v
return (
<ApiKeyInput
source="apiKey"
record={record}
isCreate={isCreate}
fullWidth={fullWidth}
/>
)
}}
/>,
)
return { ...utils, values: () => values }
}
const text = (key) => screen.queryByText(key)
describe('ApiKeyInput', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.setItem('userId', 'owner')
localStorage.setItem('role', 'regular')
})
it('shows a pending key with copy and regenerate on create', () => {
const { values } = renderInput({
record: {},
isCreate: true,
initialValues: { apiKey: KEY },
})
expect(screen.getByDisplayValue(KEY)).toBeInTheDocument()
expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'resources.player.actions.copyApiKey',
}),
).toBeInTheDocument()
expect(
text('resources.player.actions.revokeApiKey'),
).not.toBeInTheDocument()
fireEvent.click(
screen.getByText('resources.player.actions.regenerateApiKey'),
)
expect(values().apiKey).toMatch(KEY_FORMAT)
expect(values().apiKey).not.toBe(KEY)
})
it('masks a saved key and lets the owner regenerate or revoke', () => {
const { values } = renderInput({
record: { id: 'p1', userId: 'owner', hasApiKey: true },
})
expect(screen.queryByDisplayValue(/^nds_/)).not.toBeInTheDocument()
expect(text('resources.player.message.apiKeyActive')).toBeInTheDocument()
expect(
text('resources.player.actions.regenerateApiKey'),
).toBeInTheDocument()
fireEvent.click(screen.getByText('resources.player.actions.revokeApiKey'))
expect(values().apiKey).toBe('')
expect(
text('resources.player.message.apiKeyRevokePending'),
).toBeInTheDocument()
expect(text('resources.player.actions.generateApiKey')).toBeInTheDocument()
})
it('lets the owner generate a key when there is none', () => {
const { values } = renderInput({
record: { id: 'p1', userId: 'owner', hasApiKey: false },
})
expect(values().apiKey).toBeUndefined()
expect(text('resources.player.message.apiKeyNone')).toBeInTheDocument()
fireEvent.click(screen.getByText('resources.player.actions.generateApiKey'))
expect(values().apiKey).toMatch(KEY_FORMAT)
expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument()
})
it('lets an admin revoke but not set a key on another user player', () => {
localStorage.setItem('role', 'admin')
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } })
expect(
text('resources.player.actions.regenerateApiKey'),
).not.toBeInTheDocument()
expect(text('resources.player.actions.revokeApiKey')).toBeInTheDocument()
})
it('falls back to a prompt when the clipboard write fails', async () => {
vi.stubGlobal('isSecureContext', true)
vi.stubGlobal('prompt', vi.fn())
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
try {
renderInput({
record: {},
isCreate: true,
initialValues: { apiKey: KEY },
})
fireEvent.click(
screen.getByRole('button', {
name: 'resources.player.actions.copyApiKey',
}),
)
await waitFor(() =>
expect(window.prompt).toHaveBeenCalledWith(
'message.shareCopyToClipboard',
KEY,
),
)
expect(hooks.notify).not.toHaveBeenCalled()
} finally {
vi.unstubAllGlobals()
delete navigator.clipboard
}
})
it('shows a neutral message to an admin viewing another user player with no key', () => {
localStorage.setItem('role', 'admin')
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: false } })
expect(text('resources.player.message.apiKeyNoneOther')).toBeInTheDocument()
expect(text('resources.player.message.apiKeyNone')).not.toBeInTheDocument()
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('shows no actions to another regular user', () => {
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } })
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('is not full width unless asked', () => {
const record = { id: 'p1', userId: 'owner', hasApiKey: true }
const { container, unmount } = renderInput({ record })
expect(container.querySelector('.MuiFormControl-fullWidth')).toBeNull()
unmount()
const { container: wide } = renderInput({ record, fullWidth: true })
expect(wide.querySelector('.MuiFormControl-fullWidth')).not.toBeNull()
})
})

View file

@ -0,0 +1,33 @@
import React, { useMemo } from 'react'
import { Create, SimpleForm, required, useTranslate } from 'react-admin'
import { Title } from '../common'
import { playerInputs } from './playerInputs'
import ApiKeyInput from './ApiKeyInput'
import { generateApiKey } from './apiKey'
const PlayerCreateTitle = () => {
const translate = useTranslate()
const resourceName = translate('resources.player.name', { smart_count: 1 })
return (
<Title subTitle={translate('ra.page.create', { name: resourceName })} />
)
}
const PlayerCreate = (props) => {
// Memoized so re-renders don't swap the key the user may have already copied
const initialValues = useMemo(() => ({ apiKey: generateApiKey() }), [])
return (
<Create title={<PlayerCreateTitle />} {...props}>
<SimpleForm
variant="outlined"
redirect="list"
initialValues={initialValues}
>
{playerInputs()}
<ApiKeyInput source="apiKey" isCreate validate={required()} />
</SimpleForm>
</Create>
)
}
export default PlayerCreate

View file

@ -0,0 +1,47 @@
import * as React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import PlayerCreate from './PlayerCreate'
import ApiKeyInput from './ApiKeyInput'
const hooks = vi.hoisted(() => ({ forms: [] }))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
Create: ({ children }) => children,
SimpleForm: (props) => {
hooks.forms.push(props)
return null
},
}
})
describe('PlayerCreate', () => {
beforeEach(() => {
hooks.forms = []
})
it('pre-fills one generated API key that survives re-renders', () => {
const { rerender } = render(<PlayerCreate resource="player" />)
rerender(<PlayerCreate resource="player" />)
const [first, second] = hooks.forms.map((f) => f.initialValues)
expect(hooks.forms).toHaveLength(2)
expect(first.apiKey).toMatch(/^nds_[0-9A-Za-z]{22}$/)
expect(second).toBe(first)
})
it('requires the API key', () => {
render(<PlayerCreate resource="player" />)
const input = React.Children.toArray(hooks.forms[0].children).find(
(child) => child.type === ApiKeyInput,
)
expect(input.props.source).toBe('apiKey')
expect(input.props.isCreate).toBe(true)
expect(input.props.validate('')).toBeTruthy()
expect(input.props.validate('nds_0123456789abcdefghijkl')).toBeUndefined()
})
})

View file

@ -1,17 +1,17 @@
import {
TextInput,
BooleanInput,
TextField,
Edit,
required,
SimpleForm,
SelectInput,
ReferenceInput,
useTranslate,
DeleteButton,
DeleteWithConfirmButton,
SaveButton,
Toolbar,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import { Title } from '../common'
import config from '../config'
import { BITRATE_CHOICES } from '../consts'
import ApiKeyInput from './ApiKeyInput'
import { playerInputs } from './playerInputs'
const PlayerTitle = ({ record }) => {
const translate = useTranslate()
@ -19,24 +19,35 @@ const PlayerTitle = ({ record }) => {
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
}
const useToolbarStyles = makeStyles({
toolbar: {
display: 'flex',
justifyContent: 'space-between',
},
})
const PlayerEditToolbar = (props) => (
<Toolbar {...props} classes={useToolbarStyles()}>
<SaveButton />
{props.record?.hasApiKey ? (
<DeleteWithConfirmButton
mutationMode="pessimistic"
confirmTitle="resources.player.message.deleteWithKeyTitle"
confirmContent="resources.player.message.deleteWithKeyContent"
/>
) : (
<DeleteButton />
)}
</Toolbar>
)
const PlayerEdit = (props) => (
<Edit title={<PlayerTitle />} {...props}>
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<ReferenceInput
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
<BooleanInput source="reportRealPath" fullWidth />
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
)}
<Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}>
<SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}>
{playerInputs()}
<TextField source="client" />
<TextField source="userName" />
<ApiKeyInput source="apiKey" />
</SimpleForm>
</Edit>
)

View file

@ -0,0 +1,25 @@
import * as React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import PlayerEdit from './PlayerEdit'
const hooks = vi.hoisted(() => ({ editProps: null }))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
Edit: (props) => {
hooks.editProps = props
return null
},
}
})
describe('PlayerEdit', () => {
// An optimistic or undoable save would put the new key in react-admin's cache
it('saves pessimistically', () => {
render(<PlayerEdit resource="player" id="p1" />)
expect(hooks.editProps.mutationMode).toBe('pessimistic')
})
})

View file

@ -2,18 +2,20 @@ import React from 'react'
import {
Datagrid,
TextField,
DateField,
FunctionField,
ReferenceField,
Filter,
SearchInput,
NullableBooleanInput,
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { SimpleList, List } from '../common'
import { FaKey } from 'react-icons/fa'
import { SimpleList, List, DateField } from '../common'
const PlayerFilter = (props) => (
<Filter {...props} variant={'outlined'}>
<SearchInput id="search" source="name" alwaysOn />
<NullableBooleanInput source="hasApiKey" alwaysOn />
</Filter>
)
@ -30,7 +32,11 @@ const PlayerList = ({ permissions, ...props }) => {
<SimpleList
primaryText={(r) => r.name}
secondaryText={(r) => r.userName}
tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : '-')}
tertiaryText={(r) => (
<>
{r.hasApiKey && <FaKey />} {r.maxBitRate ? r.maxBitRate : '-'}
</>
)}
/>
) : (
<Datagrid rowClick="edit">
@ -43,6 +49,11 @@ const PlayerList = ({ permissions, ...props }) => {
source="maxBitRate"
render={(r) => (r.maxBitRate ? r.maxBitRate : '-')}
/>
<FunctionField
source="hasApiKey"
sortable={false}
render={(r) => (r.hasApiKey ? <FaKey /> : null)}
/>
<DateField source="lastSeen" showTime sortByOrder={'DESC'} />
</Datagrid>
)}

15
ui/src/player/apiKey.js Normal file
View file

@ -0,0 +1,15 @@
const API_KEY_PREFIX = 'nds_'
const ALPHABET =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const KEY_LENGTH = 22
// Bytes >= 248 are dropped so every character is equally likely (248 = 4 * 62).
export const generateApiKey = () => {
let key = ''
while (key.length < KEY_LENGTH) {
for (const b of window.crypto.getRandomValues(new Uint8Array(32))) {
if (b < 248 && key.length < KEY_LENGTH) key += ALPHABET[b % 62]
}
}
return API_KEY_PREFIX + key
}

View file

@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { generateApiKey } from './apiKey'
describe('generateApiKey', () => {
it('returns the prefix plus 22 base62 characters', () => {
for (let i = 0; i < 50; i++) {
expect(generateApiKey()).toMatch(/^nds_[0-9A-Za-z]{22}$/)
}
})
it('returns a different key each time', () => {
const keys = new Set(Array.from({ length: 100 }, generateApiKey))
expect(keys.size).toBe(100)
})
})

View file

@ -1,9 +1,11 @@
import { BsFillMusicPlayerFill } from 'react-icons/bs'
import PlayerList from './PlayerList'
import PlayerEdit from './PlayerEdit'
import PlayerCreate from './PlayerCreate'
export default {
list: PlayerList,
edit: PlayerEdit,
create: PlayerCreate,
icon: BsFillMusicPlayerFill,
}

View file

@ -0,0 +1,34 @@
import React from 'react'
import {
BooleanInput,
ReferenceInput,
SelectInput,
TextInput,
required,
} from 'react-admin'
import config from '../config'
import { BITRATE_CHOICES } from '../consts'
// Returned as an array, not a component, so SimpleForm still injects its props into each input.
export const playerInputs = () =>
[
<TextInput key="name" source="name" validate={[required()]} />,
<ReferenceInput
key="transcodingId"
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>,
<SelectInput
key="maxBitRate"
source="maxBitRate"
resettable
choices={BITRATE_CHOICES}
/>,
<BooleanInput key="reportRealPath" source="reportRealPath" fullWidth />,
(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput key="scrobbleEnabled" source="scrobbleEnabled" fullWidth />
),
].filter(Boolean)