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

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

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.addKeepAliveRoute(r)
api.addInsightsRoute(r)
api.addAppPasswordRoute(r)
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
api.addInspectRoute(r)

View File

@ -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,55 @@ 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 {
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 {
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 {

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() {
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

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

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