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) }