From 14ba9c7ed3bafac4fe3a0a519268bd69e39380de Mon Sep 17 00:00:00 2001 From: zkvvoob Date: Wed, 20 May 2026 13:45:52 +0300 Subject: [PATCH 1/5] feat(model): add app password support Adds the model, persistence, and database migration for named per-user app passwords. Secret values are AES-GCM encrypted at rest using a key generated on first run and stored in the data directory. This is a standalone primitive useful on its own for Subsonic clients and CLI/script auth and lands ahead of the OIDC work that will rely on it for IdP-managed users. --- .../20260510120100_create_app_password.go | 32 ++++ model/app_password.go | 76 ++++++++ model/datastore.go | 1 + persistence/app_password_repository.go | 176 ++++++++++++++++++ persistence/app_password_repository_test.go | 122 ++++++++++++ persistence/enc_key.go | 15 ++ persistence/persistence.go | 4 + tests/mock_app_password_repo.go | 60 ++++++ tests/mock_data_store.go | 12 ++ 9 files changed, 498 insertions(+) create mode 100644 db/migrations/20260510120100_create_app_password.go create mode 100644 model/app_password.go create mode 100644 persistence/app_password_repository.go create mode 100644 persistence/app_password_repository_test.go create mode 100644 persistence/enc_key.go create mode 100644 tests/mock_app_password_repo.go diff --git a/db/migrations/20260510120100_create_app_password.go b/db/migrations/20260510120100_create_app_password.go new file mode 100644 index 000000000..2cea9fcc7 --- /dev/null +++ b/db/migrations/20260510120100_create_app_password.go @@ -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 +} diff --git a/model/app_password.go b/model/app_password.go new file mode 100644 index 000000000..d0a62f4a7 --- /dev/null +++ b/model/app_password.go @@ -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) +} diff --git a/model/datastore.go b/model/datastore.go index 94c3c3622..3d64e641a 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -37,6 +37,7 @@ type DataStore interface { Property(ctx context.Context) PropertyRepository User(ctx context.Context) UserRepository UserProps(ctx context.Context) UserPropsRepository + AppPassword(ctx context.Context) AppPasswordRepository ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository Scrobble(ctx context.Context) ScrobbleRepository Plugin(ctx context.Context) PluginRepository diff --git a/persistence/app_password_repository.go b/persistence/app_password_repository.go new file mode 100644 index 000000000..9f6f6c341 --- /dev/null +++ b/persistence/app_password_repository.go @@ -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) diff --git a/persistence/app_password_repository_test.go b/persistence/app_password_repository_test.go new file mode 100644 index 000000000..3faf62f58 --- /dev/null +++ b/persistence/app_password_repository_test.go @@ -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")) + }) + }) +}) diff --git a/persistence/enc_key.go b/persistence/enc_key.go new file mode 100644 index 000000000..7f4e070fe --- /dev/null +++ b/persistence/enc_key.go @@ -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 +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 83211bdd5..148f6e18a 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -77,6 +77,10 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository { 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 { return NewTranscodingRepository(ctx, s.getDBXBuilder()) } diff --git a/tests/mock_app_password_repo.go b/tests/mock_app_password_repo.go new file mode 100644 index 000000000..eba7d73e0 --- /dev/null +++ b/tests/mock_app_password_repo.go @@ -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 +} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..0acb2d26b 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,6 +28,7 @@ type MockDataStore struct { MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository MockedPlugin model.PluginRepository + MockedAppPassword model.AppPasswordRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -247,6 +248,17 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { 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 { return block(db) } From 135324312609df0016a6954e07e167bc7456a8f4 Mon Sep 17 00:00:00 2001 From: zkvvoob Date: Wed, 20 May 2026 13:49:38 +0300 Subject: [PATCH 2/5] feat(server): expose REST CRUD for app passwords Adds /api/appPassword endpoints (list/create/delete) under the existing native API, scoped to the authenticated user. Create returns the plaintext secret once on issuance; subsequent reads return only metadata. --- server/nativeapi/app_password.go | 134 +++++++++++++ server/nativeapi/app_password_test.go | 274 ++++++++++++++++++++++++++ server/nativeapi/native_api.go | 1 + 3 files changed, 409 insertions(+) create mode 100644 server/nativeapi/app_password.go create mode 100644 server/nativeapi/app_password_test.go diff --git a/server/nativeapi/app_password.go b/server/nativeapi/app_password.go new file mode 100644 index 000000000..e51b614ac --- /dev/null +++ b/server/nativeapi/app_password.go @@ -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) + } +} diff --git a/server/nativeapi/app_password_test.go b/server/nativeapi/app_password_test.go new file mode 100644 index 000000000..4fe7e4576 --- /dev/null +++ b/server/nativeapi/app_password_test.go @@ -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()) + }) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..08097c81f 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -84,6 +84,7 @@ func (api *Router) routes() http.Handler { api.addMissingFilesRoute(r) api.addKeepAliveRoute(r) api.addInsightsRoute(r) + api.addAppPasswordRoute(r) r.With(adminOnlyMiddleware).Group(func(r chi.Router) { api.addInspectRoute(r) From 42e4068c52fd355e3babf9883b129c7295a66f59 Mon Sep 17 00:00:00 2001 From: zkvvoob Date: Wed, 20 May 2026 13:51:55 +0300 Subject: [PATCH 3/5] feat(subsonic): accept app passwords as Subsonic credentials Falls back to validating against the user's active app passwords when the primary password match fails and no JWT is presented. Supports both p= (plaintext / enc:) and t=/s= (token+salt md5) shapes, uses crypto/subtle for constant-time comparison, and updates last_used_at asynchronously after a successful match. Lets Subsonic clients (DSub, play:Sub, Symfonium, Feishin, etc.) authenticate with named per-application secrets instead of the primary account password. --- server/subsonic/middlewares.go | 58 ++++++++--- server/subsonic/middlewares_test.go | 154 ++++++++++++++++++++++------ 2 files changed, 170 insertions(+), 42 deletions(-) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 837852d18..8f2d33382 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "crypto/md5" + "crypto/subtle" "encoding/hex" "errors" "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) default: err = validateCredentials(usr, pass, token, salt, jwt) + if err != nil && jwt == "" { + err = validateAppPasswordCredentials(ctx, ds, usr, pass, token, salt) + } if err != nil { log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) } @@ -155,21 +159,51 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } } -func adminOnly(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - loggedUser, ok := request.UserFrom(r.Context()) - if !ok { - sendError(w, r, newError(responses.ErrorGeneric, "Internal error")) - return - } +// validateAppPasswordCredentials is the Subsonic-side fallback for users who +// authenticate with a per-application secondary password instead of their +// primary one. It only kicks in when the request is NOT presenting a JWT +// (those are exclusively for Navidrome-issued tokens). +// +// 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 || len(aps) == 0 { + return model.ErrInvalidAuth + } - if !loggedUser.IsAdmin { - sendError(w, r, newError(responses.ErrorAuthorizationFail)) - return + if strings.HasPrefix(pass, "enc:") { + if dec, err := hex.DecodeString(pass[4:]); err == nil { + 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 { diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index 3f8c07a56..c779546fd 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -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() { BeforeEach(func() { 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() { var mockedPlayers *mockPlayers var r *http.Request From 47c3e2fea3fb17f31cf263e659d8a2e502fc1843 Mon Sep 17 00:00:00 2001 From: zkvvoob Date: Wed, 20 May 2026 13:53:20 +0300 Subject: [PATCH 4/5] feat(ui): add app password manager Adds a per-user table for generating, listing, and revoking named app passwords from the user edit screen. The plaintext secret is revealed once in a copy-to-clipboard dialog on creation; subsequent reads return only metadata (created / last used / expires). --- ui/src/user/AppPasswordManager.jsx | 237 ++++++++++++++++++++++++ ui/src/user/AppPasswordManager.test.jsx | 108 +++++++++++ 2 files changed, 345 insertions(+) create mode 100644 ui/src/user/AppPasswordManager.jsx create mode 100644 ui/src/user/AppPasswordManager.test.jsx diff --git a/ui/src/user/AppPasswordManager.jsx b/ui/src/user/AppPasswordManager.jsx new file mode 100644 index 000000000..168ebdf5e --- /dev/null +++ b/ui/src/user/AppPasswordManager.jsx @@ -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 ( + + + App passwords (Subsonic clients) + + Generate a dedicated password for each Subsonic-compatible app. The + secret is shown only once. + + + + + Name + Created + Last used + Expires + + + + + {rows.map((row) => ( + + {row.name} + + {row.createdAt ? new Date(row.createdAt).toLocaleString() : ''} + + + {row.lastUsedAt + ? new Date(row.lastUsedAt).toLocaleString() + : '—'} + + + {row.expiresAt + ? new Date(row.expiresAt).toLocaleString() + : 'Never'} + + + + handleDelete(row.id)}> + + + + + + ))} + {!loading && rows.length === 0 && ( + + + + No app passwords yet. + + + + )} + +
+
+ + + + + setCreateOpen(false)} + fullWidth + maxWidth="xs" + > + New app password + + setNewName(e.target.value)} + helperText="Friendly label, e.g. 'DSub on phone'" + /> + setNewExpiresAt(e.target.value)} + InputLabelProps={{ shrink: true }} + helperText="Leave blank for no expiry" + style={{ marginTop: 16 }} + /> + + + + + + + + setCreatedSecret(null)} + fullWidth + maxWidth="sm" + > + Copy this secret now + + + This secret is shown only once. Configure your Subsonic client with + this username and the secret below — Navidrome cannot retrieve it + again. + + {createdSecret && ( + + + + ), + }} + style={{ marginTop: 16 }} + /> + )} + + + + + +
+ ) +} + +AppPasswordManager.propTypes = { + userId: PropTypes.string.isRequired, +} + +export default AppPasswordManager diff --git a/ui/src/user/AppPasswordManager.test.jsx b/ui/src/user/AppPasswordManager.test.jsx new file mode 100644 index 000000000..e2ee97049 --- /dev/null +++ b/ui/src/user/AppPasswordManager.test.jsx @@ -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('', () => { + 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() + + 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() + + 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() + 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() + 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() + const row = await screen.findByText('DSub') + + fireEvent.click(row.closest('tr').querySelector('button')) + + expect(httpClient).toHaveBeenCalledTimes(1) // only the initial list + }) +}) From e5ef453ebe553f48e0eb3814e5c547e998108358 Mon Sep 17 00:00:00 2001 From: zkvvoob Date: Wed, 20 May 2026 17:52:43 +0300 Subject: [PATCH 5/5] fix(server): address review: log DB error in app-password auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer note: The original error from GetActiveForUser is being swallowed and replaced with model.ErrInvalidAuth. If a database error occurs here, it will be difficult to diagnose because it will be logged as a simple invalid login. It's better to return the actual error so the caller can log it appropriately. Proposed implementation: If we just return err from validateAppPasswordCredentials, a DB failure gets logged as WARN "Invalid login" — same diagnostic hole, different error string. Instead, log the DB error at Error level inside validateAppPasswordCredentials before returning ErrInvalidAuth, so it shows up regardless of how the caller treats the return value. --- server/subsonic/middlewares.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 8f2d33382..ee03fcc76 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -174,7 +174,11 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { // 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 || len(aps) == 0 { + 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 }