Merge e5ef453ebe553f48e0eb3814e5c547e998108358 into add0a6dc9b8360db480f76793fa928499bf51741

This commit is contained in:
zkvvoob 2026-07-30 04:29:45 +08:00 committed by GitHub
commit d0ca5dbe5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1426 additions and 42 deletions

View File

@ -0,0 +1,32 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upCreateAppPassword, downCreateAppPassword)
}
func upCreateAppPassword(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS app_password (
id VARCHAR(255) NOT NULL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL REFERENCES user(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
secret_encrypted TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME,
expires_at DATETIME
);
CREATE INDEX IF NOT EXISTS app_password_user_id ON app_password(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS app_password_user_name ON app_password(user_id, name);`)
return err
}
func downCreateAppPassword(ctx context.Context, tx *sql.Tx) error {
return nil
}

76
model/app_password.go Normal file
View File

@ -0,0 +1,76 @@
package model
import (
"context"
"time"
)
// AppPassword represents a long-lived secondary credential a user creates so
// that Subsonic clients (which cannot perform an OIDC flow) can authenticate
// without exposing the user's primary password. Each app password is bound to
// a single user, has a human-readable name, and is stored AES-GCM-encrypted so
// that the Subsonic md5(password+salt) verification path can read the
// plaintext at request time.
type AppPassword struct {
ID string `structs:"id" json:"id"`
UserID string `structs:"user_id" json:"userId"`
Name string `structs:"name" json:"name"`
SecretEncrypted string `structs:"secret_encrypted" json:"-"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `structs:"expires_at" json:"expiresAt,omitempty"`
// Secret is the plaintext app password. It is populated in two situations
// only: (a) on the response to Create, where the caller must surface it to
// the user immediately because it is never recoverable afterwards; and (b)
// internally inside GetActiveForUser so the Subsonic auth fallback can
// compute md5(secret+salt). Never persisted, never serialized on read APIs.
Secret string `structs:"-" json:"secret,omitempty"`
}
type AppPasswords []AppPassword
// AppPasswordPublic is the read-only projection returned by listing endpoints.
// It deliberately omits SecretEncrypted and Secret so neither the ciphertext
// nor the plaintext can leak through generic JSON serialization.
type AppPasswordPublic struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type AppPasswordRepository interface {
// Create generates a cryptographically random secret, encrypts it with the
// shared password-encryption key, persists the record, and returns the
// plaintext secret along with the new record. The plaintext is returned
// exactly once; subsequent reads only ever expose the ciphertext.
//
// Parameters:
// userID - owner user ID; foreign key into the user table.
// name - human-readable label, unique per user.
// expiresAt - optional expiry; nil means the password never expires.
Create(ctx context.Context, userID, name string, expiresAt *time.Time) (plaintextSecret string, ap *AppPassword, err error)
// Delete removes the app password identified by id, but only if it is owned
// by ownerUserID. This double-check prevents users from deleting other
// users' app passwords by guessing IDs.
Delete(ctx context.Context, id, ownerUserID string) error
// GetActiveForUser returns all non-expired app passwords for the given
// user, with the Secret field populated (decrypted) so the Subsonic
// authentication fallback can perform md5(secret+salt) checks. Returns an
// empty slice if the user has no active app passwords.
GetActiveForUser(ctx context.Context, userID string) (AppPasswords, error)
// UpdateLastUsedAt sets last_used_at on the record to the current time.
// Called fire-and-forget after a successful Subsonic authentication, so
// errors are non-fatal but should be logged.
UpdateLastUsedAt(ctx context.Context, id string) error
// ListForUser returns the metadata-only public projection for the given
// user, suitable for the management UI list view.
ListForUser(ctx context.Context, userID string) ([]AppPasswordPublic, error)
}

View File

@ -37,6 +37,7 @@ type DataStore interface {
Property(ctx context.Context) PropertyRepository Property(ctx context.Context) PropertyRepository
User(ctx context.Context) UserRepository User(ctx context.Context) UserRepository
UserProps(ctx context.Context) UserPropsRepository UserProps(ctx context.Context) UserPropsRepository
AppPassword(ctx context.Context) AppPasswordRepository
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
Scrobble(ctx context.Context) ScrobbleRepository Scrobble(ctx context.Context) ScrobbleRepository
Plugin(ctx context.Context) PluginRepository Plugin(ctx context.Context) PluginRepository

View File

@ -0,0 +1,176 @@
package persistence
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
"github.com/pocketbase/dbx"
)
// appPasswordRepository persists named long-lived app passwords. Each row stores
// an AES-GCM-encrypted secret so that the Subsonic md5(secret+salt) verification
// path can recover the plaintext on each authentication attempt.
type appPasswordRepository struct {
sqlRepository
}
// appPasswordSecretBytes is the size, in raw bytes, of a generated app-password
// secret before base64 encoding. 24 bytes → 32-character URL-safe string, which
// is well above the practical brute-force threshold for the Subsonic md5+salt
// verification flow.
const appPasswordSecretBytes = 24
// NewAppPasswordRepository constructs a repository over the app_password table.
//
// Side effect: it calls NewUserRepository to guarantee that the shared password
// encryption key (encKey) has been derived and any one-time password
// re-encryption migration has run before this repository is used. Without this
// piggyback, a request that happens to hit /rest before any /api or /auth
// endpoint could observe a nil encKey.
func NewAppPasswordRepository(ctx context.Context, db dbx.Builder) model.AppPasswordRepository {
_ = NewUserRepository(ctx, db) // ensures encKey is initialised
r := &appPasswordRepository{}
r.ctx = ctx
r.db = db
r.tableName = "app_password"
r.registerModel(&model.AppPassword{}, nil)
return r
}
// Create generates a fresh secret, encrypts it, and inserts a new row.
//
// Parameters:
// - ctx : request context used for cancellation and logging.
// - userID : the owning user's ID; must already exist in the user table.
// - name : a human-readable label, unique per user.
// - expiresAt : optional expiry timestamp; nil means the password never expires.
//
// Returns the plaintext secret (which the caller must surface to the user
// exactly once — it is not retrievable afterwards) plus the persisted record.
func (r *appPasswordRepository) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
if userID == "" {
return "", nil, errors.New("appPassword: userID is required")
}
if name == "" {
return "", nil, errors.New("appPassword: name is required")
}
plaintext, err := generateAppPasswordSecret()
if err != nil {
return "", nil, err
}
encrypted, err := utils.Encrypt(ctx, encryptionKey(), plaintext)
if err != nil {
return "", nil, err
}
ap := &model.AppPassword{
ID: id.NewRandom(),
UserID: userID,
Name: name,
SecretEncrypted: encrypted,
CreatedAt: time.Now(),
ExpiresAt: expiresAt,
}
insert := Insert(r.tableName).SetMap(map[string]any{
"id": ap.ID,
"user_id": ap.UserID,
"name": ap.Name,
"secret_encrypted": ap.SecretEncrypted,
"created_at": ap.CreatedAt,
"expires_at": ap.ExpiresAt,
})
if _, err := r.executeSQL(insert); err != nil {
return "", nil, err
}
ap.Secret = plaintext
return plaintext, ap, nil
}
// Delete removes the row identified by id, but only if owned by ownerUserID.
// The compound WHERE clause prevents users from deleting other users' app
// passwords by guessing IDs.
func (r *appPasswordRepository) Delete(ctx context.Context, id, ownerUserID string) error {
del := Delete(r.tableName).Where(And{Eq{"id": id}, Eq{"user_id": ownerUserID}})
count, err := r.executeSQL(del)
if err != nil {
return err
}
if count == 0 {
return model.ErrNotFound
}
return nil
}
// GetActiveForUser returns every non-expired app password for userID, with the
// Secret field populated by decrypting SecretEncrypted. Used by the Subsonic
// authentication fallback.
func (r *appPasswordRepository) GetActiveForUser(ctx context.Context, userID string) (model.AppPasswords, error) {
now := time.Now()
sel := r.newSelect().Columns("id", "user_id", "name", "secret_encrypted", "created_at", "last_used_at", "expires_at").
Where(Eq{"user_id": userID}).
Where(Or{Eq{"expires_at": nil}, Gt{"expires_at": now}})
var rows model.AppPasswords
if err := r.queryAll(sel, &rows); err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.AppPasswords{}, nil
}
return nil, err
}
for i := range rows {
plain, err := utils.Decrypt(ctx, encryptionKey(), rows[i].SecretEncrypted)
if err != nil {
log.Warn(ctx, "Skipping app password whose secret could not be decrypted", "id", rows[i].ID, err)
continue
}
rows[i].Secret = plain
}
return rows, nil
}
// UpdateLastUsedAt sets last_used_at on the row to the current wall clock time.
// Intended to be called fire-and-forget from the Subsonic auth path.
func (r *appPasswordRepository) UpdateLastUsedAt(ctx context.Context, id string) error {
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_used_at", time.Now())
_, err := r.executeSQL(upd)
return err
}
// ListForUser returns the metadata-only public projection for the user's
// app passwords, sorted by creation time descending so the most recent appears
// first in the management UI.
func (r *appPasswordRepository) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
sel := r.newSelect().
Columns("id", "user_id", "name", "created_at", "last_used_at", "expires_at").
Where(Eq{"user_id": userID}).
OrderBy("created_at DESC")
var rows []model.AppPasswordPublic
if err := r.queryAll(sel, &rows); err != nil {
if errors.Is(err, model.ErrNotFound) {
return []model.AppPasswordPublic{}, nil
}
return nil, err
}
return rows, nil
}
// generateAppPasswordSecret returns a URL-safe base64-encoded random string
// suitable for use as a secondary password.
func generateAppPasswordSecret() (string, error) {
buf := make([]byte, appPasswordSecretBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
var _ model.AppPasswordRepository = (*appPasswordRepository)(nil)

View File

@ -0,0 +1,122 @@
package persistence
import (
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("AppPasswordRepository", func() {
var (
repo model.AppPasswordRepository
userID string
)
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
// The user repo constructor seeds the encryption key; the app password
// repo piggybacks on that via NewUserRepository in its constructor.
userRepo := NewUserRepository(ctx, GetDBXBuilder())
userID = id.NewRandom()
Expect(userRepo.Put(&model.User{
ID: userID,
UserName: "ap-user-" + userID,
Name: "AP User",
NewPassword: "irrelevant",
})).To(Succeed())
repo = NewAppPasswordRepository(ctx, GetDBXBuilder())
})
Describe("Create", func() {
It("returns plaintext and persists encrypted secret", func() {
plain, ap, err := repo.Create(GinkgoT().Context(), userID, "phone", nil)
Expect(err).ToNot(HaveOccurred())
Expect(plain).ToNot(BeEmpty())
Expect(ap.SecretEncrypted).ToNot(BeEmpty())
Expect(ap.SecretEncrypted).ToNot(Equal(plain))
})
It("rejects empty user ID", func() {
_, _, err := repo.Create(GinkgoT().Context(), "", "x", nil)
Expect(err).To(HaveOccurred())
})
})
Describe("GetActiveForUser", func() {
It("decrypts and returns active passwords", func() {
plain, _, err := repo.Create(GinkgoT().Context(), userID, "active", nil)
Expect(err).ToNot(HaveOccurred())
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
Expect(err).ToNot(HaveOccurred())
Expect(rows).To(HaveLen(1))
Expect(rows[0].Secret).To(Equal(plain))
})
It("excludes expired passwords", func() {
past := time.Now().Add(-time.Hour)
_, _, err := repo.Create(GinkgoT().Context(), userID, "expired", &past)
Expect(err).ToNot(HaveOccurred())
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
Expect(err).ToNot(HaveOccurred())
Expect(rows).To(BeEmpty())
})
})
Describe("Delete", func() {
It("removes a password owned by the user", func() {
_, ap, err := repo.Create(GinkgoT().Context(), userID, "to-delete", nil)
Expect(err).ToNot(HaveOccurred())
Expect(repo.Delete(GinkgoT().Context(), ap.ID, userID)).To(Succeed())
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
Expect(err).ToNot(HaveOccurred())
Expect(rows).To(BeEmpty())
})
It("refuses to delete a password owned by a different user", func() {
_, ap, err := repo.Create(GinkgoT().Context(), userID, "other-owned", nil)
Expect(err).ToNot(HaveOccurred())
err = repo.Delete(GinkgoT().Context(), ap.ID, "someone-else")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("UpdateLastUsedAt", func() {
It("sets last_used_at on the row", func() {
_, ap, err := repo.Create(GinkgoT().Context(), userID, "lu", nil)
Expect(err).ToNot(HaveOccurred())
Expect(repo.UpdateLastUsedAt(GinkgoT().Context(), ap.ID)).To(Succeed())
pubs, err := repo.ListForUser(GinkgoT().Context(), userID)
Expect(err).ToNot(HaveOccurred())
Expect(pubs).ToNot(BeEmpty())
Expect(pubs[0].LastUsedAt).ToNot(BeNil())
})
})
Describe("ListForUser", func() {
It("returns metadata only, ordered newest first", func() {
_, _, err := repo.Create(GinkgoT().Context(), userID, "first", nil)
Expect(err).ToNot(HaveOccurred())
time.Sleep(10 * time.Millisecond)
_, _, err = repo.Create(GinkgoT().Context(), userID, "second", nil)
Expect(err).ToNot(HaveOccurred())
pubs, err := repo.ListForUser(GinkgoT().Context(), userID)
Expect(err).ToNot(HaveOccurred())
Expect(pubs).To(HaveLen(2))
Expect(pubs[0].Name).To(Equal("second"))
Expect(pubs[1].Name).To(Equal("first"))
})
})
})

15
persistence/enc_key.go Normal file
View File

@ -0,0 +1,15 @@
package persistence
// encryptionKey returns the 32-byte AES-GCM key used to encrypt user passwords
// and app-password secrets.
//
// The key is initialised by NewUserRepository's sync.Once on first call to any
// user repository constructor; that path also performs the one-time
// re-encryption migration when conf.Server.PasswordEncryptionKey is rotated.
// Repositories that depend on this key (e.g. app_password_repository) must
// therefore ensure a user repository has been constructed at least once before
// they invoke this accessor — see appPasswordRepository's constructor for the
// canonical pattern.
func encryptionKey() []byte {
return encKey
}

View File

@ -77,6 +77,10 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, s.getDBXBuilder()) return NewUserRepository(ctx, s.getDBXBuilder())
} }
func (s *SQLStore) AppPassword(ctx context.Context) model.AppPasswordRepository {
return NewAppPasswordRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository { func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
return NewTranscodingRepository(ctx, s.getDBXBuilder()) return NewTranscodingRepository(ctx, s.getDBXBuilder())
} }

View File

@ -0,0 +1,134 @@
package nativeapi
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// addAppPasswordRoute mounts the /user/{userId}/app-password CRUD endpoints.
//
// The route is owner-or-admin gated: a non-admin user can only manage their
// own passwords; an admin can manage any user's. POST returns the freshly
// generated plaintext secret exactly once; subsequent GETs only return
// metadata.
func (api *Router) addAppPasswordRoute(r chi.Router) {
r.Route("/user/{userId}/app-password", func(r chi.Router) {
r.Use(appPasswordOwnerOrAdminMiddleware)
r.Get("/", listAppPasswords(api.ds))
r.Post("/", createAppPassword(api.ds))
r.Delete("/{id}", deleteAppPassword(api.ds))
})
}
// appPasswordOwnerOrAdminMiddleware permits the request only if the
// authenticated user owns the userId in the path or is an admin. Any other
// caller (including unauthenticated requests, which should not reach this
// far in normal routing) gets 403.
func appPasswordOwnerOrAdminMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
caller, ok := request.UserFrom(r.Context())
if !ok {
http.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
userID := chi.URLParam(r, "userId")
if !caller.IsAdmin && caller.ID != userID {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// listAppPasswords returns the metadata-only public projection of the
// caller's (or, for admins, the target user's) app passwords. The plaintext
// secret is never re-served — it is only returned by POST at creation time.
func listAppPasswords(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userId")
rows, err := ds.AppPassword(r.Context()).ListForUser(r.Context(), userID)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
_ = rest.RespondWithJSON(w, http.StatusOK, rows)
}
}
// createAppPasswordRequest is the JSON payload accepted by POST.
type createAppPasswordRequest struct {
Name string `json:"name"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
// createAppPasswordResponse is the JSON payload returned by POST. The
// "secret" field is the plaintext shown only once.
type createAppPasswordResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Secret string `json:"secret"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
// createAppPassword generates and stores a new app password and returns the
// plaintext secret in the response body. Callers must surface the secret to
// the user immediately — Navidrome cannot retrieve it again.
func createAppPassword(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userId")
var req createAppPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, "invalid request body")
return
}
if req.Name == "" {
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, "name is required")
return
}
plaintext, ap, err := ds.AppPassword(r.Context()).Create(r.Context(), userID, req.Name, req.ExpiresAt)
if err != nil {
log.Error(r, "Failed to create app password", "userId", userID, err)
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
_ = rest.RespondWithJSON(w, http.StatusCreated, createAppPasswordResponse{
ID: ap.ID,
Name: ap.Name,
Secret: plaintext,
CreatedAt: ap.CreatedAt,
ExpiresAt: ap.ExpiresAt,
})
}
}
// deleteAppPassword removes an app password the caller is allowed to
// manage. The repository enforces the ownership check redundantly: even a
// path-confusion attack that smuggled a foreign userId past the middleware
// would not delete other users' rows because the WHERE clause includes
// user_id.
func deleteAppPassword(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userId")
appPwdID := chi.URLParam(r, "id")
if err := ds.AppPassword(r.Context()).Delete(r.Context(), appPwdID, userID); err != nil {
if errors.Is(err, model.ErrNotFound) {
_ = rest.RespondWithError(w, http.StatusNotFound, "not found")
return
}
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@ -0,0 +1,274 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// stubAppPasswordRepo gives the handler-level tests deterministic control over
// what the repo returns, without dragging in encryption / DB plumbing.
type stubAppPasswordRepo struct {
model.AppPasswordRepository
list []model.AppPasswordPublic
listErr error
createErr error
deleteErr error
createdName string
createdUserID string
createdExpiresAt *time.Time
deletedID string
deletedOwnerID string
}
func (s *stubAppPasswordRepo) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
if s.listErr != nil {
return nil, s.listErr
}
out := make([]model.AppPasswordPublic, 0, len(s.list))
for _, p := range s.list {
if p.UserID == userID {
out = append(out, p)
}
}
return out, nil
}
func (s *stubAppPasswordRepo) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
s.createdUserID = userID
s.createdName = name
s.createdExpiresAt = expiresAt
if s.createErr != nil {
return "", nil, s.createErr
}
ap := &model.AppPassword{
ID: "new-id",
UserID: userID,
Name: name,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
ExpiresAt: expiresAt,
}
return "plaintext-secret", ap, nil
}
func (s *stubAppPasswordRepo) Delete(ctx context.Context, id, ownerUserID string) error {
s.deletedID = id
s.deletedOwnerID = ownerUserID
return s.deleteErr
}
var _ = Describe("App Password API", func() {
var (
ds *tests.MockDataStore
repo *stubAppPasswordRepo
router chi.Router
api *Router
owner = model.User{ID: "user-1", UserName: "alice", IsAdmin: false}
admin = model.User{ID: "admin-1", UserName: "root", IsAdmin: true}
other = model.User{ID: "user-2", UserName: "bob", IsAdmin: false}
)
BeforeEach(func() {
repo = &stubAppPasswordRepo{}
ds = &tests.MockDataStore{MockedAppPassword: repo}
api = &Router{ds: ds}
router = chi.NewRouter()
api.addAppPasswordRoute(router)
})
// serve wraps the user into the request context (the way JWTVerifier would
// in production) and exercises the chi router so the URL params are
// populated for the middleware and handlers.
serve := func(method, path string, body []byte, caller *model.User) *httptest.ResponseRecorder {
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
if caller != nil {
req = req.WithContext(request.WithUser(req.Context(), *caller))
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
Describe("appPasswordOwnerOrAdminMiddleware", func() {
It("lets the owner manage their own passwords", func() {
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("lets an admin manage any user's passwords", func() {
w := serve("GET", "/user/user-1/app-password/", nil, &admin)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("forbids non-owner non-admin access", func() {
w := serve("GET", "/user/user-1/app-password/", nil, &other)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("rejects unauthenticated requests with 401", func() {
w := serve("GET", "/user/user-1/app-password/", nil, nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("listAppPasswords", func() {
It("returns the public projection without any secret fields", func() {
repo.list = []model.AppPasswordPublic{
{ID: "ap-1", UserID: "user-1", Name: "Phone", CreatedAt: time.Now()},
{ID: "ap-2", UserID: "user-1", Name: "Laptop", CreatedAt: time.Now()},
}
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
Expect(w.Code).To(Equal(http.StatusOK))
// Decode into the typed projection - if any unexpected secret field
// leaks in, json.Decoder won't see it here, so additionally inspect
// the raw bytes.
var got []model.AppPasswordPublic
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
Expect(got).To(HaveLen(2))
raw := w.Body.String()
Expect(raw).NotTo(ContainSubstring("secret"))
Expect(raw).NotTo(ContainSubstring("Secret"))
Expect(raw).NotTo(ContainSubstring("secret_encrypted"))
})
It("returns 500 when the repo errors", func() {
repo.listErr = errors.New("boom")
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("createAppPassword", func() {
It("returns 201 with the one-time plaintext secret", func() {
body, _ := json.Marshal(map[string]any{"name": "Phone"})
w := serve("POST", "/user/user-1/app-password/", body, &owner)
Expect(w.Code).To(Equal(http.StatusCreated))
var resp createAppPasswordResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.ID).To(Equal("new-id"))
Expect(resp.Name).To(Equal("Phone"))
Expect(resp.Secret).To(Equal("plaintext-secret"))
Expect(repo.createdUserID).To(Equal("user-1"))
Expect(repo.createdName).To(Equal("Phone"))
Expect(repo.createdExpiresAt).To(BeNil())
})
It("forwards expiresAt to the repo", func() {
exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
body, _ := json.Marshal(map[string]any{"name": "TempKey", "expiresAt": exp})
w := serve("POST", "/user/user-1/app-password/", body, &owner)
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(repo.createdExpiresAt).NotTo(BeNil())
Expect(repo.createdExpiresAt.Equal(exp)).To(BeTrue())
})
It("rejects an empty name with 422", func() {
body, _ := json.Marshal(map[string]any{"name": ""})
w := serve("POST", "/user/user-1/app-password/", body, &owner)
Expect(w.Code).To(Equal(http.StatusUnprocessableEntity))
Expect(repo.createdName).To(Equal("")) // repo was not called
Expect(repo.createdUserID).To(Equal(""))
})
It("rejects a malformed body with 422", func() {
w := serve("POST", "/user/user-1/app-password/", []byte("{not json"), &owner)
Expect(w.Code).To(Equal(http.StatusUnprocessableEntity))
})
It("rejects access from a non-owner before reaching the handler", func() {
body, _ := json.Marshal(map[string]any{"name": "Phone"})
w := serve("POST", "/user/user-1/app-password/", body, &other)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(repo.createdName).To(Equal("")) // repo was not called
})
It("returns 500 when the repo errors", func() {
repo.createErr = errors.New("boom")
body, _ := json.Marshal(map[string]any{"name": "Phone"})
w := serve("POST", "/user/user-1/app-password/", body, &owner)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("deleteAppPassword", func() {
It("returns 204 on success and forwards the owner scope", func() {
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &owner)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.deletedID).To(Equal("ap-1"))
Expect(repo.deletedOwnerID).To(Equal("user-1"))
})
It("returns 404 when the repo reports not found", func() {
repo.deleteErr = model.ErrNotFound
w := serve("DELETE", "/user/user-1/app-password/missing", nil, &owner)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 500 on unexpected repo errors", func() {
repo.deleteErr = errors.New("boom")
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &owner)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
It("forbids non-owner non-admin callers before reaching the repo", func() {
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &other)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(repo.deletedID).To(Equal(""))
})
It("scopes admin deletes to the path's userId, not the admin's", func() {
// Even when an admin deletes on behalf of someone else, the
// owner-id passed to the repo must be the path's userId so the
// WHERE clause stays correct.
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &admin)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.deletedOwnerID).To(Equal("user-1"))
})
})
Describe("end-to-end URL shapes", func() {
It("404s for an unknown subpath under app-password", func() {
w := serve("GET", "/user/user-1/app-password/unknown/extra", nil, &owner)
Expect(w.Code).To(Equal(http.StatusNotFound))
// nothing should hit the create/delete tracking
Expect(repo.deletedID).To(Equal(""))
})
It("preserves the URL parameter through the middleware (sanity)", func() {
// Smoke-test: hit a path that includes a hyphen in the userId,
// confirm the middleware sees the same value chi parsed.
hyphenated := model.User{ID: "user-with-hyphen", UserName: "x", IsAdmin: false}
w := serve("GET", "/user/user-with-hyphen/app-password/", nil, &hyphenated)
Expect(w.Code).To(Equal(http.StatusOK))
// The list path uses URLParam internally - we hit the trailing
// slash one so chi resolves the param.
Expect(strings.Contains(w.Body.String(), "[]")).To(BeTrue())
})
})
})

View File

@ -85,6 +85,7 @@ func (api *Router) routes() http.Handler {
api.addMissingFilesRoute(r) api.addMissingFilesRoute(r)
api.addKeepAliveRoute(r) api.addKeepAliveRoute(r)
api.addInsightsRoute(r) api.addInsightsRoute(r)
api.addAppPasswordRoute(r)
r.With(adminOnlyMiddleware).Group(func(r chi.Router) { r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
api.addInspectRoute(r) api.addInspectRoute(r)

View File

@ -4,6 +4,7 @@ import (
"cmp" "cmp"
"context" "context"
"crypto/md5" "crypto/md5"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@ -138,6 +139,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default: default:
err = validateCredentials(usr, pass, token, salt, jwt) err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil && jwt == "" {
err = validateAppPasswordCredentials(ctx, ds, usr, pass, token, salt)
}
if err != nil { if err != nil {
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
} }
@ -155,21 +159,55 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
} }
} }
func adminOnly(next http.Handler) http.Handler { // validateAppPasswordCredentials is the Subsonic-side fallback for users who
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // authenticate with a per-application secondary password instead of their
loggedUser, ok := request.UserFrom(r.Context()) // primary one. It only kicks in when the request is NOT presenting a JWT
if !ok { // (those are exclusively for Navidrome-issued tokens).
sendError(w, r, newError(responses.ErrorGeneric, "Internal error")) //
return // Both p= (plaintext / "enc:" hex-encoded) and t=/s= (md5(secret+salt)) auth
} // shapes are supported. Comparisons use crypto/subtle to avoid leaking
// whether a username has any active app passwords through timing side
// channels.
//
// On success, last_used_at is bumped asynchronously using a context that
// outlives the HTTP request — the user-facing response has already been
// committed by the time the UPDATE returns.
func validateAppPasswordCredentials(ctx context.Context, ds model.DataStore, user *model.User, pass, token, salt string) error {
aps, err := ds.AppPassword(ctx).GetActiveForUser(ctx, user.ID)
if err != nil {
log.Error(ctx, "Failed to load app passwords during auth", "userId", user.ID, err)
return model.ErrInvalidAuth
}
if len(aps) == 0 {
return model.ErrInvalidAuth
}
if !loggedUser.IsAdmin { if strings.HasPrefix(pass, "enc:") {
sendError(w, r, newError(responses.ErrorAuthorizationFail)) if dec, err := hex.DecodeString(pass[4:]); err == nil {
return pass = string(dec)
} }
}
next.ServeHTTP(w, r) for _, ap := range aps {
}) if ap.Secret == "" {
continue
}
var matches bool
switch {
case pass != "":
matches = subtle.ConstantTimeCompare([]byte(pass), []byte(ap.Secret)) == 1
case token != "":
t := fmt.Sprintf("%x", md5.Sum([]byte(ap.Secret+salt)))
matches = subtle.ConstantTimeCompare([]byte(t), []byte(token)) == 1
}
if matches {
asyncCtx := context.WithoutCancel(ctx)
id := ap.ID
go func() { _ = ds.AppPassword(asyncCtx).UpdateLastUsedAt(asyncCtx, id) }()
return nil
}
}
return model.ErrInvalidAuth
} }
func validateCredentials(user *model.User, pass, token, salt, jwt string) error { func validateCredentials(user *model.User, pass, token, salt, jwt string) error {

View File

@ -251,6 +251,130 @@ var _ = Describe("Middlewares", func() {
}) })
}) })
When("using app password authentication", func() {
var (
appRepo *tests.MockAppPasswordRepo
usedCh chan string
userID string
)
BeforeEach(func() {
// The primary password remains "wordpass"; an app password
// for the same user is "app-secret". The fallback path is only
// hit when validateCredentials fails (i.e. p != "wordpass"),
// so every test below uses "app-secret" or its derivatives.
existing, err := ds.User(context.TODO()).FindByUsername("admin")
Expect(err).NotTo(HaveOccurred())
userID = existing.ID
usedCh = make(chan string, 1)
appRepo = &tests.MockAppPasswordRepo{
Active: model.AppPasswords{
{
ID: "ap-active",
UserID: userID,
Name: "iOS",
Secret: "app-secret",
},
},
UpdateLastUsedAtFn: func(id string) {
usedCh <- id
},
}
ds.(*tests.MockDataStore).MockedAppPassword = appRepo
})
It("authenticates with the plaintext app password via p=", func() {
r := newGetRequest("u=admin", "p=app-secret")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("admin"))
Eventually(usedCh).Should(Receive(Equal("ap-active")))
})
It("authenticates with the hex-encoded app password via enc:", func() {
// hex("app-secret") = 6170702d736563726574
r := newGetRequest("u=admin", "p=enc:6170702d736563726574")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
Eventually(usedCh).Should(Receive(Equal("ap-active")))
})
It("authenticates with md5(secret+salt) via t=/s=", func() {
salt := "abcdef"
token := fmt.Sprintf("%x", md5.Sum([]byte("app-secret"+salt)))
r := newGetRequest("u=admin", "t="+token, "s="+salt)
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
Eventually(usedCh).Should(Receive(Equal("ap-active")))
})
It("rejects a request whose p= matches no primary nor app password", func() {
r := newGetRequest("u=admin", "p=nope")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
Consistently(usedCh).ShouldNot(Receive())
})
It("rejects when the user has no active app passwords", func() {
appRepo.Active = nil
r := newGetRequest("u=admin", "p=app-secret")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
Consistently(usedCh).ShouldNot(Receive())
})
It("falls through to the existing-password check when only the primary password matches", func() {
// p=wordpass would succeed at validateCredentials; the
// fallback should not even be queried.
appRepo.Active = nil
appRepo.GetActiveErr = errors.New("repo should not be queried")
r := newGetRequest("u=admin", "p=wordpass")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
})
It("does not bump last_used_at on failed auth", func() {
r := newGetRequest("u=admin", "p=app-secret-wrong")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeFalse())
Consistently(usedCh).ShouldNot(Receive())
})
It("does not run the app-password fallback for JWT auth", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
auth.Init(ds)
// A valid app secret presented via the jwt= param must not
// authenticate — the fallback is gated on jwt == "".
appRepo.GetActiveErr = errors.New("fallback must not run for jwt requests")
r := newGetRequest("u=admin", "jwt=app-secret")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
When("using reverse proxy authentication", func() { When("using reverse proxy authentication", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
@ -308,36 +432,6 @@ var _ = Describe("Middlewares", func() {
}) })
}) })
Describe("AdminOnly", func() {
It("passes admin users", func() {
r := newGetRequest()
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true}))
adminOnly(next).ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
})
It("rejects non-admin users", func() {
r := newGetRequest()
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false}))
adminOnly(next).ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="50"`))
Expect(next.called).To(BeFalse())
})
It("returns an internal error when user is missing from context", func() {
r := newGetRequest()
adminOnly(next).ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="0"`))
Expect(next.called).To(BeFalse())
})
})
Describe("GetPlayer", func() { Describe("GetPlayer", func() {
var mockedPlayers *mockPlayers var mockedPlayers *mockPlayers
var r *http.Request var r *http.Request

View File

@ -0,0 +1,60 @@
package tests
import (
"context"
"time"
"github.com/navidrome/navidrome/model"
)
// MockAppPasswordRepo is a minimal in-memory stand-in for
// model.AppPasswordRepository. By default GetActiveForUser returns an empty
// slice, so the Subsonic auth fallback short-circuits cleanly without
// panicking on nil-method invocations of an unmocked interface.
//
// Tests that need richer behaviour can populate Active or override fields
// directly.
type MockAppPasswordRepo struct {
model.AppPasswordRepository
Active model.AppPasswords
GetActiveErr error
UpdateLastUsedAtFn func(id string)
}
func CreateMockAppPasswordRepo() *MockAppPasswordRepo {
return &MockAppPasswordRepo{}
}
func (m *MockAppPasswordRepo) GetActiveForUser(ctx context.Context, userID string) (model.AppPasswords, error) {
if m.GetActiveErr != nil {
return nil, m.GetActiveErr
}
out := make(model.AppPasswords, 0, len(m.Active))
for _, ap := range m.Active {
if ap.UserID == userID {
out = append(out, ap)
}
}
return out, nil
}
func (m *MockAppPasswordRepo) UpdateLastUsedAt(ctx context.Context, id string) error {
if m.UpdateLastUsedAtFn != nil {
m.UpdateLastUsedAtFn(id)
}
return nil
}
func (m *MockAppPasswordRepo) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
return nil, nil
}
func (m *MockAppPasswordRepo) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
ap := &model.AppPassword{ID: "mock", UserID: userID, Name: name, CreatedAt: time.Now(), ExpiresAt: expiresAt}
m.Active = append(m.Active, *ap)
return "secret", ap, nil
}
func (m *MockAppPasswordRepo) Delete(ctx context.Context, id, ownerUserID string) error {
return nil
}

View File

@ -28,6 +28,7 @@ type MockDataStore struct {
MockedScrobble model.ScrobbleRepository MockedScrobble model.ScrobbleRepository
MockedRadio model.RadioRepository MockedRadio model.RadioRepository
MockedPlugin model.PluginRepository MockedPlugin model.PluginRepository
MockedAppPassword model.AppPasswordRepository
scrobbleBufferMu sync.Mutex scrobbleBufferMu sync.Mutex
repoMu sync.Mutex repoMu sync.Mutex
@ -247,6 +248,17 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository {
return db.MockedPlugin return db.MockedPlugin
} }
func (db *MockDataStore) AppPassword(ctx context.Context) model.AppPasswordRepository {
if db.MockedAppPassword != nil {
return db.MockedAppPassword
}
if db.RealDS != nil {
return db.RealDS.AppPassword(ctx)
}
db.MockedAppPassword = CreateMockAppPasswordRepo()
return db.MockedAppPassword
}
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db) return block(db)
} }

