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.
This commit is contained in:
zkvvoob 2026-05-20 13:49:38 +03:00
parent 14ba9c7ed3
commit 1353243126
No known key found for this signature in database
GPG Key ID: 3CBAEDB5B3509ECE
3 changed files with 409 additions and 0 deletions

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

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