View File

@ -0,0 +1,237 @@
import React, { useCallback, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import {
Button,
Card,
CardActions,
CardContent,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from '@material-ui/core'
import DeleteIcon from '@material-ui/icons/Delete'
import FileCopyIcon from '@material-ui/icons/FileCopy'
import { useNotify } from 'react-admin'
import httpClient from '../dataProvider/httpClient'
import { REST_URL } from '../consts'
// AppPasswordManager renders a simple per-user table of long-lived app
// passwords used for Subsonic clients that cannot speak OIDC. The plaintext
// secret is shown exactly once on creation; afterwards only metadata
// (created/last used/expires) is visible.
const AppPasswordManager = ({ userId }) => {
const notify = useNotify()
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [newName, setNewName] = useState('')
const [newExpiresAt, setNewExpiresAt] = useState('')
const [createdSecret, setCreatedSecret] = useState(null)
const baseURL = `${REST_URL}/user/${userId}/app-password`
const refresh = useCallback(() => {
setLoading(true)
httpClient(baseURL)
.then((response) => {
const data = response.json
setRows(Array.isArray(data) ? data : [])
})
.catch((error) => notify(error.message || 'Failed to load app passwords', 'warning'))
.finally(() => setLoading(false))
}, [baseURL, notify])
useEffect(() => {
refresh()
}, [refresh])
const handleCreate = () => {
const body = { name: newName }
if (newExpiresAt) {
body.expiresAt = new Date(newExpiresAt).toISOString()
}
httpClient(baseURL, { method: 'POST', body: JSON.stringify(body) })
.then((response) => {
setCreatedSecret(response.json)
setNewName('')
setNewExpiresAt('')
setCreateOpen(false)
refresh()
})
.catch((error) =>
notify(error.message || 'Failed to create app password', 'warning'),
)
}
const handleDelete = (id) => {
if (!window.confirm('Delete this app password? Clients using it will stop working.')) {
return
}
httpClient(`${baseURL}/${id}`, { method: 'DELETE' })
.then(() => refresh())
.catch((error) =>
notify(error.message || 'Failed to delete app password', 'warning'),
)
}
const copySecret = () => {
if (!createdSecret?.secret) return
navigator.clipboard
?.writeText(createdSecret.secret)
.then(() => notify('Secret copied to clipboard', 'info'))
.catch(() => notify('Could not copy to clipboard', 'warning'))
}
return (
<Card style={{ marginTop: 24 }}>
<CardContent>
<Typography variant="h6">App passwords (Subsonic clients)</Typography>
<Typography variant="body2" color="textSecondary">
Generate a dedicated password for each Subsonic-compatible app. The
secret is shown only once.
</Typography>
<Table size="small" style={{ marginTop: 16 }}>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Created</TableCell>
<TableCell>Last used</TableCell>
<TableCell>Expires</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.name}</TableCell>
<TableCell>
{row.createdAt ? new Date(row.createdAt).toLocaleString() : ''}
</TableCell>
<TableCell>
{row.lastUsedAt
? new Date(row.lastUsedAt).toLocaleString()
: '—'}
</TableCell>
<TableCell>
{row.expiresAt
? new Date(row.expiresAt).toLocaleString()
: 'Never'}
</TableCell>
<TableCell align="right">
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(row.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="textSecondary">
No app passwords yet.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
<CardActions>
<Button color="primary" onClick={() => setCreateOpen(true)}>
Generate new
</Button>
</CardActions>
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
fullWidth
maxWidth="xs"
>
<DialogTitle>New app password</DialogTitle>
<DialogContent>
<TextField
label="Name"
fullWidth
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
helperText="Friendly label, e.g. 'DSub on phone'"
/>
<TextField
label="Expires"
type="datetime-local"
fullWidth
value={newExpiresAt}
onChange={(e) => setNewExpiresAt(e.target.value)}
InputLabelProps={{ shrink: true }}
helperText="Leave blank for no expiry"
style={{ marginTop: 16 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button color="primary" disabled={!newName} onClick={handleCreate}>
Generate
</Button>
</DialogActions>
</Dialog>
<Dialog
open={createdSecret !== null}
onClose={() => setCreatedSecret(null)}
fullWidth
maxWidth="sm"
>
<DialogTitle>Copy this secret now</DialogTitle>
<DialogContent>
<DialogContentText>
This secret is shown only once. Configure your Subsonic client with
this username and the secret below — Navidrome cannot retrieve it
again.
</DialogContentText>
{createdSecret && (
<TextField
fullWidth
variant="outlined"
value={createdSecret.secret || ''}
InputProps={{
readOnly: true,
endAdornment: (
<IconButton onClick={copySecret} size="small">
<FileCopyIcon fontSize="small" />
</IconButton>
),
}}
style={{ marginTop: 16 }}
/>
)}
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => setCreatedSecret(null)}>
Done
</Button>
</DialogActions>
</Dialog>
</Card>
)
}
AppPasswordManager.propTypes = {
userId: PropTypes.string.isRequired,
}
export default AppPasswordManager

View File

@ -0,0 +1,108 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import AppPasswordManager from './AppPasswordManager.jsx'
const notify = vi.fn()
vi.mock('react-admin', () => ({
useNotify: () => notify,
}))
const httpClient = vi.fn()
vi.mock('../dataProvider/httpClient', () => ({
default: (...args) => httpClient(...args),
}))
vi.mock('../consts', () => ({
REST_URL: '/api',
}))
const password = (overrides = {}) => ({
id: 'ap1',
name: 'DSub',
createdAt: '2026-05-01T10:00:00Z',
lastUsedAt: null,
expiresAt: null,
...overrides,
})
describe('<AppPasswordManager />', () => {
beforeEach(() => {
httpClient.mockReset()
notify.mockReset()
vi.spyOn(window, 'confirm').mockReturnValue(true)
})
it('shows the empty state when the user has no app passwords', async () => {
httpClient.mockResolvedValueOnce({ json: [] })
render(<AppPasswordManager userId="u1" />)
expect(await screen.findByText('No app passwords yet.')).toBeInTheDocument()
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password')
})
it('renders a row for each existing app password', async () => {
httpClient.mockResolvedValueOnce({
json: [password({ name: 'DSub' }), password({ id: 'ap2', name: 'Symfonium' })],
})
render(<AppPasswordManager userId="u1" />)
expect(await screen.findByText('DSub')).toBeInTheDocument()
expect(screen.getByText('Symfonium')).toBeInTheDocument()
})
it('creates a password and reveals the secret exactly once', async () => {
httpClient
.mockResolvedValueOnce({ json: [] }) // initial list
.mockResolvedValueOnce({ json: { id: 'ap1', name: 'CLI', secret: 's3cret' } }) // create
.mockResolvedValueOnce({ json: [password({ name: 'CLI' })] }) // refresh
render(<AppPasswordManager userId="u1" />)
await screen.findByText('No app passwords yet.')
fireEvent.click(screen.getByRole('button', { name: /generate new/i }))
const nameInput = screen.getAllByRole('textbox')[0]
fireEvent.change(nameInput, { target: { value: 'CLI' } })
fireEvent.click(screen.getByRole('button', { name: /^generate$/i }))
expect(await screen.findByDisplayValue('s3cret')).toBeInTheDocument()
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password', {
method: 'POST',
body: JSON.stringify({ name: 'CLI' }),
})
})
it('deletes a password only after the user confirms', async () => {
httpClient
.mockResolvedValueOnce({ json: [password({ id: 'ap1', name: 'DSub' })] }) // initial list
.mockResolvedValueOnce({ json: {} }) // delete
.mockResolvedValueOnce({ json: [] }) // refresh
render(<AppPasswordManager userId="u1" />)
const row = await screen.findByText('DSub')
fireEvent.click(row.closest('tr').querySelector('button'))
await waitFor(() =>
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password/ap1', {
method: 'DELETE',
}),
)
})
it('does not delete when the user cancels the confirmation', async () => {
window.confirm.mockReturnValue(false)
httpClient.mockResolvedValueOnce({
json: [password({ id: 'ap1', name: 'DSub' })],
})
render(<AppPasswordManager userId="u1" />)
const row = await screen.findByText('DSub')
fireEvent.click(row.closest('tr').querySelector('button'))
expect(httpClient).toHaveBeenCalledTimes(1) // only the initial list
})
})