From 2afe2ffe0ae69e9468f69618ec360c254fc2524e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 00:54:10 -0400 Subject: [PATCH 01/41] feat(api): add api_grant storage for API v1 grants --- .../20260926045200_create_api_grant.sql | 23 +++ model/datastore.go | 1 + model/grant.go | 62 ++++++ persistence/grant_repository.go | 123 +++++++++++ persistence/grant_repository_test.go | 192 ++++++++++++++++++ persistence/persistence.go | 10 + tests/mock_data_store.go | 14 ++ tests/mock_grant_repo.go | 8 + 8 files changed, 433 insertions(+) create mode 100644 db/migrations/20260926045200_create_api_grant.sql create mode 100644 model/grant.go create mode 100644 persistence/grant_repository.go create mode 100644 persistence/grant_repository_test.go create mode 100644 tests/mock_grant_repo.go diff --git a/db/migrations/20260926045200_create_api_grant.sql b/db/migrations/20260926045200_create_api_grant.sql new file mode 100644 index 000000000..84fec2622 --- /dev/null +++ b/db/migrations/20260926045200_create_api_grant.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +create table api_grant ( + id varchar not null primary key, + user_id varchar not null references user(id) on delete cascade, + name varchar not null, + client varchar not null, + client_version varchar not null default '', + scopes varchar not null default '', + provider varchar not null, + secret_hash varchar not null unique, + user_epoch integer not null default 0, + created_at datetime not null, + last_used_at datetime, + last_used_ip varchar not null default '' +); +create index api_grant_user_id on api_grant(user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop table api_grant; +-- +goose StatementEnd diff --git a/model/datastore.go b/model/datastore.go index 6ded8c575..1175d212d 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -37,6 +37,7 @@ type DataStore interface { Plugin() PluginRepository Artwork() ArtworkRepository ArtworkQueue() ArtworkQueueRepository + Grant() GrantRepository WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error diff --git a/model/grant.go b/model/grant.go new file mode 100644 index 000000000..875c3bc86 --- /dev/null +++ b/model/grant.go @@ -0,0 +1,62 @@ +package model + +import ( + "context" + "database/sql/driver" + "fmt" + "strings" + "time" +) + +type Grant struct { + ID string `structs:"id" json:"id"` + UserID string `structs:"user_id" json:"userId"` + Name string `structs:"name" json:"name"` + Client string `structs:"client" json:"client"` + ClientVersion string `structs:"client_version" json:"clientVersion"` + Scopes Scopes `structs:"scopes" json:"scopes"` + Provider string `structs:"provider" json:"provider"` + SecretHash string `structs:"secret_hash" json:"-"` + UserEpoch int `structs:"user_epoch" json:"-"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt"` + LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"` +} + +type Grants []Grant + +// Scopes is stored as a single space-separated column. +type Scopes []string + +func (s Scopes) Value() (driver.Value, error) { + return strings.Join(s, " "), nil +} + +func (s *Scopes) Scan(src any) error { + switch v := src.(type) { + case string: + *s = strings.Fields(v) + case []byte: + *s = strings.Fields(string(v)) + case nil: + *s = nil + default: + return fmt.Errorf("cannot scan %T into Scopes", src) + } + return nil +} + +type GrantRepository interface { + Put(ctx context.Context, g *Grant) error + Get(ctx context.Context, id string) (*Grant, error) + FindBySecretHash(ctx context.Context, hash string) (*Grant, error) + GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (Grants, error) + CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error) + Delete(ctx context.Context, id string) error + DeleteForUser(ctx context.Context, userID, id string) error + DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error + SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error + DeleteIfEpoch(ctx context.Context, id string, epoch int) error + Touch(ctx context.Context, id, ip string, at, notSince time.Time) error + DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) +} diff --git a/persistence/grant_repository.go b/persistence/grant_repository.go new file mode 100644 index 000000000..7a9ca9fe8 --- /dev/null +++ b/persistence/grant_repository.go @@ -0,0 +1,123 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type grantRepository struct { + sqlRepository +} + +func NewGrantRepository(db dbx.Builder) model.GrantRepository { + r := &grantRepository{} + r.db = db + r.tableName = "api_grant" + return r +} + +const grantLastActivity = "COALESCE(last_used_at, created_at)" + +func (r *grantRepository) Put(ctx context.Context, g *model.Grant) error { + if g.ID == "" { + g.ID = id.NewRandom() + } + if g.CreatedAt.IsZero() { + g.CreatedAt = time.Now() + } + // Stored as UTC: SQLite compares these timestamps as strings. + g.CreatedAt = g.CreatedAt.UTC() + if g.LastUsedAt != nil { + t := g.LastUsedAt.UTC() + g.LastUsedAt = &t + } + values, err := toSQLArgs(*g) + if err != nil { + return err + } + _, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values)) + return err +} + +func (r *grantRepository) Get(ctx context.Context, id string) (*model.Grant, error) { + return r.findOne(ctx, Eq{"id": id}) +} + +func (r *grantRepository) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) { + return r.findOne(ctx, Eq{"secret_hash": hash}) +} + +func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Grant, error) { + var g model.Grant + if err := r.queryOne(ctx, r.newSelect(ctx).Columns("*").Where(cond), &g); err != nil { + return nil, err + } + return &g, nil +} + +func (r *grantRepository) activeForUser(userID string, idleSince time.Time) Sqlizer { + return And{Eq{"user_id": userID}, Expr(grantLastActivity+" >= ?", idleSince.UTC())} +} + +func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (model.Grants, error) { + sel := r.newSelect(ctx).Columns("*").Where(r.activeForUser(userID, idleSince)). + OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id"). + Offset(uint64(offset)).Limit(uint64(limit)) + var res model.Grants + err := r.queryAll(ctx, sel, &res) + return res, err +} + +func (r *grantRepository) CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error) { + return r.count(ctx, Select().Where(r.activeForUser(userID, idleSince))) +} + +func (r *grantRepository) Delete(ctx context.Context, id string) error { + return r.delete(ctx, Eq{"id": id}) +} + +func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error { + n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID})) + if err != nil { + return err + } + if n == 0 { + return model.ErrNotFound + } + return nil +} + +func (r *grantRepository) DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error { + _, err := r.executeSQL(ctx, Delete(r.tableName).Where(And{Eq{"user_id": userID}, NotEq{"user_epoch": epoch}})) + return err +} + +// SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back. +func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error { + cond := Eq{"user_id": userID, "user_epoch": fromEpoch} + if onlyID != "" { + cond["id"] = onlyID + } + _, err := r.executeSQL(ctx, Update(r.tableName).Set("user_epoch", toEpoch).Where(cond)) + return err +} + +func (r *grantRepository) DeleteIfEpoch(ctx context.Context, id string, epoch int) error { + return r.delete(ctx, Eq{"id": id, "user_epoch": epoch}) +} + +func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error { + upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip). + Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}}) + _, err := r.executeSQL(ctx, upd) + return err +} + +func (r *grantRepository) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) { + return r.executeSQL(ctx, Delete(r.tableName).Where(Expr(grantLastActivity+" < ?", idleSince.UTC()))) +} diff --git a/persistence/grant_repository_test.go b/persistence/grant_repository_test.go new file mode 100644 index 000000000..f621a5cc2 --- /dev/null +++ b/persistence/grant_repository_test.go @@ -0,0 +1,192 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GrantRepository", func() { + var ctx context.Context + var repo model.GrantRepository + var now time.Time + + newGrant := func(userID, hash string) *model.Grant { + return &model.Grant{UserID: userID, Name: "TV", Client: "TestApp", Scopes: model.Scopes{"all"}, + Provider: "password", SecretHash: hash, CreatedAt: now} + } + + BeforeEach(func() { + ctx = log.NewContext(GinkgoT().Context()) + repo = NewGrantRepository(GetDBXBuilder()) + now = time.Now().UTC().Truncate(time.Second) + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("delete from api_grant").Execute() + }) + }) + + It("stores a grant and finds it by id and by secret hash", func() { + g := newGrant(adminUser.ID, "hash-1") + g.Scopes = model.Scopes{"read", "password"} + Expect(repo.Put(ctx, g)).To(Succeed()) + Expect(g.ID).ToNot(BeEmpty()) + + byID, err := repo.Get(ctx, g.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(byID.Scopes).To(Equal(model.Scopes{"read", "password"})) + Expect(byID.LastUsedAt).To(BeNil()) + Expect(byID.LastUsedIP).To(BeEmpty()) + + byHash, err := repo.FindBySecretHash(ctx, "hash-1") + Expect(err).ToNot(HaveOccurred()) + Expect(byHash.ID).To(Equal(g.ID)) + }) + + It("returns ErrNotFound for unknown ids and hashes", func() { + _, err := repo.Get(ctx, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = repo.FindBySecretHash(ctx, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("lists and counts only the user's non-idle grants by lastUsedAt, never-used ones last", func() { + old := newGrant(adminUser.ID, "h-old") + old.CreatedAt = now.Add(-100 * 24 * time.Hour) + usedEarly := newGrant(adminUser.ID, "h-used-early") + usedEarly.CreatedAt = now.Add(-10 * time.Hour) + earlyUse := now.Add(-5 * time.Hour) + usedEarly.LastUsedAt = &earlyUse + usedLate := newGrant(adminUser.ID, "h-used-late") + usedLate.CreatedAt = now.Add(-10 * time.Hour) + lateUse := now.Add(-time.Hour) + usedLate.LastUsedAt = &lateUse + freshNeverUsed := newGrant(adminUser.ID, "h-fresh") // newer than both uses, but never used + other := newGrant(regularUser.ID, "h-other") + for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other} { + Expect(repo.Put(ctx, g)).To(Succeed()) + } + idleSince := now.Add(-90 * 24 * time.Hour) + + list, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect([]string{list[0].ID, list[1].ID, list[2].ID}).To(Equal([]string{usedLate.ID, usedEarly.ID, freshNeverUsed.ID})) + + Expect(repo.CountForUser(ctx, adminUser.ID, idleSince)).To(Equal(int64(3))) + + page, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 1, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(page).To(HaveLen(1)) + Expect(page[0].ID).To(Equal(usedEarly.ID)) + }) + + It("deletes a grant only for its owner", func() { + g := newGrant(adminUser.ID, "h-own") + Expect(repo.Put(ctx, g)).To(Succeed()) + Expect(repo.DeleteForUser(ctx, regularUser.ID, g.ID)).To(MatchError(model.ErrNotFound)) + Expect(repo.DeleteForUser(ctx, adminUser.ID, g.ID)).To(Succeed()) + _, err := repo.Get(ctx, g.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("moves epochs forward and deletes grants left on other epochs", func() { + keep := newGrant(adminUser.ID, "h-keep") + drop := newGrant(adminUser.ID, "h-drop") + Expect(repo.Put(ctx, keep)).To(Succeed()) + Expect(repo.Put(ctx, drop)).To(Succeed()) + + Expect(repo.SetEpoch(ctx, adminUser.ID, 0, 3, keep.ID)).To(Succeed()) + Expect(repo.DeleteOtherEpochs(ctx, adminUser.ID, 3)).To(Succeed()) + + kept, err := repo.Get(ctx, keep.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.UserEpoch).To(Equal(3)) + _, err = repo.Get(ctx, drop.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + + Expect(repo.SetEpoch(ctx, adminUser.ID, 3, 4, "")).To(Succeed()) + kept, _ = repo.Get(ctx, keep.ID) + Expect(kept.UserEpoch).To(Equal(4)) + }) + + It("never moves a grant that is not on fromEpoch", func() { + stale := newGrant(adminUser.ID, "h-stale") // left behind by an earlier password change + stale.UserEpoch = 1 + current := newGrant(adminUser.ID, "h-current") + current.UserEpoch = 2 + Expect(repo.Put(ctx, stale)).To(Succeed()) + Expect(repo.Put(ctx, current)).To(Succeed()) + + Expect(repo.SetEpoch(ctx, adminUser.ID, 2, 3, "")).To(Succeed()) + got, _ := repo.Get(ctx, stale.ID) + Expect(got.UserEpoch).To(Equal(1)) + got, _ = repo.Get(ctx, current.ID) + Expect(got.UserEpoch).To(Equal(3)) + }) + + It("deletes by epoch only while the row is still on it", func() { + g := newGrant(adminUser.ID, "h-cond") + g.UserEpoch = 5 + Expect(repo.Put(ctx, g)).To(Succeed()) + Expect(repo.DeleteIfEpoch(ctx, g.ID, 4)).To(Succeed()) + _, err := repo.Get(ctx, g.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.DeleteIfEpoch(ctx, g.ID, 5)).To(Succeed()) + _, err = repo.Get(ctx, g.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("touches a never-used grant, then throttles until notSince passes", func() { + g := newGrant(adminUser.ID, "h-touch") + Expect(repo.Put(ctx, g)).To(Succeed()) + + Expect(repo.Touch(ctx, g.ID, "10.0.0.1", now, now.Add(-5*time.Minute))).To(Succeed()) + got, _ := repo.Get(ctx, g.ID) + Expect(got.LastUsedAt).ToNot(BeNil()) + Expect(got.LastUsedAt.UTC()).To(BeTemporally("==", now)) + Expect(got.LastUsedIP).To(Equal("10.0.0.1")) + + later := now.Add(time.Minute) + Expect(repo.Touch(ctx, g.ID, "10.0.0.2", later, later.Add(-5*time.Minute))).To(Succeed()) + got, _ = repo.Get(ctx, g.ID) + Expect(got.LastUsedIP).To(Equal("10.0.0.1")) + + muchLater := now.Add(6 * time.Minute) + Expect(repo.Touch(ctx, g.ID, "10.0.0.3", muchLater, muchLater.Add(-5*time.Minute))).To(Succeed()) + got, _ = repo.Get(ctx, g.ID) + Expect(got.LastUsedIP).To(Equal("10.0.0.3")) + }) + + It("deletes idle grants, using created_at for never-used ones", func() { + idle := newGrant(adminUser.ID, "h-idle") + idle.CreatedAt = now.Add(-100 * 24 * time.Hour) + usedRecently := newGrant(adminUser.ID, "h-used-recently") + usedRecently.CreatedAt = now.Add(-100 * 24 * time.Hour) + recentUse := now.Add(-time.Hour) + usedRecently.LastUsedAt = &recentUse + Expect(repo.Put(ctx, idle)).To(Succeed()) + Expect(repo.Put(ctx, usedRecently)).To(Succeed()) + + n, err := repo.DeleteIdle(ctx, now.Add(-90*24*time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + _, err = repo.Get(ctx, usedRecently.ID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("deletes a user's grants when the user is deleted", func() { + users := NewUserRepository(GetDBXBuilder()) + u := model.User{ID: "grant-owner", UserName: "grant-owner", NewPassword: "pw"} + Expect(users.Put(ctx, &u)).To(Succeed()) + g := newGrant(u.ID, "h-cascade") + Expect(repo.Put(ctx, g)).To(Succeed()) + + Expect(users.Delete(request.WithUser(ctx, adminUser), u.ID)).To(Succeed()) + _, err := repo.Get(ctx, g.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/persistence/persistence.go b/persistence/persistence.go index 44e944bff..8feda0c8f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -37,6 +37,7 @@ type SQLStore struct { plugin func() model.PluginRepository artwork func() model.ArtworkRepository artworkQueue func() model.ArtworkQueueRepository + grant func() model.GrantRepository } // Repositories are built on first use, so a transaction store only pays for the ones its block touches. @@ -64,6 +65,7 @@ func newSQLStore(db dbx.Builder) *SQLStore { plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }), artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }), artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }), + grant: sync.OnceValue(func() model.GrantRepository { return NewGrantRepository(db) }), } } @@ -155,6 +157,10 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository { return s.artworkQueue() } +func (s *SQLStore) Grant() model.GrantRepository { + return s.grant() +} + func scopeLabel(scope []string) string { if len(scope) > 0 { return scope[0] @@ -271,6 +277,10 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }), trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }), trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }), + trace(ctx, "purge idle API grants", func() error { + _, err := s.grant().DeleteIdle(ctx, time.Now().Add(-90*24*time.Hour)) + return err + }), ) if err != nil { return fmt.Errorf("tidying up database: %w", err) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index a5e4126cf..79b8529b4 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -30,6 +30,7 @@ type MockDataStore struct { MockedPlugin model.PluginRepository MockedArtwork model.ArtworkRepository MockedArtworkQueue model.ArtworkQueueRepository + MockedGrant model.GrantRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -321,6 +322,19 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository { return db.MockedArtworkQueue } +func (db *MockDataStore) Grant() model.GrantRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedGrant != nil { + return db.MockedGrant + } + if db.RealDS != nil { + return db.RealDS.Grant() + } + db.MockedGrant = &MockedGrantRepo{} + return db.MockedGrant +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_grant_repo.go b/tests/mock_grant_repo.go new file mode 100644 index 000000000..be9134ece --- /dev/null +++ b/tests/mock_grant_repo.go @@ -0,0 +1,8 @@ +package tests + +import "github.com/navidrome/navidrome/model" + +// MockedGrantRepo exists so MockDataStore satisfies DataStore; auth tests use a real database. +type MockedGrantRepo struct { + model.GrantRepository +} From 5688283d7818032af965b21d8d9b4ac8b186e774 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 00:57:00 -0400 Subject: [PATCH 02/41] feat(persistence): add PropertyRepository.PutIfAbsent --- model/properties.go | 1 + persistence/property_repository.go | 6 ++++++ persistence/property_repository_test.go | 6 ++++++ tests/mock_property_repo.go | 11 +++++++++++ 4 files changed, 24 insertions(+) diff --git a/model/properties.go b/model/properties.go index 24b56db26..11edf8a20 100644 --- a/model/properties.go +++ b/model/properties.go @@ -4,6 +4,7 @@ import "context" type PropertyRepository interface { Put(ctx context.Context, id string, value string) error + PutIfAbsent(ctx context.Context, id string, value string) error Get(ctx context.Context, id string) (string, error) Delete(ctx context.Context, id string) error DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) diff --git a/persistence/property_repository.go b/persistence/property_repository.go index 29bb2b564..87d97491c 100644 --- a/persistence/property_repository.go +++ b/persistence/property_repository.go @@ -34,6 +34,12 @@ func (r propertyRepository) Put(ctx context.Context, id string, value string) er return err } +func (r propertyRepository) PutIfAbsent(ctx context.Context, id string, value string) error { + insert := Insert(r.tableName).Columns("id", "value").Values(id, value).Options("OR IGNORE") + _, err := r.executeSQL(ctx, insert) + return err +} + func (r propertyRepository) Get(ctx context.Context, id string) (string, error) { sel := Select("value").From(r.tableName).Where(Eq{"id": id}) resp := struct { diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 880b315ec..0f4f9ebe5 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -33,4 +33,10 @@ var _ = Describe("Property Repository", func() { It("returns a default value if property does not exist", func() { Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default")) }) + + It("PutIfAbsent inserts once and never overwrites", func() { + Expect(pr.PutIfAbsent(ctx, "pia", "first")).To(Succeed()) + Expect(pr.PutIfAbsent(ctx, "pia", "second")).To(Succeed()) + Expect(pr.Get(ctx, "pia")).To(Equal("first")) + }) }) diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go index 949f894c1..f86aa434d 100644 --- a/tests/mock_property_repo.go +++ b/tests/mock_property_repo.go @@ -27,6 +27,17 @@ func (p *MockedPropertyRepo) Put(_ context.Context, id string, value string) err return nil } +func (p *MockedPropertyRepo) PutIfAbsent(_ context.Context, id string, value string) error { + if p.Error != nil { + return p.Error + } + p.init() + if _, ok := p.Data[id]; !ok { + p.Data[id] = value + } + return nil +} + func (p *MockedPropertyRepo) Get(_ context.Context, id string) (string, error) { if p.Error != nil { return "", p.Error From 158721ea604cac255a7724d2084ab753b77f90e1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:01:31 -0400 Subject: [PATCH 03/41] fix(server): create the first admin inside one locked transaction --- core/auth/auth_test.go | 1 + core/auth/first_admin.go | 40 +++++++++++++++ core/auth/first_admin_test.go | 97 +++++++++++++++++++++++++++++++++++ server/auth.go | 35 ++----------- server/auth_test.go | 22 ++++++-- 5 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 core/auth/first_admin.go create mode 100644 core/auth/first_admin_test.go diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index c86dcd08c..05da7ec65 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -15,6 +15,7 @@ import ( ) func TestAuth(t *testing.T) { + tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Auth Test Suite") diff --git a/core/auth/first_admin.go b/core/auth/first_admin.go new file mode 100644 index 000000000..319b325eb --- /dev/null +++ b/core/auth/first_admin.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ErrSetupComplete = errors.New("setup already complete") + +// CreateFirstAdmin must run inside ds.WithTxImmediate, so the count and the insert cannot interleave. +func CreateFirstAdmin(ctx context.Context, tx model.DataStore, username, password string) (*model.User, error) { + count, err := tx.User().CountAll(ctx) + if err != nil { + return nil, fmt.Errorf("counting users: %w", err) + } + if count > 0 { + return nil, ErrSetupComplete + } + log.Warn(ctx, "Creating initial user", "user", username) + u := model.User{ + ID: id.NewRandom(), + UserName: username, + Name: cases.Title(language.Und).String(username), + NewPassword: password, + IsAdmin: true, + LastLoginAt: new(time.Now()), + } + if err := tx.User().Put(ctx, &u); err != nil { + return nil, fmt.Errorf("creating initial user: %w", err) + } + return tx.User().Get(ctx, u.ID) +} diff --git a/core/auth/first_admin_test.go b/core/auth/first_admin_test.go new file mode 100644 index 000000000..d22cea2f2 --- /dev/null +++ b/core/auth/first_admin_test.go @@ -0,0 +1,97 @@ +package auth_test + +import ( + "context" + "path/filepath" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("CreateFirstAdmin", Ordered, func() { + var ctx context.Context + var ds model.DataStore + + BeforeAll(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "first-admin.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000" + DeferCleanup(db.Init(GinkgoT().Context())) + ds = persistence.New(db.Db()) + }) + + BeforeEach(func() { + ctx = GinkgoT().Context() + _, err := db.Db().ExecContext(ctx, "delete from user") + Expect(err).ToNot(HaveOccurred()) + }) + + createWith := func(name string, wrap func(model.DataStore) model.DataStore) (*model.User, error) { + var u *model.User + err := ds.WithTxImmediate(func(tx model.DataStore) error { + var err error + u, err = auth.CreateFirstAdmin(ctx, wrap(tx), name, "secret") + return err + }) + return u, err + } + create := func(name string) (*model.User, error) { + return createWith(name, func(tx model.DataStore) model.DataStore { return tx }) + } + + It("creates an admin with a title-cased name and returns it with its id", func() { + u, err := create("john") + Expect(err).ToNot(HaveOccurred()) + Expect(u.ID).ToNot(BeEmpty()) + Expect(u.IsAdmin).To(BeTrue()) + Expect(u.Name).To(Equal("John")) + + stored, err := ds.User().FindByUsernameWithPassword(ctx, "john") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.Password).To(Equal("secret")) + }) + + It("refuses once any user exists", func() { + _, err := create("first") + Expect(err).ToNot(HaveOccurred()) + _, err = create("second") + Expect(err).To(MatchError(auth.ErrSetupComplete)) + }) + + It("lets exactly one of two concurrent setups win", func() { + var wg sync.WaitGroup + errs := make([]error, 2) + for i, name := range []string{"racer-a", "racer-b"} { + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + _, errs[i] = createWith(name, func(tx model.DataStore) model.DataStore { return slowCountDS{tx} }) + }() + } + wg.Wait() + Expect(errs).To(ContainElement(BeNil())) + Expect(errs).To(ContainElement(MatchError(auth.ErrSetupComplete))) + Expect(ds.User().CountAll(ctx)).To(Equal(int64(1))) + }) +}) + +type slowCountDS struct{ model.DataStore } + +func (d slowCountDS) User() model.UserRepository { return slowCountUsers{d.DataStore.User()} } + +type slowCountUsers struct{ model.UserRepository } + +// Holds the transaction open after counting, so an unlocked count would interleave with the other racer. +func (u slowCountUsers) CountAll(ctx context.Context, opts ...model.QueryOptions) (int64, error) { + n, err := u.UserRepository.CountAll(ctx, opts...) + time.Sleep(50 * time.Millisecond) + return n, err +} diff --git a/server/auth.go b/server/auth.go index 3e58359da..dc70c5048 100644 --- a/server/auth.go +++ b/server/auth.go @@ -13,7 +13,6 @@ import ( "slices" "strings" "sync" - "time" "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" @@ -26,8 +25,6 @@ import ( "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/gravatar" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) var ( @@ -127,16 +124,14 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) return } - c, err := ds.User().CountAll(r.Context()) - if err != nil { - _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) - return - } - if c > 0 { + err = ds.WithTxImmediate(func(tx model.DataStore) error { + _, err := auth.CreateFirstAdmin(r.Context(), tx, username, password) + return err + }) + if errors.Is(err, auth.ErrSetupComplete) { _ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin") return } - err = createAdminUser(r.Context(), ds, username, password) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) return @@ -145,26 +140,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request } } -func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error { - log.Warn(ctx, "Creating initial user", "user", username) - caser := cases.Title(language.Und) - initialUser := model.User{ - ID: id.NewRandom(), - UserName: username, - Name: caser.String(username), - Email: "", - NewPassword: password, - IsAdmin: true, - LastLoginAt: new(time.Now()), - } - err := ds.User().Put(ctx, &initialUser) - if err != nil { - log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err) - return fmt.Errorf("creating initial user: %w", err) - } - return nil -} - func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, password string) (*model.User, error) { u, err := userRepo.FindByUsernameWithPassword(ctx, userName) if errors.Is(err, model.ErrNotFound) { diff --git a/server/auth_test.go b/server/auth_test.go index 1095fafc9..e016de5e1 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -74,14 +74,30 @@ var _ = Describe("Auth", func() { }) }) - Describe("createAdminUser", func() { + Describe("CreateFirstAdmin", func() { It("returns the error when the user cannot be saved", func() { - ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}} - err := createAdminUser(context.Background(), ds, "johndoe", "secret") + failing := dsWithFailingPut(errors.New("db is down")) + err := failing.WithTxImmediate(func(tx model.DataStore) error { + _, err := auth.CreateFirstAdmin(ctx, tx, "johndoe", "secret") + return err + }) Expect(err).To(MatchError(ContainSubstring("db is down"))) }) }) + Describe("createAdmin when a user already exists", func() { + It("responds 403", func() { + req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"another", "password":"secret"}`)) + resp = httptest.NewRecorder() + Expect(ds.User().Put(ctx, &model.User{UserName: "johndoe", NewPassword: "secret"})).To(Succeed()) + + createAdmin(ds)(resp, req) + + Expect(resp.Code).To(Equal(http.StatusForbidden)) + Expect(resp.Body.String()).To(ContainSubstring("Cannot create another first admin")) + }) + }) + Describe("createAdmin when the user cannot be stored", func() { It("responds 500 rather than falling through to login", func() { failing := dsWithFailingPut(errors.New("db is down")) From 21ce7c71154bddf1c2bc2a7d5a3ed9b117da2b62 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:04:31 -0400 Subject: [PATCH 04/41] feat(api): add API v1 scope rules and grant secrets --- core/apiauth/apiauth_suite_test.go | 17 +++++++ core/apiauth/scopes.go | 77 ++++++++++++++++++++++++++++++ core/apiauth/scopes_test.go | 66 +++++++++++++++++++++++++ core/apiauth/secret.go | 20 ++++++++ core/apiauth/secret_test.go | 23 +++++++++ 5 files changed, 203 insertions(+) create mode 100644 core/apiauth/apiauth_suite_test.go create mode 100644 core/apiauth/scopes.go create mode 100644 core/apiauth/scopes_test.go create mode 100644 core/apiauth/secret.go create mode 100644 core/apiauth/secret_test.go diff --git a/core/apiauth/apiauth_suite_test.go b/core/apiauth/apiauth_suite_test.go new file mode 100644 index 000000000..5d6143b0e --- /dev/null +++ b/core/apiauth/apiauth_suite_test.go @@ -0,0 +1,17 @@ +package apiauth + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAPIAuth(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "API Auth Suite") +} diff --git a/core/apiauth/scopes.go b/core/apiauth/scopes.go new file mode 100644 index 000000000..bc3ac9e27 --- /dev/null +++ b/core/apiauth/scopes.go @@ -0,0 +1,77 @@ +package apiauth + +import ( + "slices" + "strings" +) + +const ( + ScopeAll = "all" + ScopeRead = "read" + ScopePassword = "password" + ScopeAdmin = "admin" +) + +// KnownScopes lists the scopes of modules this server implements; `all` expands to these. +var KnownScopes = []string{ScopeRead, ScopePassword} + +func known(s string) bool { + return slices.Contains(KnownScopes, s) +} + +func normalize(in []string) []string { + out := slices.Clone(in) + slices.Sort(out) + return slices.Compact(out) +} + +// Entitled returns the scopes a new grant stores. +func Entitled(requested []string, isAdmin bool) []string { + if requested == nil { + return []string{ScopeAll} + } + var out []string + for _, s := range requested { + switch { + case s == ScopeAdmin && !isAdmin: + case s == ScopeAll || known(s): + out = append(out, s) + } + } + return normalize(out) +} + +// Expand turns a grant's stored scopes into the concrete scopes a token may carry right now. +func Expand(granted []string, isAdmin bool) []string { + var out []string + for _, s := range granted { + if s == ScopeAll { + out = append(out, KnownScopes...) + continue + } + out = append(out, s) + } + out = slices.DeleteFunc(out, func(s string) bool { + return !known(s) || (s == ScopeAdmin && !isAdmin) + }) + return normalize(out) +} + +// Attenuate returns the requested subset of available; a nil request means "everything available". +func Attenuate(available, requested []string) []string { + if requested == nil { + return normalize(available) + } + out := []string{} + for _, s := range requested { + if Satisfies(available, s) { + out = append(out, s) + } + } + return normalize(out) +} + +func Satisfies(scopes []string, required string) bool { + return slices.Contains(scopes, required) || + (!strings.HasSuffix(required, ":write") && slices.Contains(scopes, required+":write")) +} diff --git a/core/apiauth/scopes_test.go b/core/apiauth/scopes_test.go new file mode 100644 index 000000000..dc0183b24 --- /dev/null +++ b/core/apiauth/scopes_test.go @@ -0,0 +1,66 @@ +package apiauth + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("scopes", func() { + BeforeEach(func() { + saved := KnownScopes + KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin, "playlists", "playlists:write"} + DeferCleanup(func() { KnownScopes = saved }) + }) + + Describe("Entitled", func() { + It("stores all when nothing is requested", func() { + Expect(Entitled(nil, false)).To(Equal([]string{ScopeAll})) + }) + It("drops unknown scopes and admin for non-admins", func() { + Expect(Entitled([]string{"read", "future", "admin"}, false)).To(Equal([]string{"read"})) + }) + It("keeps admin for admins and keeps all", func() { + Expect(Entitled([]string{"admin", "all"}, true)).To(Equal([]string{"admin", "all"})) + }) + }) + + Describe("Expand", func() { + It("replaces all with every known scope except admin for non-admins", func() { + Expect(Expand([]string{ScopeAll}, false)).To(Equal([]string{"password", "playlists", "playlists:write", "read"})) + }) + It("includes admin for admins", func() { + Expect(Expand([]string{ScopeAll}, true)).To(ContainElement("admin")) + }) + It("drops admin from explicit scopes when the user is no longer an admin", func() { + Expect(Expand([]string{"admin", "read"}, false)).To(Equal([]string{"read"})) + }) + It("drops scopes that are no longer known", func() { + Expect(Expand([]string{"read", "retired"}, false)).To(Equal([]string{"read"})) + }) + }) + + Describe("Attenuate", func() { + available := []string{"playlists:write", "read"} + It("returns everything when no subset is asked", func() { + Expect(Attenuate(available, nil)).To(Equal([]string{"playlists:write", "read"})) + }) + It("returns nothing for an explicit empty request", func() { + Expect(Attenuate(available, []string{})).To(BeEmpty()) + }) + It("returns the overlap and drops unknown scopes", func() { + Expect(Attenuate(available, []string{"read", "sync"})).To(Equal([]string{"read"})) + }) + It("grants the base scope when only its :write form is available", func() { + Expect(Attenuate(available, []string{"playlists"})).To(Equal([]string{"playlists"})) + }) + }) + + Describe("Satisfies", func() { + It("accepts the exact scope or its :write form", func() { + Expect(Satisfies([]string{"read"}, "read")).To(BeTrue()) + Expect(Satisfies([]string{"playlists:write"}, "playlists")).To(BeTrue()) + Expect(Satisfies([]string{"playlists"}, "playlists:write")).To(BeFalse()) + Expect(Satisfies(nil, "read")).To(BeFalse()) + }) + }) +}) diff --git a/core/apiauth/secret.go b/core/apiauth/secret.go new file mode 100644 index 000000000..beaed16c5 --- /dev/null +++ b/core/apiauth/secret.go @@ -0,0 +1,20 @@ +package apiauth + +import ( + "crypto/sha256" + "encoding/hex" + + "github.com/navidrome/navidrome/model/id" +) + +const secretPrefix = "ndg_" + +func newSecret() (secret, hash string) { + secret = secretPrefix + id.NewRandom() + return secret, hashSecret(secret) +} + +func hashSecret(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} diff --git a/core/apiauth/secret_test.go b/core/apiauth/secret_test.go new file mode 100644 index 000000000..3f9bae3da --- /dev/null +++ b/core/apiauth/secret_test.go @@ -0,0 +1,23 @@ +package apiauth + +import ( + "regexp" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("grant secrets", func() { + It("are ndg_ plus 22 base62 characters, hashed as hex SHA-256", func() { + secret, hash := newSecret() + Expect(secret).To(MatchRegexp(`^ndg_[0-9A-Za-z]{22}$`)) + Expect(hash).To(MatchRegexp(`^[0-9a-f]{64}$`)) + Expect(hashSecret(secret)).To(Equal(hash)) + }) + It("are unique", func() { + a, _ := newSecret() + b, _ := newSecret() + Expect(a).ToNot(Equal(b)) + Expect(regexp.MustCompile(`^ndg_`).MatchString(a)).To(BeTrue()) + }) +}) From 7dd8dba12b77107e9261af66f421b4de4d01553b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:07:13 -0400 Subject: [PATCH 05/41] feat(api): add grant liveness cache --- core/apiauth/cache.go | 87 ++++++++++++++++++++++++++++++++++++++ core/apiauth/cache_test.go | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 core/apiauth/cache.go create mode 100644 core/apiauth/cache_test.go diff --git a/core/apiauth/cache.go b/core/apiauth/cache.go new file mode 100644 index 000000000..136b5c336 --- /dev/null +++ b/core/apiauth/cache.go @@ -0,0 +1,87 @@ +package apiauth + +import ( + "maps" + "sync" + "time" +) + +const maxLivenessEntries = 1024 + +type livenessEntry struct { + userID string + epoch int + lastUsedAt time.Time + expires time.Time +} + +// livenessCache bounds how long a node trusts "this grant exists" without asking the DB. +type livenessCache struct { + mu sync.Mutex + ttl time.Duration + gen uint64 + entries map[string]livenessEntry + evicted map[string]uint64 // grant id -> generation of its last eviction + floor uint64 // fills started before the last trim of evicted are dropped +} + +func newLivenessCache(ttl time.Duration) *livenessCache { + return &livenessCache{ttl: ttl, entries: map[string]livenessEntry{}, evicted: map[string]uint64{}} +} + +func (c *livenessCache) begin() uint64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.gen +} + +func (c *livenessCache) get(id string, now time.Time) (livenessEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[id] + if !ok || !now.Before(e.expires) { + return livenessEntry{}, false + } + return e, true +} + +// put ignores a fill whose DB read started before the grant was last evicted. +func (c *livenessCache) put(id string, e livenessEntry, now time.Time, started uint64) { + c.mu.Lock() + defer c.mu.Unlock() + if started < c.floor || c.evicted[id] > started { + return + } + if len(c.entries) >= maxLivenessEntries { + maps.DeleteFunc(c.entries, func(_ string, v livenessEntry) bool { return !now.Before(v.expires) }) + } + if len(c.evicted) >= maxLivenessEntries { + clear(c.evicted) + c.floor = c.gen + } + e.expires = now.Add(c.ttl) + c.entries[id] = e +} + +func (c *livenessCache) evict(id string) { + c.mu.Lock() + defer c.mu.Unlock() + c.gen++ + c.evicted[id] = c.gen + delete(c.entries, id) +} + +func (c *livenessCache) markUsed(id string, at time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + if e, ok := c.entries[id]; ok { + e.lastUsedAt = at + c.entries[id] = e + } +} + +func (c *livenessCache) len() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.entries) +} diff --git a/core/apiauth/cache_test.go b/core/apiauth/cache_test.go new file mode 100644 index 000000000..999f3e24a --- /dev/null +++ b/core/apiauth/cache_test.go @@ -0,0 +1,84 @@ +package apiauth + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("livenessCache", func() { + var c *livenessCache + var t0 time.Time + + BeforeEach(func() { + c = newLivenessCache(30 * time.Second) + t0 = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + }) + + It("returns an entry until its TTL passes", func() { + c.put("g1", livenessEntry{userID: "u1", epoch: 2}, t0, c.begin()) + e, ok := c.get("g1", t0.Add(29*time.Second)) + Expect(ok).To(BeTrue()) + Expect(e.userID).To(Equal("u1")) + Expect(e.epoch).To(Equal(2)) + _, ok = c.get("g1", t0.Add(30*time.Second)) + Expect(ok).To(BeFalse()) + }) + + It("forgets evicted entries", func() { + c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) + c.evict("g1") + _, ok := c.get("g1", t0) + Expect(ok).To(BeFalse()) + }) + + It("drops a fill that started before an eviction of the same grant", func() { + started := c.begin() // a request reads the grant from the DB... + c.evict("g1") // ...a logout deletes and evicts it... + c.put("g1", livenessEntry{userID: "u1"}, t0, started) // ...then the slow request tries to cache it + _, ok := c.get("g1", t0) + Expect(ok).To(BeFalse()) + }) + + It("still accepts fills of other grants and later fills of the same grant", func() { + started := c.begin() + c.evict("g1") + c.put("g2", livenessEntry{userID: "u2"}, t0, started) + _, ok := c.get("g2", t0) + Expect(ok).To(BeTrue()) + c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) + _, ok = c.get("g1", t0) + Expect(ok).To(BeTrue()) + }) + + It("still drops a racing fill after the eviction log is trimmed", func() { + started := c.begin() + c.evict("g1") + for i := range maxLivenessEntries { + c.evict(fmt.Sprint("other", i)) + } + c.put("fresh", livenessEntry{}, t0, c.begin()) + c.put("g1", livenessEntry{userID: "u1"}, t0, started) + _, ok := c.get("g1", t0) + Expect(ok).To(BeFalse()) + }) + + It("records the last use without extending the TTL", func() { + c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) + c.markUsed("g1", t0.Add(10*time.Second)) + e, _ := c.get("g1", t0.Add(11*time.Second)) + Expect(e.lastUsedAt).To(Equal(t0.Add(10 * time.Second))) + _, ok := c.get("g1", t0.Add(30*time.Second)) + Expect(ok).To(BeFalse()) + }) + + It("drops expired entries when it grows", func() { + for i := range maxLivenessEntries { + c.put(fmt.Sprint(i), livenessEntry{}, t0, c.begin()) + } + c.put("fresh", livenessEntry{}, t0.Add(time.Minute), c.begin()) + Expect(c.len()).To(Equal(1)) + }) +}) From 899cc428fdb679ab85c19569cc29ed3303b74e51 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:11:07 -0400 Subject: [PATCH 06/41] feat(api): add the API v1 signing key and access-token JWTs --- consts/consts.go | 1 + core/apiauth/db_test.go | 35 ++++++++++ core/apiauth/signer.go | 132 ++++++++++++++++++++++++++++++++++++ core/apiauth/signer_test.go | 121 +++++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 core/apiauth/db_test.go create mode 100644 core/apiauth/signer.go create mode 100644 core/apiauth/signer_test.go diff --git a/consts/consts.go b/consts/consts.go index 9bdac9125..c97c72cab 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -32,6 +32,7 @@ const ( UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" JWTSecretKey = "JWTSecret" JWTPublicSecretKey = "JWTPublicSecret" + JWTAPIv1SecretKey = "JWTAPIv1Secret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour DefaultSmartRefresh = 5 * time.Second diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go new file mode 100644 index 000000000..0dc791634 --- /dev/null +++ b/core/apiauth/db_test.go @@ -0,0 +1,35 @@ +package apiauth + +import ( + "context" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/persistence" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var realDS model.DataStore + +// One database for the whole suite: db.Db() is a process-wide singleton. +var _ = BeforeSuite(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiauth.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000" + DeferCleanup(db.Init(GinkgoT().Context())) + realDS = persistence.New(db.Db()) +}) + +//nolint:unused +func createUser(ctx context.Context, password string, admin bool) model.User { + name := "user-" + id.NewRandom() + u := model.User{UserName: name, Name: name, NewPassword: password, IsAdmin: admin} + ExpectWithOffset(1, realDS.User().Put(ctx, &u)).To(Succeed()) + stored, err := realDS.User().FindByUsername(ctx, name) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + return *stored +} diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go new file mode 100644 index 000000000..fd1fc97c8 --- /dev/null +++ b/core/apiauth/signer.go @@ -0,0 +1,132 @@ +package apiauth + +import ( + "cmp" + "context" + "crypto/sha256" + "errors" + "fmt" + "strings" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils" +) + +const Audience = "navidrome-api-v1" + +var ErrTokenExpired = errors.New("access token expired") + +type claims struct { + UserID string + GrantID string + Scopes []string + IssuedAt time.Time + ExpiresAt time.Time +} + +type signer struct { + auth *jwtauth.JWTAuth +} + +func newJWTAuth(key []byte, now func() time.Time) *jwtauth.JWTAuth { + return jwtauth.New("HS256", key, nil, + jwt.WithAudience(Audience), + jwt.WithClock(jwt.ClockFunc(now)), + // jwx accepts a token with no exp at all unless the claim is required. + jwt.WithRequiredClaim(jwt.ExpirationKey), + jwt.WithRequiredClaim(jwt.SubjectKey), + ) +} + +// loadSigner reads the shared v1 key, creating it insert-if-absent so concurrent nodes agree on one key. +func loadSigner(ctx context.Context, ds model.DataStore, now func() time.Time) (*signer, error) { + key, err := loadKey(ctx, ds) + if err != nil { + return nil, err + } + return &signer{auth: newJWTAuth([]byte(key), now)}, nil +} + +func loadKey(ctx context.Context, ds model.DataStore) (string, error) { + enc, err := utils.Encrypt(ctx, encryptionKey(), id.NewRandom()) + if err != nil { + return "", fmt.Errorf("encrypting API v1 key: %w", err) + } + if err := ds.Property().PutIfAbsent(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { + return "", fmt.Errorf("storing API v1 key: %w", err) + } + stored, err := ds.Property().Get(ctx, consts.JWTAPIv1SecretKey) + if err != nil { + return "", fmt.Errorf("reading API v1 key: %w", err) + } + if key, err := utils.Decrypt(ctx, encryptionKey(), stored); err == nil { + return key, nil + } + // A changed PasswordEncryptionKey makes the old key unreadable; replacing it only ends current access tokens. + // The lock and re-read make concurrent nodes agree on one replacement. + var key string + err = ds.WithTxImmediate(func(tx model.DataStore) error { + current, err := tx.Property().Get(ctx, consts.JWTAPIv1SecretKey) + if err != nil { + return err + } + if k, err := utils.Decrypt(ctx, encryptionKey(), current); err == nil { + key = k + return nil + } + log.Warn(ctx, "Could not decrypt API v1 key, replacing it") + if err := tx.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { + return err + } + key, err = utils.Decrypt(ctx, encryptionKey(), enc) + return err + }) + if err != nil { + return "", fmt.Errorf("replacing API v1 key: %w", err) + } + return key, nil +} + +func encryptionKey() []byte { + sum := sha256.Sum256([]byte(cmp.Or(conf.Server.PasswordEncryptionKey, consts.DefaultEncryptionKey))) + return sum[:] +} + +func (s *signer) sign(c claims) (string, error) { + _, tok, err := s.auth.Encode(map[string]any{ + jwt.SubjectKey: c.UserID, + jwt.AudienceKey: []string{Audience}, + jwt.IssuedAtKey: c.IssuedAt, + jwt.ExpirationKey: c.ExpiresAt, + "gid": c.GrantID, + "scope": strings.Join(c.Scopes, " "), + }) + return tok, err +} + +func (s *signer) parse(token string) (claims, error) { + tok, err := jwtauth.VerifyToken(s.auth, token) + if errors.Is(err, jwtauth.ErrExpired) { + return claims{}, ErrTokenExpired + } + if err != nil { + return claims{}, model.ErrInvalidAuth + } + var c claims + c.UserID, _ = tok.Subject() + c.IssuedAt, _ = tok.IssuedAt() + c.ExpiresAt, _ = tok.Expiration() + var scope string + if tok.Get("gid", &c.GrantID) != nil || tok.Get("scope", &scope) != nil || c.UserID == "" || c.GrantID == "" { + return claims{}, model.ErrInvalidAuth + } + c.Scopes = strings.Fields(scope) + return c, nil +} diff --git a/core/apiauth/signer_test.go b/core/apiauth/signer_test.go new file mode 100644 index 000000000..599f8de05 --- /dev/null +++ b/core/apiauth/signer_test.go @@ -0,0 +1,121 @@ +package apiauth + +import ( + "context" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("signer", func() { + var ctx context.Context + var now time.Time + clock := func() time.Time { return now } + + BeforeEach(func() { + ctx = GinkgoT().Context() + now = time.Now().UTC().Truncate(time.Second) + Expect(realDS.Property().Delete(ctx, consts.JWTAPIv1SecretKey)).To(Or(Succeed(), MatchError(model.ErrNotFound))) + }) + + It("round-trips claims", func() { + s, err := loadSigner(ctx, realDS, clock) + Expect(err).ToNot(HaveOccurred()) + tok, err := s.sign(claims{UserID: "u1", GrantID: "g1", Scopes: []string{"read", "password"}, + IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) + Expect(err).ToNot(HaveOccurred()) + + c, err := s.parse(tok) + Expect(err).ToNot(HaveOccurred()) + Expect(c.UserID).To(Equal("u1")) + Expect(c.GrantID).To(Equal("g1")) + Expect(c.Scopes).To(Equal([]string{"read", "password"})) + Expect(c.ExpiresAt).To(BeTemporally("==", now.Add(time.Hour))) + }) + + It("round-trips a token with no scopes", func() { + s, _ := loadSigner(ctx, realDS, clock) + tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) + + c, err := s.parse(tok) + Expect(err).ToNot(HaveOccurred()) + Expect(c.Scopes).To(BeEmpty()) + }) + + It("reports expiry distinctly", func() { + s, _ := loadSigner(ctx, realDS, clock) + tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) + now = now.Add(time.Hour + time.Second) + _, err := s.parse(tok) + Expect(err).To(MatchError(ErrTokenExpired)) + }) + + It("rejects garbage and tokens signed with another key", func() { + s, _ := loadSigner(ctx, realDS, clock) + _, err := s.parse("not-a-token") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + + other := &signer{} + *other = *s + other.auth = newJWTAuth([]byte("another-key"), clock) + tok, _ := other.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) + _, err = s.parse(tok) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rejects a correctly signed token with no exp, or with another audience", func() { + s, _ := loadSigner(ctx, realDS, clock) + _, noExp, err := s.auth.Encode(map[string]any{ + jwt.SubjectKey: "u1", jwt.AudienceKey: []string{Audience}, "gid": "g1", "scope": "read", + }) + Expect(err).ToNot(HaveOccurred()) + _, err = s.parse(noExp) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + + _, otherAud, _ := s.auth.Encode(map[string]any{ + jwt.SubjectKey: "u1", jwt.AudienceKey: []string{"navidrome-other"}, jwt.ExpirationKey: now.Add(time.Hour), + "gid": "g1", "scope": "read", + }) + _, err = s.parse(otherAud) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + loadConcurrently := func() []*signer { + var wg sync.WaitGroup + signers := make([]*signer, 4) + for i := range signers { + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + var err error + signers[i], err = loadSigner(ctx, realDS, clock) + Expect(err).ToNot(HaveOccurred()) + }() + } + wg.Wait() + return signers + } + + expectSameKey := func(signers []*signer) { + tok, _ := signers[0].sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) + for _, s := range signers[1:] { + _, err := s.parse(tok) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + } + } + + It("gives every concurrent loader the same key", func() { + expectSameKey(loadConcurrently()) + }) + + It("gives every concurrent loader the same replacement for an unreadable key", func() { + Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, "not-decryptable")).To(Succeed()) + expectSameKey(loadConcurrently()) + }) +}) From 8c0ba43e9aea78db7e0277381d34b70ed622faac Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:14:38 -0400 Subject: [PATCH 07/41] fix(api): tolerate small clock skew between nodes on access tokens --- core/apiauth/signer.go | 4 ++++ core/apiauth/signer_test.go | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go index fd1fc97c8..a2c21f39f 100644 --- a/core/apiauth/signer.go +++ b/core/apiauth/signer.go @@ -21,6 +21,9 @@ import ( const Audience = "navidrome-api-v1" +// Tokens minted on one node are verified on others, whose clocks may differ slightly. +const clockSkew = 30 * time.Second + var ErrTokenExpired = errors.New("access token expired") type claims struct { @@ -39,6 +42,7 @@ func newJWTAuth(key []byte, now func() time.Time) *jwtauth.JWTAuth { return jwtauth.New("HS256", key, nil, jwt.WithAudience(Audience), jwt.WithClock(jwt.ClockFunc(now)), + jwt.WithAcceptableSkew(clockSkew), // jwx accepts a token with no exp at all unless the claim is required. jwt.WithRequiredClaim(jwt.ExpirationKey), jwt.WithRequiredClaim(jwt.SubjectKey), diff --git a/core/apiauth/signer_test.go b/core/apiauth/signer_test.go index 599f8de05..acb197dde 100644 --- a/core/apiauth/signer_test.go +++ b/core/apiauth/signer_test.go @@ -50,11 +50,25 @@ var _ = Describe("signer", func() { It("reports expiry distinctly", func() { s, _ := loadSigner(ctx, realDS, clock) tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) - now = now.Add(time.Hour + time.Second) + now = now.Add(time.Hour + clockSkew + time.Second) _, err := s.parse(tok) Expect(err).To(MatchError(ErrTokenExpired)) }) + It("accepts a token issued slightly ahead of the verifier's clock", func() { + s, _ := loadSigner(ctx, realDS, clock) + tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now.Add(5 * time.Second), ExpiresAt: now.Add(time.Hour)}) + _, err := s.parse(tok) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects a token issued further ahead than the allowed skew", func() { + s, _ := loadSigner(ctx, realDS, clock) + tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now.Add(clockSkew + time.Second), ExpiresAt: now.Add(time.Hour)}) + _, err := s.parse(tok) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + It("rejects garbage and tokens signed with another key", func() { s, _ := loadSigner(ctx, realDS, clock) _, err := s.parse("not-a-token") From 0bbca1b133dd249b25090b8c3be020e3afbbc92e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:18:21 -0400 Subject: [PATCH 08/41] feat(api): add API v1 login, setup, grant resolution and token minting --- core/apiauth/context.go | 14 ++ core/apiauth/credentials.go | 68 +++++++++ core/apiauth/credentials_test.go | 63 ++++++++ core/apiauth/db_test.go | 1 - core/apiauth/export_test.go | 11 ++ core/apiauth/service.go | 245 +++++++++++++++++++++++++++++++ core/apiauth/service_test.go | 159 ++++++++++++++++++++ 7 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 core/apiauth/context.go create mode 100644 core/apiauth/credentials.go create mode 100644 core/apiauth/credentials_test.go create mode 100644 core/apiauth/export_test.go create mode 100644 core/apiauth/service.go create mode 100644 core/apiauth/service_test.go diff --git a/core/apiauth/context.go b/core/apiauth/context.go new file mode 100644 index 000000000..082d92d45 --- /dev/null +++ b/core/apiauth/context.go @@ -0,0 +1,14 @@ +package apiauth + +import "context" + +type principalKey struct{} + +func WithPrincipal(ctx context.Context, p *Principal) context.Context { + return context.WithValue(ctx, principalKey{}, p) +} + +func PrincipalFrom(ctx context.Context) (*Principal, bool) { + p, ok := ctx.Value(principalKey{}).(*Principal) + return p, ok +} diff --git a/core/apiauth/credentials.go b/core/apiauth/credentials.go new file mode 100644 index 000000000..43834ab51 --- /dev/null +++ b/core/apiauth/credentials.go @@ -0,0 +1,68 @@ +package apiauth + +import ( + "context" + "crypto/subtle" + "errors" + + "github.com/navidrome/navidrome/model" +) + +type Outcome int + +const ( + NotMine Outcome = iota + Authenticated + Rejected + Unavailable +) + +type CredentialResult struct { + Outcome Outcome + User *model.User + Provider string + PasswordLocal bool +} + +type CredentialChecker interface { + Check(ctx context.Context, username, password string) (CredentialResult, error) +} + +// checkCredentials asks each checker in turn; only NotMine moves on, so an owning provider's "no" is final. +func checkCredentials(ctx context.Context, checkers []CredentialChecker, username, password string) (CredentialResult, error) { + for _, c := range checkers { + res, err := c.Check(ctx, username, password) + if err != nil { + return CredentialResult{}, err + } + switch res.Outcome { + case NotMine: + continue + case Authenticated: + return res, nil + case Unavailable: + return CredentialResult{}, model.ErrNotAvailable + default: + return CredentialResult{}, model.ErrInvalidAuth + } + } + return CredentialResult{}, model.ErrInvalidAuth +} + +type dbChecker struct { + ds model.DataStore +} + +func (c dbChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) { + u, err := c.ds.User().FindByUsernameWithPassword(ctx, username) + if errors.Is(err, model.ErrNotFound) { + return CredentialResult{Outcome: NotMine}, nil + } + if err != nil { + return CredentialResult{}, err + } + if subtle.ConstantTimeCompare([]byte(u.Password), []byte(password)) != 1 { + return CredentialResult{Outcome: Rejected}, nil + } + return CredentialResult{Outcome: Authenticated, User: u, Provider: "password", PasswordLocal: true}, nil +} diff --git a/core/apiauth/credentials_test.go b/core/apiauth/credentials_test.go new file mode 100644 index 000000000..a57d54c29 --- /dev/null +++ b/core/apiauth/credentials_test.go @@ -0,0 +1,63 @@ +package apiauth + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeChecker struct { + res CredentialResult + err error + hit bool +} + +func (f *fakeChecker) Check(context.Context, string, string) (CredentialResult, error) { + f.hit = true + return f.res, f.err +} + +var _ = Describe("credential chain", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + + It("authenticates against the database with the stored password", func() { + u := createUser(ctx, "pw", false) + res, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "pw") + Expect(err).ToNot(HaveOccurred()) + Expect(res.Outcome).To(Equal(Authenticated)) + Expect(res.User.ID).To(Equal(u.ID)) + Expect(res.Provider).To(Equal("password")) + Expect(res.PasswordLocal).To(BeTrue()) + }) + + It("rejects a wrong password and an unknown user the same way", func() { + u := createUser(ctx, "pw", false) + _, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "nope") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + _, err = checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, "ghost", "pw") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("moves on only from NotMine, and an owner's rejection stops the chain", func() { + owner := &fakeChecker{res: CredentialResult{Outcome: Rejected}} + later := &fakeChecker{res: CredentialResult{Outcome: Authenticated, User: &model.User{ID: "x"}}} + _, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: NotMine}}, owner, later}, "a", "b") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + Expect(later.hit).To(BeFalse()) + }) + + It("maps Unavailable to ErrNotAvailable and passes through checker errors", func() { + _, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: Unavailable}}}, "a", "b") + Expect(err).To(MatchError(model.ErrNotAvailable)) + boom := errors.New("boom") + _, err = checkCredentials(ctx, []CredentialChecker{&fakeChecker{err: boom}}, "a", "b") + Expect(err).To(MatchError(boom)) + }) +}) diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go index 0dc791634..69de6cd62 100644 --- a/core/apiauth/db_test.go +++ b/core/apiauth/db_test.go @@ -24,7 +24,6 @@ var _ = BeforeSuite(func() { realDS = persistence.New(db.Db()) }) -//nolint:unused func createUser(ctx context.Context, password string, admin bool) model.User { name := "user-" + id.NewRandom() u := model.User{UserName: name, Name: name, NewPassword: password, IsAdmin: admin} diff --git a/core/apiauth/export_test.go b/core/apiauth/export_test.go new file mode 100644 index 000000000..fdfde677c --- /dev/null +++ b/core/apiauth/export_test.go @@ -0,0 +1,11 @@ +package apiauth + +import ( + "time" + + "github.com/navidrome/navidrome/model" +) + +func (s *Service) SetClock(now func() time.Time) { s.now = now } + +func (s *Service) SetCheckers(f func(model.DataStore) []CredentialChecker) { s.checkers = f } diff --git a/core/apiauth/service.go b/core/apiauth/service.go new file mode 100644 index 000000000..d6cdf4e20 --- /dev/null +++ b/core/apiauth/service.go @@ -0,0 +1,245 @@ +package apiauth + +import ( + "cmp" + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const ( + TokenTTL = time.Hour + IdleExpiry = 90 * 24 * time.Hour + cacheTTL = 30 * time.Second + touchInterval = 5 * time.Minute +) + +var ( + ErrInsufficientScope = errors.New("insufficient scope") + ErrPasswordManagedExternally = errors.New("password is managed externally") + ErrCurrentPasswordMismatch = errors.New("current password does not match") +) + +type ClientMeta struct { + Name string + Client string + ClientVersion string +} + +type Issued struct { + Secret string + Grant model.Grant + User model.User +} + +type AccessToken struct { + Token string + ExpiresIn time.Duration + Scopes []string +} + +type Principal struct { + User model.User + GrantID string + Scopes []string +} + +type Service struct { + ds model.DataStore + checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction + cache *livenessCache + now func() time.Time + signer func() (*signer, error) +} + +func New(ds model.DataStore) *Service { + s := &Service{ + ds: ds, + checkers: func(ds model.DataStore) []CredentialChecker { + return []CredentialChecker{dbChecker{ds: ds}} + }, + cache: newLivenessCache(cacheTTL), + now: time.Now, + } + // Loaded on first use so building the router never touches the database. + s.signer = sync.OnceValues(func() (*signer, error) { + return loadSigner(context.Background(), ds, func() time.Time { return s.now() }) + }) + return s +} + +func PasswordChangeable(u model.User) bool { + return u.IsAdmin || conf.Server.EnableUserEditing +} + +func (s *Service) Login(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) { + res, err := checkCredentials(ctx, s.checkers(s.ds), username, password) + if err != nil { + return nil, err + } + issued, err := s.issue(ctx, s.ds, *res.User, res.Provider, meta, scopes) + if err != nil { + return nil, err + } + if err := s.ds.User().UpdateLastLoginAt(ctx, res.User.ID); err != nil { + log.Warn(ctx, "API v1: could not update last login", "user", res.User.UserName, err) + } + return issued, nil +} + +func (s *Service) Setup(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) { + var issued *Issued + err := s.ds.WithTxImmediate(func(tx model.DataStore) error { + u, err := auth.CreateFirstAdmin(ctx, tx, username, password) + if err != nil { + return err + } + issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes) + return err + }) + return issued, err +} + +// issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead. +func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, provider string, meta ClientMeta, scopes []string) (*Issued, error) { + secret, hash := newSecret() + g := model.Grant{ + UserID: u.ID, + Name: cmp.Or(meta.Name, meta.Client), + Client: meta.Client, + ClientVersion: meta.ClientVersion, + Scopes: Entitled(scopes, u.IsAdmin), + Provider: provider, + SecretHash: hash, + UserEpoch: u.TokenEpoch, + CreatedAt: s.now(), + } + if err := ds.Grant().Put(ctx, &g); err != nil { + return nil, fmt.Errorf("storing grant: %w", err) + } + return &Issued{Secret: secret, Grant: g, User: u}, nil +} + +func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Principal, error) { + g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret)) + if errors.Is(err, model.ErrNotFound) { + return nil, model.ErrInvalidAuth + } + if err != nil { + return nil, err + } + lastActivity := g.CreatedAt + if g.LastUsedAt != nil { + lastActivity = *g.LastUsedAt + } + if !s.now().Before(lastActivity.Add(IdleExpiry)) { + s.dropGrant(ctx, g.ID) + return nil, model.ErrInvalidAuth + } + u, err := s.loadUser(ctx, g.UserID) + if err != nil { + return nil, err + } + if g.UserEpoch != u.TokenEpoch { + if g, u, err = s.settleEpoch(ctx, g.ID); err != nil { + return nil, err + } + } + s.touch(ctx, g.ID, ip, g.LastUsedAt) + return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil +} + +func (s *Service) Mint(ctx context.Context, p *Principal, requested []string) (*AccessToken, error) { + sg, err := s.signer() + if err != nil { + return nil, err + } + now := s.now() + scopes := Attenuate(p.Scopes, requested) + tok, err := sg.sign(claims{UserID: p.User.ID, GrantID: p.GrantID, Scopes: scopes, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) + if err != nil { + return nil, fmt.Errorf("signing access token: %w", err) + } + return &AccessToken{Token: tok, ExpiresIn: TokenTTL, Scopes: scopes}, nil +} + +func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, error) { + u, err := s.ds.User().Get(ctx, userID) + if errors.Is(err, model.ErrNotFound) { + return nil, model.ErrInvalidAuth + } + return u, err +} + +func (s *Service) dropGrant(ctx context.Context, id string) { + s.cache.evict(id) + if err := s.ds.Grant().Delete(ctx, id); err != nil { + log.Warn(ctx, "API v1: could not delete dead grant", "grant", id, err) + } +} + +// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password +// change and make a kept grant look dead. The delete only fires while the grant is on the epoch seen here. +func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant, *model.User, error) { + var g *model.Grant + var u *model.User + err := s.ds.WithTx(func(tx model.DataStore) error { + var err error + if g, err = tx.Grant().Get(ctx, grantID); err != nil { + return err + } + u, err = tx.User().Get(ctx, g.UserID) + return err + }) + if errors.Is(err, model.ErrNotFound) { + s.cache.evict(grantID) + return nil, nil, model.ErrInvalidAuth + } + if err != nil { + return nil, nil, err + } + if g.UserEpoch != u.TokenEpoch { + s.cache.evict(grantID) + if err := s.ds.Grant().DeleteIfEpoch(ctx, grantID, g.UserEpoch); err != nil { + log.Warn(ctx, "API v1: could not delete dead grant", "grant", grantID, err) + } + return nil, nil, model.ErrInvalidAuth + } + return g, u, nil +} + +// touch writes last_used at most every touchInterval; the SQL condition keeps that true across nodes. +func (s *Service) touch(ctx context.Context, id, ip string, lastUsed *time.Time) { + now := s.now() + if lastUsed != nil && now.Before(lastUsed.Add(touchInterval)) { + return + } + if err := s.ds.Grant().Touch(ctx, id, ip, now, now.Add(-touchInterval)); err != nil { + log.Warn(ctx, "API v1: could not record grant use", "grant", id, err) + return + } + s.cache.markUsed(id, now) +} + +func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principal, error) { + sg, err := s.signer() + if err != nil { + return nil, err + } + c, err := sg.parse(token) + if err != nil { + return nil, err + } + u, err := s.loadUser(ctx, c.UserID) + if err != nil { + return nil, err + } + return &Principal{User: *u, GrantID: c.GrantID, Scopes: c.Scopes}, nil +} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go new file mode 100644 index 000000000..706e09ca3 --- /dev/null +++ b/core/apiauth/service_test.go @@ -0,0 +1,159 @@ +package apiauth + +import ( + "context" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} + +var _ = Describe("Service: grants and tokens", func() { + var ctx context.Context + var svc *Service + var now time.Time + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + now = time.Now().UTC().Truncate(time.Second) + svc = New(realDS) + svc.SetClock(func() time.Time { return now }) + }) + + Describe("Login", func() { + It("creates a grant storing all, the user's epoch and the client metadata", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.Secret).To(HavePrefix("ndg_")) + Expect(issued.User.ID).To(Equal(u.ID)) + Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeAll})) + Expect(issued.Grant.Provider).To(Equal("password")) + Expect(issued.Grant.Name).To(Equal("Living room")) + Expect(issued.Grant.UserEpoch).To(Equal(u.TokenEpoch)) + + stored, err := realDS.Grant().FindBySecretHash(ctx, hashSecret(issued.Secret)) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.ID).To(Equal(issued.Grant.ID)) + }) + + It("defaults the grant name to the client", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, u.UserName, "pw", ClientMeta{Client: "OnlyClient"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.Grant.Name).To(Equal("OnlyClient")) + }) + + It("accepts the username in any case", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, strings.ToUpper(u.UserName), "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.User.ID).To(Equal(u.ID)) + }) + + It("stores only known requested scopes", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{"read", "future", "admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeRead})) + }) + + It("fails with ErrInvalidAuth for bad credentials", func() { + u := createUser(ctx, "pw", false) + _, err := svc.Login(ctx, u.UserName, "wrong", meta, nil) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Describe("Setup", func() { + It("refuses when users exist", func() { + createUser(ctx, "pw", false) + _, err := svc.Setup(ctx, "newadmin", "pw", meta, nil) + Expect(err).To(MatchError(auth.ErrSetupComplete)) + }) + // The empty-database path is covered end to end in server/apiv1, which owns a fresh DB. + }) + + Describe("ResolveGrant and Mint", func() { + It("mints a token with the grant's expanded scopes and a 1h lifetime", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + + p, err := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9") + Expect(err).ToNot(HaveOccurred()) + Expect(p.GrantID).To(Equal(issued.Grant.ID)) + Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) + + tok, err := svc.Mint(ctx, p, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(tok.ExpiresIn).To(Equal(time.Hour)) + Expect(tok.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) + + principal, err := svc.Authenticate(ctx, tok.Token, "10.0.0.9") + Expect(err).ToNot(HaveOccurred()) + Expect(principal.User.ID).To(Equal(u.ID)) + }) + + It("attenuates to the requested subset", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + p, _ := svc.ResolveGrant(ctx, issued.Secret, "") + tok, err := svc.Mint(ctx, p, []string{"read", "sync"}) + Expect(err).ToNot(HaveOccurred()) + Expect(tok.Scopes).To(Equal([]string{ScopeRead})) + }) + + It("counts minting as use", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + p, _ := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9") + _, err := svc.Mint(ctx, p, nil) + Expect(err).ToNot(HaveOccurred()) + g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(g.LastUsedAt).ToNot(BeNil()) + Expect(g.LastUsedIP).To(Equal("10.0.0.9")) + }) + + It("rejects unknown secrets", func() { + _, err := svc.ResolveGrant(ctx, "ndg_unknown", "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("deletes and rejects a grant idle for 90 days, including one never used", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + now = now.Add(IdleExpiry + time.Second) + _, err := svc.ResolveGrant(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + _, err = realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("rejects a grant whose epoch is behind the user's", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + u.NewPassword = "changed-elsewhere" + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + _, err := svc.ResolveGrant(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Describe("PasswordChangeable", func() { + It("follows EnableUserEditing for non-admins only", func() { + conf.Server.EnableUserEditing = false + Expect(PasswordChangeable(model.User{IsAdmin: true})).To(BeTrue()) + Expect(PasswordChangeable(model.User{})).To(BeFalse()) + conf.Server.EnableUserEditing = true + Expect(PasswordChangeable(model.User{})).To(BeTrue()) + }) + }) +}) From e3cf8495075f73d6fcbab03eaf79be4286f3e0d0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:22:54 -0400 Subject: [PATCH 09/41] fix(api): retry the signing-key load after a failure and drop the password from issued grants --- core/apiauth/service.go | 23 ++++++++++++++----- core/apiauth/service_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/core/apiauth/service.go b/core/apiauth/service.go index d6cdf4e20..1f1fd4906 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -56,7 +56,8 @@ type Service struct { checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction cache *livenessCache now func() time.Time - signer func() (*signer, error) + signerMu sync.Mutex + sg *signer } func New(ds model.DataStore) *Service { @@ -68,13 +69,24 @@ func New(ds model.DataStore) *Service { cache: newLivenessCache(cacheTTL), now: time.Now, } - // Loaded on first use so building the router never touches the database. - s.signer = sync.OnceValues(func() (*signer, error) { - return loadSigner(context.Background(), ds, func() time.Time { return s.now() }) - }) return s } +// signer loads the key on first use, so building the router never touches the database; only a success is kept. +func (s *Service) signer() (*signer, error) { + s.signerMu.Lock() + defer s.signerMu.Unlock() + if s.sg != nil { + return s.sg, nil + } + sg, err := loadSigner(context.Background(), s.ds, func() time.Time { return s.now() }) + if err != nil { + return nil, err + } + s.sg = sg + return sg, nil +} + func PasswordChangeable(u model.User) bool { return u.IsAdmin || conf.Server.EnableUserEditing } @@ -109,6 +121,7 @@ func (s *Service) Setup(ctx context.Context, username, password string, meta Cli // issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead. func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, provider string, meta ClientMeta, scopes []string) (*Issued, error) { + u.Password = "" secret, hash := newSecret() g := model.Grant{ UserID: u.ID, diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go index 706e09ca3..06d716faa 100644 --- a/core/apiauth/service_test.go +++ b/core/apiauth/service_test.go @@ -2,6 +2,7 @@ package apiauth import ( "context" + "errors" "strings" "time" @@ -13,6 +14,30 @@ import ( . "github.com/onsi/gomega" ) +var errFlakyProps = errors.New("database is locked") + +type flakyPropsDS struct { + model.DataStore + failures int +} + +func (d *flakyPropsDS) Property() model.PropertyRepository { + return &flakyProps{PropertyRepository: d.DataStore.Property(), ds: d} +} + +type flakyProps struct { + model.PropertyRepository + ds *flakyPropsDS +} + +func (p *flakyProps) PutIfAbsent(ctx context.Context, id, value string) error { + if p.ds.failures > 0 { + p.ds.failures-- + return errFlakyProps + } + return p.PropertyRepository.PutIfAbsent(ctx, id, value) +} + var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} var _ = Describe("Service: grants and tokens", func() { @@ -35,6 +60,7 @@ var _ = Describe("Service: grants and tokens", func() { Expect(err).ToNot(HaveOccurred()) Expect(issued.Secret).To(HavePrefix("ndg_")) Expect(issued.User.ID).To(Equal(u.ID)) + Expect(issued.User.Password).To(BeEmpty()) Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeAll})) Expect(issued.Grant.Provider).To(Equal("password")) Expect(issued.Grant.Name).To(Equal("Living room")) @@ -102,6 +128,23 @@ var _ = Describe("Service: grants and tokens", func() { Expect(principal.User.ID).To(Equal(u.ID)) }) + It("retries loading the signing key after a failed load", func() { + flaky := &flakyPropsDS{DataStore: realDS, failures: 1} + svc = New(flaky) + svc.SetClock(func() time.Time { return now }) + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + p, err := svc.ResolveGrant(ctx, issued.Secret, "") + Expect(err).ToNot(HaveOccurred()) + + _, err = svc.Mint(ctx, p, nil) + Expect(err).To(MatchError(errFlakyProps)) + tok, err := svc.Mint(ctx, p, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(tok.Token).ToNot(BeEmpty()) + }) + It("attenuates to the requested subset", func() { u := createUser(ctx, "pw", false) issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) From 71c379ff44ef07020b4b2864587a6969c2b26d27 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:26:33 -0400 Subject: [PATCH 10/41] feat(api): add API v1 token checks, grant management and password change --- core/apiauth/service.go | 121 +++++++++- core/apiauth/service_session_test.go | 335 +++++++++++++++++++++++++++ 2 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 core/apiauth/service_session_test.go diff --git a/core/apiauth/service.go b/core/apiauth/service.go index 1f1fd4906..04012c77c 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "slices" "sync" "time" @@ -254,5 +255,123 @@ func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principa if err != nil { return nil, err } - return &Principal{User: *u, GrantID: c.GrantID, Scopes: c.Scopes}, nil + entry, u, err := s.liveGrant(ctx, c.GrantID, u) + if err != nil { + return nil, err + } + if entry.userID != c.UserID { + return nil, model.ErrInvalidAuth + } + if slices.Contains(c.Scopes, ScopeAdmin) && !u.IsAdmin { + return nil, ErrInsufficientScope + } + var lastUsed *time.Time + if !entry.lastUsedAt.IsZero() { + lastUsed = &entry.lastUsedAt + } + s.touch(ctx, c.GrantID, ip, lastUsed) + return &Principal{User: *u, GrantID: c.GrantID, Scopes: Expand(c.Scopes, u.IsAdmin)}, nil +} + +// liveGrant trusts the cache only while its epoch matches; a mismatch is settled from one consistent read. +func (s *Service) liveGrant(ctx context.Context, id string, u *model.User) (livenessEntry, *model.User, error) { + now := s.now() + if e, ok := s.cache.get(id, now); ok && e.epoch == u.TokenEpoch { + return e, u, nil + } + started := s.cache.begin() + g, err := s.ds.Grant().Get(ctx, id) + if errors.Is(err, model.ErrNotFound) { + s.cache.evict(id) + return livenessEntry{}, nil, model.ErrInvalidAuth + } + if err != nil { + return livenessEntry{}, nil, err + } + if g.UserEpoch != u.TokenEpoch { + if g, u, err = s.settleEpoch(ctx, id); err != nil { + return livenessEntry{}, nil, err + } + } + e := livenessEntry{userID: g.UserID, epoch: g.UserEpoch} + if g.LastUsedAt != nil { + e.lastUsedAt = *g.LastUsedAt + } + s.cache.put(id, e, now, started) + return e, u, nil +} + +func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit int) (model.Grants, int64, error) { + idleSince := s.now().Add(-IdleExpiry) + grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, idleSince, offset, limit) + if err != nil { + return nil, 0, err + } + total, err := s.ds.Grant().CountForUser(ctx, p.User.ID, idleSince) + return grants, total, err +} + +func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) error { + if err := s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID); err != nil { + return err + } + s.cache.evict(grantID) + return nil +} + +func (s *Service) Logout(ctx context.Context, p *Principal) error { + return s.RevokeGrant(ctx, p, p.GrantID) +} + +// ChangePassword does every check inside the locked transaction, so a reset that lands first is never overwritten. +func (s *Service) ChangePassword(ctx context.Context, p *Principal, current, newPassword string, revokeOthers bool) error { + return s.ds.WithTxImmediate(func(tx model.DataStore) error { + u, err := tx.User().Get(ctx, p.User.ID) + if errors.Is(err, model.ErrNotFound) { + return model.ErrInvalidAuth + } + if err != nil { + return err + } + g, err := tx.Grant().Get(ctx, p.GrantID) + if errors.Is(err, model.ErrNotFound) { + return model.ErrInvalidAuth + } + if err != nil { + return err + } + if g.UserID != u.ID || g.UserEpoch != u.TokenEpoch { + return model.ErrInvalidAuth + } + if !PasswordChangeable(*u) { + return model.ErrNotAuthorized + } + res, err := checkCredentials(ctx, s.checkers(tx), u.UserName, current) + if errors.Is(err, model.ErrInvalidAuth) { + return ErrCurrentPasswordMismatch + } + if err != nil { + return err + } + if !res.PasswordLocal { + return ErrPasswordManagedExternally + } + oldEpoch := u.TokenEpoch + u.NewPassword = newPassword + if err := tx.User().Put(ctx, u); err != nil { + return err + } + updated, err := tx.User().Get(ctx, u.ID) + if err != nil { + return err + } + keep := "" + if revokeOthers { + keep = p.GrantID + } + if err := tx.Grant().SetEpoch(ctx, u.ID, oldEpoch, updated.TokenEpoch, keep); err != nil { + return err + } + return tx.Grant().DeleteOtherEpochs(ctx, u.ID, updated.TokenEpoch) + }) } diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go new file mode 100644 index 000000000..929899422 --- /dev/null +++ b/core/apiauth/service_session_test.go @@ -0,0 +1,335 @@ +package apiauth + +import ( + "context" + "errors" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Service: sessions", func() { + var ctx context.Context + var svc *Service + var now time.Time + + login := func(u model.User) (*Issued, *Principal, *AccessToken) { + issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + p, err := svc.ResolveGrant(ctx, issued.Secret, "") + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + tok, err := svc.Mint(ctx, p, nil) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + return issued, p, tok + } + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + now = time.Now().UTC().Truncate(time.Second) + svc = New(realDS) + svc.SetClock(func() time.Time { return now }) + }) + + Describe("Authenticate", func() { + It("returns the token's scopes and marks the grant used", func() { + u := createUser(ctx, "pw", false) + issued, _, tok := login(u) + now = now.Add(10 * time.Minute) + p, err := svc.Authenticate(ctx, tok.Token, "10.1.1.1") + Expect(err).ToNot(HaveOccurred()) + Expect(p.GrantID).To(Equal(issued.Grant.ID)) + Expect(p.Scopes).To(Equal(tok.Scopes)) + g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(g.LastUsedIP).To(Equal("10.1.1.1")) + }) + + It("reports an expired token as ErrTokenExpired", func() { + u := createUser(ctx, "pw", false) + _, _, tok := login(u) + now = now.Add(TokenTTL + clockSkew + time.Second) + _, err := svc.Authenticate(ctx, tok.Token, "") + Expect(err).To(MatchError(ErrTokenExpired)) + }) + + It("rejects a token at once on the node that revoked its grant", func() { + u := createUser(ctx, "pw", false) + _, p, tok := login(u) + _, err := svc.Authenticate(ctx, tok.Token, "") + Expect(err).ToNot(HaveOccurred()) + Expect(svc.Logout(ctx, p)).To(Succeed()) + _, err = svc.Authenticate(ctx, tok.Token, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("stops a token revoked on another node within the cache time", func() { + u := createUser(ctx, "pw", false) + issued, _, tok := login(u) + _, err := svc.Authenticate(ctx, tok.Token, "") + Expect(err).ToNot(HaveOccurred()) + + Expect(realDS.Grant().Delete(ctx, issued.Grant.ID)).To(Succeed()) // another node + _, err = svc.Authenticate(ctx, tok.Token, "") + Expect(err).ToNot(HaveOccurred()) // still cached + now = now.Add(cacheTTL) + _, err = svc.Authenticate(ctx, tok.Token, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("kills grants when the password changes anywhere else", func() { + u := createUser(ctx, "pw", false) + _, _, tok := login(u) + u.NewPassword = "reset-by-admin" + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + _, err := svc.Authenticate(ctx, tok.Token, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("does not kill a grant kept by a password change made through another node", func() { + u := createUser(ctx, "pw", false) + _, p, tok := login(u) + _, err := svc.Authenticate(ctx, tok.Token, "") // caches the old epoch + Expect(err).ToNot(HaveOccurred()) + + other := New(realDS) // another node + other.SetClock(func() time.Time { return now }) + Expect(other.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) + + _, err = svc.Authenticate(ctx, tok.Token, "") + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects a token carrying admin once the user is no longer an admin", func() { + saved := KnownScopes + KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin} + DeferCleanup(func() { KnownScopes = saved }) + u := createUser(ctx, "pw", true) + _, p, tok := login(u) + Expect(tok.Scopes).To(ContainElement(ScopeAdmin)) + + u.IsAdmin = false + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + _, err := svc.Authenticate(ctx, tok.Token, "") + Expect(err).To(MatchError(ErrInsufficientScope)) + + fresh, err := svc.Mint(ctx, &Principal{User: u, GrantID: p.GrantID, Scopes: Expand(model.Scopes{ScopeAll}, false)}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(fresh.Scopes).ToNot(ContainElement(ScopeAdmin)) + }) + + It("rejects a live token after its user is deleted, and the grant row is gone", func() { + u := createUser(ctx, "pw", false) + issued, _, tok := login(u) + Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed()) + _, err := realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + now = now.Add(cacheTTL) + _, err = svc.Authenticate(ctx, tok.Token, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rejects a token whose grant belongs to another user, even across an epoch change", func() { + alice := createUser(ctx, "pw", false) + bob := createUser(ctx, "pw", false) + bobGrant, _, _ := login(bob) + alice.NewPassword = "bumped" + Expect(realDS.User().Put(ctx, &alice)).To(Succeed()) + + sg, err := svc.signer() + Expect(err).ToNot(HaveOccurred()) + tok, err := sg.sign(claims{UserID: alice.ID, GrantID: bobGrant.Grant.ID, Scopes: []string{ScopeRead}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) + Expect(err).ToNot(HaveOccurred()) + _, err = svc.Authenticate(ctx, tok, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("does not delete a kept grant when the user was read before a password change", func() { + u := createUser(ctx, "pw", false) + _, p, _ := login(u) + stale, err := realDS.User().Get(ctx, u.ID) // read before the change lands + Expect(err).ToNot(HaveOccurred()) + Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) + + _, _, err = svc.liveGrant(ctx, p.GrantID, stale) + Expect(err).ToNot(HaveOccurred()) + _, err = realDS.Grant().Get(ctx, p.GrantID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("leaves a login that raced a password change with a dead grant", func() { + u := createUser(ctx, "pw", false) + reached, release := make(chan struct{}), make(chan struct{}) + svc.SetCheckers(func(ds model.DataStore) []CredentialChecker { + return []CredentialChecker{pausingChecker{inner: dbChecker{ds: ds}, reached: reached, release: release}} + }) + var issued *Issued + var loginErr error + done := make(chan struct{}) + go func() { + defer GinkgoRecover() + defer close(done) + issued, loginErr = svc.Login(ctx, u.UserName, "pw", meta, nil) + }() + <-reached // credentials (and the old epoch) were read + u.NewPassword = "changed-meanwhile" + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + close(release) + <-done + + Expect(loginErr).ToNot(HaveOccurred()) + _, err := New(realDS).ResolveGrant(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Describe("grant management", func() { + It("lists the user's grants and marks the current one", func() { + u := createUser(ctx, "pw", false) + first, _, _ := login(u) + _, p, _ := login(u) + grants, total, err := svc.ListGrants(ctx, p, 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(Equal(int64(2))) + Expect(grants).To(HaveLen(2)) + Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID)) + }) + + It("refuses to revoke another user's grant", func() { + alice := createUser(ctx, "pw", false) + bob := createUser(ctx, "pw", false) + aliceGrant, _, _ := login(alice) + _, bobP, _ := login(bob) + Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("ChangePassword", func() { + It("revokes other grants by default and keeps the caller's", func() { + u := createUser(ctx, "pw", false) + _, _, otherTok := login(u) + _, p, myTok := login(u) + Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed()) + + _, err := svc.Authenticate(ctx, myTok.Token, "") + Expect(err).ToNot(HaveOccurred()) + now = now.Add(cacheTTL) + _, err = svc.Authenticate(ctx, otherTok.Token, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + + _, err = svc.Login(ctx, u.UserName, "pw2", meta, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("keeps every grant when revokeOthers is false", func() { + u := createUser(ctx, "pw", false) + _, _, otherTok := login(u) + _, p, _ := login(u) + Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) + now = now.Add(cacheTTL) + _, err := svc.Authenticate(ctx, otherTok.Token, "") + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects a wrong current password without changing anything", func() { + u := createUser(ctx, "pw", false) + _, p, _ := login(u) + err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true) + Expect(err).To(MatchError(ErrCurrentPasswordMismatch)) + _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("is forbidden for non-admins when user editing is off", func() { + conf.Server.EnableUserEditing = false + u := createUser(ctx, "pw", false) + _, p, _ := login(u) + err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("does not revive grants killed by an earlier reset when keeping grants", func() { + u := createUser(ctx, "pw", false) + killed, _, _ := login(u) + u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + + issued2, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil) + Expect(err).ToNot(HaveOccurred()) + p2, err := svc.ResolveGrant(ctx, issued2.Secret, "") + Expect(err).ToNot(HaveOccurred()) + Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed()) + + _, err = svc.ResolveGrant(ctx, killed.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rejects a caller whose grant was revoked before the change ran", func() { + u := createUser(ctx, "pw", false) + _, p, _ := login(u) + Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed()) + err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rolls back the password and epoch when a grant update fails", func() { + u := createUser(ctx, "pw", false) + _, p, _ := login(u) + failing := New(failingEpochDS{realDS}) + failing.SetClock(func() time.Time { return now }) + + err := failing.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) + Expect(err).To(MatchError(ContainSubstring("boom"))) + + reloaded, _ := realDS.User().Get(ctx, u.ID) + Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch)) + _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + _, err = svc.Authenticate(ctx, mustMint(svc, ctx, p), "") + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) + +type pausingChecker struct { + inner CredentialChecker + reached, release chan struct{} +} + +func (c pausingChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) { + res, err := c.inner.Check(ctx, username, password) + close(c.reached) + <-c.release + return res, err +} + +// failingEpochDS makes SetEpoch fail inside WithTxImmediate, to prove the whole change rolls back. +type failingEpochDS struct{ model.DataStore } + +func (f failingEpochDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { + return f.DataStore.WithTxImmediate(func(tx model.DataStore) error { + return block(failingEpochTx{tx}) + }, scope...) +} + +type failingEpochTx struct{ model.DataStore } + +func (f failingEpochTx) Grant() model.GrantRepository { return failingGrants{f.DataStore.Grant()} } + +type failingGrants struct{ model.GrantRepository } + +func (failingGrants) SetEpoch(context.Context, string, int, int, string) error { + return errors.New("boom") +} + +func mustMint(svc *Service, ctx context.Context, p *Principal) string { + tok, err := svc.Mint(ctx, p, nil) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + return tok.Token +} From 3e645959f88192f43a2d34a84f2335cf4ea5c791 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:35:19 -0400 Subject: [PATCH 11/41] feat(api): enforce API v1 security from the spec and harden problem responses --- api/bundled/openapi.json | 13 + api/bundled/openapi.yaml | 12 + api/openapi/components/schemas/Problem.yaml | 9 + api/openapi/paths/openapi.yaml | 2 + api/openapi/paths/server.yaml | 1 + server/apiv1/api.go | 44 ++- server/apiv1/api_gen.go | 35 +- server/apiv1/api_test.go | 13 + server/apiv1/gate.go | 341 ++++++++++++++++++++ server/apiv1/gate_test.go | 315 ++++++++++++++++++ server/apiv1/problem.go | 70 +++- server/apiv1/problem_test.go | 68 +++- server/apiv1/reference.go | 29 ++ 13 files changed, 918 insertions(+), 34 deletions(-) create mode 100644 server/apiv1/gate.go create mode 100644 server/apiv1/gate_test.go create mode 100644 server/apiv1/reference.go diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index bfe794c71..51b5f1e6d 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -29,6 +29,7 @@ "tags": [ "server" ], + "security": [], "summary": "Describe the server", "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n", "responses": { @@ -56,6 +57,7 @@ "tags": [ "server" ], + "security": [], "summary": "Get the OpenAPI document (JSON)", "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", "responses": { @@ -89,6 +91,7 @@ "tags": [ "server" ], + "security": [], "summary": "Get the OpenAPI document (YAML)", "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", "responses": { @@ -195,13 +198,23 @@ "enum": [ "validation", "unauthorized", + "token_expired", "forbidden", + "insufficient_scope", "not_found", "method_not_allowed", + "setup_complete", + "password_managed_externally", + "payload_too_large", + "rate_limited", "unavailable", "internal" ] }, + "referenceId": { + "type": "string", + "description": "Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request." + }, "errors": { "type": "array", "description": "Per-field failures. Present only when `code` is `validation`.", diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index f1ae95b76..3f19d5a72 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -30,6 +30,7 @@ paths: x-module: core x-stability-level: alpha tags: [server] + security: [] summary: Describe the server description: | Returns the public server description. No authentication required. @@ -50,6 +51,7 @@ paths: x-module: core x-stability-level: alpha tags: [server] + security: [] summary: Get the OpenAPI document (JSON) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: @@ -71,6 +73,7 @@ paths: x-module: core x-stability-level: alpha tags: [server] + security: [] summary: Get the OpenAPI document (YAML) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: @@ -152,11 +155,20 @@ components: enum: - validation - unauthorized + - token_expired - forbidden + - insufficient_scope - not_found - method_not_allowed + - setup_complete + - password_managed_externally + - payload_too_large + - rate_limited - unavailable - internal + referenceId: + type: string + description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request. errors: type: array description: Per-field failures. Present only when `code` is `validation`. diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml index 0fd36d4b1..b2224432a 100644 --- a/api/openapi/components/schemas/Problem.yaml +++ b/api/openapi/components/schemas/Problem.yaml @@ -23,11 +23,20 @@ properties: enum: - validation - unauthorized + - token_expired - forbidden + - insufficient_scope - not_found - method_not_allowed + - setup_complete + - password_managed_externally + - payload_too_large + - rate_limited - unavailable - internal + referenceId: + type: string + description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request. errors: type: array description: Per-field failures. Present only when `code` is `validation`. diff --git a/api/openapi/paths/openapi.yaml b/api/openapi/paths/openapi.yaml index 3c25dc8b7..bc0b874f6 100644 --- a/api/openapi/paths/openapi.yaml +++ b/api/openapi/paths/openapi.yaml @@ -4,6 +4,7 @@ json: x-module: core x-stability-level: alpha tags: [server] + security: [] summary: Get the OpenAPI document (JSON) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: @@ -25,6 +26,7 @@ yaml: x-module: core x-stability-level: alpha tags: [server] + security: [] summary: Get the OpenAPI document (YAML) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml index 1f881dbb1..ef30bcb10 100644 --- a/api/openapi/paths/server.yaml +++ b/api/openapi/paths/server.yaml @@ -3,6 +3,7 @@ get: x-module: core x-stability-level: alpha tags: [server] + security: [] summary: Describe the server description: | Returns the public server description. No authentication required. diff --git a/server/apiv1/api.go b/server/apiv1/api.go index a75de3d8d..76926d738 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -7,26 +7,54 @@ import ( "slices" "strings" + "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) +const maxBodyBytes = 1 << 20 + type Router struct { http.Handler - ds model.DataStore + ds model.DataStore + auth *apiauth.Service } func New(ds model.DataStore) *Router { - rt := &Router{ds: ds} + rt := &Router{ds: ds, auth: apiauth.New(ds)} rt.Handler = rt.routes() return rt } +var gateRulesV1 = gateRules{ + limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true}, + noScope: map[string]bool{"getCapabilities": true}, + grantOps: map[string]bool{"createAccessToken": true}, +} + +func limitBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + } + next.ServeHTTP(w, r) + }) +} + func (rt *Router) routes() http.Handler { r := chi.NewRouter() - r.Use(problemRecoverer, headAsGet(r)) + doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) + if err != nil { + log.Fatal("API v1: cannot load the embedded OpenAPI spec", err) + } + g, err := newGate(doc, r, rt.auth, gateRulesV1) + if err != nil { + log.Fatal("API v1: the embedded OpenAPI spec breaks the security rules", err) + } + r.Use(referenceIDMiddleware, problemRecoverer, headAsGet(r), limitBody, g.handler) r.NotFound(func(w http.ResponseWriter, req *http.Request) { writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint") }) @@ -40,11 +68,19 @@ func (rt *Router) routes() http.Handler { strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{ RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { - writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error()) + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeProblemStatus(w, req, http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge, "request body too large") + return + } + writeProblemStatus(w, req, http.StatusBadRequest, ProblemCodeValidation, "request body is not valid JSON") }, ResponseErrorHandlerFunc: writeProblem, }) HandlerWithOptions(strict, ChiServerOptions{BaseRouter: r, ErrorHandlerFunc: bindingErrorHandler}) + if err := g.checkRoutes(); err != nil { + log.Fatal("API v1: routes and the embedded OpenAPI spec disagree", err) + } return r } diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index 4bffd47fc..cffd7ece0 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -15,13 +15,19 @@ import ( // Defines values for ProblemCode. const ( - ProblemCodeForbidden ProblemCode = "forbidden" - ProblemCodeInternal ProblemCode = "internal" - ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" - ProblemCodeNotFound ProblemCode = "not_found" - ProblemCodeUnauthorized ProblemCode = "unauthorized" - ProblemCodeUnavailable ProblemCode = "unavailable" - ProblemCodeValidation ProblemCode = "validation" + ProblemCodeForbidden ProblemCode = "forbidden" + ProblemCodeInsufficientScope ProblemCode = "insufficient_scope" + ProblemCodeInternal ProblemCode = "internal" + ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" + ProblemCodeNotFound ProblemCode = "not_found" + ProblemCodePasswordManagedExternally ProblemCode = "password_managed_externally" + ProblemCodePayloadTooLarge ProblemCode = "payload_too_large" + ProblemCodeRateLimited ProblemCode = "rate_limited" + ProblemCodeSetupComplete ProblemCode = "setup_complete" + ProblemCodeTokenExpired ProblemCode = "token_expired" + ProblemCodeUnauthorized ProblemCode = "unauthorized" + ProblemCodeUnavailable ProblemCode = "unavailable" + ProblemCodeValidation ProblemCode = "validation" ) // Valid indicates whether the value is a known member of the ProblemCode enum. @@ -29,12 +35,24 @@ func (e ProblemCode) Valid() bool { switch e { case ProblemCodeForbidden: return true + case ProblemCodeInsufficientScope: + return true case ProblemCodeInternal: return true case ProblemCodeMethodNotAllowed: return true case ProblemCodeNotFound: return true + case ProblemCodePasswordManagedExternally: + return true + case ProblemCodePayloadTooLarge: + return true + case ProblemCodeRateLimited: + return true + case ProblemCodeSetupComplete: + return true + case ProblemCodeTokenExpired: + return true case ProblemCodeUnauthorized: return true case ProblemCodeUnavailable: @@ -72,6 +90,9 @@ type Problem struct { // Errors Per-field failures. Present only when `code` is `validation`. Errors *[]ValidationError `json:"errors,omitempty"` + // ReferenceId Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request. + ReferenceId *string `json:"referenceId,omitempty"` + // Status HTTP status code of this response. Status int `json:"status"` diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go index 2552e40b0..b8743f622 100644 --- a/server/apiv1/api_test.go +++ b/server/apiv1/api_test.go @@ -67,6 +67,19 @@ var _ = Describe("Router", func() { Expect(p.Detail).To(BeNil()) }) + It("tags internal errors with a referenceId that is also on the request's log lines", func() { + logs := captureLogs() + w := httptest.NewRecorder() + h := referenceIDMiddleware(problemRecoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("kaboom") + }))) + h.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), http.MethodGet, "/boom", nil)) + p := decodeProblem(w) + Expect(p.ReferenceId).ToNot(BeNil()) + Expect(*p.ReferenceId).To(MatchRegexp(`^[0-9A-Za-z]{22}$`)) + Expect(logs.String()).To(ContainSubstring(*p.ReferenceId)) + }) + It("re-panics http.ErrAbortHandler so the server can drop the connection", func() { Expect(func() { panicking(http.ErrAbortHandler).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/abort", nil)) diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go new file mode 100644 index 000000000..19234413b --- /dev/null +++ b/server/apiv1/gate.go @@ -0,0 +1,341 @@ +package apiv1 + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "slices" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/apiauth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +type authenticator interface { + Authenticate(ctx context.Context, token, ip string) (*apiauth.Principal, error) + ResolveGrant(ctx context.Context, secret, ip string) (*apiauth.Principal, error) +} + +type authKind int + +const ( + authPublic authKind = iota + authToken + authGrant +) + +type gateOp struct { + id string + route *routers.Route + kind authKind + scope string + limited bool +} + +type gate struct { + mux chi.Routes + ops map[string]*gateOp + auth authenticator + limiter func(http.Handler) http.Handler +} + +// Modules that ride another module's scope; every other module's scope is its own name. +var moduleScope = map[string]string{ + "core": apiauth.ScopeRead, + "transcoding": "streaming", + "custom-tags": apiauth.ScopeRead, + "grouping": apiauth.ScopeRead, + "smart-playlists": "playlists:write", +} + +type gateRules struct { + limited map[string]bool // login-type operations, throttled per client IP + noScope map[string]bool // the only token operations allowed without x-scope + grantOps map[string]bool // the only operations allowed to use grantAuth +} + +func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) { + g := &gate{mux: mux, ops: map[string]*gateOp{}, auth: auth} + for path, item := range doc.Paths.Map() { + for method, op := range item.Operations() { + gop, err := buildGateOp(doc, path, item, method, op, rules) + if err != nil { + return nil, err + } + gop.limited = rules.limited[op.OperationID] + g.ops[method+" "+path] = gop + } + } + if conf.Server.AuthRequestLimit > 0 { + g.limiter = httprate.LimitBy(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength, + func(r *http.Request) (string, error) { return server.ClientIP(r), nil }, + httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { + writeProblemStatus(w, r, http.StatusTooManyRequests, ProblemCodeRateLimited, "too many requests") + })) + } + return g, nil +} + +// buildGateOp enforces the allowed security forms, so a spec edit cannot silently drop a requirement. +func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method string, op *openapi3.Operation, rules gateRules) (*gateOp, error) { + id := op.OperationID + gop := &gateOp{id: id, route: &routers.Route{Spec: doc, Path: path, PathItem: item, Method: method, Operation: op}} + if op.Security == nil { + return nil, fmt.Errorf("operation %s must declare security explicitly", id) + } + rawScope, hasScope := op.Extensions["x-scope"] + scope, isString := rawScope.(string) + if hasScope && (!isString || scope == "") { + return nil, fmt.Errorf("operation %s: x-scope must be a non-empty string", id) + } + module, _ := op.Extensions["x-module"].(string) + switch reqs := *op.Security; { + case len(reqs) == 0: + gop.kind = authPublic + case len(reqs) == 1 && isScheme(reqs[0], "bearerAuth"): + gop.kind = authToken + case len(reqs) == 1 && isScheme(reqs[0], "grantAuth") && rules.grantOps[id]: + gop.kind = authGrant + default: + return nil, fmt.Errorf("operation %s has a security requirement outside the allowed forms", id) + } + if gop.kind == authToken && scope == "" && !rules.noScope[id] { + return nil, fmt.Errorf("operation %s: bearerAuth needs x-scope", id) + } + if scope != "" { + if gop.kind != authToken { + return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id) + } + base := module + if s, ok := moduleScope[module]; ok { + base = s + } + if scope != base && scope != base+":write" { + return nil, fmt.Errorf("operation %s: x-scope %q does not match module %q", op.OperationID, scope, module) + } + if !slices.Contains(apiauth.KnownScopes, scope) && scope != apiauth.ScopeAdmin { + return nil, fmt.Errorf("operation %s: unknown x-scope %q", op.OperationID, scope) + } + } + gop.scope = scope + return gop, nil +} + +// isScheme requires the scheme alone with an empty scope list, as OpenAPI 3.0.3 demands for http schemes. +func isScheme(req openapi3.SecurityRequirement, name string) bool { + scopes, ok := req[name] + return ok && len(req) == 1 && len(scopes) == 0 +} + +// checkRoutes fails when a routed pattern has no spec operation or a spec operation has no route. +func (g *gate) checkRoutes() error { + err := chi.Walk(g.mux, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if _, ok := g.ops[method+" "+route]; !ok { + return fmt.Errorf("route %s %s is not in the spec", method, route) + } + return nil + }) + if err != nil { + return err + } + for _, op := range g.ops { + if g.mux.Find(chi.NewRouteContext(), op.route.Method, op.route.Path) != op.route.Path { + return fmt.Errorf("spec operation %s (%s %s) has no route", op.id, op.route.Method, op.route.Path) + } + } + return nil +} + +func (g *gate) handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := routePath(r) + method := r.Method + if method == http.MethodHead && !g.mux.Match(chi.NewRouteContext(), http.MethodHead, path) { + method = http.MethodGet + } + rctx := chi.NewRouteContext() + pattern := g.mux.Find(rctx, method, path) + if pattern == "" { + next.ServeHTTP(w, r) + return + } + op, ok := g.ops[method+" "+pattern] + if !ok { + log.Error(r.Context(), "API v1: routed pattern missing from the spec", "method", method, "pattern", pattern) + writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "") + return + } + serve := func(w http.ResponseWriter, r *http.Request) { + r, ok := g.authorize(w, r, op) + if !ok { + return + } + if !g.validate(w, r, op, rctx) { + return + } + next.ServeHTTP(w, r) + } + if op.limited && g.limiter != nil { + g.limiter(http.HandlerFunc(serve)).ServeHTTP(w, r) + return + } + serve(w, r) + }) +} + +func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*http.Request, bool) { + if op.kind == authPublic { + return r, true + } + token, ok := bearerToken(r) + if !ok { + w.Header().Set("WWW-Authenticate", "Bearer") + writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "") + return r, false + } + ip := server.ClientIP(r) + var p *apiauth.Principal + var err error + if op.kind == authGrant { + p, err = g.auth.ResolveGrant(r.Context(), token, ip) + } else { + p, err = g.auth.Authenticate(r.Context(), token, ip) + } + switch { + case errors.Is(err, apiauth.ErrTokenExpired), errors.Is(err, model.ErrInvalidAuth): + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + writeProblem(w, r, err) + return r, false + case errors.Is(err, apiauth.ErrInsufficientScope): + w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope"`) + writeProblem(w, r, err) + return r, false + case err != nil: + writeProblem(w, r, err) + return r, false + } + if op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q`, op.scope)) + writeProblem(w, r, apiauth.ErrInsufficientScope) + return r, false + } + ctx := apiauth.WithPrincipal(request.WithUser(r.Context(), p.User), p) + return r.WithContext(ctx), true +} + +func bearerToken(r *http.Request) (string, bool) { + scheme, token, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") + token = strings.TrimSpace(token) + if !ok || !strings.EqualFold(scheme, "Bearer") || token == "" { + return "", false + } + return token, true +} + +func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx *chi.Context) bool { + params := map[string]string{} + for i, k := range rctx.URLParams.Keys { + params[k] = rctx.URLParams.Values[i] + } + err := openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ + Request: r, PathParams: params, Route: op.route, + Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true}, + }) + if err == nil { + return true + } + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeProblemStatus(w, r, http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge, "request body too large") + return false + } + fields := sanitizeValidation(err) + log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id, "errors", fields) + writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "the request does not match the API schema", fields...) + return false +} + +var missingProperty = regexp.MustCompile(`property "([^"]+)" is missing`) + +// sanitizeValidation keeps only field paths and fixed messages: kin-openapi errors can embed the submitted value. +// It walks wrappers by concrete type, not errors.As, because MultiError.As would skip the RequestError that names the parameter. +func sanitizeValidation(err error) []ValidationError { + var out []ValidationError + var walk func(err error, param string) + walk = func(err error, param string) { + switch e := err.(type) { //nolint:errorlint + case openapi3.MultiError: + for _, child := range e { + walk(child, param) + } + case *openapi3filter.RequestError: + if e.Parameter != nil { + param = e.Parameter.Name + } + switch { + case errors.Is(e.Err, openapi3filter.ErrInvalidRequired), errors.Is(e.Err, openapi3filter.ErrInvalidEmptyValue): + out = append(out, ValidationError{Field: param, Message: "is required"}) + case e.Err != nil: + walk(e.Err, param) + default: + out = append(out, ValidationError{Field: param, Message: "is invalid"}) + } + case *openapi3.SchemaError: + field := strings.Join(e.JSONPointer(), ".") + if field == "" && e.SchemaField == "required" { + if m := missingProperty.FindStringSubmatch(e.Reason); m != nil { + field = m[1] + } + } + switch { + case param != "" && field != "": + field = param + "." + field + case field == "": + field = param + } + out = append(out, ValidationError{Field: field, Message: schemaMessage(e.SchemaField)}) + default: + if inner := errors.Unwrap(err); inner != nil { + walk(inner, param) + return + } + out = append(out, ValidationError{Field: param, Message: "is invalid"}) + } + } + walk(err, "") + return out +} + +func schemaMessage(keyword string) string { + switch keyword { + case "required": + return "is required" + case "maxLength", "maxItems": + return "is too long" + case "minLength", "minItems": + return "is too short" + case "maximum", "exclusiveMaximum": + return "is too large" + case "minimum", "exclusiveMinimum": + return "is too small" + case "pattern", "format": + return "has an invalid format" + case "enum": + return "is not an allowed value" + case "type", "nullable": + return "has the wrong type" + default: + return "is invalid" + } +} diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go new file mode 100644 index 000000000..a74f76b26 --- /dev/null +++ b/server/apiv1/gate_test.go @@ -0,0 +1,315 @@ +package apiv1 + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/apiauth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const gateSpec = ` +openapi: 3.0.3 +info: {title: t, version: "1"} +paths: + /open: + get: {operationId: open, x-module: core, security: [], responses: {'200': {description: ok}}} + /things/{id}: + get: + operationId: getThing + x-module: core + x-scope: read + security: [{bearerAuth: []}] + parameters: [{name: id, in: path, required: true, schema: {type: string, maxLength: 3}}] + responses: {'200': {description: ok}} + /things: + post: + operationId: createThing + x-module: password + x-scope: password + security: [{bearerAuth: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: {name: {type: string, maxLength: 5}} + responses: {'200': {description: ok}} + /caps: + get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}} + /mint: + post: {operationId: mint, x-module: core, security: [{grantAuth: []}], responses: {'200': {description: ok}}} + /limited: + post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer} + grantAuth: {type: http, scheme: bearer} +` + +type fakeAuth struct { + principal *apiauth.Principal + err error + gotToken string + gotSecret string +} + +func (f *fakeAuth) Authenticate(_ context.Context, token, _ string) (*apiauth.Principal, error) { + f.gotToken = token + return f.principal, f.err +} + +func (f *fakeAuth) ResolveGrant(_ context.Context, secret, _ string) (*apiauth.Principal, error) { + f.gotSecret = secret + return f.principal, f.err +} + +var testGateRules = gateRules{ + limited: map[string]bool{"limited": true}, + noScope: map[string]bool{"caps": true}, + grantOps: map[string]bool{"mint": true}, +} + +var _ = Describe("spec gate", func() { + var ctx context.Context + var fa *fakeAuth + var mux *chi.Mux + var g *gate + var reached string + + build := func(spec string) (*chi.Mux, error) { + doc, err := openapi3.NewLoader().LoadFromData([]byte(spec)) + Expect(err).ToNot(HaveOccurred()) + m := chi.NewRouter() + g, err = newGate(doc, m, fa, testGateRules) + if err != nil { + return nil, err + } + m.Use(headAsGet(m), g.handler) + ok := func(name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + reached = name + if p, found := apiauth.PrincipalFrom(r.Context()); found { + w.Header().Set("X-User", p.User.ID) + } + w.WriteHeader(http.StatusOK) + } + } + m.Get("/open", ok("open")) + m.Get("/things/{id}", ok("getThing")) + m.Post("/things", ok("createThing")) + m.Get("/caps", ok("caps")) + m.Post("/mint", ok("mint")) + m.Post("/limited", ok("limited")) + return m, nil + } + + do := func(method, path, auth, body string) *httptest.ResponseRecorder { + var req *http.Request + if body != "" { + req = httptest.NewRequestWithContext(ctx, method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequestWithContext(ctx, method, path, nil) + } + if auth != "" { + req.Header.Set("Authorization", auth) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w + } + + BeforeEach(func() { + ctx = GinkgoT().Context() + reached = "" + fa = &fakeAuth{principal: &apiauth.Principal{User: model.User{ID: "u1"}, GrantID: "g1", Scopes: []string{"read"}}} + var err error + mux, err = build(gateSpec) + Expect(err).ToNot(HaveOccurred()) + }) + + It("lets public operations through without a token", func() { + Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK)) + Expect(reached).To(Equal("open")) + }) + + It("requires a token, with a Bearer challenge", func() { + w := do(http.MethodGet, "/things/1", "", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized)) + Expect(reached).To(BeEmpty()) + }) + + It("accepts the Bearer scheme in any case and trims spaces", func() { + w := do(http.MethodGet, "/things/1", "bearer tok-1 ", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.gotToken).To(Equal("tok-1")) + Expect(w.Header().Get("X-User")).To(Equal("u1")) + }) + + It("maps an expired token to token_expired", func() { + fa.err = apiauth.ErrTokenExpired + w := do(http.MethodGet, "/things/1", "Bearer x", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeTokenExpired)) + }) + + It("maps other auth failures to unauthorized with invalid_token", func() { + fa.err = model.ErrInvalidAuth + w := do(http.MethodGet, "/things/1", "Bearer x", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized)) + }) + + It("rejects a token without the operation's scope", func() { + w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) + }) + + It("lets any valid token through an operation with no x-scope", func() { + fa.principal.Scopes = nil + Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK)) + }) + + It("uses ResolveGrant for grantAuth operations", func() { + Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Code).To(Equal(http.StatusOK)) + Expect(fa.gotSecret).To(Equal("ndg_secret")) + Expect(fa.gotToken).To(BeEmpty()) + }) + + It("checks HEAD on a protected GET", func() { + w := do(http.MethodHead, "/things/1", "", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("turns an insufficient-scope error from Authenticate into a 403 challenge", func() { + fa.err = apiauth.ErrInsufficientScope // e.g. a token carrying admin after demotion + w := do(http.MethodGet, "/caps", "Bearer x", "") + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope"`)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) + }) + + It("works when mounted under a base path", func() { + root := chi.NewRouter() + root.Mount("/music/api/v1", mux) + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/1", nil) + w := httptest.NewRecorder() + root.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("authenticates before validating", func() { + w := do(http.MethodPost, "/things", "", `{"name":"far-too-long"}`) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns and logs sanitised validation errors that never echo the value", func() { + logs := captureLogs() + fa.principal.Scopes = []string{"password"} + w := do(http.MethodPost, "/things", "Bearer x", `{"name":"hunter2-secret"}`) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is too long"})) + Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) + Expect(logs.String()).To(ContainSubstring("failed validation")) + Expect(logs.String()).ToNot(ContainSubstring("hunter2")) + }) + + It("reports a missing required body field by name", func() { + fa.principal.Scopes = []string{"password"} + w := do(http.MethodPost, "/things", "Bearer x", `{}`) + Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is required"})) + }) + + It("validates path parameters", func() { + w := do(http.MethodGet, "/things/toolong", "Bearer x", "") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"})) + }) + + It("passes unknown paths through to the router's 404", func() { + Expect(do(http.MethodGet, "/nope", "", "").Code).To(Equal(http.StatusNotFound)) + }) + + It("fails closed for a routed pattern the spec does not know", func() { + mux.Get("/extra", func(w http.ResponseWriter, r *http.Request) { reached = "extra" }) + w := do(http.MethodGet, "/extra", "", "") + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + Expect(reached).To(BeEmpty()) + }) + + It("rate-limits the listed operations with a 429 problem", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 1 + var err error + mux, err = build(gateSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(do(http.MethodPost, "/limited", "", "").Code).To(Equal(http.StatusOK)) + w := do(http.MethodPost, "/limited", "", "") + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeRateLimited)) + }) + + DescribeTable("refuses specs that break the security rules", + func(bad string) { + _, err := build(bad) + Expect(err).To(HaveOccurred()) + }, + Entry("missing security", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core,", 1)), + Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)), + Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)), + Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)), + Entry("grantAuth outside the allowlist", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)), + Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)), + Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)), + Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)), + ) + + It("checks routes against the spec in both directions", func() { + Expect(g.checkRoutes()).To(Succeed()) + + mux.Get("/extra", func(http.ResponseWriter, *http.Request) {}) + Expect(g.checkRoutes()).To(MatchError(ContainSubstring("GET /extra is not in the spec"))) + + extraOp := strings.Replace(gateSpec, "components:", ` /unrouted: + get: {operationId: unrouted, x-module: core, security: [], responses: {'200': {description: ok}}} +components:`, 1) + _, err := build(extraOp) + Expect(err).ToNot(HaveOccurred()) + Expect(g.checkRoutes()).To(MatchError(ContainSubstring("unrouted"))) + }) +}) + +// captureLogs sends debug logs to a buffer for the rest of the spec. +func captureLogs() *bytes.Buffer { + buf := &bytes.Buffer{} + log.SetOutput(buf) + log.SetLevel(log.LevelDebug) + DeferCleanup(func() { + log.SetOutput(os.Stderr) + log.SetLevel(log.LevelFatal) + }) + return buf +} diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go index aa6389357..9d38431a9 100644 --- a/server/apiv1/problem.go +++ b/server/apiv1/problem.go @@ -5,24 +5,69 @@ import ( "errors" "net/http" + "github.com/navidrome/navidrome/core/apiauth" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) const problemContentType = "application/problem+json" +type clientError struct { + err error + detail string +} + +func (e *clientError) Error() string { return e.detail } +func (e *clientError) Unwrap() error { return e.err } + +// ClientError marks detail as safe to show clients; err still decides the status and code. +func ClientError(err error, detail string) error { + return &clientError{err: err, detail: detail} +} + +type fieldErrors struct { + fields []ValidationError +} + +func (e *fieldErrors) Error() string { return "validation failed" } +func (e *fieldErrors) Unwrap() error { return model.ErrValidation } + +func validationFailed(fields ...ValidationError) error { + return &fieldErrors{fields: fields} +} + func writeProblem(w http.ResponseWriter, r *http.Request, err error) { status, code := classifyError(err) - detail := err.Error() if status == http.StatusInternalServerError { log.Error(r.Context(), "API v1: unexpected error", "path", r.URL.Path, err) - detail = "" + writeProblemStatus(w, r, status, code, "") + return + } + log.Debug(r.Context(), "API v1: request failed", "path", r.URL.Path, "status", status, "code", code, err) + var detail string + var ce *clientError + if errors.As(err, &ce) { + detail = ce.detail + } + var fe *fieldErrors + if errors.As(err, &fe) { + writeProblemStatus(w, r, status, code, detail, fe.fields...) + return } writeProblemStatus(w, r, status, code, detail) } func classifyError(err error) (int, ProblemCode) { switch { + case errors.Is(err, apiauth.ErrTokenExpired): + return http.StatusUnauthorized, ProblemCodeTokenExpired + case errors.Is(err, apiauth.ErrInsufficientScope): + return http.StatusForbidden, ProblemCodeInsufficientScope + case errors.Is(err, auth.ErrSetupComplete): + return http.StatusConflict, ProblemCodeSetupComplete + case errors.Is(err, apiauth.ErrPasswordManagedExternally): + return http.StatusConflict, ProblemCodePasswordManagedExternally case errors.Is(err, model.ErrNotFound): return http.StatusNotFound, ProblemCodeNotFound case errors.Is(err, model.ErrNotAuthorized): @@ -45,6 +90,15 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code if len(fieldErrors) > 0 { p.Errors = &fieldErrors } + if status == http.StatusInternalServerError { + if ref := referenceIDFrom(r.Context()); ref != "" { + p.ReferenceId = &ref + } + } + // Every 401 carries a Bearer challenge; callers may set a more specific one first. + if status == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" { + w.Header().Set("WWW-Authenticate", "Bearer") + } w.Header().Set("Content-Type", problemContentType) w.WriteHeader(status) if err := json.NewEncoder(w).Encode(p); err != nil { @@ -53,20 +107,20 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code } func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) { - var fieldErrors []ValidationError + var fieldErrs []ValidationError var required *RequiredParamError var invalid *InvalidParamFormatError var tooMany *TooManyValuesForParamError var unmarshal *UnmarshalingParamError switch { case errors.As(err, &required): - fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"}) + fieldErrs = append(fieldErrs, ValidationError{Field: required.ParamName, Message: "is required"}) case errors.As(err, &invalid): - fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()}) + fieldErrs = append(fieldErrs, ValidationError{Field: invalid.ParamName, Message: "has an invalid value"}) case errors.As(err, &tooMany): - fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"}) + fieldErrs = append(fieldErrs, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"}) case errors.As(err, &unmarshal): - fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()}) + fieldErrs = append(fieldErrs, ValidationError{Field: unmarshal.ParamName, Message: "has an invalid value"}) } - writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...) + writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "invalid request parameters", fieldErrs...) } diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go index 256296b3c..fccb439f0 100644 --- a/server/apiv1/problem_test.go +++ b/server/apiv1/problem_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" + "github.com/navidrome/navidrome/core/apiauth" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -46,20 +48,51 @@ var _ = Describe("problem", func() { Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized), Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation), Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable), + Entry("token expired", apiauth.ErrTokenExpired, http.StatusUnauthorized, ProblemCodeTokenExpired), + Entry("insufficient scope", apiauth.ErrInsufficientScope, http.StatusForbidden, ProblemCodeInsufficientScope), + Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete), + Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally), Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal), ) - DescribeTable("keeps the wrapping context as detail for client errors", - func(err error) { - writeProblem(w, r, err) - p := decodeProblem(w) - Expect(p.Status).To(Equal(http.StatusNotFound)) - Expect(p.Detail).ToNot(BeNil()) - Expect(*p.Detail).To(ContainSubstring("album 123")) - }, - Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)), - Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)), - ) + It("shows detail only for errors marked as client-facing", func() { + writeProblem(w, r, fmt.Errorf("album 123: %w", model.ErrNotFound)) + Expect(decodeProblem(w).Detail).To(BeNil()) + + w = httptest.NewRecorder() + writeProblem(w, r, ClientError(model.ErrNotFound, "album not found")) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeNotFound)) + Expect(*p.Detail).To(Equal("album not found")) + }) + + It("writes field errors from validationFailed", func() { + writeProblem(w, r, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"})) + p := decodeProblem(w) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"})) + }) + + It("adds a Bearer challenge to every 401 unless one is already set", func() { + writeProblem(w, r, model.ErrInvalidAuth) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) + + w = httptest.NewRecorder() + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + writeProblem(w, r, model.ErrInvalidAuth) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) + }) + + It("adds the request's referenceId to internal errors only", func() { + r = r.WithContext(withReferenceID(r.Context(), "ref-123")) + writeProblem(w, r, errors.New("boom")) + Expect(*decodeProblem(w).ReferenceId).To(Equal("ref-123")) + + w = httptest.NewRecorder() + writeProblem(w, r, model.ErrNotFound) + Expect(decodeProblem(w).ReferenceId).To(BeNil()) + }) It("hides details for internal errors", func() { writeProblem(w, r, errors.New("db password is hunter2")) @@ -95,14 +128,19 @@ var _ = Describe("problem", func() { Expect(p.Errors).ToNot(BeNil()) Expect(*p.Errors).To(HaveLen(1)) Expect((*p.Errors)[0].Field).To(Equal(field)) - Expect((*p.Errors)[0].Message).To(ContainSubstring(message)) + Expect((*p.Errors)[0].Message).To(Equal(message)) }, Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"), - Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"), - Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"), - Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"), + Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "abc": invalid syntax`)}, "offset", "has an invalid value"), + Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "expected a single value"), + Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "has an invalid value"), ) + It("never echoes the submitted value", func() { + bindingErrorHandler(w, r, &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "hunter2": invalid syntax`)}) + Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) + }) + It("still returns a validation problem for unknown binding errors", func() { bindingErrorHandler(w, r, errors.New("weird")) p := decodeProblem(w) diff --git a/server/apiv1/reference.go b/server/apiv1/reference.go new file mode 100644 index 000000000..bc614878c --- /dev/null +++ b/server/apiv1/reference.go @@ -0,0 +1,29 @@ +package apiv1 + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" +) + +type referenceIDKey struct{} + +func withReferenceID(ctx context.Context, ref string) context.Context { + return context.WithValue(ctx, referenceIDKey{}, ref) +} + +func referenceIDFrom(ctx context.Context) string { + ref, _ := ctx.Value(referenceIDKey{}).(string) + return ref +} + +// referenceIDMiddleware tags every log line of the request with an id that 500 problems also carry. +func referenceIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ref := id.NewRandom() + ctx := log.NewContext(withReferenceID(r.Context(), ref), "referenceId", ref) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} From 4f2d3507579fc3bd2f853bf3242a4aed208603db Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:42:07 -0400 Subject: [PATCH 12/41] fix(api): route the spec gate on chi's exact path and name the scope in every insufficient-scope challenge --- server/apiv1/api.go | 4 +++- server/apiv1/gate.go | 15 +++++++++++---- server/apiv1/gate_test.go | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 76926d738..c8e25618f 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -1,6 +1,7 @@ package apiv1 import ( + "cmp" "errors" "net/http" "runtime/debug" @@ -126,9 +127,10 @@ func headAsGet(mux chi.Routes) func(http.Handler) http.Handler { } } +// routePath must pick the same path chi's routeHTTP dispatches on, or the gate could vet a different route. func routePath(req *http.Request) string { if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { return rctx.RoutePath } - return req.URL.Path + return cmp.Or(req.URL.RawPath, req.URL.Path, "/") } diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index 19234413b..d125eb846 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -218,22 +218,29 @@ func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*h writeProblem(w, r, err) return r, false case errors.Is(err, apiauth.ErrInsufficientScope): - w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope"`) - writeProblem(w, r, err) + insufficientScope(w, r, op, err) return r, false case err != nil: writeProblem(w, r, err) return r, false } if op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q`, op.scope)) - writeProblem(w, r, apiauth.ErrInsufficientScope) + insufficientScope(w, r, op, apiauth.ErrInsufficientScope) return r, false } ctx := apiauth.WithPrincipal(request.WithUser(r.Context(), p.User), p) return r.WithContext(ctx), true } +func insufficientScope(w http.ResponseWriter, r *http.Request, op *gateOp, err error) { + challenge := `Bearer error="insufficient_scope"` + if op.scope != "" { + challenge += fmt.Sprintf(`, scope=%q`, op.scope) + } + w.Header().Set("WWW-Authenticate", challenge) + writeProblem(w, r, err) +} + func bearerToken(r *http.Request) (string, bool) { scheme, token, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") token = strings.TrimSpace(token) diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go index a74f76b26..6fb7daa6c 100644 --- a/server/apiv1/gate_test.go +++ b/server/apiv1/gate_test.go @@ -209,6 +209,26 @@ var _ = Describe("spec gate", func() { Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) }) + It("names the operation's scope when Authenticate reports an insufficient scope", func() { + fa.err = apiauth.ErrInsufficientScope + w := do(http.MethodGet, "/things/1", "Bearer x", "") + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`)) + }) + + It("looks routes up on the raw path, as chi dispatches them", func() { + w := do(http.MethodGet, "/things/a%2Fb", "", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(reached).To(BeEmpty()) + + root := chi.NewRouter() + root.Mount("/music/api/v1", mux) + w = httptest.NewRecorder() + root.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/a%2Fb", nil)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(reached).To(BeEmpty()) + }) + It("works when mounted under a base path", func() { root := chi.NewRouter() root.Mount("/music/api/v1", mux) From 294c1a9a6a5f64ca7416329384bddde4c549dd54 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:50:16 -0400 Subject: [PATCH 13/41] feat(api): add API v1 login, setup, token, grant and password endpoints Adds the seven auth operations to the spec (createAccessToken, listGrants, revokeGrant and logout in core; login, setupFirstAdmin and changePassword in the password module), the bearerAuth/grantAuth schemes, and vacuum rules requiring explicit security and a known x-scope. The strict handlers sit on core/apiauth and are covered end to end against a real SQLite database. The first operations with parameters make the generated code import github.com/oapi-codegen/runtime. An oapi-codegen overlay renames the shared offset/limit parameter types, since a generated Offset clashes with Ginkgo's dot-imported Offset in this package's tests; the published spec is unchanged. --- api/.vacuum.yaml | 16 + api/bundled/openapi.json | 797 ++++++++- api/bundled/openapi.yaml | 543 ++++++- .../components/headers/WWWAuthenticate.yaml | 3 + .../components/responses/Conflict.yaml | 5 + .../components/responses/Forbidden.yaml | 3 + .../components/responses/PayloadTooLarge.yaml | 5 + .../components/responses/TooManyRequests.yaml | 10 + .../components/responses/Unauthorized.yaml | 3 + .../components/schemas/AccessToken.yaml | 19 + api/openapi/components/schemas/AuthUser.yaml | 19 + .../schemas/CredentialsRequest.yaml | 34 + api/openapi/components/schemas/Grant.yaml | 41 + .../components/schemas/GrantCreated.yaml | 16 + api/openapi/components/schemas/GrantList.yaml | 18 + .../components/schemas/LogoutResponse.yaml | 8 + .../schemas/PasswordChangeRequest.yaml | 18 + api/openapi/components/schemas/Problem.yaml | 2 +- api/openapi/components/schemas/Scope.yaml | 5 + .../components/schemas/ScopeRequest.yaml | 4 + .../components/schemas/TokenRequest.yaml | 9 + api/openapi/openapi.yaml | 27 +- api/openapi/paths/auth.yaml | 213 +++ go.mod | 2 + go.sum | 11 + server/apiv1/api_gen.go | 1431 ++++++++++++++++- server/apiv1/apiv1_suite_test.go | 11 + server/apiv1/auth_handlers.go | 71 + server/apiv1/auth_test.go | 234 +++ server/apiv1/db_test.go | 13 + server/apiv1/dto.go | 78 + server/apiv1/oapi-codegen-overlay.yaml | 12 + server/apiv1/oapi-codegen.yaml | 2 + server/apiv1/password_handlers.go | 47 + 34 files changed, 3719 insertions(+), 11 deletions(-) create mode 100644 api/openapi/components/headers/WWWAuthenticate.yaml create mode 100644 api/openapi/components/responses/Conflict.yaml create mode 100644 api/openapi/components/responses/PayloadTooLarge.yaml create mode 100644 api/openapi/components/responses/TooManyRequests.yaml create mode 100644 api/openapi/components/schemas/AccessToken.yaml create mode 100644 api/openapi/components/schemas/AuthUser.yaml create mode 100644 api/openapi/components/schemas/CredentialsRequest.yaml create mode 100644 api/openapi/components/schemas/Grant.yaml create mode 100644 api/openapi/components/schemas/GrantCreated.yaml create mode 100644 api/openapi/components/schemas/GrantList.yaml create mode 100644 api/openapi/components/schemas/LogoutResponse.yaml create mode 100644 api/openapi/components/schemas/PasswordChangeRequest.yaml create mode 100644 api/openapi/components/schemas/Scope.yaml create mode 100644 api/openapi/components/schemas/ScopeRequest.yaml create mode 100644 api/openapi/components/schemas/TokenRequest.yaml create mode 100644 api/openapi/paths/auth.yaml create mode 100644 server/apiv1/auth_handlers.go create mode 100644 server/apiv1/auth_test.go create mode 100644 server/apiv1/db_test.go create mode 100644 server/apiv1/dto.go create mode 100644 server/apiv1/oapi-codegen-overlay.yaml create mode 100644 server/apiv1/password_handlers.go diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml index bd31e23f8..f658b6c01 100644 --- a/api/.vacuum.yaml +++ b/api/.vacuum.yaml @@ -36,6 +36,22 @@ rules: - sharing - radio - admin + - password + nd-operation-security-required: + description: Every operation declares security explicitly (use [] for public operations). + severity: error + given: $.paths[*][get,put,post,delete,patch] + then: + field: security + function: defined + nd-operation-x-scope: + description: An operation's x-scope is a known scope. Cross-checks with x-module and security run in Go (server/apiv1 newGate). + severity: error + given: $.paths[*][get,put,post,delete,patch]['x-scope'] + then: + function: enumeration + functionOptions: + values: [read, password] nd-operation-stability-level-required: description: Every operation declares its stability level, which the breaking-change gate relies on. severity: error diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index 51b5f1e6d..0a5d889c8 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Navidrome API", "version": "1.0.0", - "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n", + "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n", "license": { "name": "GPL-3.0", "url": "https://www.gnu.org/licenses/gpl-3.0.html" @@ -18,6 +18,10 @@ { "name": "server", "description": "Server discovery and the published OpenAPI document." + }, + { + "name": "auth", + "description": "Grants, access tokens, and login methods." } ], "paths": { @@ -83,6 +87,58 @@ } } }, + "/auth/token": { + "post": { + "operationId": "createAccessToken", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "Mint an access token", + "description": "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential.", + "security": [ + { + "grantAuth": [] + } + ], + "requestBody": { + "description": "Scopes to narrow the token to. Optional.", + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The new access token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/openapi.yaml": { "get": { "operationId": "getOpenAPISpecYAML", @@ -116,6 +172,302 @@ } } } + }, + "/auth/grants": { + "get": { + "operationId": "listGrants", + "x-module": "core", + "x-scope": "read", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "List my grants", + "description": "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/offset" + }, + { + "$ref": "#/components/parameters/limit" + } + ], + "responses": { + "200": { + "description": "A page of grants.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantList" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/auth/grants/{id}": { + "delete": { + "operationId": "revokeGrant", + "x-module": "core", + "x-scope": "read", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "Revoke one of my grants", + "description": "Revokes the grant and every token minted from it. Another user's grant id answers 404.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Grant id.", + "schema": { + "type": "string", + "maxLength": 64 + } + } + ], + "responses": { + "204": { + "description": "Revoked." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/auth/logout": { + "post": { + "operationId": "logout", + "x-module": "core", + "x-scope": "read", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "Log out", + "description": "Revokes the grant that made this request.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Logged out.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/auth/login": { + "post": { + "operationId": "login", + "x-module": "password", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "Log in with a password", + "description": "Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.", + "security": [], + "requestBody": { + "description": "The credentials and a description of the client.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The new grant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantCreated" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/auth/setup": { + "post": { + "operationId": "setupFirstAdmin", + "x-module": "password", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "Create the first admin", + "description": "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409.", + "security": [], + "requestBody": { + "description": "The credentials and a description of the client.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialsRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The admin was created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantCreated" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/auth/password": { + "post": { + "operationId": "changePassword", + "x-module": "password", + "x-scope": "password", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "Change my password", + "description": "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. Answers 409 `password_managed_externally` when the password is not stored by this server.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "description": "The current and the new password.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChangeRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Password changed." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -123,8 +475,12 @@ "bearerAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT", - "description": "Short-lived access token minted from a device grant. Not yet applied to any operation." + "description": "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." + }, + "grantAuth": { + "type": "http", + "scheme": "bearer", + "description": "Long-lived grant secret. Accepted only by `POST /auth/token`." } }, "schemas": { @@ -190,7 +546,7 @@ }, "detail": { "type": "string", - "description": "Human-readable explanation specific to this occurrence. Omitted for internal errors." + "description": "Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients." }, "code": { "type": "string", @@ -241,6 +597,320 @@ "description": "Why the value was rejected." } } + }, + "TokenRequest": { + "type": "object", + "description": "Optional narrowing of a new access token.", + "properties": { + "scopes": { + "type": "array", + "maxItems": 32, + "description": "Subset of the grant's scopes. Omit for all of them; an empty list asks for none.", + "items": { + "$ref": "#/components/schemas/ScopeRequest" + } + } + } + }, + "AccessToken": { + "type": "object", + "description": "A short-lived access token. Opaque; clients must not decode it.", + "required": [ + "accessToken", + "tokenType", + "expiresIn", + "scopes" + ], + "properties": { + "accessToken": { + "type": "string", + "description": "The token. Send it as `Authorization: Bearer \u003ctoken\u003e`." + }, + "tokenType": { + "type": "string", + "enum": [ + "Bearer" + ], + "description": "Always `Bearer`." + }, + "expiresIn": { + "type": "integer", + "description": "Seconds until the token expires." + }, + "scopes": { + "type": "array", + "description": "Scopes the token actually carries, which may be fewer than requested.", + "items": { + "$ref": "#/components/schemas/Scope" + } + } + } + }, + "GrantList": { + "type": "object", + "description": "A page of the caller's grants.", + "required": [ + "items", + "total", + "offset", + "limit" + ], + "properties": { + "items": { + "type": "array", + "description": "Grants on this page, by last use, most recent first; never-used grants last.", + "items": { + "$ref": "#/components/schemas/Grant" + } + }, + "total": { + "type": "integer", + "description": "Total number of grants." + }, + "offset": { + "type": "integer", + "description": "Zero-based index of the first returned item." + }, + "limit": { + "type": "integer", + "description": "Maximum number of items in this page." + } + } + }, + "LogoutResponse": { + "type": "object", + "description": "Result of a logout.", + "required": [ + "logoutUrl" + ], + "properties": { + "logoutUrl": { + "type": "string", + "nullable": true, + "description": "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do." + } + } + }, + "CredentialsRequest": { + "type": "object", + "description": "Username, password and client description for a login or first-admin setup.", + "required": [ + "username", + "password", + "client" + ], + "properties": { + "username": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Login name." + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Password." + }, + "client": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Name of the client app." + }, + "clientVersion": { + "type": "string", + "maxLength": 32, + "description": "Version of the client app." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Label for this grant. Defaults to `client`." + }, + "scopes": { + "type": "array", + "maxItems": 32, + "description": "Scopes the grant may hold. Omit for `all`.", + "items": { + "$ref": "#/components/schemas/ScopeRequest" + } + } + } + }, + "GrantCreated": { + "type": "object", + "description": "Returned by every login method. The secret is shown only here; store it and never parse it.", + "required": [ + "secret", + "grant", + "user" + ], + "properties": { + "secret": { + "type": "string", + "maxLength": 512, + "description": "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`." + }, + "grant": { + "description": "The new grant.", + "allOf": [ + { + "$ref": "#/components/schemas/Grant" + } + ] + }, + "user": { + "description": "The user the grant belongs to.", + "allOf": [ + { + "$ref": "#/components/schemas/AuthUser" + } + ] + } + } + }, + "PasswordChangeRequest": { + "type": "object", + "description": "Change the caller's own password.", + "required": [ + "currentPassword", + "newPassword" + ], + "properties": { + "currentPassword": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "The current password." + }, + "newPassword": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "The new password." + }, + "revokeOtherGrants": { + "type": "boolean", + "default": true, + "description": "Revoke every other grant of the user. The calling grant always survives. Default true." + } + } + }, + "ScopeRequest": { + "type": "string", + "description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.", + "pattern": "^[a-z][a-z-]*(:write)?$", + "maxLength": 64 + }, + "Scope": { + "type": "string", + "description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n", + "enum": [ + "all", + "read", + "password" + ] + }, + "Grant": { + "type": "object", + "description": "A long-lived grant held by one client of one user.", + "required": [ + "id", + "name", + "client", + "clientVersion", + "scopes", + "provider", + "createdAt", + "lastUsedAt", + "lastUsedIp", + "current" + ], + "properties": { + "id": { + "type": "string", + "description": "Grant id." + }, + "name": { + "type": "string", + "description": "Label shown to the user." + }, + "client": { + "type": "string", + "description": "Name of the client app that holds the grant." + }, + "clientVersion": { + "type": "string", + "nullable": true, + "description": "Version of the client app, when it sent one." + }, + "scopes": { + "type": "array", + "description": "Scopes this grant may mint tokens for.", + "items": { + "$ref": "#/components/schemas/Scope" + } + }, + "provider": { + "type": "string", + "description": "How the grant was created, for example `password` or `setup`. Free-form; new values may appear." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the grant was created." + }, + "lastUsedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the grant was last used, at a coarse granularity. Null until first use." + }, + "lastUsedIp": { + "type": "string", + "nullable": true, + "description": "Client IP of the last use. Null until first use." + }, + "current": { + "type": "boolean", + "description": "True for the grant that made this request." + } + } + }, + "AuthUser": { + "type": "object", + "description": "The user a grant belongs to.", + "required": [ + "id", + "userName", + "name", + "isAdmin", + "passwordChangeable" + ], + "properties": { + "id": { + "type": "string", + "description": "User id." + }, + "userName": { + "type": "string", + "description": "Login name." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "isAdmin": { + "type": "boolean", + "description": "Whether the user is an administrator." + }, + "passwordChangeable": { + "type": "boolean", + "description": "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false." + } + } } }, "responses": { @@ -261,6 +931,119 @@ "$ref": "#/components/headers/ETag" } } + }, + "BadRequest": { + "description": "The request is malformed or fails validation.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "Unauthorized": { + "description": "Missing, invalid, or expired credentials.", + "headers": { + "WWW-Authenticate": { + "$ref": "#/components/headers/WWWAuthenticate" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body is too large (`payload_too_large`).", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "Forbidden": { + "description": "The caller is authenticated but not allowed to do this.", + "headers": { + "WWW-Authenticate": { + "$ref": "#/components/headers/WWWAuthenticate" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "NotFound": { + "description": "No such resource or endpoint.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "TooManyRequests": { + "description": "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds.", + "headers": { + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "parameters": { + "offset": { + "name": "offset", + "in": "query", + "description": "Zero-based index of the first item to return.", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + "limit": { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return.", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 100 + } } }, "headers": { @@ -269,6 +1052,12 @@ "schema": { "type": "string" } + }, + "WWWAuthenticate": { + "description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.", + "schema": { + "type": "string" + } } } } diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index 3f19d5a72..778791f97 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -15,6 +15,10 @@ info: `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods in its `Allow` header. + + Operations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in + `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted + from it, stops working within one access-token lifetime at most. license: name: GPL-3.0 url: https://www.gnu.org/licenses/gpl-3.0.html @@ -23,6 +27,8 @@ servers: tags: - name: server description: Server discovery and the published OpenAPI document. + - name: auth + description: Grants, access tokens, and login methods. paths: /server: get: @@ -67,6 +73,37 @@ paths: description: OpenAPI 3.0 document. '304': $ref: '#/components/responses/NotModified' + /auth/token: + post: + operationId: createAccessToken + x-module: core + x-stability-level: alpha + tags: [auth] + summary: Mint an access token + description: "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential." + security: [{grantAuth: []}] + requestBody: + description: Scopes to narrow the token to. Optional. + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: The new access token. + content: + application/json: + schema: + $ref: '#/components/schemas/AccessToken' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '500': + $ref: '#/components/responses/InternalError' /openapi.yaml: get: operationId: getOpenAPISpecYAML @@ -89,13 +126,198 @@ paths: description: OpenAPI 3.0 document. '304': $ref: '#/components/responses/NotModified' + /auth/grants: + get: + operationId: listGrants + x-module: core + x-scope: read + x-stability-level: alpha + tags: [auth] + summary: List my grants + description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed." + security: [{bearerAuth: []}] + parameters: + - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/limit' + responses: + '200': + description: A page of grants. + content: + application/json: + schema: + $ref: '#/components/schemas/GrantList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /auth/grants/{id}: + delete: + operationId: revokeGrant + x-module: core + x-scope: read + x-stability-level: alpha + tags: [auth] + summary: Revoke one of my grants + description: "Revokes the grant and every token minted from it. Another user's grant id answers 404." + security: [{bearerAuth: []}] + parameters: + - name: id + in: path + required: true + description: Grant id. + schema: + type: string + maxLength: 64 + responses: + '204': + description: Revoked. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + /auth/logout: + post: + operationId: logout + x-module: core + x-scope: read + x-stability-level: alpha + tags: [auth] + summary: Log out + description: Revokes the grant that made this request. + security: [{bearerAuth: []}] + responses: + '200': + description: Logged out. + content: + application/json: + schema: + $ref: '#/components/schemas/LogoutResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /auth/login: + post: + operationId: login + x-module: password + x-stability-level: alpha + tags: [auth] + summary: Log in with a password + description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way. + security: [] + requestBody: + description: The credentials and a description of the client. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialsRequest' + responses: + '200': + description: The new grant. + content: + application/json: + schema: + $ref: '#/components/schemas/GrantCreated' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /auth/setup: + post: + operationId: setupFirstAdmin + x-module: password + x-stability-level: alpha + tags: [auth] + summary: Create the first admin + description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409." + security: [] + requestBody: + description: The credentials and a description of the client. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialsRequest' + responses: + '201': + description: The admin was created. + content: + application/json: + schema: + $ref: '#/components/schemas/GrantCreated' + '400': + $ref: '#/components/responses/BadRequest' + '409': + $ref: '#/components/responses/Conflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /auth/password: + post: + operationId: changePassword + x-module: password + x-scope: password + x-stability-level: alpha + tags: [auth] + summary: Change my password + description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. Answers 409 `password_managed_externally` when the password is not stored by this server." + security: [{bearerAuth: []}] + requestBody: + description: The current and the new password. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordChangeRequest' + responses: + '204': + description: Password changed. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' components: securitySchemes: bearerAuth: type: http scheme: bearer - bearerFormat: JWT - description: Short-lived access token minted from a device grant. Not yet applied to any operation. + description: "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." + grantAuth: + type: http + scheme: bearer + description: "Long-lived grant secret. Accepted only by `POST /auth/token`." schemas: ServerInfo: type: object @@ -148,7 +370,7 @@ components: description: HTTP status code of this response. detail: type: string - description: Human-readable explanation specific to this occurrence. Omitted for internal errors. + description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. code: type: string description: Machine-readable error code, and the value clients switch on. New codes may be added. @@ -187,6 +409,244 @@ components: message: type: string description: Why the value was rejected. + TokenRequest: + type: object + description: Optional narrowing of a new access token. + properties: + scopes: + type: array + maxItems: 32 + description: "Subset of the grant's scopes. Omit for all of them; an empty list asks for none." + items: + $ref: '#/components/schemas/ScopeRequest' + AccessToken: + type: object + description: "A short-lived access token. Opaque; clients must not decode it." + required: + - accessToken + - tokenType + - expiresIn + - scopes + properties: + accessToken: + type: string + description: "The token. Send it as `Authorization: Bearer `." + tokenType: + type: string + enum: + - Bearer + description: "Always `Bearer`." + expiresIn: + type: integer + description: Seconds until the token expires. + scopes: + type: array + description: "Scopes the token actually carries, which may be fewer than requested." + items: + $ref: '#/components/schemas/Scope' + GrantList: + type: object + description: "A page of the caller's grants." + required: + - items + - total + - offset + - limit + properties: + items: + type: array + description: "Grants on this page, by last use, most recent first; never-used grants last." + items: + $ref: '#/components/schemas/Grant' + total: + type: integer + description: Total number of grants. + offset: + type: integer + description: Zero-based index of the first returned item. + limit: + type: integer + description: Maximum number of items in this page. + LogoutResponse: + type: object + description: Result of a logout. + required: + - logoutUrl + properties: + logoutUrl: + type: string + nullable: true + description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do." + CredentialsRequest: + type: object + description: "Username, password and client description for a login or first-admin setup." + required: + - username + - password + - client + properties: + username: + type: string + minLength: 1 + maxLength: 255 + description: Login name. + password: + type: string + minLength: 1 + maxLength: 1024 + description: Password. + client: + type: string + minLength: 1 + maxLength: 64 + description: Name of the client app. + clientVersion: + type: string + maxLength: 32 + description: Version of the client app. + name: + type: string + minLength: 1 + maxLength: 64 + description: "Label for this grant. Defaults to `client`." + scopes: + type: array + maxItems: 32 + description: "Scopes the grant may hold. Omit for `all`." + items: + $ref: '#/components/schemas/ScopeRequest' + GrantCreated: + type: object + description: "Returned by every login method. The secret is shown only here; store it and never parse it." + required: + - secret + - grant + - user + properties: + secret: + type: string + maxLength: 512 + description: "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`." + grant: + description: The new grant. + allOf: + - $ref: '#/components/schemas/Grant' + user: + description: The user the grant belongs to. + allOf: + - $ref: '#/components/schemas/AuthUser' + PasswordChangeRequest: + type: object + description: "Change the caller's own password." + required: + - currentPassword + - newPassword + properties: + currentPassword: + type: string + minLength: 1 + maxLength: 1024 + description: The current password. + newPassword: + type: string + minLength: 1 + maxLength: 1024 + description: The new password. + revokeOtherGrants: + type: boolean + default: true + description: "Revoke every other grant of the user. The calling grant always survives. Default true." + ScopeRequest: + type: string + description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working." + pattern: '^[a-z][a-z-]*(:write)?$' + maxLength: 64 + Scope: + type: string + description: | + A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on + grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. + enum: + - all + - read + - password + Grant: + type: object + description: A long-lived grant held by one client of one user. + required: + - id + - name + - client + - clientVersion + - scopes + - provider + - createdAt + - lastUsedAt + - lastUsedIp + - current + properties: + id: + type: string + description: Grant id. + name: + type: string + description: Label shown to the user. + client: + type: string + description: Name of the client app that holds the grant. + clientVersion: + type: string + nullable: true + description: "Version of the client app, when it sent one." + scopes: + type: array + description: Scopes this grant may mint tokens for. + items: + $ref: '#/components/schemas/Scope' + provider: + type: string + description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear." + createdAt: + type: string + format: date-time + description: When the grant was created. + lastUsedAt: + type: string + format: date-time + nullable: true + description: "When the grant was last used, at a coarse granularity. Null until first use." + lastUsedIp: + type: string + nullable: true + description: Client IP of the last use. Null until first use. + current: + type: boolean + description: True for the grant that made this request. + AuthUser: + type: object + description: The user a grant belongs to. + required: + - id + - userName + - name + - isAdmin + - passwordChangeable + properties: + id: + type: string + description: User id. + userName: + type: string + description: Login name. + name: + type: string + description: Display name. + isAdmin: + type: boolean + description: Whether the user is an administrator. + passwordChangeable: + type: boolean + description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false." responses: InternalError: description: Unexpected server failure. Details are in the server log. @@ -199,8 +659,85 @@ components: headers: ETag: $ref: '#/components/headers/ETag' + BadRequest: + description: The request is malformed or fails validation. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Unauthorized: + description: Missing, invalid, or expired credentials. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WWWAuthenticate' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + PayloadTooLarge: + description: "The request body is too large (`payload_too_large`)." + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Forbidden: + description: The caller is authenticated but not allowed to do this. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WWWAuthenticate' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + NotFound: + description: No such resource or endpoint. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + TooManyRequests: + description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds." + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Conflict: + description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`." + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + parameters: + offset: + name: offset + in: query + description: Zero-based index of the first item to return. + required: false + schema: + type: integer + minimum: 0 + default: 0 + limit: + name: limit + in: query + description: Maximum number of items to return. + required: false + schema: + type: integer + minimum: 1 + maximum: 2000 + default: 100 headers: ETag: description: Entity tag for `If-None-Match` revalidation. schema: type: string + WWWAuthenticate: + description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' + schema: + type: string diff --git a/api/openapi/components/headers/WWWAuthenticate.yaml b/api/openapi/components/headers/WWWAuthenticate.yaml new file mode 100644 index 000000000..65d5fb2fb --- /dev/null +++ b/api/openapi/components/headers/WWWAuthenticate.yaml @@ -0,0 +1,3 @@ +description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' +schema: + type: string diff --git a/api/openapi/components/responses/Conflict.yaml b/api/openapi/components/responses/Conflict.yaml new file mode 100644 index 000000000..a602ffd6b --- /dev/null +++ b/api/openapi/components/responses/Conflict.yaml @@ -0,0 +1,5 @@ +description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`." +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/Forbidden.yaml b/api/openapi/components/responses/Forbidden.yaml index 6259185ea..9f39b6b53 100644 --- a/api/openapi/components/responses/Forbidden.yaml +++ b/api/openapi/components/responses/Forbidden.yaml @@ -1,4 +1,7 @@ description: The caller is authenticated but not allowed to do this. +headers: + WWW-Authenticate: + $ref: ../headers/WWWAuthenticate.yaml content: application/problem+json: schema: diff --git a/api/openapi/components/responses/PayloadTooLarge.yaml b/api/openapi/components/responses/PayloadTooLarge.yaml new file mode 100644 index 000000000..4918bf1e7 --- /dev/null +++ b/api/openapi/components/responses/PayloadTooLarge.yaml @@ -0,0 +1,5 @@ +description: "The request body is too large (`payload_too_large`)." +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/TooManyRequests.yaml b/api/openapi/components/responses/TooManyRequests.yaml new file mode 100644 index 000000000..de82cd59e --- /dev/null +++ b/api/openapi/components/responses/TooManyRequests.yaml @@ -0,0 +1,10 @@ +description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds." +headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/Unauthorized.yaml b/api/openapi/components/responses/Unauthorized.yaml index 0209f4dd9..87d5bab08 100644 --- a/api/openapi/components/responses/Unauthorized.yaml +++ b/api/openapi/components/responses/Unauthorized.yaml @@ -1,4 +1,7 @@ description: Missing, invalid, or expired credentials. +headers: + WWW-Authenticate: + $ref: ../headers/WWWAuthenticate.yaml content: application/problem+json: schema: diff --git a/api/openapi/components/schemas/AccessToken.yaml b/api/openapi/components/schemas/AccessToken.yaml new file mode 100644 index 000000000..5a692aa13 --- /dev/null +++ b/api/openapi/components/schemas/AccessToken.yaml @@ -0,0 +1,19 @@ +type: object +description: "A short-lived access token. Opaque; clients must not decode it." +required: [accessToken, tokenType, expiresIn, scopes] +properties: + accessToken: + type: string + description: "The token. Send it as `Authorization: Bearer `." + tokenType: + type: string + enum: [Bearer] + description: "Always `Bearer`." + expiresIn: + type: integer + description: Seconds until the token expires. + scopes: + type: array + description: "Scopes the token actually carries, which may be fewer than requested." + items: + $ref: ./Scope.yaml diff --git a/api/openapi/components/schemas/AuthUser.yaml b/api/openapi/components/schemas/AuthUser.yaml new file mode 100644 index 000000000..722a6823c --- /dev/null +++ b/api/openapi/components/schemas/AuthUser.yaml @@ -0,0 +1,19 @@ +type: object +description: The user a grant belongs to. +required: [id, userName, name, isAdmin, passwordChangeable] +properties: + id: + type: string + description: User id. + userName: + type: string + description: Login name. + name: + type: string + description: Display name. + isAdmin: + type: boolean + description: Whether the user is an administrator. + passwordChangeable: + type: boolean + description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false." diff --git a/api/openapi/components/schemas/CredentialsRequest.yaml b/api/openapi/components/schemas/CredentialsRequest.yaml new file mode 100644 index 000000000..08e198b23 --- /dev/null +++ b/api/openapi/components/schemas/CredentialsRequest.yaml @@ -0,0 +1,34 @@ +type: object +description: "Username, password and client description for a login or first-admin setup." +required: [username, password, client] +properties: + username: + type: string + minLength: 1 + maxLength: 255 + description: Login name. + password: + type: string + minLength: 1 + maxLength: 1024 + description: Password. + client: + type: string + minLength: 1 + maxLength: 64 + description: Name of the client app. + clientVersion: + type: string + maxLength: 32 + description: Version of the client app. + name: + type: string + minLength: 1 + maxLength: 64 + description: "Label for this grant. Defaults to `client`." + scopes: + type: array + maxItems: 32 + description: "Scopes the grant may hold. Omit for `all`." + items: + $ref: ./ScopeRequest.yaml diff --git a/api/openapi/components/schemas/Grant.yaml b/api/openapi/components/schemas/Grant.yaml new file mode 100644 index 000000000..54d5a3473 --- /dev/null +++ b/api/openapi/components/schemas/Grant.yaml @@ -0,0 +1,41 @@ +type: object +description: A long-lived grant held by one client of one user. +required: [id, name, client, clientVersion, scopes, provider, createdAt, lastUsedAt, lastUsedIp, current] +properties: + id: + type: string + description: Grant id. + name: + type: string + description: Label shown to the user. + client: + type: string + description: Name of the client app that holds the grant. + clientVersion: + type: string + nullable: true + description: "Version of the client app, when it sent one." + scopes: + type: array + description: Scopes this grant may mint tokens for. + items: + $ref: ./Scope.yaml + provider: + type: string + description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear." + createdAt: + type: string + format: date-time + description: When the grant was created. + lastUsedAt: + type: string + format: date-time + nullable: true + description: "When the grant was last used, at a coarse granularity. Null until first use." + lastUsedIp: + type: string + nullable: true + description: Client IP of the last use. Null until first use. + current: + type: boolean + description: True for the grant that made this request. diff --git a/api/openapi/components/schemas/GrantCreated.yaml b/api/openapi/components/schemas/GrantCreated.yaml new file mode 100644 index 000000000..eba757e90 --- /dev/null +++ b/api/openapi/components/schemas/GrantCreated.yaml @@ -0,0 +1,16 @@ +type: object +description: "Returned by every login method. The secret is shown only here; store it and never parse it." +required: [secret, grant, user] +properties: + secret: + type: string + maxLength: 512 + description: "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`." + grant: + description: The new grant. + allOf: + - $ref: ./Grant.yaml + user: + description: The user the grant belongs to. + allOf: + - $ref: ./AuthUser.yaml diff --git a/api/openapi/components/schemas/GrantList.yaml b/api/openapi/components/schemas/GrantList.yaml new file mode 100644 index 000000000..8e80391ae --- /dev/null +++ b/api/openapi/components/schemas/GrantList.yaml @@ -0,0 +1,18 @@ +type: object +description: "A page of the caller's grants." +required: [items, total, offset, limit] +properties: + items: + type: array + description: "Grants on this page, by last use, most recent first; never-used grants last." + items: + $ref: ./Grant.yaml + total: + type: integer + description: Total number of grants. + offset: + type: integer + description: Zero-based index of the first returned item. + limit: + type: integer + description: Maximum number of items in this page. diff --git a/api/openapi/components/schemas/LogoutResponse.yaml b/api/openapi/components/schemas/LogoutResponse.yaml new file mode 100644 index 000000000..12700fba7 --- /dev/null +++ b/api/openapi/components/schemas/LogoutResponse.yaml @@ -0,0 +1,8 @@ +type: object +description: Result of a logout. +required: [logoutUrl] +properties: + logoutUrl: + type: string + nullable: true + description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do." diff --git a/api/openapi/components/schemas/PasswordChangeRequest.yaml b/api/openapi/components/schemas/PasswordChangeRequest.yaml new file mode 100644 index 000000000..c176014c4 --- /dev/null +++ b/api/openapi/components/schemas/PasswordChangeRequest.yaml @@ -0,0 +1,18 @@ +type: object +description: "Change the caller's own password." +required: [currentPassword, newPassword] +properties: + currentPassword: + type: string + minLength: 1 + maxLength: 1024 + description: The current password. + newPassword: + type: string + minLength: 1 + maxLength: 1024 + description: The new password. + revokeOtherGrants: + type: boolean + default: true + description: "Revoke every other grant of the user. The calling grant always survives. Default true." diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml index b2224432a..c9030e7b0 100644 --- a/api/openapi/components/schemas/Problem.yaml +++ b/api/openapi/components/schemas/Problem.yaml @@ -16,7 +16,7 @@ properties: description: HTTP status code of this response. detail: type: string - description: Human-readable explanation specific to this occurrence. Omitted for internal errors. + description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. code: type: string description: Machine-readable error code, and the value clients switch on. New codes may be added. diff --git a/api/openapi/components/schemas/Scope.yaml b/api/openapi/components/schemas/Scope.yaml new file mode 100644 index 000000000..a1763945f --- /dev/null +++ b/api/openapi/components/schemas/Scope.yaml @@ -0,0 +1,5 @@ +type: string +description: | + A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on + grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. +enum: [all, read, password] diff --git a/api/openapi/components/schemas/ScopeRequest.yaml b/api/openapi/components/schemas/ScopeRequest.yaml new file mode 100644 index 000000000..a9a936147 --- /dev/null +++ b/api/openapi/components/schemas/ScopeRequest.yaml @@ -0,0 +1,4 @@ +type: string +description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working." +pattern: '^[a-z][a-z-]*(:write)?$' +maxLength: 64 diff --git a/api/openapi/components/schemas/TokenRequest.yaml b/api/openapi/components/schemas/TokenRequest.yaml new file mode 100644 index 000000000..12a0b9c73 --- /dev/null +++ b/api/openapi/components/schemas/TokenRequest.yaml @@ -0,0 +1,9 @@ +type: object +description: Optional narrowing of a new access token. +properties: + scopes: + type: array + maxItems: 32 + description: "Subset of the grant's scopes. Omit for all of them; an empty list asks for none." + items: + $ref: ./ScopeRequest.yaml diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 73cbb7b27..02d3a56a4 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -15,6 +15,10 @@ info: `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods in its `Allow` header. + + Operations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in + `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted + from it, stops working within one access-token lifetime at most. license: name: GPL-3.0 url: https://www.gnu.org/licenses/gpl-3.0.html @@ -23,6 +27,8 @@ servers: tags: - name: server description: Server discovery and the published OpenAPI document. + - name: auth + description: Grants, access tokens, and login methods. paths: /server: $ref: ./paths/server.yaml @@ -30,10 +36,27 @@ paths: $ref: ./paths/openapi.yaml#/json /openapi.yaml: $ref: ./paths/openapi.yaml#/yaml + /auth/token: + $ref: ./paths/auth.yaml#/token + /auth/grants: + $ref: ./paths/auth.yaml#/grants + /auth/grants/{id}: + $ref: ./paths/auth.yaml#/grant + /auth/logout: + $ref: ./paths/auth.yaml#/logout + /auth/login: + $ref: ./paths/auth.yaml#/login + /auth/setup: + $ref: ./paths/auth.yaml#/setup + /auth/password: + $ref: ./paths/auth.yaml#/password components: securitySchemes: bearerAuth: type: http scheme: bearer - bearerFormat: JWT - description: Short-lived access token minted from a device grant. Not yet applied to any operation. + description: "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." + grantAuth: + type: http + scheme: bearer + description: "Long-lived grant secret. Accepted only by `POST /auth/token`." diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml new file mode 100644 index 000000000..9a3aedf37 --- /dev/null +++ b/api/openapi/paths/auth.yaml @@ -0,0 +1,213 @@ +token: + post: + operationId: createAccessToken + x-module: core + x-stability-level: alpha + tags: [auth] + summary: Mint an access token + description: "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential." + security: [{grantAuth: []}] + requestBody: + description: Scopes to narrow the token to. Optional. + required: false + content: + application/json: + schema: + $ref: ../components/schemas/TokenRequest.yaml + responses: + '200': + description: The new access token. + content: + application/json: + schema: + $ref: ../components/schemas/AccessToken.yaml + '400': + $ref: ../components/responses/BadRequest.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '413': + $ref: ../components/responses/PayloadTooLarge.yaml + '500': + $ref: ../components/responses/InternalError.yaml +grants: + get: + operationId: listGrants + x-module: core + x-scope: read + x-stability-level: alpha + tags: [auth] + summary: List my grants + description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed." + security: [{bearerAuth: []}] + parameters: + - $ref: ../components/parameters/offset.yaml + - $ref: ../components/parameters/limit.yaml + responses: + '200': + description: A page of grants. + content: + application/json: + schema: + $ref: ../components/schemas/GrantList.yaml + '400': + $ref: ../components/responses/BadRequest.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '403': + $ref: ../components/responses/Forbidden.yaml + '500': + $ref: ../components/responses/InternalError.yaml +grant: + delete: + operationId: revokeGrant + x-module: core + x-scope: read + x-stability-level: alpha + tags: [auth] + summary: Revoke one of my grants + description: "Revokes the grant and every token minted from it. Another user's grant id answers 404." + security: [{bearerAuth: []}] + parameters: + - name: id + in: path + required: true + description: Grant id. + schema: + type: string + maxLength: 64 + responses: + '204': + description: Revoked. + '400': + $ref: ../components/responses/BadRequest.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '403': + $ref: ../components/responses/Forbidden.yaml + '404': + $ref: ../components/responses/NotFound.yaml + '500': + $ref: ../components/responses/InternalError.yaml +logout: + post: + operationId: logout + x-module: core + x-scope: read + x-stability-level: alpha + tags: [auth] + summary: Log out + description: Revokes the grant that made this request. + security: [{bearerAuth: []}] + responses: + '200': + description: Logged out. + content: + application/json: + schema: + $ref: ../components/schemas/LogoutResponse.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '403': + $ref: ../components/responses/Forbidden.yaml + '500': + $ref: ../components/responses/InternalError.yaml +login: + post: + operationId: login + x-module: password + x-stability-level: alpha + tags: [auth] + summary: Log in with a password + description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way. + security: [] + requestBody: + description: The credentials and a description of the client. + required: true + content: + application/json: + schema: + $ref: ../components/schemas/CredentialsRequest.yaml + responses: + '200': + description: The new grant. + content: + application/json: + schema: + $ref: ../components/schemas/GrantCreated.yaml + '400': + $ref: ../components/responses/BadRequest.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '413': + $ref: ../components/responses/PayloadTooLarge.yaml + '429': + $ref: ../components/responses/TooManyRequests.yaml + '500': + $ref: ../components/responses/InternalError.yaml +setup: + post: + operationId: setupFirstAdmin + x-module: password + x-stability-level: alpha + tags: [auth] + summary: Create the first admin + description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409." + security: [] + requestBody: + description: The credentials and a description of the client. + required: true + content: + application/json: + schema: + $ref: ../components/schemas/CredentialsRequest.yaml + responses: + '201': + description: The admin was created. + content: + application/json: + schema: + $ref: ../components/schemas/GrantCreated.yaml + '400': + $ref: ../components/responses/BadRequest.yaml + '409': + $ref: ../components/responses/Conflict.yaml + '413': + $ref: ../components/responses/PayloadTooLarge.yaml + '429': + $ref: ../components/responses/TooManyRequests.yaml + '500': + $ref: ../components/responses/InternalError.yaml +password: + post: + operationId: changePassword + x-module: password + x-scope: password + x-stability-level: alpha + tags: [auth] + summary: Change my password + description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. Answers 409 `password_managed_externally` when the password is not stored by this server." + security: [{bearerAuth: []}] + requestBody: + description: The current and the new password. + required: true + content: + application/json: + schema: + $ref: ../components/schemas/PasswordChangeRequest.yaml + responses: + '204': + description: Password changed. + '400': + $ref: ../components/responses/BadRequest.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '403': + $ref: ../components/responses/Forbidden.yaml + '409': + $ref: ../components/responses/Conflict.yaml + '413': + $ref: ../components/responses/PayloadTooLarge.yaml + '429': + $ref: ../components/responses/TooManyRequests.yaml + '500': + $ref: ../components/responses/InternalError.yaml diff --git a/go.mod b/go.mod index e96b8c8b3..12cc95e73 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.52 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 + github.com/oapi-codegen/runtime v1.7.0 github.com/onsi/ginkgo/v2 v2.33.0 github.com/onsi/gomega v1.44.0 github.com/pelletier/go-toml/v2 v2.4.3 @@ -74,6 +75,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index fe6dbbc3f..ed9f32f13 100644 --- a/go.sum +++ b/go.sum @@ -6,14 +6,18 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8= github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw= github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= @@ -136,6 +140,7 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8 github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -188,6 +193,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= @@ -255,6 +264,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -263,6 +273,7 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index cffd7ece0..9851e485c 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -7,12 +7,31 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "net/http" + "time" "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" ) +// Defines values for AccessTokenTokenType. +const ( + AccessTokenTokenTypeBearer AccessTokenTokenType = "Bearer" +) + +// Valid indicates whether the value is a known member of the AccessTokenTokenType enum. +func (e AccessTokenTokenType) Valid() bool { + switch e { + case AccessTokenTokenTypeBearer: + return true + default: + return false + } +} + // Defines values for ProblemCode. const ( ProblemCodeForbidden ProblemCode = "forbidden" @@ -64,6 +83,27 @@ func (e ProblemCode) Valid() bool { } } +// Defines values for Scope. +const ( + ScopeAll Scope = "all" + ScopePassword Scope = "password" + ScopeRead Scope = "read" +) + +// Valid indicates whether the value is a known member of the Scope enum. +func (e Scope) Valid() bool { + switch e { + case ScopeAll: + return true + case ScopePassword: + return true + case ScopeRead: + return true + default: + return false + } +} + // Defines values for ServerInfoLoginMethods. const ( ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" @@ -79,12 +119,147 @@ func (e ServerInfoLoginMethods) Valid() bool { } } +// AccessToken A short-lived access token. Opaque; clients must not decode it. +type AccessToken struct { + // AccessToken The token. Send it as `Authorization: Bearer `. + AccessToken string `json:"accessToken"` + + // ExpiresIn Seconds until the token expires. + ExpiresIn int `json:"expiresIn"` + + // Scopes Scopes the token actually carries, which may be fewer than requested. + Scopes []Scope `json:"scopes"` + + // TokenType Always `Bearer`. + TokenType AccessTokenTokenType `json:"tokenType"` +} + +// AccessTokenTokenType Always `Bearer`. +type AccessTokenTokenType string + +// AuthUser The user a grant belongs to. +type AuthUser struct { + // Id User id. + Id string `json:"id"` + + // IsAdmin Whether the user is an administrator. + IsAdmin bool `json:"isAdmin"` + + // Name Display name. + Name string `json:"name"` + + // PasswordChangeable Whether `POST /auth/password` can change this user's password. Clients hide "change password" when false. + PasswordChangeable bool `json:"passwordChangeable"` + + // UserName Login name. + UserName string `json:"userName"` +} + +// CredentialsRequest Username, password and client description for a login or first-admin setup. +type CredentialsRequest struct { + // Client Name of the client app. + Client string `json:"client"` + + // ClientVersion Version of the client app. + ClientVersion *string `json:"clientVersion,omitempty"` + + // Name Label for this grant. Defaults to `client`. + Name *string `json:"name,omitempty"` + + // Password Password. + Password string `json:"password"` + + // Scopes Scopes the grant may hold. Omit for `all`. + Scopes *[]ScopeRequest `json:"scopes,omitempty"` + + // Username Login name. + Username string `json:"username"` +} + +// Grant A long-lived grant held by one client of one user. +type Grant struct { + // Client Name of the client app that holds the grant. + Client string `json:"client"` + + // ClientVersion Version of the client app, when it sent one. + ClientVersion *string `json:"clientVersion"` + + // CreatedAt When the grant was created. + CreatedAt time.Time `json:"createdAt"` + + // Current True for the grant that made this request. + Current bool `json:"current"` + + // Id Grant id. + Id string `json:"id"` + + // LastUsedAt When the grant was last used, at a coarse granularity. Null until first use. + LastUsedAt *time.Time `json:"lastUsedAt"` + + // LastUsedIp Client IP of the last use. Null until first use. + LastUsedIp *string `json:"lastUsedIp"` + + // Name Label shown to the user. + Name string `json:"name"` + + // Provider How the grant was created, for example `password` or `setup`. Free-form; new values may appear. + Provider string `json:"provider"` + + // Scopes Scopes this grant may mint tokens for. + Scopes []Scope `json:"scopes"` +} + +// GrantCreated Returned by every login method. The secret is shown only here; store it and never parse it. +type GrantCreated struct { + // Grant The new grant. + Grant Grant `json:"grant"` + + // Secret Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`. + Secret string `json:"secret"` + + // User The user the grant belongs to. + User AuthUser `json:"user"` +} + +// GrantList A page of the caller's grants. +type GrantList struct { + // Items Grants on this page, by last use, most recent first; never-used grants last. + Items []Grant `json:"items"` + + // Limit Maximum number of items in this page. + Limit int `json:"limit"` + + // Offset Zero-based index of the first returned item. + Offset int `json:"offset"` + + // Total Total number of grants. + Total int `json:"total"` +} + +// LogoutResponse Result of a logout. +type LogoutResponse struct { + // LogoutUrl Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do. + LogoutUrl *string `json:"logoutUrl"` +} + +// PasswordChangeRequest Change the caller's own password. +type PasswordChangeRequest struct { + // CurrentPassword The current password. + CurrentPassword string `json:"currentPassword"` + + // NewPassword The new password. + NewPassword string `json:"newPassword"` + + // RevokeOtherGrants Revoke every other grant of the user. The calling grant always survives. Default true. + RevokeOtherGrants *bool `json:"revokeOtherGrants,omitempty"` +} + // Problem RFC 9457 problem details, returned for every 4xx and 5xx response. type Problem struct { // Code Machine-readable error code, and the value clients switch on. New codes may be added. Code ProblemCode `json:"code"` - // Detail Human-readable explanation specific to this occurrence. Omitted for internal errors. + // Detail Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. Detail *string `json:"detail,omitempty"` // Errors Per-field failures. Present only when `code` is `validation`. @@ -108,6 +283,13 @@ type Problem struct { // ProblemCode Machine-readable error code, and the value clients switch on. New codes may be added. type ProblemCode string +// Scope A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on +// grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. +type Scope string + +// ScopeRequest A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working. +type ScopeRequest = string + // ServerInfo Public server description. Everything an add-server screen needs before login. type ServerInfo struct { // LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. @@ -129,6 +311,12 @@ type ServerInfo struct { // ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. type ServerInfoLoginMethods string +// TokenRequest Optional narrowing of a new access token. +type TokenRequest struct { + // Scopes Subset of the grant's scopes. Omit for all of them; an empty list asks for none. + Scopes *[]ScopeRequest `json:"scopes,omitempty"` +} + // ValidationError One field-level validation failure. type ValidationError struct { // Field Name of the offending query parameter, path parameter, or body field (dotted for nested). @@ -138,11 +326,80 @@ type ValidationError struct { Message string `json:"message"` } +// LimitParam defines model for limit. +type LimitParam = int + +// OffsetParam defines model for offset. +type OffsetParam = int + +// BadRequest RFC 9457 problem details, returned for every 4xx and 5xx response. +type BadRequest = Problem + +// Conflict RFC 9457 problem details, returned for every 4xx and 5xx response. +type Conflict = Problem + +// Forbidden RFC 9457 problem details, returned for every 4xx and 5xx response. +type Forbidden = Problem + // InternalError RFC 9457 problem details, returned for every 4xx and 5xx response. type InternalError = Problem +// NotFound RFC 9457 problem details, returned for every 4xx and 5xx response. +type NotFound = Problem + +// PayloadTooLarge RFC 9457 problem details, returned for every 4xx and 5xx response. +type PayloadTooLarge = Problem + +// TooManyRequests RFC 9457 problem details, returned for every 4xx and 5xx response. +type TooManyRequests = Problem + +// Unauthorized RFC 9457 problem details, returned for every 4xx and 5xx response. +type Unauthorized = Problem + +// ListGrantsParams defines parameters for ListGrants. +type ListGrantsParams struct { + // OffsetParam Zero-based index of the first item to return. + OffsetParam *OffsetParam `form:"offset,omitempty" json:"offset,omitempty"` + + // LimitParam Maximum number of items to return. + LimitParam *LimitParam `form:"limit,omitempty" json:"limit,omitempty"` +} + +// LoginJSONRequestBody defines body for Login for application/json ContentType. +type LoginJSONRequestBody = CredentialsRequest + +// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType. +type ChangePasswordJSONRequestBody = PasswordChangeRequest + +// SetupFirstAdminJSONRequestBody defines body for SetupFirstAdmin for application/json ContentType. +type SetupFirstAdminJSONRequestBody = CredentialsRequest + +// CreateAccessTokenJSONRequestBody defines body for CreateAccessToken for application/json ContentType. +type CreateAccessTokenJSONRequestBody = TokenRequest + // ServerInterface represents all server handlers. type ServerInterface interface { + // ListGrants List my grants + // (GET /auth/grants) + ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) + // RevokeGrant Revoke one of my grants + // (DELETE /auth/grants/{id}) + RevokeGrant(w http.ResponseWriter, r *http.Request, id string) + // Login Log in with a password + // (POST /auth/login) + Login(w http.ResponseWriter, r *http.Request) + // Logout Log out + // (POST /auth/logout) + Logout(w http.ResponseWriter, r *http.Request) + // ChangePassword Change my password + // (POST /auth/password) + ChangePassword(w http.ResponseWriter, r *http.Request) + // SetupFirstAdmin Create the first admin + // (POST /auth/setup) + SetupFirstAdmin(w http.ResponseWriter, r *http.Request) + // CreateAccessToken Mint an access token + // (POST /auth/token) + CreateAccessToken(w http.ResponseWriter, r *http.Request) // GetServerInfo Describe the server // (GET /server) GetServerInfo(w http.ResponseWriter, r *http.Request) @@ -152,6 +409,48 @@ type ServerInterface interface { type Unimplemented struct{} +// ListGrants List my grants +// (GET /auth/grants) +func (_ Unimplemented) ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// RevokeGrant Revoke one of my grants +// (DELETE /auth/grants/{id}) +func (_ Unimplemented) RevokeGrant(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Login Log in with a password +// (POST /auth/login) +func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Logout Log out +// (POST /auth/logout) +func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ChangePassword Change my password +// (POST /auth/password) +func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// SetupFirstAdmin Create the first admin +// (POST /auth/setup) +func (_ Unimplemented) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// CreateAccessToken Mint an access token +// (POST /auth/token) +func (_ Unimplemented) CreateAccessToken(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // GetServerInfo Describe the server // (GET /server) func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -167,6 +466,148 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler +// ListGrants operation middleware +func (siw *ServerInterfaceWrapper) ListGrants(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ListGrantsParams + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "offset", r.URL.Query(), ¶ms.OffsetParam, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "offset"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.LimitParam, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListGrants(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RevokeGrant operation middleware +func (siw *ServerInterfaceWrapper) RevokeGrant(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: r.URL.RawPath == ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RevokeGrant(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Login operation middleware +func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Login(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Logout operation middleware +func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Logout(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ChangePassword operation middleware +func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ChangePassword(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SetupFirstAdmin operation middleware +func (siw *ServerInterfaceWrapper) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetupFirstAdmin(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateAccessToken operation middleware +func (siw *ServerInterfaceWrapper) CreateAccessToken(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateAccessToken(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetServerInfo operation middleware func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -294,6 +735,27 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl ErrorHandlerFunc: options.ErrorHandlerFunc, } + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/auth/grants", wrapper.ListGrants) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/auth/grants/{id}", wrapper.RevokeGrant) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/login", wrapper.Login) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/logout", wrapper.Logout) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/password", wrapper.ChangePassword) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/setup", wrapper.SetupFirstAdmin) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/token", wrapper.CreateAccessToken) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) }) @@ -301,8 +763,751 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl return r } +type BadRequestApplicationProblemPlusJSONResponse Problem + +type ConflictApplicationProblemPlusJSONResponse Problem + +type ForbiddenResponseHeaders struct { + WWWAuthenticate *string +} +type ForbiddenApplicationProblemPlusJSONResponse struct { + Body Problem + + Headers ForbiddenResponseHeaders +} + type InternalErrorApplicationProblemPlusJSONResponse Problem +type NotFoundApplicationProblemPlusJSONResponse Problem + +type PayloadTooLargeApplicationProblemPlusJSONResponse Problem + +type TooManyRequestsResponseHeaders struct { + RetryAfter *int +} +type TooManyRequestsApplicationProblemPlusJSONResponse struct { + Body Problem + + Headers TooManyRequestsResponseHeaders +} + +type UnauthorizedResponseHeaders struct { + WWWAuthenticate *string +} +type UnauthorizedApplicationProblemPlusJSONResponse struct { + Body Problem + + Headers UnauthorizedResponseHeaders +} + +type ListGrantsRequestObject struct { + Params ListGrantsParams +} + +type ListGrantsResponseObject interface { + VisitListGrantsResponse(w http.ResponseWriter) error +} + +type ListGrants200JSONResponse GrantList + +func (response ListGrants200JSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response ListGrants400ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response ListGrants401ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response ListGrants403ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response ListGrants500ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrantRequestObject struct { + Id string `json:"id"` +} + +type RevokeGrantResponseObject interface { + VisitRevokeGrantResponse(w http.ResponseWriter) error +} + +type RevokeGrant204Response struct { +} + +func (response RevokeGrant204Response) VisitRevokeGrantResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type RevokeGrant400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant400ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant401ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant403ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant404ApplicationProblemPlusJSONResponse struct { + NotFoundApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant404ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant500ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type LoginRequestObject struct { + Body *LoginJSONRequestBody +} + +type LoginResponseObject interface { + VisitLoginResponse(w http.ResponseWriter) error +} + +type Login200JSONResponse GrantCreated + +func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type Login400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response Login400ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type Login401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response Login401ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type Login413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response Login413ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(413) + _, err := buf.WriteTo(w) + return err +} + +type Login429ApplicationProblemPlusJSONResponse struct { + TooManyRequestsApplicationProblemPlusJSONResponse +} + +func (response Login429ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.RetryAfter != nil { + w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) + } + w.WriteHeader(429) + _, err := buf.WriteTo(w) + return err +} + +type Login500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response Login500ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type LogoutRequestObject struct { +} + +type LogoutResponseObject interface { + VisitLogoutResponse(w http.ResponseWriter) error +} + +type Logout200JSONResponse LogoutResponse + +func (response Logout200JSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type Logout401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response Logout401ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type Logout403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response Logout403ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type Logout500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response Logout500ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type ChangePasswordRequestObject struct { + Body *ChangePasswordJSONRequestBody +} + +type ChangePasswordResponseObject interface { + VisitChangePasswordResponse(w http.ResponseWriter) error +} + +type ChangePassword204Response struct { +} + +func (response ChangePassword204Response) VisitChangePasswordResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type ChangePassword400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response ChangePassword400ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response ChangePassword401ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response ChangePassword403ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword409ApplicationProblemPlusJSONResponse struct { + ConflictApplicationProblemPlusJSONResponse +} + +func (response ChangePassword409ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(409) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response ChangePassword413ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(413) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword429ApplicationProblemPlusJSONResponse struct { + TooManyRequestsApplicationProblemPlusJSONResponse +} + +func (response ChangePassword429ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.RetryAfter != nil { + w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) + } + w.WriteHeader(429) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response ChangePassword500ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdminRequestObject struct { + Body *SetupFirstAdminJSONRequestBody +} + +type SetupFirstAdminResponseObject interface { + VisitSetupFirstAdminResponse(w http.ResponseWriter) error +} + +type SetupFirstAdmin201JSONResponse GrantCreated + +func (response SetupFirstAdmin201JSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin400ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin409ApplicationProblemPlusJSONResponse struct { + ConflictApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin409ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(409) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin413ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(413) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin429ApplicationProblemPlusJSONResponse struct { + TooManyRequestsApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin429ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.RetryAfter != nil { + w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) + } + w.WriteHeader(429) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin500ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type CreateAccessTokenRequestObject struct { + Body *CreateAccessTokenJSONRequestBody +} + +type CreateAccessTokenResponseObject interface { + VisitCreateAccessTokenResponse(w http.ResponseWriter) error +} + +type CreateAccessToken200JSONResponse AccessToken + +func (response CreateAccessToken200JSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type CreateAccessToken400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken400ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type CreateAccessToken401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken401ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type CreateAccessToken413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken413ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(413) + _, err := buf.WriteTo(w) + return err +} + +type CreateAccessToken500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken500ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + type GetServerInfoRequestObject struct { } @@ -342,6 +1547,27 @@ func (response GetServerInfo500ApplicationProblemPlusJSONResponse) VisitGetServe // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // ListGrants List my grants + // (GET /auth/grants) + ListGrants(ctx context.Context, request ListGrantsRequestObject) (ListGrantsResponseObject, error) + // RevokeGrant Revoke one of my grants + // (DELETE /auth/grants/{id}) + RevokeGrant(ctx context.Context, request RevokeGrantRequestObject) (RevokeGrantResponseObject, error) + // Login Log in with a password + // (POST /auth/login) + Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) + // Logout Log out + // (POST /auth/logout) + Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) + // ChangePassword Change my password + // (POST /auth/password) + ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error) + // SetupFirstAdmin Create the first admin + // (POST /auth/setup) + SetupFirstAdmin(ctx context.Context, request SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) + // CreateAccessToken Mint an access token + // (POST /auth/token) + CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) // GetServerInfo Describe the server // (GET /server) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) @@ -386,6 +1612,209 @@ type strictHandler struct { options StrictHTTPServerOptions } +// ListGrants operation middleware +func (sh *strictHandler) ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) { + var request ListGrantsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListGrants(ctx, request.(ListGrantsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListGrants") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListGrantsResponseObject); ok { + if err := validResponse.VisitListGrantsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// RevokeGrant operation middleware +func (sh *strictHandler) RevokeGrant(w http.ResponseWriter, r *http.Request, id string) { + var request RevokeGrantRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.RevokeGrant(ctx, request.(RevokeGrantRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RevokeGrant") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RevokeGrantResponseObject); ok { + if err := validResponse.VisitRevokeGrantResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Login operation middleware +func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) { + var request LoginRequestObject + + var body LoginJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Login(ctx, request.(LoginRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Login") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LoginResponseObject); ok { + if err := validResponse.VisitLoginResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Logout operation middleware +func (sh *strictHandler) Logout(w http.ResponseWriter, r *http.Request) { + var request LogoutRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Logout(ctx, request.(LogoutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Logout") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LogoutResponseObject); ok { + if err := validResponse.VisitLogoutResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ChangePassword operation middleware +func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + var request ChangePasswordRequestObject + + var body ChangePasswordJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ChangePassword(ctx, request.(ChangePasswordRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ChangePassword") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ChangePasswordResponseObject); ok { + if err := validResponse.VisitChangePasswordResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SetupFirstAdmin operation middleware +func (sh *strictHandler) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { + var request SetupFirstAdminRequestObject + + var body SetupFirstAdminJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SetupFirstAdmin(ctx, request.(SetupFirstAdminRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SetupFirstAdmin") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SetupFirstAdminResponseObject); ok { + if err := validResponse.VisitSetupFirstAdminResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// CreateAccessToken operation middleware +func (sh *strictHandler) CreateAccessToken(w http.ResponseWriter, r *http.Request) { + var request CreateAccessTokenRequestObject + + var body CreateAccessTokenJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + if !errors.Is(err, io.EOF) { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + } else { + request.Body = &body + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateAccessToken(ctx, request.(CreateAccessTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateAccessToken") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateAccessTokenResponseObject); ok { + if err := validResponse.VisitCreateAccessTokenResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetServerInfo operation middleware func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { var request GetServerInfoRequestObject diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index f89244e38..6875b2330 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" "testing" "github.com/getkin/kin-openapi/openapi3" @@ -14,7 +15,11 @@ import ( "github.com/getkin/kin-openapi/routers/gorillamux" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -29,7 +34,13 @@ func TestAPIv1(t *testing.T) { var specRouter routers.Router +// One database for the suite (db.Db() is a process-wide singleton); each spec clears users and grants. var _ = BeforeSuite(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiv1.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000" + DeferCleanup(db.Init(GinkgoT().Context())) + realDS = persistence.New(db.Db()) + doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) Expect(err).ToNot(HaveOccurred()) specRouter, err = gorillamux.NewRouter(doc) diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go new file mode 100644 index 000000000..543b1e446 --- /dev/null +++ b/server/apiv1/auth_handlers.go @@ -0,0 +1,71 @@ +package apiv1 + +import ( + "cmp" + "context" + + "github.com/navidrome/navidrome/core/apiauth" +) + +const defaultPageSize = 100 + +func (rt *Router) CreateAccessToken(ctx context.Context, req CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) { + p, err := principal(apiauth.PrincipalFrom(ctx)) + if err != nil { + return nil, err + } + var requested []string + if req.Body != nil { + requested = fromScopeRequests(req.Body.Scopes) + } + tok, err := rt.auth.Mint(ctx, p, requested) + if err != nil { + return nil, err + } + return CreateAccessToken200JSONResponse{ + AccessToken: tok.Token, + TokenType: AccessTokenTokenTypeBearer, + ExpiresIn: int(tok.ExpiresIn.Seconds()), + Scopes: toScopes(tok.Scopes), + }, nil +} + +func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) { + p, err := principal(apiauth.PrincipalFrom(ctx)) + if err != nil { + return nil, err + } + offset := deref(req.Params.OffsetParam) + limit := cmp.Or(deref(req.Params.LimitParam), defaultPageSize) + grants, total, err := rt.auth.ListGrants(ctx, p, offset, limit) + if err != nil { + return nil, err + } + items := make([]Grant, len(grants)) + for i, g := range grants { + items[i] = toGrant(g, p.GrantID) + } + return ListGrants200JSONResponse{Items: items, Total: int(total), Offset: offset, Limit: limit}, nil +} + +func (rt *Router) RevokeGrant(ctx context.Context, req RevokeGrantRequestObject) (RevokeGrantResponseObject, error) { + p, err := principal(apiauth.PrincipalFrom(ctx)) + if err != nil { + return nil, err + } + if err := rt.auth.RevokeGrant(ctx, p, req.Id); err != nil { + return nil, err + } + return RevokeGrant204Response{}, nil +} + +func (rt *Router) Logout(ctx context.Context, _ LogoutRequestObject) (LogoutResponseObject, error) { + p, err := principal(apiauth.PrincipalFrom(ctx)) + if err != nil { + return nil, err + } + if err := rt.auth.Logout(ctx, p); err != nil { + return nil, err + } + return Logout200JSONResponse{LogoutUrl: nil}, nil +} diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go new file mode 100644 index 000000000..da45a2141 --- /dev/null +++ b/server/apiv1/auth_test.go @@ -0,0 +1,234 @@ +package apiv1 + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("auth endpoints", func() { + var ctx context.Context + var router *Router + + call := func(method, path, bearer string, body any) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + b, _ := json.Marshal(body) + req = httptest.NewRequestWithContext(ctx, method, path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequestWithContext(ctx, method, path, nil) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + return serve(router, req) + } + + creds := func(user, pw string) map[string]any { + return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"} + } + + decode := func(w *httptest.ResponseRecorder, v any) { + ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String()) + } + + setup := func() GrantCreated { + w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String()) + var gc GrantCreated + decode(w, &gc) + return gc + } + + mint := func(secret string, body any) AccessToken { + w := call(http.MethodPost, "/api/v1/auth/token", secret, body) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String()) + var at AccessToken + decode(w, &at) + return at + } + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 0 + resetDB() + router = New(realDS) + }) + + It("lets exactly one of a v1 setup and a v0 first-admin creation win", func() { + var wg sync.WaitGroup + var v1Code int + var v0Err error + wg.Add(2) + go func() { + defer GinkgoRecover() + defer wg.Done() + v1Code = call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code + }() + go func() { + defer GinkgoRecover() + defer wg.Done() + v0Err = realDS.WithTxImmediate(func(tx model.DataStore) error { // what v0 /auth/createAdmin runs + _, err := auth.CreateFirstAdmin(ctx, tx, "v0admin", "pw") + return err + }) + }() + wg.Wait() + Expect(realDS.User().CountAll(ctx)).To(Equal(int64(1))) + Expect(v1Code == http.StatusCreated).ToNot(Equal(v0Err == nil), "exactly one must win") + }) + + It("sets up the first admin once, then answers 409 setup_complete", func() { + gc := setup() + Expect(gc.Secret).To(HavePrefix("ndg_")) + Expect(gc.User.IsAdmin).To(BeTrue()) + Expect(gc.Grant.Provider).To(Equal("setup")) + Expect(gc.Grant.Current).To(BeTrue()) + + w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw")) + Expect(w.Code).To(Equal(http.StatusConflict)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete)) + }) + + It("logs in, mints a token, and uses it on a scoped endpoint", func() { + setup() + w := call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw")) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + var gc GrantCreated + decode(w, &gc) + Expect(gc.User.PasswordChangeable).To(BeTrue()) + + at := mint(gc.Secret, nil) + Expect(at.TokenType).To(Equal(AccessTokenTokenTypeBearer)) + Expect(at.ExpiresIn).To(Equal(3600)) + + w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + var list GrantList + decode(w, &list) + Expect(list.Total).To(Equal(2)) + Expect(list.Limit).To(Equal(100)) + }) + + It("fails login the same way for an unknown user and a wrong password, with a Bearer challenge", func() { + setup() + a := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong")) + b := call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw")) + Expect(a.Code).To(Equal(http.StatusUnauthorized)) + Expect(a.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) + Expect(a.Body.String()).To(Equal(b.Body.String())) + }) + + It("treats no body and {} as all scopes, and [] as no scopes", func() { + gc := setup() + all := mint(gc.Secret, nil) + Expect(all.Scopes).To(ConsistOf(ScopeRead, ScopePassword)) + Expect(mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword)) + + none := mint(gc.Secret, map[string]any{"scopes": []string{}}) + Expect(none.Scopes).To(BeEmpty()) + w := call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) + }) + + It("drops unknown requested scopes instead of rejecting them", func() { + gc := setup() + at := mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}}) + Expect(at.Scopes).To(ConsistOf(ScopeRead)) + }) + + It("does not let a token without read log out or revoke grants", func() { + gc := setup() + narrow := mint(gc.Secret, map[string]any{"scopes": []string{"password"}}) + Expect(call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) + Expect(call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) + }) + + It("logs out: the token stops at once and logoutUrl is null", func() { + gc := setup() + at := mint(gc.Secret, nil) + w := call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) + + w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() { + gc := setup() + tok := mint(gc.Secret, nil).AccessToken + Expect(call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound)) + w := call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, nil) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"})) + }) + + It("changes the password, keeping the caller and revoking the rest", func() { + gc := setup() + otherLogin := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) + var other GrantCreated + decode(otherLogin, &other) + at := mint(gc.Secret, nil) + + w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String()) + + Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) + Expect(call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("reports a wrong current password as a field error", func() { + gc := setup() + at := mint(gc.Secret, nil) + w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "nope", "newPassword": "pw2"}) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + p := decodeProblem(w) + Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"})) + }) + + DescribeTable("rejects bad credential bodies with a field error and no echo", + func(body map[string]any, field string) { + w := call(http.MethodPost, "/api/v1/auth/setup", "", body) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(*p.Errors).To(ContainElement(HaveField("Field", field))) + Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) + }, + Entry("missing client", map[string]any{"username": "a", "password": "hunter2"}, "client"), + Entry("empty password", map[string]any{"username": "a", "password": "", "client": "hunter2"}, "password"), + Entry("client too long", map[string]any{"username": "a", "password": "hunter2", "client": strings.Repeat("x", 65)}, "client"), + Entry("bad scope format", map[string]any{"username": "a", "password": "hunter2", "client": "c", "scopes": []string{"NOT OK"}}, "scopes.0"), + ) + + DescribeTable("rejects a body over 1 MiB with 413", + func(body func(string) io.Reader) { + big := `{"username":"a","password":"` + strings.Repeat("a", maxBodyBytes) + `","client":"c"}` + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", body(big)) + req.Header.Set("Content-Type", "application/json") + w := serve(router, req) + Expect(w.Code).To(Equal(http.StatusRequestEntityTooLarge)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodePayloadTooLarge)) + }, + Entry("with a declared length", func(s string) io.Reader { return strings.NewReader(s) }), + // io.MultiReader hides the length, so the request has ContentLength -1, like a chunked upload. + Entry("with no declared length", func(s string) io.Reader { return io.MultiReader(strings.NewReader(s)) }), + ) +}) diff --git a/server/apiv1/db_test.go b/server/apiv1/db_test.go new file mode 100644 index 000000000..6b292a10b --- /dev/null +++ b/server/apiv1/db_test.go @@ -0,0 +1,13 @@ +package apiv1 + +import ( + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" +) + +var realDS model.DataStore + +func resetDB() { + _, _ = db.Db().Exec("delete from api_grant") + _, _ = db.Db().Exec("delete from user") +} diff --git a/server/apiv1/dto.go b/server/apiv1/dto.go new file mode 100644 index 000000000..10ec338b1 --- /dev/null +++ b/server/apiv1/dto.go @@ -0,0 +1,78 @@ +package apiv1 + +import ( + "github.com/navidrome/navidrome/core/apiauth" + "github.com/navidrome/navidrome/model" +) + +func toScopes(in []string) []Scope { + out := make([]Scope, len(in)) + for i, s := range in { + out[i] = Scope(s) + } + return out +} + +// fromScopeRequests keeps nil (all scopes) apart from an empty list (no scopes). +func fromScopeRequests(in *[]ScopeRequest) []string { + if in == nil { + return nil + } + return append([]string{}, *in...) +} + +func nullable(s string) *string { + if s == "" { + return nil + } + return &s +} + +func toGrant(g model.Grant, currentID string) Grant { + return Grant{ + Id: g.ID, + Name: g.Name, + Client: g.Client, + ClientVersion: nullable(g.ClientVersion), + Scopes: toScopes(g.Scopes), + Provider: g.Provider, + CreatedAt: g.CreatedAt, + LastUsedAt: g.LastUsedAt, + LastUsedIp: nullable(g.LastUsedIP), + Current: g.ID == currentID, + } +} + +func toGrantCreated(i *apiauth.Issued) GrantCreated { + return GrantCreated{ + Secret: i.Secret, + Grant: toGrant(i.Grant, i.Grant.ID), + User: AuthUser{ + Id: i.User.ID, + UserName: i.User.UserName, + Name: i.User.Name, + IsAdmin: i.User.IsAdmin, + PasswordChangeable: apiauth.PasswordChangeable(i.User), + }, + } +} + +func clientMeta(c CredentialsRequest) apiauth.ClientMeta { + return apiauth.ClientMeta{Client: c.Client, Name: deref(c.Name), ClientVersion: deref(c.ClientVersion)} +} + +// principal fails closed if the gate did not attach a principal to the context. +func principal(p *apiauth.Principal, ok bool) (*apiauth.Principal, error) { + if !ok || p == nil { + return nil, model.ErrInvalidAuth + } + return p, nil +} + +func deref[T any](p *T) T { + var zero T + if p == nil { + return zero + } + return *p +} diff --git a/server/apiv1/oapi-codegen-overlay.yaml b/server/apiv1/oapi-codegen-overlay.yaml new file mode 100644 index 000000000..36a546287 --- /dev/null +++ b/server/apiv1/oapi-codegen-overlay.yaml @@ -0,0 +1,12 @@ +overlay: 1.0.0 +info: + title: Go type names for the API v1 server + version: 1.0.0 +actions: + # Ginkgo's dot-imported Offset would clash with a generated Offset type in this package's tests. + - target: $.components.parameters.offset + update: + x-go-name: OffsetParam + - target: $.components.parameters.limit + update: + x-go-name: LimitParam diff --git a/server/apiv1/oapi-codegen.yaml b/server/apiv1/oapi-codegen.yaml index b9236de1a..8301d8088 100644 --- a/server/apiv1/oapi-codegen.yaml +++ b/server/apiv1/oapi-codegen.yaml @@ -8,5 +8,7 @@ output-options: exclude-operation-ids: - getOpenAPISpecJSON - getOpenAPISpecYAML + overlay: + path: server/apiv1/oapi-codegen-overlay.yaml compatibility: always-prefix-enum-values: true diff --git a/server/apiv1/password_handlers.go b/server/apiv1/password_handlers.go new file mode 100644 index 000000000..a96d905d0 --- /dev/null +++ b/server/apiv1/password_handlers.go @@ -0,0 +1,47 @@ +package apiv1 + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/apiauth" +) + +// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same. +func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) { + b := *req.Body + issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) + if err != nil { + return nil, err + } + return Login200JSONResponse(toGrantCreated(issued)), nil +} + +func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) { + b := *req.Body + issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) + if err != nil { + return nil, err + } + return SetupFirstAdmin201JSONResponse(toGrantCreated(issued)), nil +} + +func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) { + p, err := principal(apiauth.PrincipalFrom(ctx)) + if err != nil { + return nil, err + } + b := *req.Body + revoke := true + if b.RevokeOtherGrants != nil { + revoke = *b.RevokeOtherGrants + } + err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke) + if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) { + return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}) + } + if err != nil { + return nil, err + } + return ChangePassword204Response{}, nil +} From 28d023449d8281de7278d09b83cefb9c5040522b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:57:43 -0400 Subject: [PATCH 14/41] feat(api): report login methods and add GET /capabilities --- api/bundled/openapi.json | 137 +++++++++++--- api/bundled/openapi.yaml | 96 +++++++--- .../components/schemas/Capabilities.yaml | 10 ++ .../components/schemas/CoreCapability.yaml | 5 + .../components/schemas/LoginMethods.yaml | 8 + .../schemas/PasswordCapability.yaml | 5 + .../schemas/PasswordLoginMethod.yaml | 2 + .../components/schemas/ServerInfo.yaml | 6 +- api/openapi/openapi.yaml | 4 +- api/openapi/paths/capabilities.yaml | 16 ++ api/openapi/paths/server.yaml | 3 +- server/apiv1/api_gen.go | 167 +++++++++++++++--- server/apiv1/api_test.go | 14 ++ server/apiv1/capabilities.go | 10 ++ server/apiv1/capabilities_test.go | 53 ++++++ server/apiv1/server_info.go | 2 +- server/apiv1/server_info_test.go | 3 +- 17 files changed, 460 insertions(+), 81 deletions(-) create mode 100644 api/openapi/components/schemas/Capabilities.yaml create mode 100644 api/openapi/components/schemas/CoreCapability.yaml create mode 100644 api/openapi/components/schemas/LoginMethods.yaml create mode 100644 api/openapi/components/schemas/PasswordCapability.yaml create mode 100644 api/openapi/components/schemas/PasswordLoginMethod.yaml create mode 100644 api/openapi/paths/capabilities.yaml create mode 100644 server/apiv1/capabilities.go create mode 100644 server/apiv1/capabilities_test.go diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index 0a5d889c8..a2cfce901 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Navidrome API", "version": "1.0.0", - "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n", + "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n", "license": { "name": "GPL-3.0", "url": "https://www.gnu.org/licenses/gpl-3.0.html" @@ -35,7 +35,7 @@ ], "security": [], "summary": "Describe the server", - "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n", + "description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n", "responses": { "200": { "description": "Server description.", @@ -53,6 +53,41 @@ } } }, + "/capabilities": { + "get": { + "operationId": "getCapabilities", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "server" + ], + "summary": "List implemented capability modules", + "description": "The capability modules this server implements. Any valid access token may read it, whatever its scopes.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Implemented modules.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Capabilities" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/openapi.json": { "get": { "operationId": "getOpenAPISpecJSON", @@ -512,17 +547,23 @@ "description": "True until the first admin user has been created." }, "loginMethods": { - "type": "array", - "description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.", - "items": { - "type": "string", - "enum": [ - "password" - ] - } + "$ref": "#/components/schemas/LoginMethods" } } }, + "LoginMethods": { + "type": "object", + "description": "Login methods this server accepts, keyed by method. A missing key means the method is not offered.\nKeys are optional on purpose: discovery is read by clients of any version against servers of any\nversion, so new methods are added as new optional keys. Clients ignore keys they do not know.\n", + "properties": { + "password": { + "$ref": "#/components/schemas/PasswordLoginMethod" + } + } + }, + "PasswordLoginMethod": { + "type": "object", + "description": "Username and password login (`POST /auth/login`). No settings yet." + }, "Problem": { "type": "object", "description": "RFC 9457 problem details, returned for every 4xx and 5xx response.", @@ -598,6 +639,44 @@ } } }, + "Capabilities": { + "type": "object", + "description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling token may use.\n", + "properties": { + "core": { + "$ref": "#/components/schemas/CoreCapability" + }, + "password": { + "$ref": "#/components/schemas/PasswordCapability" + } + } + }, + "CoreCapability": { + "type": "object", + "description": "The mandatory core module.", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer", + "description": "Module version. Bumped only on semantic change." + } + } + }, + "PasswordCapability": { + "type": "object", + "description": "The password login module (login, first-admin setup, password change).", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer", + "description": "Module version. Bumped only on semantic change." + } + } + }, "TokenRequest": { "type": "object", "description": "Optional narrowing of a new access token.", @@ -924,6 +1003,21 @@ } } }, + "Unauthorized": { + "description": "Missing, invalid, or expired credentials.", + "headers": { + "WWW-Authenticate": { + "$ref": "#/components/headers/WWWAuthenticate" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, "NotModified": { "description": "Not modified.", "headers": { @@ -942,21 +1036,6 @@ } } }, - "Unauthorized": { - "description": "Missing, invalid, or expired credentials.", - "headers": { - "WWW-Authenticate": { - "$ref": "#/components/headers/WWWAuthenticate" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, "PayloadTooLarge": { "description": "The request body is too large (`payload_too_large`).", "content": { @@ -1047,14 +1126,14 @@ } }, "headers": { - "ETag": { - "description": "Entity tag for `If-None-Match` revalidation.", + "WWWAuthenticate": { + "description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.", "schema": { "type": "string" } }, - "WWWAuthenticate": { - "description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.", + "ETag": { + "description": "Entity tag for `If-None-Match` revalidation.", "schema": { "type": "string" } diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index 778791f97..f4a76c006 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: | Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. + capability modules through `GET /capabilities` and never sniff versions. Enums are open: new values may be added to any enum within v1. Clients must accept values they do not recognise instead of failing. @@ -40,8 +40,7 @@ paths: summary: Describe the server description: | Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. + Capability modules are listed by `GET /capabilities`. responses: '200': description: Server description. @@ -51,6 +50,23 @@ paths: $ref: '#/components/schemas/ServerInfo' '500': $ref: '#/components/responses/InternalError' + /capabilities: + get: + operationId: getCapabilities + x-module: core + x-stability-level: alpha + tags: [server] + summary: List implemented capability modules + description: The capability modules this server implements. Any valid access token may read it, whatever its scopes. + security: [{bearerAuth: []}] + responses: + '200': + description: Implemented modules. + content: + application/json: + schema: {$ref: '#/components/schemas/Capabilities'} + '401': {$ref: '#/components/responses/Unauthorized'} + '500': {$ref: '#/components/responses/InternalError'} /openapi.json: get: operationId: getOpenAPISpecJSON @@ -342,12 +358,19 @@ components: type: boolean description: True until the first admin user has been created. loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: - - password + $ref: '#/components/schemas/LoginMethods' + LoginMethods: + type: object + description: | + Login methods this server accepts, keyed by method. A missing key means the method is not offered. + Keys are optional on purpose: discovery is read by clients of any version against servers of any + version, so new methods are added as new optional keys. Clients ignore keys they do not know. + properties: + password: + $ref: '#/components/schemas/PasswordLoginMethod' + PasswordLoginMethod: + type: object + description: Username and password login (`POST /auth/login`). No settings yet. Problem: type: object description: RFC 9457 problem details, returned for every 4xx and 5xx response. @@ -409,6 +432,35 @@ components: message: type: string description: Why the value was rejected. + Capabilities: + type: object + description: | + Capability modules this server implements, keyed by module. Keys are optional; a missing key means the + module is not implemented. New modules are added as new optional keys. These are server facts, not what + the calling token may use. + properties: + core: + $ref: '#/components/schemas/CoreCapability' + password: + $ref: '#/components/schemas/PasswordCapability' + CoreCapability: + type: object + description: The mandatory core module. + required: + - version + properties: + version: + type: integer + description: Module version. Bumped only on semantic change. + PasswordCapability: + type: object + description: The password login module (login, first-admin setup, password change). + required: + - version + properties: + version: + type: integer + description: Module version. Bumped only on semantic change. TokenRequest: type: object description: Optional narrowing of a new access token. @@ -654,6 +706,15 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + Unauthorized: + description: Missing, invalid, or expired credentials. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WWWAuthenticate' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' NotModified: description: Not modified. headers: @@ -665,15 +726,6 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' - Unauthorized: - description: Missing, invalid, or expired credentials. - headers: - WWW-Authenticate: - $ref: '#/components/headers/WWWAuthenticate' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' PayloadTooLarge: description: "The request body is too large (`payload_too_large`)." content: @@ -733,11 +785,11 @@ components: maximum: 2000 default: 100 headers: - ETag: - description: Entity tag for `If-None-Match` revalidation. - schema: - type: string WWWAuthenticate: description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' schema: type: string + ETag: + description: Entity tag for `If-None-Match` revalidation. + schema: + type: string diff --git a/api/openapi/components/schemas/Capabilities.yaml b/api/openapi/components/schemas/Capabilities.yaml new file mode 100644 index 000000000..26445a312 --- /dev/null +++ b/api/openapi/components/schemas/Capabilities.yaml @@ -0,0 +1,10 @@ +type: object +description: | + Capability modules this server implements, keyed by module. Keys are optional; a missing key means the + module is not implemented. New modules are added as new optional keys. These are server facts, not what + the calling token may use. +properties: + core: + $ref: ./CoreCapability.yaml + password: + $ref: ./PasswordCapability.yaml diff --git a/api/openapi/components/schemas/CoreCapability.yaml b/api/openapi/components/schemas/CoreCapability.yaml new file mode 100644 index 000000000..c52c21318 --- /dev/null +++ b/api/openapi/components/schemas/CoreCapability.yaml @@ -0,0 +1,5 @@ +type: object +description: The mandatory core module. +required: [version] +properties: + version: {type: integer, description: Module version. Bumped only on semantic change.} diff --git a/api/openapi/components/schemas/LoginMethods.yaml b/api/openapi/components/schemas/LoginMethods.yaml new file mode 100644 index 000000000..106d3a6ed --- /dev/null +++ b/api/openapi/components/schemas/LoginMethods.yaml @@ -0,0 +1,8 @@ +type: object +description: | + Login methods this server accepts, keyed by method. A missing key means the method is not offered. + Keys are optional on purpose: discovery is read by clients of any version against servers of any + version, so new methods are added as new optional keys. Clients ignore keys they do not know. +properties: + password: + $ref: ./PasswordLoginMethod.yaml diff --git a/api/openapi/components/schemas/PasswordCapability.yaml b/api/openapi/components/schemas/PasswordCapability.yaml new file mode 100644 index 000000000..319cdbf3d --- /dev/null +++ b/api/openapi/components/schemas/PasswordCapability.yaml @@ -0,0 +1,5 @@ +type: object +description: The password login module (login, first-admin setup, password change). +required: [version] +properties: + version: {type: integer, description: Module version. Bumped only on semantic change.} diff --git a/api/openapi/components/schemas/PasswordLoginMethod.yaml b/api/openapi/components/schemas/PasswordLoginMethod.yaml new file mode 100644 index 000000000..e8a1f0ee2 --- /dev/null +++ b/api/openapi/components/schemas/PasswordLoginMethod.yaml @@ -0,0 +1,2 @@ +type: object +description: Username and password login (`POST /auth/login`). No settings yet. diff --git a/api/openapi/components/schemas/ServerInfo.yaml b/api/openapi/components/schemas/ServerInfo.yaml index 8906924eb..f80fbce5c 100644 --- a/api/openapi/components/schemas/ServerInfo.yaml +++ b/api/openapi/components/schemas/ServerInfo.yaml @@ -15,8 +15,4 @@ properties: type: boolean description: True until the first admin user has been created. loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: [password] + $ref: ./LoginMethods.yaml diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 02d3a56a4..5e55ef2ea 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: | Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. + capability modules through `GET /capabilities` and never sniff versions. Enums are open: new values may be added to any enum within v1. Clients must accept values they do not recognise instead of failing. @@ -32,6 +32,8 @@ tags: paths: /server: $ref: ./paths/server.yaml + /capabilities: + $ref: ./paths/capabilities.yaml /openapi.json: $ref: ./paths/openapi.yaml#/json /openapi.yaml: diff --git a/api/openapi/paths/capabilities.yaml b/api/openapi/paths/capabilities.yaml new file mode 100644 index 000000000..111ba723d --- /dev/null +++ b/api/openapi/paths/capabilities.yaml @@ -0,0 +1,16 @@ +get: + operationId: getCapabilities + x-module: core + x-stability-level: alpha + tags: [server] + summary: List implemented capability modules + description: The capability modules this server implements. Any valid access token may read it, whatever its scopes. + security: [{bearerAuth: []}] + responses: + '200': + description: Implemented modules. + content: + application/json: + schema: {$ref: ../components/schemas/Capabilities.yaml} + '401': {$ref: ../components/responses/Unauthorized.yaml} + '500': {$ref: ../components/responses/InternalError.yaml} diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml index ef30bcb10..1e17a3b87 100644 --- a/api/openapi/paths/server.yaml +++ b/api/openapi/paths/server.yaml @@ -7,8 +7,7 @@ get: summary: Describe the server description: | Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. + Capability modules are listed by `GET /capabilities`. responses: '200': description: Server description. diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index 9851e485c..ccca0efc0 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -104,21 +104,6 @@ func (e Scope) Valid() bool { } } -// Defines values for ServerInfoLoginMethods. -const ( - ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" -) - -// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum. -func (e ServerInfoLoginMethods) Valid() bool { - switch e { - case ServerInfoLoginMethodsPassword: - return true - default: - return false - } -} - // AccessToken A short-lived access token. Opaque; clients must not decode it. type AccessToken struct { // AccessToken The token. Send it as `Authorization: Bearer `. @@ -155,6 +140,23 @@ type AuthUser struct { UserName string `json:"userName"` } +// Capabilities Capability modules this server implements, keyed by module. Keys are optional; a missing key means the +// module is not implemented. New modules are added as new optional keys. These are server facts, not what +// the calling token may use. +type Capabilities struct { + // Core The mandatory core module. + Core *CoreCapability `json:"core,omitempty"` + + // Password The password login module (login, first-admin setup, password change). + Password *PasswordCapability `json:"password,omitempty"` +} + +// CoreCapability The mandatory core module. +type CoreCapability struct { + // Version Module version. Bumped only on semantic change. + Version int `json:"version"` +} + // CredentialsRequest Username, password and client description for a login or first-admin setup. type CredentialsRequest struct { // Client Name of the client app. @@ -236,12 +238,26 @@ type GrantList struct { Total int `json:"total"` } +// LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered. +// Keys are optional on purpose: discovery is read by clients of any version against servers of any +// version, so new methods are added as new optional keys. Clients ignore keys they do not know. +type LoginMethods struct { + // Password Username and password login (`POST /auth/login`). No settings yet. + Password *PasswordLoginMethod `json:"password,omitempty"` +} + // LogoutResponse Result of a logout. type LogoutResponse struct { // LogoutUrl Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do. LogoutUrl *string `json:"logoutUrl"` } +// PasswordCapability The password login module (login, first-admin setup, password change). +type PasswordCapability struct { + // Version Module version. Bumped only on semantic change. + Version int `json:"version"` +} + // PasswordChangeRequest Change the caller's own password. type PasswordChangeRequest struct { // CurrentPassword The current password. @@ -254,6 +270,9 @@ type PasswordChangeRequest struct { RevokeOtherGrants *bool `json:"revokeOtherGrants,omitempty"` } +// PasswordLoginMethod Username and password login (`POST /auth/login`). No settings yet. +type PasswordLoginMethod = map[string]interface{} + // Problem RFC 9457 problem details, returned for every 4xx and 5xx response. type Problem struct { // Code Machine-readable error code, and the value clients switch on. New codes may be added. @@ -292,8 +311,10 @@ type ScopeRequest = string // ServerInfo Public server description. Everything an add-server screen needs before login. type ServerInfo struct { - // LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - LoginMethods []ServerInfoLoginMethods `json:"loginMethods"` + // LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered. + // Keys are optional on purpose: discovery is read by clients of any version against servers of any + // version, so new methods are added as new optional keys. Clients ignore keys they do not know. + LoginMethods LoginMethods `json:"loginMethods"` // Name Human-readable server product name. Name string `json:"name"` @@ -308,9 +329,6 @@ type ServerInfo struct { SpecVersion string `json:"specVersion"` } -// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. -type ServerInfoLoginMethods string - // TokenRequest Optional narrowing of a new access token. type TokenRequest struct { // Scopes Subset of the grant's scopes. Omit for all of them; an empty list asks for none. @@ -400,6 +418,9 @@ type ServerInterface interface { // CreateAccessToken Mint an access token // (POST /auth/token) CreateAccessToken(w http.ResponseWriter, r *http.Request) + // GetCapabilities List implemented capability modules + // (GET /capabilities) + GetCapabilities(w http.ResponseWriter, r *http.Request) // GetServerInfo Describe the server // (GET /server) GetServerInfo(w http.ResponseWriter, r *http.Request) @@ -451,6 +472,12 @@ func (_ Unimplemented) CreateAccessToken(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNotImplemented) } +// GetCapabilities List implemented capability modules +// (GET /capabilities) +func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // GetServerInfo Describe the server // (GET /server) func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -608,6 +635,20 @@ func (siw *ServerInterfaceWrapper) CreateAccessToken(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// GetCapabilities operation middleware +func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCapabilities(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetServerInfo operation middleware func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -756,6 +797,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/auth/token", wrapper.CreateAccessToken) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) }) @@ -1508,6 +1552,62 @@ func (response CreateAccessToken500ApplicationProblemPlusJSONResponse) VisitCrea return err } +type GetCapabilitiesRequestObject struct { +} + +type GetCapabilitiesResponseObject interface { + VisitGetCapabilitiesResponse(w http.ResponseWriter) error +} + +type GetCapabilities200JSONResponse Capabilities + +func (response GetCapabilities200JSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetCapabilities401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response GetCapabilities401ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type GetCapabilities500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response GetCapabilities500ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + type GetServerInfoRequestObject struct { } @@ -1568,6 +1668,9 @@ type StrictServerInterface interface { // CreateAccessToken Mint an access token // (POST /auth/token) CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) + // GetCapabilities List implemented capability modules + // (GET /capabilities) + GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) // GetServerInfo Describe the server // (GET /server) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) @@ -1815,6 +1918,30 @@ func (sh *strictHandler) CreateAccessToken(w http.ResponseWriter, r *http.Reques } } +// GetCapabilities operation middleware +func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) { + var request GetCapabilitiesRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetCapabilities(ctx, request.(GetCapabilitiesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetCapabilities") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetCapabilitiesResponseObject); ok { + if err := validResponse.VisitGetCapabilitiesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetServerInfo operation middleware func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { var request GetServerInfoRequestObject diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go index b8743f622..9e38672d1 100644 --- a/server/apiv1/api_test.go +++ b/server/apiv1/api_test.go @@ -4,6 +4,9 @@ import ( "net/http" "net/http/httptest" + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/api" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -16,6 +19,17 @@ var _ = Describe("Router", func() { router = New(&tests.MockDataStore{}) }) + It("routes every operation in the embedded spec", func() { + doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) + Expect(err).ToNot(HaveOccurred()) + mux := New(&tests.MockDataStore{}).Handler.(chi.Routes) + for path, item := range doc.Paths.Map() { + for method := range item.Operations() { + Expect(mux.Find(chi.NewRouteContext(), method, path)).To(Equal(path), method+" "+path) + } + } + }) + It("returns a 404 problem for unknown paths", func() { w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)) Expect(w.Code).To(Equal(http.StatusNotFound)) diff --git a/server/apiv1/capabilities.go b/server/apiv1/capabilities.go new file mode 100644 index 000000000..f3b8c78c7 --- /dev/null +++ b/server/apiv1/capabilities.go @@ -0,0 +1,10 @@ +package apiv1 + +import "context" + +func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) { + return GetCapabilities200JSONResponse{ + Core: &CoreCapability{Version: 1}, + Password: &PasswordCapability{Version: 1}, + }, nil +} diff --git a/server/apiv1/capabilities_test.go b/server/apiv1/capabilities_test.go new file mode 100644 index 000000000..ac1ec1b69 --- /dev/null +++ b/server/apiv1/capabilities_test.go @@ -0,0 +1,53 @@ +package apiv1 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GET /capabilities", func() { + var ctx context.Context + var router *Router + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + resetDB() + router = New(realDS) + }) + + It("needs a token", func() { + w := serve(router, httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("lists core and password for any valid token, even one with no scopes", func() { + body, _ := json.Marshal(map[string]any{"username": "admin", "password": "pw", "client": "c"}) + setupReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body)) + setupReq.Header.Set("Content-Type", "application/json") + var gc GrantCreated + Expect(json.Unmarshal(serve(router, setupReq).Body.Bytes(), &gc)).To(Succeed()) + + tokReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", bytes.NewReader([]byte(`{"scopes":[]}`))) + tokReq.Header.Set("Content-Type", "application/json") + tokReq.Header.Set("Authorization", "Bearer "+gc.Secret) + var at AccessToken + Expect(json.Unmarshal(serve(router, tokReq).Body.Bytes(), &at)).To(Succeed()) + + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil) + req.Header.Set("Authorization", "Bearer "+at.AccessToken) + w := serve(router, req) + Expect(w.Code).To(Equal(http.StatusOK)) + var caps Capabilities + Expect(json.Unmarshal(w.Body.Bytes(), &caps)).To(Succeed()) + Expect(caps.Core.Version).To(Equal(1)) + Expect(caps.Password.Version).To(Equal(1)) + }) +}) diff --git a/server/apiv1/server_info.go b/server/apiv1/server_info.go index 458363efe..d7d46175a 100644 --- a/server/apiv1/server_info.go +++ b/server/apiv1/server_info.go @@ -18,6 +18,6 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec ServerVersion: consts.Version, SpecVersion: api.SpecVersion(), SetupRequired: count == 0, - LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword}, + LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}}, }, nil } diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_info_test.go index b9dae6f8d..3d6c938d9 100644 --- a/server/apiv1/server_info_test.go +++ b/server/apiv1/server_info_test.go @@ -43,7 +43,8 @@ var _ = Describe("GET /server", func() { Expect(info.ServerVersion).To(Equal(consts.Version)) Expect(info.SpecVersion).To(Equal(api.SpecVersion())) Expect(info.SetupRequired).To(BeTrue()) - Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword)) + Expect(info.LoginMethods.Password).ToNot(BeNil()) + Expect(w.Body.String()).To(ContainSubstring(`"loginMethods":{"password":{}}`)) }) It("reports setupRequired=false once a user exists", func() { From b1cfa932bea0d06291e8f4ee5809cc442047426e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:21:16 -0400 Subject: [PATCH 15/41] fix(api): make logout idempotent and hide grants left on a stale epoch Logout answered an undeclared 404 when its grant was already gone, for example revoked by another node inside the liveness cache window or by a concurrent logout. It now treats a missing grant as success and still evicts the cache entry, so logout always answers 200. Grants left on an older user epoch (after a password reset through the existing UI, or a login that raced a password change) are dead but only deleted when presented. Listing and counting grants now filter on the user's current epoch, so those grants no longer show up. dropGrant now deletes before evicting, like RevokeGrant, so a concurrent cache fill cannot re-cache a grant that is being dropped. --- core/apiauth/service.go | 15 ++++++++++---- core/apiauth/service_session_test.go | 29 ++++++++++++++++++++++++++++ model/grant.go | 4 ++-- persistence/grant_repository.go | 13 +++++++------ persistence/grant_repository_test.go | 13 ++++++++----- server/apiv1/auth_test.go | 11 +++++++++++ 6 files changed, 68 insertions(+), 17 deletions(-) diff --git a/core/apiauth/service.go b/core/apiauth/service.go index 04012c77c..29b5075b3 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -192,11 +192,12 @@ func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, err return u, err } +// dropGrant evicts after deleting, so a concurrent fill cannot re-cache the dead grant. func (s *Service) dropGrant(ctx context.Context, id string) { - s.cache.evict(id) if err := s.ds.Grant().Delete(ctx, id); err != nil { log.Warn(ctx, "API v1: could not delete dead grant", "grant", id, err) } + s.cache.evict(id) } // settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password @@ -303,11 +304,11 @@ func (s *Service) liveGrant(ctx context.Context, id string, u *model.User) (live func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit int) (model.Grants, int64, error) { idleSince := s.now().Add(-IdleExpiry) - grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, idleSince, offset, limit) + grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince, offset, limit) if err != nil { return nil, 0, err } - total, err := s.ds.Grant().CountForUser(ctx, p.User.ID, idleSince) + total, err := s.ds.Grant().CountForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince) return grants, total, err } @@ -319,8 +320,14 @@ func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) return nil } +// Logout succeeds when the grant is already gone, e.g. revoked by another node or a concurrent logout. func (s *Service) Logout(ctx context.Context, p *Principal) error { - return s.RevokeGrant(ctx, p, p.GrantID) + err := s.RevokeGrant(ctx, p, p.GrantID) + if errors.Is(err, model.ErrNotFound) { + s.cache.evict(p.GrantID) + return nil + } + return err } // ChangePassword does every check inside the locked transaction, so a reset that lands first is never overwritten. diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go index 929899422..de0d19033 100644 --- a/core/apiauth/service_session_test.go +++ b/core/apiauth/service_session_test.go @@ -199,6 +199,35 @@ var _ = Describe("Service: sessions", func() { Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID)) }) + It("lists only grants on the user's current epoch", func() { + u := createUser(ctx, "pw", false) + login(u) + u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + issued, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil) + Expect(err).ToNot(HaveOccurred()) + p, err := svc.ResolveGrant(ctx, issued.Secret, "") + Expect(err).ToNot(HaveOccurred()) + + grants, total, err := svc.ListGrants(ctx, p, 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(Equal(int64(1))) + Expect(grants).To(HaveLen(1)) + Expect(grants[0].ID).To(Equal(issued.Grant.ID)) + }) + + It("logs out successfully when the grant is already gone", func() { + u := createUser(ctx, "pw", false) + _, p, tok := login(u) + _, err := svc.Authenticate(ctx, tok.Token, "") + Expect(err).ToNot(HaveOccurred()) + Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed()) // another node + + Expect(svc.Logout(ctx, p)).To(Succeed()) + _, err = svc.Authenticate(ctx, tok.Token, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + It("refuses to revoke another user's grant", func() { alice := createUser(ctx, "pw", false) bob := createUser(ctx, "pw", false) diff --git a/model/grant.go b/model/grant.go index 875c3bc86..f1475c93f 100644 --- a/model/grant.go +++ b/model/grant.go @@ -50,8 +50,8 @@ type GrantRepository interface { Put(ctx context.Context, g *Grant) error Get(ctx context.Context, id string) (*Grant, error) FindBySecretHash(ctx context.Context, hash string) (*Grant, error) - GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (Grants, error) - CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error) + GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (Grants, error) + CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) Delete(ctx context.Context, id string) error DeleteForUser(ctx context.Context, userID, id string) error DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error diff --git a/persistence/grant_repository.go b/persistence/grant_repository.go index 7a9ca9fe8..fcd58bfa3 100644 --- a/persistence/grant_repository.go +++ b/persistence/grant_repository.go @@ -60,12 +60,13 @@ func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Gra return &g, nil } -func (r *grantRepository) activeForUser(userID string, idleSince time.Time) Sqlizer { - return And{Eq{"user_id": userID}, Expr(grantLastActivity+" >= ?", idleSince.UTC())} +// activeForUser skips grants left on an older epoch: they are dead but only deleted when presented. +func (r *grantRepository) activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer { + return And{Eq{"user_id": userID, "user_epoch": epoch}, Expr(grantLastActivity+" >= ?", idleSince.UTC())} } -func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (model.Grants, error) { - sel := r.newSelect(ctx).Columns("*").Where(r.activeForUser(userID, idleSince)). +func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (model.Grants, error) { + sel := r.newSelect(ctx).Columns("*").Where(r.activeForUser(userID, epoch, idleSince)). OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id"). Offset(uint64(offset)).Limit(uint64(limit)) var res model.Grants @@ -73,8 +74,8 @@ func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, idle return res, err } -func (r *grantRepository) CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error) { - return r.count(ctx, Select().Where(r.activeForUser(userID, idleSince))) +func (r *grantRepository) CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) { + return r.count(ctx, Select().Where(r.activeForUser(userID, epoch, idleSince))) } func (r *grantRepository) Delete(ctx context.Context, id string) error { diff --git a/persistence/grant_repository_test.go b/persistence/grant_repository_test.go index f621a5cc2..bb140ebd6 100644 --- a/persistence/grant_repository_test.go +++ b/persistence/grant_repository_test.go @@ -54,7 +54,7 @@ var _ = Describe("GrantRepository", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) - It("lists and counts only the user's non-idle grants by lastUsedAt, never-used ones last", func() { + It("lists and counts only the user's non-idle grants on the given epoch by lastUsedAt, never-used ones last", func() { old := newGrant(adminUser.ID, "h-old") old.CreatedAt = now.Add(-100 * 24 * time.Hour) usedEarly := newGrant(adminUser.ID, "h-used-early") @@ -67,18 +67,21 @@ var _ = Describe("GrantRepository", func() { usedLate.LastUsedAt = &lateUse freshNeverUsed := newGrant(adminUser.ID, "h-fresh") // newer than both uses, but never used other := newGrant(regularUser.ID, "h-other") - for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other} { + staleEpoch := newGrant(adminUser.ID, "h-stale-epoch") + staleEpoch.UserEpoch = 1 + for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other, staleEpoch} { Expect(repo.Put(ctx, g)).To(Succeed()) } idleSince := now.Add(-90 * 24 * time.Hour) - list, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 0, 10) + list, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 0, 10) Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) Expect([]string{list[0].ID, list[1].ID, list[2].ID}).To(Equal([]string{usedLate.ID, usedEarly.ID, freshNeverUsed.ID})) - Expect(repo.CountForUser(ctx, adminUser.ID, idleSince)).To(Equal(int64(3))) + Expect(repo.CountForUser(ctx, adminUser.ID, 0, idleSince)).To(Equal(int64(3))) - page, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 1, 1) + page, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 1, 1) Expect(err).ToNot(HaveOccurred()) Expect(page).To(HaveLen(1)) Expect(page[0].ID).To(Equal(usedEarly.ID)) diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index da45a2141..d360e2b49 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -171,6 +171,17 @@ var _ = Describe("auth endpoints", func() { Expect(call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) }) + It("logs out with 200 when another node already revoked the grant", func() { + gc := setup() + at := mint(gc.Secret, nil) + Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant + Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed()) + + w := call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) + }) + It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() { gc := setup() tok := mint(gc.Secret, nil).AccessToken From 3e3b73a9cc129af80585ab1912a276fa9a211f00 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:21:16 -0400 Subject: [PATCH 16/41] fix(api): use a 256-bit API v1 signing key The HS256 key was 22 base62 characters, about 128 bits, below the 256 bits RFC 7518 section 3.2 asks for. New keys are 32 bytes from crypto/rand, hex-encoded before being encrypted and stored. Keys already stored keep working unchanged. --- core/apiauth/signer.go | 12 ++++++++++-- core/apiauth/signer_test.go | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go index a2c21f39f..e4814d3c8 100644 --- a/core/apiauth/signer.go +++ b/core/apiauth/signer.go @@ -3,7 +3,9 @@ package apiauth import ( "cmp" "context" + "crypto/rand" "crypto/sha256" + "encoding/hex" "errors" "fmt" "strings" @@ -15,7 +17,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils" ) @@ -59,7 +60,7 @@ func loadSigner(ctx context.Context, ds model.DataStore, now func() time.Time) ( } func loadKey(ctx context.Context, ds model.DataStore) (string, error) { - enc, err := utils.Encrypt(ctx, encryptionKey(), id.NewRandom()) + enc, err := utils.Encrypt(ctx, encryptionKey(), newKey()) if err != nil { return "", fmt.Errorf("encrypting API v1 key: %w", err) } @@ -98,6 +99,13 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { return key, nil } +// newKey returns 256 random bits, the minimum RFC 7518 asks of an HS256 key. +func newKey() string { + b := make([]byte, 32) + _, _ = rand.Read(b) // never fails since Go 1.24 + return hex.EncodeToString(b) +} + func encryptionKey() []byte { sum := sha256.Sum256([]byte(cmp.Or(conf.Server.PasswordEncryptionKey, consts.DefaultEncryptionKey))) return sum[:] diff --git a/core/apiauth/signer_test.go b/core/apiauth/signer_test.go index acb197dde..b749d6193 100644 --- a/core/apiauth/signer_test.go +++ b/core/apiauth/signer_test.go @@ -2,12 +2,14 @@ package apiauth import ( "context" + "encoding/hex" "sync" "time" "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,6 +25,21 @@ var _ = Describe("signer", func() { Expect(realDS.Property().Delete(ctx, consts.JWTAPIv1SecretKey)).To(Or(Succeed(), MatchError(model.ErrNotFound))) }) + It("creates a 256-bit key", func() { + key, err := loadKey(ctx, realDS) + Expect(err).ToNot(HaveOccurred()) + raw, err := hex.DecodeString(key) + Expect(err).ToNot(HaveOccurred()) + Expect(raw).To(HaveLen(32)) + }) + + It("keeps using a stored key created in the older format", func() { + enc, err := utils.Encrypt(ctx, encryptionKey(), "legacy22charskeyABCDEF") + Expect(err).ToNot(HaveOccurred()) + Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc)).To(Succeed()) + Expect(loadKey(ctx, realDS)).To(Equal("legacy22charskeyABCDEF")) + }) + It("round-trips claims", func() { s, err := loadSigner(ctx, realDS, clock) Expect(err).ToNot(HaveOccurred()) From 406a2e9cf439e2e68d818522ba0acb55db9f7d3d Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:21:16 -0400 Subject: [PATCH 17/41] fix(api): never widen the scopes an access token claims Authenticate ran token claims through Expand, so a token claiming `all` would have gained every known scope. Only a holder of the signing key could mint one, but tokens should carry concrete scopes only. Claims now go through Allowed, which keeps known scopes (and admin only for admins) and never expands `all`. --- core/apiauth/scopes.go | 7 ++++++- core/apiauth/scopes_test.go | 10 ++++++++++ core/apiauth/service.go | 2 +- core/apiauth/service_session_test.go | 12 ++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/core/apiauth/scopes.go b/core/apiauth/scopes.go index bc3ac9e27..62c840f1f 100644 --- a/core/apiauth/scopes.go +++ b/core/apiauth/scopes.go @@ -51,7 +51,12 @@ func Expand(granted []string, isAdmin bool) []string { } out = append(out, s) } - out = slices.DeleteFunc(out, func(s string) bool { + return Allowed(out, isAdmin) +} + +// Allowed keeps the concrete scopes the user may hold now; unlike Expand it never widens `all`. +func Allowed(scopes []string, isAdmin bool) []string { + out := slices.DeleteFunc(slices.Clone(scopes), func(s string) bool { return !known(s) || (s == ScopeAdmin && !isAdmin) }) return normalize(out) diff --git a/core/apiauth/scopes_test.go b/core/apiauth/scopes_test.go index dc0183b24..23826abd3 100644 --- a/core/apiauth/scopes_test.go +++ b/core/apiauth/scopes_test.go @@ -39,6 +39,16 @@ var _ = Describe("scopes", func() { }) }) + Describe("Allowed", func() { + It("never widens all", func() { + Expect(Allowed([]string{ScopeAll}, true)).To(BeEmpty()) + }) + It("keeps known scopes, and admin only for admins", func() { + Expect(Allowed([]string{"read", "retired", "admin"}, false)).To(Equal([]string{"read"})) + Expect(Allowed([]string{"read", "admin"}, true)).To(Equal([]string{"admin", "read"})) + }) + }) + Describe("Attenuate", func() { available := []string{"playlists:write", "read"} It("returns everything when no subset is asked", func() { diff --git a/core/apiauth/service.go b/core/apiauth/service.go index 29b5075b3..5082017b8 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -271,7 +271,7 @@ func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principa lastUsed = &entry.lastUsedAt } s.touch(ctx, c.GrantID, ip, lastUsed) - return &Principal{User: *u, GrantID: c.GrantID, Scopes: Expand(c.Scopes, u.IsAdmin)}, nil + return &Principal{User: *u, GrantID: c.GrantID, Scopes: Allowed(c.Scopes, u.IsAdmin)}, nil } // liveGrant trusts the cache only while its epoch matches; a mismatch is settled from one consistent read. diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go index de0d19033..b871f279b 100644 --- a/core/apiauth/service_session_test.go +++ b/core/apiauth/service_session_test.go @@ -133,6 +133,18 @@ var _ = Describe("Service: sessions", func() { Expect(err).To(MatchError(model.ErrInvalidAuth)) }) + It("grants no scopes to a signed token claiming all", func() { + u := createUser(ctx, "pw", true) + _, p, _ := login(u) + sg, err := svc.signer() + Expect(err).ToNot(HaveOccurred()) + tok, err := sg.sign(claims{UserID: u.ID, GrantID: p.GrantID, Scopes: []string{ScopeAll, "unknown"}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) + Expect(err).ToNot(HaveOccurred()) + got, err := svc.Authenticate(ctx, tok, "") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Scopes).To(BeEmpty()) + }) + It("rejects a token whose grant belongs to another user, even across an epoch change", func() { alice := createUser(ctx, "pw", false) bob := createUser(ctx, "pw", false) From 870942c7dd14178b52b92a978c316e6666360967 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:21:25 -0400 Subject: [PATCH 18/41] fix(api): record the full client IP, challenge presented tokens and mark token responses no-store The gate passed server.ClientIP to Authenticate and ResolveGrant, which masks IPv6 addresses to their /64 for rate limiting, so lastUsedIp stored a prefix. A new server.ClientAddr returns the resolved address unmasked; ClientIP builds on it and stays the rate limiter key. A 401 raised after a token was accepted by the gate, such as a password change whose grant was revoked mid-request, carried a bare Bearer challenge. writeProblemStatus now answers Bearer error="invalid_token" whenever the request presented a bearer token. login, setupFirstAdmin and createAccessToken responses now carry Cache-Control: no-store (RFC 6749 section 5.1), set by the gate for a small list of operations so their error responses are covered too. The v0/v1 setup race test also checks the v1 status is 201 or 409. --- server/apiv1/api.go | 1 + server/apiv1/auth_test.go | 28 ++++++++++++++++++++++++++++ server/apiv1/gate.go | 8 +++++++- server/apiv1/gate_test.go | 32 ++++++++++++++++++++++++++++---- server/apiv1/problem.go | 6 +++++- server/apiv1/problem_test.go | 16 +++++++++++++++- server/middlewares.go | 7 ++++++- server/middlewares_test.go | 29 +++++++++++++++++++++++++++++ 8 files changed, 119 insertions(+), 8 deletions(-) diff --git a/server/apiv1/api.go b/server/apiv1/api.go index c8e25618f..ac6831f81 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -34,6 +34,7 @@ var gateRulesV1 = gateRules{ limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true}, noScope: map[string]bool{"getCapabilities": true}, grantOps: map[string]bool{"createAccessToken": true}, + noStore: map[string]bool{"login": true, "setupFirstAdmin": true, "createAccessToken": true}, } func limitBody(next http.Handler) http.Handler { diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index d360e2b49..89edd3ed2 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -89,6 +89,7 @@ var _ = Describe("auth endpoints", func() { }() wg.Wait() Expect(realDS.User().CountAll(ctx)).To(Equal(int64(1))) + Expect(v1Code).To(Or(Equal(http.StatusCreated), Equal(http.StatusConflict))) Expect(v1Code == http.StatusCreated).ToNot(Equal(v0Err == nil), "exactly one must win") }) @@ -182,6 +183,33 @@ var _ = Describe("auth endpoints", func() { Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) }) + It("challenges with invalid_token when the grant is revoked while a password change runs", func() { + gc := setup() + at := mint(gc.Secret, nil) + Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant + Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed()) + + w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String()) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) + }) + + It("marks grant and token responses no-store", func() { + w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + var gc GrantCreated + decode(w, &gc) + + w = call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + + w = call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + }) + It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() { gc := setup() tok := mint(gc.Secret, nil).AccessToken diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index d125eb846..1002de01e 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -41,6 +41,7 @@ type gateOp struct { kind authKind scope string limited bool + noStore bool } type gate struct { @@ -63,6 +64,7 @@ type gateRules struct { limited map[string]bool // login-type operations, throttled per client IP noScope map[string]bool // the only token operations allowed without x-scope grantOps map[string]bool // the only operations allowed to use grantAuth + noStore map[string]bool // operations whose responses carry a secret or token } func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) { @@ -74,6 +76,7 @@ func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRule return nil, err } gop.limited = rules.limited[op.OperationID] + gop.noStore = rules.noStore[op.OperationID] g.ops[method+" "+path] = gop } } @@ -176,6 +179,9 @@ func (g *gate) handler(next http.Handler) http.Handler { writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "") return } + if op.noStore { + w.Header().Set("Cache-Control", "no-store") + } serve := func(w http.ResponseWriter, r *http.Request) { r, ok := g.authorize(w, r, op) if !ok { @@ -204,7 +210,7 @@ func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*h writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "") return r, false } - ip := server.ClientIP(r) + ip := server.ClientAddr(r) var p *apiauth.Principal var err error if op.kind == authGrant { diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go index 6fb7daa6c..3476ccd09 100644 --- a/server/apiv1/gate_test.go +++ b/server/apiv1/gate_test.go @@ -65,15 +65,16 @@ type fakeAuth struct { err error gotToken string gotSecret string + gotIP string } -func (f *fakeAuth) Authenticate(_ context.Context, token, _ string) (*apiauth.Principal, error) { - f.gotToken = token +func (f *fakeAuth) Authenticate(_ context.Context, token, ip string) (*apiauth.Principal, error) { + f.gotToken, f.gotIP = token, ip return f.principal, f.err } -func (f *fakeAuth) ResolveGrant(_ context.Context, secret, _ string) (*apiauth.Principal, error) { - f.gotSecret = secret +func (f *fakeAuth) ResolveGrant(_ context.Context, secret, ip string) (*apiauth.Principal, error) { + f.gotSecret, f.gotIP = secret, ip return f.principal, f.err } @@ -81,6 +82,7 @@ var testGateRules = gateRules{ limited: map[string]bool{"limited": true}, noScope: map[string]bool{"caps": true}, grantOps: map[string]bool{"mint": true}, + noStore: map[string]bool{"mint": true}, } var _ = Describe("spec gate", func() { @@ -190,6 +192,28 @@ var _ = Describe("spec gate", func() { Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK)) }) + DescribeTable("passes the full client address to the authenticator, not the rate-limit /64", + func(method, path string) { + req := httptest.NewRequestWithContext(ctx, method, path, nil) + req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321" + req.Header.Set("Authorization", "Bearer x") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6")) + }, + Entry("access token", http.MethodGet, "/things/1"), + Entry("grant", http.MethodPost, "/mint"), + ) + + It("marks only the listed operations' responses no-store, errors included", func() { + Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) + fa.err = model.ErrInvalidAuth + Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) + fa.err = nil + Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty()) + }) + It("uses ResolveGrant for grantAuth operations", func() { Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Code).To(Equal(http.StatusOK)) Expect(fa.gotSecret).To(Equal("ndg_secret")) diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go index 9d38431a9..2c2c531a4 100644 --- a/server/apiv1/problem.go +++ b/server/apiv1/problem.go @@ -97,7 +97,11 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code } // Every 401 carries a Bearer challenge; callers may set a more specific one first. if status == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" { - w.Header().Set("WWW-Authenticate", "Bearer") + challenge := "Bearer" + if _, sent := bearerToken(r); sent { + challenge = `Bearer error="invalid_token"` + } + w.Header().Set("WWW-Authenticate", challenge) } w.Header().Set("Content-Type", problemContentType) w.WriteHeader(status) diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go index fccb439f0..9cc33838c 100644 --- a/server/apiv1/problem_test.go +++ b/server/apiv1/problem_test.go @@ -1,6 +1,7 @@ package apiv1 import ( + "context" "encoding/json" "errors" "fmt" @@ -21,12 +22,14 @@ func decodeProblem(w *httptest.ResponseRecorder) Problem { } var _ = Describe("problem", func() { + var ctx context.Context var w *httptest.ResponseRecorder var r *http.Request BeforeEach(func() { + ctx = GinkgoT().Context() w = httptest.NewRecorder() - r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil) + r = httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/server", nil) }) Describe("writeProblem", func() { @@ -84,6 +87,17 @@ var _ = Describe("problem", func() { Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) }) + It("challenges a 401 with invalid_token when the request carried a bearer token", func() { + r.Header.Set("Authorization", "Bearer tok") + writeProblem(w, r, model.ErrInvalidAuth) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) + + w = httptest.NewRecorder() + r.Header.Set("Authorization", "Basic dXNlcjpwdw==") + writeProblem(w, r, model.ErrInvalidAuth) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) + }) + It("adds the request's referenceId to internal errors only", func() { r = r.WithContext(withReferenceID(r.Context(), "ref-123")) writeProblem(w, r, errors.New("boom")) diff --git a/server/middlewares.go b/server/middlewares.go index b65a2d6e1..3d8199187 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -243,7 +243,12 @@ func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http // ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The // peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket. func ClientIP(r *http.Request) string { - return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))) + return httprate.CanonicalizeIP(ClientAddr(r)) +} + +// ClientAddr returns the client IP resolved by realIPMiddleware unmasked, for recording who made a request. +func ClientAddr(r *http.Request) string { + return cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)) } // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 15cf70341..58ffa2e37 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -494,6 +494,35 @@ var _ = Describe("middlewares", func() { }) }) + Describe("ClientAddr", func() { + var ctx context.Context + var addr, ip string + BeforeEach(func() { + ctx = GinkgoT().Context() + conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" + }) + call := func(h http.Handler, peer, xff string) { + r := httptest.NewRequestWithContext(ctx, "POST", "/auth/login", nil) + r.RemoteAddr = peer + r.Header.Set("X-Forwarded-For", xff) + h.ServeHTTP(httptest.NewRecorder(), r) + } + capture := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + addr, ip = ClientAddr(r), ClientIP(r) + }) + + It("returns the full resolved IPv6 address, while ClientIP keeps the /64 for rate limiting", func() { + call(realIPMiddleware(capture), "10.0.0.1:1234", "2001:db8:1:2:3:4:5:6") + Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6")) + Expect(ip).To(Equal("2001:db8:1:2::")) + }) + + It("falls back to the peer host without the middleware", func() { + call(capture, "[2001:db8:1:2:3:4:5:6]:1234", "") + Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6")) + }) + }) + Describe("ClientIPRateLimiter", func() { var handler http.Handler JustBeforeEach(func() { From 88a652346b8e256ca70f680fe0008bdf6c0609c0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:21:25 -0400 Subject: [PATCH 19/41] docs(api): say a password change ends the user's other Navidrome sessions changePassword now documents that on Navidrome the change also ends the user's sessions on its other APIs, regardless of revokeOtherGrants, which only covers API v1 grants. --- api/bundled/openapi.json | 2 +- api/bundled/openapi.yaml | 2 +- api/openapi/paths/auth.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index a2cfce901..c46fef1a7 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -459,7 +459,7 @@ "auth" ], "summary": "Change my password", - "description": "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. Answers 409 `password_managed_externally` when the password is not stored by this server.", + "description": "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server.", "security": [ { "bearerAuth": [] diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index f4a76c006..5b07e614d 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -298,7 +298,7 @@ paths: x-stability-level: alpha tags: [auth] summary: Change my password - description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. Answers 409 `password_managed_externally` when the password is not stored by this server." + description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server." security: [{bearerAuth: []}] requestBody: description: The current and the new password. diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml index 9a3aedf37..5f7e3d58d 100644 --- a/api/openapi/paths/auth.yaml +++ b/api/openapi/paths/auth.yaml @@ -185,7 +185,7 @@ password: x-stability-level: alpha tags: [auth] summary: Change my password - description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. Answers 409 `password_managed_externally` when the password is not stored by this server." + description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server." security: [{bearerAuth: []}] requestBody: description: The current and the new password. From a41e6567069b6cd724fbb418c3e9a43e6f5abd8a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:33:22 -0400 Subject: [PATCH 20/41] refactor(api): reuse existing helpers in API v1 auth Use gg.V, slice.Map, chi's RequestSize, core/auth's encryption key and ClientIPRateLimiter instead of local copies; share the grant idle expiry constant and a Grant.LastActivity helper; pass last use as a time value. --- consts/consts.go | 1 + core/apiauth/service.go | 29 ++++++++++------------------- core/apiauth/signer.go | 17 +++++------------ core/apiauth/signer_test.go | 3 ++- core/auth/auth.go | 6 +++--- model/grant.go | 7 +++++++ model/grant_test.go | 24 ++++++++++++++++++++++++ persistence/grant_repository.go | 9 ++++----- persistence/persistence.go | 3 ++- server/apiv1/api.go | 12 ++---------- server/apiv1/auth_handlers.go | 5 +++-- server/apiv1/dto.go | 18 ++++-------------- server/apiv1/gate.go | 3 +-- server/middlewares.go | 4 ++-- 14 files changed, 70 insertions(+), 71 deletions(-) create mode 100644 model/grant_test.go diff --git a/consts/consts.go b/consts/consts.go index c97c72cab..0808bff79 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -35,6 +35,7 @@ const ( JWTAPIv1SecretKey = "JWTAPIv1Secret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour + APIv1GrantIdleExpiry = 90 * 24 * time.Hour DefaultSmartRefresh = 5 * time.Second DefaultShareExpiration = 8760 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year diff --git a/core/apiauth/service.go b/core/apiauth/service.go index 5082017b8..0bd3a22bc 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -10,14 +10,16 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/gg" ) const ( TokenTTL = time.Hour - IdleExpiry = 90 * 24 * time.Hour + IdleExpiry = consts.APIv1GrantIdleExpiry cacheTTL = 30 * time.Second touchInterval = 5 * time.Minute ) @@ -149,11 +151,7 @@ func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Princip if err != nil { return nil, err } - lastActivity := g.CreatedAt - if g.LastUsedAt != nil { - lastActivity = *g.LastUsedAt - } - if !s.now().Before(lastActivity.Add(IdleExpiry)) { + if !s.now().Before(g.LastActivity().Add(IdleExpiry)) { s.dropGrant(ctx, g.ID) return nil, model.ErrInvalidAuth } @@ -166,7 +164,7 @@ func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Princip return nil, err } } - s.touch(ctx, g.ID, ip, g.LastUsedAt) + s.touch(ctx, g.ID, ip, gg.V(g.LastUsedAt)) return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil } @@ -230,10 +228,10 @@ func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant return g, u, nil } -// touch writes last_used at most every touchInterval; the SQL condition keeps that true across nodes. -func (s *Service) touch(ctx context.Context, id, ip string, lastUsed *time.Time) { +// touch writes last_used at most every touchInterval (a zero lastUsed means never); the SQL condition keeps that true across nodes. +func (s *Service) touch(ctx context.Context, id, ip string, lastUsed time.Time) { now := s.now() - if lastUsed != nil && now.Before(lastUsed.Add(touchInterval)) { + if !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) { return } if err := s.ds.Grant().Touch(ctx, id, ip, now, now.Add(-touchInterval)); err != nil { @@ -266,11 +264,7 @@ func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principa if slices.Contains(c.Scopes, ScopeAdmin) && !u.IsAdmin { return nil, ErrInsufficientScope } - var lastUsed *time.Time - if !entry.lastUsedAt.IsZero() { - lastUsed = &entry.lastUsedAt - } - s.touch(ctx, c.GrantID, ip, lastUsed) + s.touch(ctx, c.GrantID, ip, entry.lastUsedAt) return &Principal{User: *u, GrantID: c.GrantID, Scopes: Allowed(c.Scopes, u.IsAdmin)}, nil } @@ -294,10 +288,7 @@ func (s *Service) liveGrant(ctx context.Context, id string, u *model.User) (live return livenessEntry{}, nil, err } } - e := livenessEntry{userID: g.UserID, epoch: g.UserEpoch} - if g.LastUsedAt != nil { - e.lastUsedAt = *g.LastUsedAt - } + e := livenessEntry{userID: g.UserID, epoch: g.UserEpoch, lastUsedAt: gg.V(g.LastUsedAt)} s.cache.put(id, e, now, started) return e, u, nil } diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go index e4814d3c8..6290b7130 100644 --- a/core/apiauth/signer.go +++ b/core/apiauth/signer.go @@ -1,10 +1,8 @@ package apiauth import ( - "cmp" "context" "crypto/rand" - "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -13,8 +11,8 @@ import ( "github.com/go-chi/jwtauth/v5" "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -60,7 +58,7 @@ func loadSigner(ctx context.Context, ds model.DataStore, now func() time.Time) ( } func loadKey(ctx context.Context, ds model.DataStore) (string, error) { - enc, err := utils.Encrypt(ctx, encryptionKey(), newKey()) + enc, err := utils.Encrypt(ctx, auth.EncryptionKey(), newKey()) if err != nil { return "", fmt.Errorf("encrypting API v1 key: %w", err) } @@ -71,7 +69,7 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { if err != nil { return "", fmt.Errorf("reading API v1 key: %w", err) } - if key, err := utils.Decrypt(ctx, encryptionKey(), stored); err == nil { + if key, err := utils.Decrypt(ctx, auth.EncryptionKey(), stored); err == nil { return key, nil } // A changed PasswordEncryptionKey makes the old key unreadable; replacing it only ends current access tokens. @@ -82,7 +80,7 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { if err != nil { return err } - if k, err := utils.Decrypt(ctx, encryptionKey(), current); err == nil { + if k, err := utils.Decrypt(ctx, auth.EncryptionKey(), current); err == nil { key = k return nil } @@ -90,7 +88,7 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { if err := tx.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { return err } - key, err = utils.Decrypt(ctx, encryptionKey(), enc) + key, err = utils.Decrypt(ctx, auth.EncryptionKey(), enc) return err }) if err != nil { @@ -106,11 +104,6 @@ func newKey() string { return hex.EncodeToString(b) } -func encryptionKey() []byte { - sum := sha256.Sum256([]byte(cmp.Or(conf.Server.PasswordEncryptionKey, consts.DefaultEncryptionKey))) - return sum[:] -} - func (s *signer) sign(c claims) (string, error) { _, tok, err := s.auth.Encode(map[string]any{ jwt.SubjectKey: c.UserID, diff --git a/core/apiauth/signer_test.go b/core/apiauth/signer_test.go index b749d6193..7d3d622b9 100644 --- a/core/apiauth/signer_test.go +++ b/core/apiauth/signer_test.go @@ -8,6 +8,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" @@ -34,7 +35,7 @@ var _ = Describe("signer", func() { }) It("keeps using a stored key created in the older format", func() { - enc, err := utils.Encrypt(ctx, encryptionKey(), "legacy22charskeyABCDEF") + enc, err := utils.Encrypt(ctx, auth.EncryptionKey(), "legacy22charskeyABCDEF") Expect(err).ToNot(HaveOccurred()) Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc)).To(Succeed()) Expect(loadKey(ctx, realDS)).To(Equal("legacy22charskeyABCDEF")) diff --git a/core/auth/auth.go b/core/auth/auth.go index 1bdc917da..43314a7c1 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -53,7 +53,7 @@ func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) str log.Info(ctx, "Creating new JWT secret", "key", key) return createNewSecret(ctx, ds, key) } - if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { + if secret, err = utils.Decrypt(ctx, EncryptionKey(), secret); err != nil { log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err) return createNewSecret(ctx, ds, key) } @@ -171,7 +171,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { func createNewSecret(ctx context.Context, ds model.DataStore, key string) string { secret := id.NewRandom() - encSecret, err := utils.Encrypt(ctx, getEncKey(), secret) + encSecret, err := utils.Encrypt(ctx, EncryptionKey(), secret) if err != nil { log.Error(ctx, "Could not encrypt JWT secret", err) return secret @@ -195,7 +195,7 @@ func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) { return jwtauth.VerifyToken(TokenAuth, tokenStr) } -func getEncKey() []byte { +func EncryptionKey() []byte { key := cmp.Or( conf.Server.PasswordEncryptionKey, consts.DefaultEncryptionKey, diff --git a/model/grant.go b/model/grant.go index f1475c93f..1e8fe46e1 100644 --- a/model/grant.go +++ b/model/grant.go @@ -23,6 +23,13 @@ type Grant struct { LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"` } +func (g Grant) LastActivity() time.Time { + if g.LastUsedAt != nil { + return *g.LastUsedAt + } + return g.CreatedAt +} + type Grants []Grant // Scopes is stored as a single space-separated column. diff --git a/model/grant_test.go b/model/grant_test.go new file mode 100644 index 000000000..dd59a2422 --- /dev/null +++ b/model/grant_test.go @@ -0,0 +1,24 @@ +package model_test + +import ( + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Grant", func() { + Describe("LastActivity", func() { + created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + It("is the creation time for a grant never used", func() { + Expect(model.Grant{CreatedAt: created}.LastActivity()).To(Equal(created)) + }) + + It("is the last use once the grant was used", func() { + used := created.Add(time.Hour) + Expect(model.Grant{CreatedAt: created, LastUsedAt: &used}.LastActivity()).To(Equal(used)) + }) + }) +}) diff --git a/persistence/grant_repository.go b/persistence/grant_repository.go index fcd58bfa3..0e561315c 100644 --- a/persistence/grant_repository.go +++ b/persistence/grant_repository.go @@ -61,12 +61,12 @@ func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Gra } // activeForUser skips grants left on an older epoch: they are dead but only deleted when presented. -func (r *grantRepository) activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer { +func activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer { return And{Eq{"user_id": userID, "user_epoch": epoch}, Expr(grantLastActivity+" >= ?", idleSince.UTC())} } func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (model.Grants, error) { - sel := r.newSelect(ctx).Columns("*").Where(r.activeForUser(userID, epoch, idleSince)). + sel := r.newSelect(ctx).Columns("*").Where(activeForUser(userID, epoch, idleSince)). OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id"). Offset(uint64(offset)).Limit(uint64(limit)) var res model.Grants @@ -75,7 +75,7 @@ func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, epoc } func (r *grantRepository) CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) { - return r.count(ctx, Select().Where(r.activeForUser(userID, epoch, idleSince))) + return r.count(ctx, Select().Where(activeForUser(userID, epoch, idleSince))) } func (r *grantRepository) Delete(ctx context.Context, id string) error { @@ -94,8 +94,7 @@ func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) } func (r *grantRepository) DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error { - _, err := r.executeSQL(ctx, Delete(r.tableName).Where(And{Eq{"user_id": userID}, NotEq{"user_epoch": epoch}})) - return err + return r.delete(ctx, And{Eq{"user_id": userID}, NotEq{"user_epoch": epoch}}) } // SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back. diff --git a/persistence/persistence.go b/persistence/persistence.go index 8feda0c8f..9656be178 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -278,7 +279,7 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }), trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }), trace(ctx, "purge idle API grants", func() error { - _, err := s.grant().DeleteIdle(ctx, time.Now().Add(-90*24*time.Hour)) + _, err := s.grant().DeleteIdle(ctx, time.Now().Add(-consts.APIv1GrantIdleExpiry)) return err }), ) diff --git a/server/apiv1/api.go b/server/apiv1/api.go index ac6831f81..53076063a 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -10,6 +10,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/api" "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/log" @@ -37,15 +38,6 @@ var gateRulesV1 = gateRules{ noStore: map[string]bool{"login": true, "setupFirstAdmin": true, "createAccessToken": true}, } -func limitBody(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Body != nil { - r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) - } - next.ServeHTTP(w, r) - }) -} - func (rt *Router) routes() http.Handler { r := chi.NewRouter() doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) @@ -56,7 +48,7 @@ func (rt *Router) routes() http.Handler { if err != nil { log.Fatal("API v1: the embedded OpenAPI spec breaks the security rules", err) } - r.Use(referenceIDMiddleware, problemRecoverer, headAsGet(r), limitBody, g.handler) + r.Use(referenceIDMiddleware, problemRecoverer, headAsGet(r), middleware.RequestSize(maxBodyBytes), g.handler) r.NotFound(func(w http.ResponseWriter, req *http.Request) { writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint") }) diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go index 543b1e446..26eecd386 100644 --- a/server/apiv1/auth_handlers.go +++ b/server/apiv1/auth_handlers.go @@ -5,6 +5,7 @@ import ( "context" "github.com/navidrome/navidrome/core/apiauth" + "github.com/navidrome/navidrome/utils/gg" ) const defaultPageSize = 100 @@ -35,8 +36,8 @@ func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) ( if err != nil { return nil, err } - offset := deref(req.Params.OffsetParam) - limit := cmp.Or(deref(req.Params.LimitParam), defaultPageSize) + offset := gg.V(req.Params.OffsetParam) + limit := cmp.Or(gg.V(req.Params.LimitParam), defaultPageSize) grants, total, err := rt.auth.ListGrants(ctx, p, offset, limit) if err != nil { return nil, err diff --git a/server/apiv1/dto.go b/server/apiv1/dto.go index 10ec338b1..9cab02595 100644 --- a/server/apiv1/dto.go +++ b/server/apiv1/dto.go @@ -3,14 +3,12 @@ package apiv1 import ( "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/slice" ) func toScopes(in []string) []Scope { - out := make([]Scope, len(in)) - for i, s := range in { - out[i] = Scope(s) - } - return out + return slice.Map(in, func(s string) Scope { return Scope(s) }) } // fromScopeRequests keeps nil (all scopes) apart from an empty list (no scopes). @@ -58,7 +56,7 @@ func toGrantCreated(i *apiauth.Issued) GrantCreated { } func clientMeta(c CredentialsRequest) apiauth.ClientMeta { - return apiauth.ClientMeta{Client: c.Client, Name: deref(c.Name), ClientVersion: deref(c.ClientVersion)} + return apiauth.ClientMeta{Client: c.Client, Name: gg.V(c.Name), ClientVersion: gg.V(c.ClientVersion)} } // principal fails closed if the gate did not attach a principal to the context. @@ -68,11 +66,3 @@ func principal(p *apiauth.Principal, ok bool) (*apiauth.Principal, error) { } return p, nil } - -func deref[T any](p *T) T { - var zero T - if p == nil { - return zero - } - return *p -} diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index 1002de01e..527ec2bcc 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -81,8 +81,7 @@ func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRule } } if conf.Server.AuthRequestLimit > 0 { - g.limiter = httprate.LimitBy(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength, - func(r *http.Request) (string, error) { return server.ClientIP(r), nil }, + g.limiter = server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength, httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { writeProblemStatus(w, r, http.StatusTooManyRequests, ProblemCodeRateLimited, "too many requests") })) diff --git a/server/middlewares.go b/server/middlewares.go index 3d8199187..149ddbfc3 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -234,10 +234,10 @@ func trustedProxyPrefixes(list string) []string { // ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers // cannot be rotated for a fresh bucket. -func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { +func ClientIPRateLimiter(requestLimit int, windowLength time.Duration, opts ...httprate.Option) func(http.Handler) http.Handler { return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { return ClientIP(r), nil - }) + }, opts...) } // ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The From 2aca5fe365c6282fe64e6a27ceebec60155c648c Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:35:53 -0400 Subject: [PATCH 21/41] refactor(api): simplify the API v1 spec gate Key operations by a struct, share the validation options, derive the route method from chi, and set every rule-derived field in buildGateOp. Build WWW-Authenticate challenges and 413 problems in one place, fetch the principal through one helper, and refuse gate rules that name an operation missing from the spec. --- server/apiv1/api.go | 13 +++- server/apiv1/auth_handlers.go | 9 ++- server/apiv1/dto.go | 7 +- server/apiv1/gate.go | 108 +++++++++++++++--------------- server/apiv1/gate_test.go | 19 ++++++ server/apiv1/password_handlers.go | 2 +- server/apiv1/problem.go | 29 ++++++++ 7 files changed, 122 insertions(+), 65 deletions(-) diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 53076063a..70c6a7bc5 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -62,9 +62,8 @@ func (rt *Router) routes() http.Handler { strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{ RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { - var tooLarge *http.MaxBytesError - if errors.As(err, &tooLarge) { - writeProblemStatus(w, req, http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge, "request body too large") + if tooLarge(err) { + writeProblem(w, req, ClientError(err, tooLargeDetail)) return } writeProblemStatus(w, req, http.StatusBadRequest, ProblemCodeValidation, "request body is not valid JSON") @@ -120,6 +119,14 @@ func headAsGet(mux chi.Routes) func(http.Handler) http.Handler { } } +// routeMethod is the method chi dispatches on, which headAsGet sets to GET for a HEAD only GET serves. +func routeMethod(req *http.Request) string { + if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RouteMethod != "" { + return rctx.RouteMethod + } + return req.Method +} + // routePath must pick the same path chi's routeHTTP dispatches on, or the gate could vet a different route. func routePath(req *http.Request) string { if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go index 26eecd386..1ddfaa33b 100644 --- a/server/apiv1/auth_handlers.go +++ b/server/apiv1/auth_handlers.go @@ -4,14 +4,13 @@ import ( "cmp" "context" - "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/utils/gg" ) const defaultPageSize = 100 func (rt *Router) CreateAccessToken(ctx context.Context, req CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) { - p, err := principal(apiauth.PrincipalFrom(ctx)) + p, err := principalFrom(ctx) if err != nil { return nil, err } @@ -32,7 +31,7 @@ func (rt *Router) CreateAccessToken(ctx context.Context, req CreateAccessTokenRe } func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) { - p, err := principal(apiauth.PrincipalFrom(ctx)) + p, err := principalFrom(ctx) if err != nil { return nil, err } @@ -50,7 +49,7 @@ func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) ( } func (rt *Router) RevokeGrant(ctx context.Context, req RevokeGrantRequestObject) (RevokeGrantResponseObject, error) { - p, err := principal(apiauth.PrincipalFrom(ctx)) + p, err := principalFrom(ctx) if err != nil { return nil, err } @@ -61,7 +60,7 @@ func (rt *Router) RevokeGrant(ctx context.Context, req RevokeGrantRequestObject) } func (rt *Router) Logout(ctx context.Context, _ LogoutRequestObject) (LogoutResponseObject, error) { - p, err := principal(apiauth.PrincipalFrom(ctx)) + p, err := principalFrom(ctx) if err != nil { return nil, err } diff --git a/server/apiv1/dto.go b/server/apiv1/dto.go index 9cab02595..c65830a6a 100644 --- a/server/apiv1/dto.go +++ b/server/apiv1/dto.go @@ -1,6 +1,8 @@ package apiv1 import ( + "context" + "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/gg" @@ -59,8 +61,9 @@ func clientMeta(c CredentialsRequest) apiauth.ClientMeta { return apiauth.ClientMeta{Client: c.Client, Name: gg.V(c.Name), ClientVersion: gg.V(c.ClientVersion)} } -// principal fails closed if the gate did not attach a principal to the context. -func principal(p *apiauth.Principal, ok bool) (*apiauth.Principal, error) { +// principalFrom fails closed if the gate did not attach a principal to the context. +func principalFrom(ctx context.Context) (*apiauth.Principal, error) { + p, ok := apiauth.PrincipalFrom(ctx) if !ok || p == nil { return nil, model.ErrInvalidAuth } diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index 527ec2bcc..ea4e2b2c9 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -1,6 +1,7 @@ package apiv1 import ( + "cmp" "context" "errors" "fmt" @@ -17,7 +18,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" ) @@ -36,7 +36,6 @@ const ( ) type gateOp struct { - id string route *routers.Route kind authKind scope string @@ -44,9 +43,13 @@ type gateOp struct { noStore bool } +func (o *gateOp) id() string { return o.route.Operation.OperationID } + +type opKey struct{ method, path string } + type gate struct { mux chi.Routes - ops map[string]*gateOp + ops map[opKey]*gateOp auth authenticator limiter func(http.Handler) http.Handler } @@ -68,18 +71,21 @@ type gateRules struct { } func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) { - g := &gate{mux: mux, ops: map[string]*gateOp{}, auth: auth} + g := &gate{mux: mux, ops: map[opKey]*gateOp{}, auth: auth} + ids := map[string]bool{} for path, item := range doc.Paths.Map() { for method, op := range item.Operations() { gop, err := buildGateOp(doc, path, item, method, op, rules) if err != nil { return nil, err } - gop.limited = rules.limited[op.OperationID] - gop.noStore = rules.noStore[op.OperationID] - g.ops[method+" "+path] = gop + g.ops[opKey{method, path}] = gop + ids[op.OperationID] = true } } + if err := rules.check(ids); err != nil { + return nil, err + } if conf.Server.AuthRequestLimit > 0 { g.limiter = server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength, httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { @@ -89,10 +95,27 @@ func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRule return g, nil } +// check fails on a rule naming an operation the spec lacks, so a typo cannot silently disable the rule. +func (rules gateRules) check(ids map[string]bool) error { + sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "grantOps": rules.grantOps, "noStore": rules.noStore} + for name, set := range sets { + for id := range set { + if !ids[id] { + return fmt.Errorf("gate rule %s names unknown operation %s", name, id) + } + } + } + return nil +} + // buildGateOp enforces the allowed security forms, so a spec edit cannot silently drop a requirement. func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method string, op *openapi3.Operation, rules gateRules) (*gateOp, error) { id := op.OperationID - gop := &gateOp{id: id, route: &routers.Route{Spec: doc, Path: path, PathItem: item, Method: method, Operation: op}} + gop := &gateOp{ + route: &routers.Route{Spec: doc, Path: path, PathItem: item, Method: method, Operation: op}, + limited: rules.limited[id], + noStore: rules.noStore[id], + } if op.Security == nil { return nil, fmt.Errorf("operation %s must declare security explicitly", id) } @@ -119,15 +142,12 @@ func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method s if gop.kind != authToken { return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id) } - base := module - if s, ok := moduleScope[module]; ok { - base = s - } + base := cmp.Or(moduleScope[module], module) if scope != base && scope != base+":write" { - return nil, fmt.Errorf("operation %s: x-scope %q does not match module %q", op.OperationID, scope, module) + return nil, fmt.Errorf("operation %s: x-scope %q does not match module %q", id, scope, module) } - if !slices.Contains(apiauth.KnownScopes, scope) && scope != apiauth.ScopeAdmin { - return nil, fmt.Errorf("operation %s: unknown x-scope %q", op.OperationID, scope) + if !slices.Contains(apiauth.KnownScopes, scope) { + return nil, fmt.Errorf("operation %s: unknown x-scope %q", id, scope) } } gop.scope = scope @@ -143,7 +163,7 @@ func isScheme(req openapi3.SecurityRequirement, name string) bool { // checkRoutes fails when a routed pattern has no spec operation or a spec operation has no route. func (g *gate) checkRoutes() error { err := chi.Walk(g.mux, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { - if _, ok := g.ops[method+" "+route]; !ok { + if _, ok := g.ops[opKey{method, route}]; !ok { return fmt.Errorf("route %s %s is not in the spec", method, route) } return nil @@ -153,7 +173,7 @@ func (g *gate) checkRoutes() error { } for _, op := range g.ops { if g.mux.Find(chi.NewRouteContext(), op.route.Method, op.route.Path) != op.route.Path { - return fmt.Errorf("spec operation %s (%s %s) has no route", op.id, op.route.Method, op.route.Path) + return fmt.Errorf("spec operation %s (%s %s) has no route", op.id(), op.route.Method, op.route.Path) } } return nil @@ -161,18 +181,14 @@ func (g *gate) checkRoutes() error { func (g *gate) handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := routePath(r) - method := r.Method - if method == http.MethodHead && !g.mux.Match(chi.NewRouteContext(), http.MethodHead, path) { - method = http.MethodGet - } + method := routeMethod(r) rctx := chi.NewRouteContext() - pattern := g.mux.Find(rctx, method, path) + pattern := g.mux.Find(rctx, method, routePath(r)) if pattern == "" { next.ServeHTTP(w, r) return } - op, ok := g.ops[method+" "+pattern] + op, ok := g.ops[opKey{method, pattern}] if !ok { log.Error(r.Context(), "API v1: routed pattern missing from the spec", "method", method, "pattern", pattern) writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "") @@ -205,7 +221,6 @@ func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*h } token, ok := bearerToken(r) if !ok { - w.Header().Set("WWW-Authenticate", "Bearer") writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "") return r, false } @@ -217,35 +232,20 @@ func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*h } else { p, err = g.auth.Authenticate(r.Context(), token, ip) } - switch { - case errors.Is(err, apiauth.ErrTokenExpired), errors.Is(err, model.ErrInvalidAuth): - w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) - writeProblem(w, r, err) - return r, false - case errors.Is(err, apiauth.ErrInsufficientScope): - insufficientScope(w, r, op, err) - return r, false - case err != nil: - writeProblem(w, r, err) - return r, false + if err == nil && op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { + err = apiauth.ErrInsufficientScope } - if op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { - insufficientScope(w, r, op, apiauth.ErrInsufficientScope) + if errors.Is(err, apiauth.ErrInsufficientScope) { + err = &scopeError{scope: op.scope} + } + if err != nil { + writeProblem(w, r, err) return r, false } ctx := apiauth.WithPrincipal(request.WithUser(r.Context(), p.User), p) return r.WithContext(ctx), true } -func insufficientScope(w http.ResponseWriter, r *http.Request, op *gateOp, err error) { - challenge := `Bearer error="insufficient_scope"` - if op.scope != "" { - challenge += fmt.Sprintf(`, scope=%q`, op.scope) - } - w.Header().Set("WWW-Authenticate", challenge) - writeProblem(w, r, err) -} - func bearerToken(r *http.Request) (string, bool) { scheme, token, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") token = strings.TrimSpace(token) @@ -255,25 +255,25 @@ func bearerToken(r *http.Request) (string, bool) { return token, true } +var validationOptions = &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true} + func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx *chi.Context) bool { - params := map[string]string{} + params := make(map[string]string, len(rctx.URLParams.Keys)) for i, k := range rctx.URLParams.Keys { params[k] = rctx.URLParams.Values[i] } err := openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ - Request: r, PathParams: params, Route: op.route, - Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true}, + Request: r, PathParams: params, Route: op.route, Options: validationOptions, }) if err == nil { return true } - var tooLarge *http.MaxBytesError - if errors.As(err, &tooLarge) { - writeProblemStatus(w, r, http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge, "request body too large") + if tooLarge(err) { + writeProblem(w, r, ClientError(err, tooLargeDetail)) return false } fields := sanitizeValidation(err) - log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id, "errors", fields) + log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id(), "errors", fields) writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "the request does not match the API schema", fields...) return false } diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go index 3476ccd09..dc8f8aeb6 100644 --- a/server/apiv1/gate_test.go +++ b/server/apiv1/gate_test.go @@ -3,6 +3,7 @@ package apiv1 import ( "bytes" "context" + "maps" "net/http" "net/http/httptest" "os" @@ -329,6 +330,24 @@ var _ = Describe("spec gate", func() { Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)), Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)), Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)), + Entry("x-scope not in KnownScopes", strings.Replace(gateSpec, "x-module: password\n x-scope: password", "x-module: admin\n x-scope: admin", 1)), + ) + + DescribeTable("refuses rules that name an operation missing from the spec", + func(set func(*gateRules) *map[string]bool) { + doc, err := openapi3.NewLoader().LoadFromData([]byte(gateSpec)) + Expect(err).ToNot(HaveOccurred()) + rules := testGateRules + m := set(&rules) + *m = maps.Clone(*m) + (*m)["typo"] = true + _, err = newGate(doc, chi.NewRouter(), fa, rules) + Expect(err).To(MatchError(ContainSubstring("typo"))) + }, + Entry("limited", func(r *gateRules) *map[string]bool { return &r.limited }), + Entry("noScope", func(r *gateRules) *map[string]bool { return &r.noScope }), + Entry("grantOps", func(r *gateRules) *map[string]bool { return &r.grantOps }), + Entry("noStore", func(r *gateRules) *map[string]bool { return &r.noStore }), ) It("checks routes against the spec in both directions", func() { diff --git a/server/apiv1/password_handlers.go b/server/apiv1/password_handlers.go index a96d905d0..5eef55090 100644 --- a/server/apiv1/password_handlers.go +++ b/server/apiv1/password_handlers.go @@ -27,7 +27,7 @@ func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminReques } func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) { - p, err := principal(apiauth.PrincipalFrom(ctx)) + p, err := principalFrom(ctx) if err != nil { return nil, err } diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go index 2c2c531a4..197a7dde6 100644 --- a/server/apiv1/problem.go +++ b/server/apiv1/problem.go @@ -3,6 +3,7 @@ package apiv1 import ( "encoding/json" "errors" + "fmt" "net/http" "github.com/navidrome/navidrome/core/apiauth" @@ -26,6 +27,20 @@ func ClientError(err error, detail string) error { return &clientError{err: err, detail: detail} } +// scopeError names the scope an operation requires, for the insufficient_scope challenge. +type scopeError struct { + scope string +} + +func (e *scopeError) Error() string { return apiauth.ErrInsufficientScope.Error() } +func (e *scopeError) Unwrap() error { return apiauth.ErrInsufficientScope } + +const tooLargeDetail = "request body too large" + +func tooLarge(err error) bool { + return errors.As(err, new(*http.MaxBytesError)) +} + type fieldErrors struct { fields []ValidationError } @@ -45,6 +60,9 @@ func writeProblem(w http.ResponseWriter, r *http.Request, err error) { return } log.Debug(r.Context(), "API v1: request failed", "path", r.URL.Path, "status", status, "code", code, err) + if code == ProblemCodeInsufficientScope { + w.Header().Set("WWW-Authenticate", scopeChallenge(err)) + } var detail string var ce *clientError if errors.As(err, &ce) { @@ -58,8 +76,19 @@ func writeProblem(w http.ResponseWriter, r *http.Request, err error) { writeProblemStatus(w, r, status, code, detail) } +func scopeChallenge(err error) string { + challenge := `Bearer error="insufficient_scope"` + var se *scopeError + if errors.As(err, &se) && se.scope != "" { + challenge += fmt.Sprintf(`, scope=%q`, se.scope) + } + return challenge +} + func classifyError(err error) (int, ProblemCode) { switch { + case tooLarge(err): + return http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge case errors.Is(err, apiauth.ErrTokenExpired): return http.StatusUnauthorized, ProblemCodeTokenExpired case errors.Is(err, apiauth.ErrInsufficientScope): From c479c4f581835655feb25503fd18cd1d324e6e4f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:39:09 -0400 Subject: [PATCH 22/41] refactor(api): tighten API v1 auth internals and first-admin setup Load the signer lock-free once cached, read the signing key before generating one, cap the liveness cache at its limit, share one grantable-scope predicate, and let CreateFirstAdmin open its own locked transaction with an optional in-transaction follow-up. --- core/apiauth/cache.go | 7 +++++ core/apiauth/cache_test.go | 13 +++++++++ core/apiauth/scopes.go | 12 ++++---- core/apiauth/service.go | 24 +++++++++------- core/apiauth/service_test.go | 6 ++-- core/apiauth/signer.go | 38 ++++++++++++++++++------ core/auth/first_admin.go | 54 ++++++++++++++++++++++------------- core/auth/first_admin_test.go | 31 +++++++++++++------- server/apiv1/auth_test.go | 6 +--- server/auth.go | 5 +--- server/auth_test.go | 5 +--- 11 files changed, 129 insertions(+), 72 deletions(-) diff --git a/core/apiauth/cache.go b/core/apiauth/cache.go index 136b5c336..d19d53dd5 100644 --- a/core/apiauth/cache.go +++ b/core/apiauth/cache.go @@ -55,6 +55,13 @@ func (c *livenessCache) put(id string, e livenessEntry, now time.Time, started u if len(c.entries) >= maxLivenessEntries { maps.DeleteFunc(c.entries, func(_ string, v livenessEntry) bool { return !now.Before(v.expires) }) } + if _, refresh := c.entries[id]; !refresh && len(c.entries) >= maxLivenessEntries { + // Dropping an arbitrary live entry only costs that grant one extra DB read. + for k := range c.entries { + delete(c.entries, k) + break + } + } if len(c.evicted) >= maxLivenessEntries { clear(c.evicted) c.floor = c.gen diff --git a/core/apiauth/cache_test.go b/core/apiauth/cache_test.go index 999f3e24a..a3429897d 100644 --- a/core/apiauth/cache_test.go +++ b/core/apiauth/cache_test.go @@ -81,4 +81,17 @@ var _ = Describe("livenessCache", func() { c.put("fresh", livenessEntry{}, t0.Add(time.Minute), c.begin()) Expect(c.len()).To(Equal(1)) }) + + It("never grows past its cap, even when every entry is live", func() { + for i := range maxLivenessEntries { + c.put(fmt.Sprint(i), livenessEntry{}, t0, c.begin()) + } + c.put("fresh", livenessEntry{}, t0, c.begin()) + Expect(c.len()).To(Equal(maxLivenessEntries)) + _, ok := c.get("fresh", t0) + Expect(ok).To(BeTrue()) + + c.put("fresh", livenessEntry{userID: "u1"}, t0, c.begin()) + Expect(c.len()).To(Equal(maxLivenessEntries)) + }) }) diff --git a/core/apiauth/scopes.go b/core/apiauth/scopes.go index 62c840f1f..367c19519 100644 --- a/core/apiauth/scopes.go +++ b/core/apiauth/scopes.go @@ -19,6 +19,10 @@ func known(s string) bool { return slices.Contains(KnownScopes, s) } +func grantable(s string, isAdmin bool) bool { + return known(s) && (s != ScopeAdmin || isAdmin) +} + func normalize(in []string) []string { out := slices.Clone(in) slices.Sort(out) @@ -32,9 +36,7 @@ func Entitled(requested []string, isAdmin bool) []string { } var out []string for _, s := range requested { - switch { - case s == ScopeAdmin && !isAdmin: - case s == ScopeAll || known(s): + if s == ScopeAll || grantable(s, isAdmin) { out = append(out, s) } } @@ -56,9 +58,7 @@ func Expand(granted []string, isAdmin bool) []string { // Allowed keeps the concrete scopes the user may hold now; unlike Expand it never widens `all`. func Allowed(scopes []string, isAdmin bool) []string { - out := slices.DeleteFunc(slices.Clone(scopes), func(s string) bool { - return !known(s) || (s == ScopeAdmin && !isAdmin) - }) + out := slices.DeleteFunc(slices.Clone(scopes), func(s string) bool { return !grantable(s, isAdmin) }) return normalize(out) } diff --git a/core/apiauth/service.go b/core/apiauth/service.go index 0bd3a22bc..4cbf25eea 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -7,6 +7,7 @@ import ( "fmt" "slices" "sync" + "sync/atomic" "time" "github.com/navidrome/navidrome/conf" @@ -60,7 +61,7 @@ type Service struct { cache *livenessCache now func() time.Time signerMu sync.Mutex - sg *signer + sg atomic.Pointer[signer] } func New(ds model.DataStore) *Service { @@ -77,16 +78,19 @@ func New(ds model.DataStore) *Service { // signer loads the key on first use, so building the router never touches the database; only a success is kept. func (s *Service) signer() (*signer, error) { + if sg := s.sg.Load(); sg != nil { + return sg, nil + } s.signerMu.Lock() defer s.signerMu.Unlock() - if s.sg != nil { - return s.sg, nil + if sg := s.sg.Load(); sg != nil { + return sg, nil } sg, err := loadSigner(context.Background(), s.ds, func() time.Time { return s.now() }) if err != nil { return nil, err } - s.sg = sg + s.sg.Store(sg) return sg, nil } @@ -111,15 +115,15 @@ func (s *Service) Login(ctx context.Context, username, password string, meta Cli func (s *Service) Setup(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) { var issued *Issued - err := s.ds.WithTxImmediate(func(tx model.DataStore) error { - u, err := auth.CreateFirstAdmin(ctx, tx, username, password) - if err != nil { - return err - } + _, err := auth.CreateFirstAdmin(ctx, s.ds, username, password, func(tx model.DataStore, u *model.User) error { + var err error issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes) return err }) - return issued, err + if err != nil { + return nil, err + } + return issued, nil } // issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead. diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go index 06d716faa..fb51ac6a2 100644 --- a/core/apiauth/service_test.go +++ b/core/apiauth/service_test.go @@ -30,12 +30,12 @@ type flakyProps struct { ds *flakyPropsDS } -func (p *flakyProps) PutIfAbsent(ctx context.Context, id, value string) error { +func (p *flakyProps) Get(ctx context.Context, id string) (string, error) { if p.ds.failures > 0 { p.ds.failures-- - return errFlakyProps + return "", errFlakyProps } - return p.PropertyRepository.PutIfAbsent(ctx, id, value) + return p.PropertyRepository.Get(ctx, id) } var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go index 6290b7130..055f6a69e 100644 --- a/core/apiauth/signer.go +++ b/core/apiauth/signer.go @@ -58,14 +58,10 @@ func loadSigner(ctx context.Context, ds model.DataStore, now func() time.Time) ( } func loadKey(ctx context.Context, ds model.DataStore) (string, error) { - enc, err := utils.Encrypt(ctx, auth.EncryptionKey(), newKey()) - if err != nil { - return "", fmt.Errorf("encrypting API v1 key: %w", err) - } - if err := ds.Property().PutIfAbsent(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { - return "", fmt.Errorf("storing API v1 key: %w", err) - } stored, err := ds.Property().Get(ctx, consts.JWTAPIv1SecretKey) + if errors.Is(err, model.ErrNotFound) { + stored, err = createKey(ctx, ds) + } if err != nil { return "", fmt.Errorf("reading API v1 key: %w", err) } @@ -85,11 +81,15 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { return nil } log.Warn(ctx, "Could not decrypt API v1 key, replacing it") + k, enc, err := newEncryptedKey(ctx) + if err != nil { + return err + } if err := tx.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { return err } - key, err = utils.Decrypt(ctx, auth.EncryptionKey(), enc) - return err + key = k + return nil }) if err != nil { return "", fmt.Errorf("replacing API v1 key: %w", err) @@ -97,6 +97,26 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { return key, nil } +// createKey re-reads after the insert-if-absent, so nodes racing to create the key agree on the winner. +func createKey(ctx context.Context, ds model.DataStore) (string, error) { + _, enc, err := newEncryptedKey(ctx) + if err != nil { + return "", err + } + if err := ds.Property().PutIfAbsent(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { + return "", fmt.Errorf("storing API v1 key: %w", err) + } + return ds.Property().Get(ctx, consts.JWTAPIv1SecretKey) +} + +func newEncryptedKey(ctx context.Context) (key, enc string, err error) { + key = newKey() + if enc, err = utils.Encrypt(ctx, auth.EncryptionKey(), key); err != nil { + return "", "", fmt.Errorf("encrypting API v1 key: %w", err) + } + return key, enc, nil +} + // newKey returns 256 random bits, the minimum RFC 7518 asks of an HS256 key. func newKey() string { b := make([]byte, 32) diff --git a/core/auth/first_admin.go b/core/auth/first_admin.go index 319b325eb..a082e6222 100644 --- a/core/auth/first_admin.go +++ b/core/auth/first_admin.go @@ -15,26 +15,40 @@ import ( var ErrSetupComplete = errors.New("setup already complete") -// CreateFirstAdmin must run inside ds.WithTxImmediate, so the count and the insert cannot interleave. -func CreateFirstAdmin(ctx context.Context, tx model.DataStore, username, password string) (*model.User, error) { - count, err := tx.User().CountAll(ctx) +// CreateFirstAdmin counts and inserts in one locked transaction, so racing setups cannot both win. +// then, if not nil, runs in that same transaction with the new user. +func CreateFirstAdmin(ctx context.Context, ds model.DataStore, username, password string, then func(tx model.DataStore, u *model.User) error) (*model.User, error) { + var created *model.User + err := ds.WithTxImmediate(func(tx model.DataStore) error { + count, err := tx.User().CountAll(ctx) + if err != nil { + return fmt.Errorf("counting users: %w", err) + } + if count > 0 { + return ErrSetupComplete + } + log.Warn(ctx, "Creating initial user", "user", username) + u := model.User{ + ID: id.NewRandom(), + UserName: username, + Name: cases.Title(language.Und).String(username), + NewPassword: password, + IsAdmin: true, + LastLoginAt: new(time.Now()), + } + if err := tx.User().Put(ctx, &u); err != nil { + return fmt.Errorf("creating initial user: %w", err) + } + if created, err = tx.User().Get(ctx, u.ID); err != nil { + return err + } + if then != nil { + return then(tx, created) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("counting users: %w", err) + return nil, err } - if count > 0 { - return nil, ErrSetupComplete - } - log.Warn(ctx, "Creating initial user", "user", username) - u := model.User{ - ID: id.NewRandom(), - UserName: username, - Name: cases.Title(language.Und).String(username), - NewPassword: password, - IsAdmin: true, - LastLoginAt: new(time.Now()), - } - if err := tx.User().Put(ctx, &u); err != nil { - return nil, fmt.Errorf("creating initial user: %w", err) - } - return tx.User().Get(ctx, u.ID) + return created, nil } diff --git a/core/auth/first_admin_test.go b/core/auth/first_admin_test.go index d22cea2f2..0cbbcf575 100644 --- a/core/auth/first_admin_test.go +++ b/core/auth/first_admin_test.go @@ -2,6 +2,7 @@ package auth_test import ( "context" + "errors" "path/filepath" "sync" "time" @@ -33,17 +34,8 @@ var _ = Describe("CreateFirstAdmin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) }) - createWith := func(name string, wrap func(model.DataStore) model.DataStore) (*model.User, error) { - var u *model.User - err := ds.WithTxImmediate(func(tx model.DataStore) error { - var err error - u, err = auth.CreateFirstAdmin(ctx, wrap(tx), name, "secret") - return err - }) - return u, err - } create := func(name string) (*model.User, error) { - return createWith(name, func(tx model.DataStore) model.DataStore { return tx }) + return auth.CreateFirstAdmin(ctx, ds, name, "secret", nil) } It("creates an admin with a title-cased name and returns it with its id", func() { @@ -65,6 +57,19 @@ var _ = Describe("CreateFirstAdmin", Ordered, func() { Expect(err).To(MatchError(auth.ErrSetupComplete)) }) + It("runs then in the same transaction, rolling the user back when it fails", func() { + boom := errors.New("boom") + var seen string + _, err := auth.CreateFirstAdmin(ctx, ds, "john", "secret", func(tx model.DataStore, u *model.User) error { + seen = u.ID + Expect(tx.User().CountAll(ctx)).To(Equal(int64(1))) + return boom + }) + Expect(err).To(MatchError(boom)) + Expect(seen).ToNot(BeEmpty()) + Expect(ds.User().CountAll(ctx)).To(BeZero()) + }) + It("lets exactly one of two concurrent setups win", func() { var wg sync.WaitGroup errs := make([]error, 2) @@ -73,7 +78,7 @@ var _ = Describe("CreateFirstAdmin", Ordered, func() { go func() { defer GinkgoRecover() defer wg.Done() - _, errs[i] = createWith(name, func(tx model.DataStore) model.DataStore { return slowCountDS{tx} }) + _, errs[i] = auth.CreateFirstAdmin(ctx, slowCountDS{ds}, name, "secret", nil) }() } wg.Wait() @@ -87,6 +92,10 @@ type slowCountDS struct{ model.DataStore } func (d slowCountDS) User() model.UserRepository { return slowCountUsers{d.DataStore.User()} } +func (d slowCountDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { + return d.DataStore.WithTxImmediate(func(tx model.DataStore) error { return block(slowCountDS{tx}) }, scope...) +} + type slowCountUsers struct{ model.UserRepository } // Holds the transaction open after counting, so an unlocked count would interleave with the other racer. diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index 89edd3ed2..f1b3e7c0c 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -82,10 +81,7 @@ var _ = Describe("auth endpoints", func() { go func() { defer GinkgoRecover() defer wg.Done() - v0Err = realDS.WithTxImmediate(func(tx model.DataStore) error { // what v0 /auth/createAdmin runs - _, err := auth.CreateFirstAdmin(ctx, tx, "v0admin", "pw") - return err - }) + _, v0Err = auth.CreateFirstAdmin(ctx, realDS, "v0admin", "pw", nil) // what v0 /auth/createAdmin runs }() wg.Wait() Expect(realDS.User().CountAll(ctx)).To(Equal(int64(1))) diff --git a/server/auth.go b/server/auth.go index dc70c5048..e772c1da2 100644 --- a/server/auth.go +++ b/server/auth.go @@ -124,10 +124,7 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) return } - err = ds.WithTxImmediate(func(tx model.DataStore) error { - _, err := auth.CreateFirstAdmin(r.Context(), tx, username, password) - return err - }) + _, err = auth.CreateFirstAdmin(r.Context(), ds, username, password, nil) if errors.Is(err, auth.ErrSetupComplete) { _ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin") return diff --git a/server/auth_test.go b/server/auth_test.go index e016de5e1..b7db3a881 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -77,10 +77,7 @@ var _ = Describe("Auth", func() { Describe("CreateFirstAdmin", func() { It("returns the error when the user cannot be saved", func() { failing := dsWithFailingPut(errors.New("db is down")) - err := failing.WithTxImmediate(func(tx model.DataStore) error { - _, err := auth.CreateFirstAdmin(ctx, tx, "johndoe", "secret") - return err - }) + _, err := auth.CreateFirstAdmin(ctx, failing, "johndoe", "secret", nil) Expect(err).To(MatchError(ContainSubstring("db is down"))) }) }) From 45d8a7724d07ab425646c9be7bbe217be96b7dc7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:41:23 -0400 Subject: [PATCH 23/41] test(api): share API v1 test helpers and check scopes in Go Move the end-to-end call/setup/mint helpers to a suite-level test client and the apiauth login/mustMint helpers to package level. Replace the hardcoded vacuum x-scope enum with a Go test that every known scope is a valid spec Scope; the gate already rejects unknown x-scope values. --- api/.vacuum.yaml | 8 -- core/apiauth/db_test.go | 17 +++ core/apiauth/service_session_test.go | 70 +++++------- core/apiauth/service_test.go | 8 +- server/apiv1/apiv1_suite_test.go | 47 ++++++++ server/apiv1/auth_test.go | 157 ++++++++++----------------- server/apiv1/capabilities_test.go | 28 ++--- server/apiv1/dto_test.go | 15 +++ 8 files changed, 172 insertions(+), 178 deletions(-) create mode 100644 server/apiv1/dto_test.go diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml index f658b6c01..261502224 100644 --- a/api/.vacuum.yaml +++ b/api/.vacuum.yaml @@ -44,14 +44,6 @@ rules: then: field: security function: defined - nd-operation-x-scope: - description: An operation's x-scope is a known scope. Cross-checks with x-module and security run in Go (server/apiv1 newGate). - severity: error - given: $.paths[*][get,put,post,delete,patch]['x-scope'] - then: - function: enumeration - functionOptions: - values: [read, password] nd-operation-stability-level-required: description: Every operation declares its stability level, which the breaking-change gate relies on. severity: error diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go index 69de6cd62..6fdd20459 100644 --- a/core/apiauth/db_test.go +++ b/core/apiauth/db_test.go @@ -32,3 +32,20 @@ func createUser(ctx context.Context, password string, admin bool) model.User { ExpectWithOffset(1, err).ToNot(HaveOccurred()) return *stored } + +// login runs the full client flow for a user whose password is "pw": grant, resolve, then mint. +func login(ctx context.Context, svc *Service, u model.User) (*Issued, *Principal, *AccessToken) { + issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + p, err := svc.ResolveGrant(ctx, issued.Secret, "") + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + tok, err := svc.Mint(ctx, p, nil) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + return issued, p, tok +} + +func mustMint(ctx context.Context, svc *Service, p *Principal) string { + tok, err := svc.Mint(ctx, p, nil) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + return tok.Token +} diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go index b871f279b..1a83d2ee4 100644 --- a/core/apiauth/service_session_test.go +++ b/core/apiauth/service_session_test.go @@ -18,16 +18,6 @@ var _ = Describe("Service: sessions", func() { var svc *Service var now time.Time - login := func(u model.User) (*Issued, *Principal, *AccessToken) { - issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - p, err := svc.ResolveGrant(ctx, issued.Secret, "") - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - tok, err := svc.Mint(ctx, p, nil) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - return issued, p, tok - } - BeforeEach(func() { ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) @@ -39,7 +29,7 @@ var _ = Describe("Service: sessions", func() { Describe("Authenticate", func() { It("returns the token's scopes and marks the grant used", func() { u := createUser(ctx, "pw", false) - issued, _, tok := login(u) + issued, _, tok := login(ctx, svc, u) now = now.Add(10 * time.Minute) p, err := svc.Authenticate(ctx, tok.Token, "10.1.1.1") Expect(err).ToNot(HaveOccurred()) @@ -51,7 +41,7 @@ var _ = Describe("Service: sessions", func() { It("reports an expired token as ErrTokenExpired", func() { u := createUser(ctx, "pw", false) - _, _, tok := login(u) + _, _, tok := login(ctx, svc, u) now = now.Add(TokenTTL + clockSkew + time.Second) _, err := svc.Authenticate(ctx, tok.Token, "") Expect(err).To(MatchError(ErrTokenExpired)) @@ -59,7 +49,7 @@ var _ = Describe("Service: sessions", func() { It("rejects a token at once on the node that revoked its grant", func() { u := createUser(ctx, "pw", false) - _, p, tok := login(u) + _, p, tok := login(ctx, svc, u) _, err := svc.Authenticate(ctx, tok.Token, "") Expect(err).ToNot(HaveOccurred()) Expect(svc.Logout(ctx, p)).To(Succeed()) @@ -69,7 +59,7 @@ var _ = Describe("Service: sessions", func() { It("stops a token revoked on another node within the cache time", func() { u := createUser(ctx, "pw", false) - issued, _, tok := login(u) + issued, _, tok := login(ctx, svc, u) _, err := svc.Authenticate(ctx, tok.Token, "") Expect(err).ToNot(HaveOccurred()) @@ -83,7 +73,7 @@ var _ = Describe("Service: sessions", func() { It("kills grants when the password changes anywhere else", func() { u := createUser(ctx, "pw", false) - _, _, tok := login(u) + _, _, tok := login(ctx, svc, u) u.NewPassword = "reset-by-admin" Expect(realDS.User().Put(ctx, &u)).To(Succeed()) _, err := svc.Authenticate(ctx, tok.Token, "") @@ -92,7 +82,7 @@ var _ = Describe("Service: sessions", func() { It("does not kill a grant kept by a password change made through another node", func() { u := createUser(ctx, "pw", false) - _, p, tok := login(u) + _, p, tok := login(ctx, svc, u) _, err := svc.Authenticate(ctx, tok.Token, "") // caches the old epoch Expect(err).ToNot(HaveOccurred()) @@ -109,7 +99,7 @@ var _ = Describe("Service: sessions", func() { KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin} DeferCleanup(func() { KnownScopes = saved }) u := createUser(ctx, "pw", true) - _, p, tok := login(u) + _, p, tok := login(ctx, svc, u) Expect(tok.Scopes).To(ContainElement(ScopeAdmin)) u.IsAdmin = false @@ -124,7 +114,7 @@ var _ = Describe("Service: sessions", func() { It("rejects a live token after its user is deleted, and the grant row is gone", func() { u := createUser(ctx, "pw", false) - issued, _, tok := login(u) + issued, _, tok := login(ctx, svc, u) Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed()) _, err := realDS.Grant().Get(ctx, issued.Grant.ID) Expect(err).To(MatchError(model.ErrNotFound)) @@ -135,7 +125,7 @@ var _ = Describe("Service: sessions", func() { It("grants no scopes to a signed token claiming all", func() { u := createUser(ctx, "pw", true) - _, p, _ := login(u) + _, p, _ := login(ctx, svc, u) sg, err := svc.signer() Expect(err).ToNot(HaveOccurred()) tok, err := sg.sign(claims{UserID: u.ID, GrantID: p.GrantID, Scopes: []string{ScopeAll, "unknown"}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) @@ -148,7 +138,7 @@ var _ = Describe("Service: sessions", func() { It("rejects a token whose grant belongs to another user, even across an epoch change", func() { alice := createUser(ctx, "pw", false) bob := createUser(ctx, "pw", false) - bobGrant, _, _ := login(bob) + bobGrant, _, _ := login(ctx, svc, bob) alice.NewPassword = "bumped" Expect(realDS.User().Put(ctx, &alice)).To(Succeed()) @@ -162,7 +152,7 @@ var _ = Describe("Service: sessions", func() { It("does not delete a kept grant when the user was read before a password change", func() { u := createUser(ctx, "pw", false) - _, p, _ := login(u) + _, p, _ := login(ctx, svc, u) stale, err := realDS.User().Get(ctx, u.ID) // read before the change lands Expect(err).ToNot(HaveOccurred()) Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) @@ -202,8 +192,8 @@ var _ = Describe("Service: sessions", func() { Describe("grant management", func() { It("lists the user's grants and marks the current one", func() { u := createUser(ctx, "pw", false) - first, _, _ := login(u) - _, p, _ := login(u) + first, _, _ := login(ctx, svc, u) + _, p, _ := login(ctx, svc, u) grants, total, err := svc.ListGrants(ctx, p, 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(total).To(Equal(int64(2))) @@ -213,7 +203,7 @@ var _ = Describe("Service: sessions", func() { It("lists only grants on the user's current epoch", func() { u := createUser(ctx, "pw", false) - login(u) + login(ctx, svc, u) u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch Expect(realDS.User().Put(ctx, &u)).To(Succeed()) issued, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil) @@ -230,7 +220,7 @@ var _ = Describe("Service: sessions", func() { It("logs out successfully when the grant is already gone", func() { u := createUser(ctx, "pw", false) - _, p, tok := login(u) + _, p, tok := login(ctx, svc, u) _, err := svc.Authenticate(ctx, tok.Token, "") Expect(err).ToNot(HaveOccurred()) Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed()) // another node @@ -243,8 +233,8 @@ var _ = Describe("Service: sessions", func() { It("refuses to revoke another user's grant", func() { alice := createUser(ctx, "pw", false) bob := createUser(ctx, "pw", false) - aliceGrant, _, _ := login(alice) - _, bobP, _ := login(bob) + aliceGrant, _, _ := login(ctx, svc, alice) + _, bobP, _ := login(ctx, svc, bob) Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound)) }) }) @@ -252,8 +242,8 @@ var _ = Describe("Service: sessions", func() { Describe("ChangePassword", func() { It("revokes other grants by default and keeps the caller's", func() { u := createUser(ctx, "pw", false) - _, _, otherTok := login(u) - _, p, myTok := login(u) + _, _, otherTok := login(ctx, svc, u) + _, p, myTok := login(ctx, svc, u) Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed()) _, err := svc.Authenticate(ctx, myTok.Token, "") @@ -268,8 +258,8 @@ var _ = Describe("Service: sessions", func() { It("keeps every grant when revokeOthers is false", func() { u := createUser(ctx, "pw", false) - _, _, otherTok := login(u) - _, p, _ := login(u) + _, _, otherTok := login(ctx, svc, u) + _, p, _ := login(ctx, svc, u) Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) now = now.Add(cacheTTL) _, err := svc.Authenticate(ctx, otherTok.Token, "") @@ -278,7 +268,7 @@ var _ = Describe("Service: sessions", func() { It("rejects a wrong current password without changing anything", func() { u := createUser(ctx, "pw", false) - _, p, _ := login(u) + _, p, _ := login(ctx, svc, u) err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true) Expect(err).To(MatchError(ErrCurrentPasswordMismatch)) _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) @@ -288,14 +278,14 @@ var _ = Describe("Service: sessions", func() { It("is forbidden for non-admins when user editing is off", func() { conf.Server.EnableUserEditing = false u := createUser(ctx, "pw", false) - _, p, _ := login(u) + _, p, _ := login(ctx, svc, u) err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("does not revive grants killed by an earlier reset when keeping grants", func() { u := createUser(ctx, "pw", false) - killed, _, _ := login(u) + killed, _, _ := login(ctx, svc, u) u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented Expect(realDS.User().Put(ctx, &u)).To(Succeed()) @@ -311,7 +301,7 @@ var _ = Describe("Service: sessions", func() { It("rejects a caller whose grant was revoked before the change ran", func() { u := createUser(ctx, "pw", false) - _, p, _ := login(u) + _, p, _ := login(ctx, svc, u) Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed()) err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) Expect(err).To(MatchError(model.ErrInvalidAuth)) @@ -321,7 +311,7 @@ var _ = Describe("Service: sessions", func() { It("rolls back the password and epoch when a grant update fails", func() { u := createUser(ctx, "pw", false) - _, p, _ := login(u) + _, p, _ := login(ctx, svc, u) failing := New(failingEpochDS{realDS}) failing.SetClock(func() time.Time { return now }) @@ -332,7 +322,7 @@ var _ = Describe("Service: sessions", func() { Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch)) _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, mustMint(svc, ctx, p), "") + _, err = svc.Authenticate(ctx, mustMint(ctx, svc, p), "") Expect(err).ToNot(HaveOccurred()) }) }) @@ -368,9 +358,3 @@ type failingGrants struct{ model.GrantRepository } func (failingGrants) SetEpoch(context.Context, string, int, int, string) error { return errors.New("boom") } - -func mustMint(svc *Service, ctx context.Context, p *Principal) string { - tok, err := svc.Mint(ctx, p, nil) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - return tok.Token -} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go index fb51ac6a2..ce6622914 100644 --- a/core/apiauth/service_test.go +++ b/core/apiauth/service_test.go @@ -111,15 +111,9 @@ var _ = Describe("Service: grants and tokens", func() { Describe("ResolveGrant and Mint", func() { It("mints a token with the grant's expanded scopes and a 1h lifetime", func() { u := createUser(ctx, "pw", false) - issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - - p, err := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9") - Expect(err).ToNot(HaveOccurred()) + issued, p, tok := login(ctx, svc, u) Expect(p.GrantID).To(Equal(issued.Grant.ID)) Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) - - tok, err := svc.Mint(ctx, p, nil) - Expect(err).ToNot(HaveOccurred()) Expect(tok.ExpiresIn).To(Equal(time.Hour)) Expect(tok.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index 6875b2330..69416c924 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -2,6 +2,8 @@ package apiv1 import ( "bytes" + "context" + "encoding/json" "errors" "io" "net/http" @@ -57,6 +59,51 @@ func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder { return w } +// testClient drives a router end to end through serve, so every response is also checked against the spec. +type testClient struct { + ctx context.Context + router http.Handler +} + +func (c testClient) call(method, path, bearer string, body any) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + b, _ := json.Marshal(body) + req = httptest.NewRequestWithContext(c.ctx, method, path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequestWithContext(c.ctx, method, path, nil) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + return serve(c.router, req) +} + +func (c testClient) setup() GrantCreated { + w := c.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String()) + var gc GrantCreated + decodeJSON(w, &gc) + return gc +} + +func (c testClient) mint(secret string, body any) AccessToken { + w := c.call(http.MethodPost, "/api/v1/auth/token", secret, body) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String()) + var at AccessToken + decodeJSON(w, &at) + return at +} + +func creds(user, pw string) map[string]any { + return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"} +} + +func decodeJSON(w *httptest.ResponseRecorder, v any) { + ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String()) +} + func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) { route, pathParams, err := specRouter.FindRoute(req) if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) { diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index f1b3e7c0c..884865d16 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -1,9 +1,7 @@ package apiv1 import ( - "bytes" "context" - "encoding/json" "io" "net/http" "net/http/httptest" @@ -19,53 +17,14 @@ import ( var _ = Describe("auth endpoints", func() { var ctx context.Context - var router *Router - - call := func(method, path, bearer string, body any) *httptest.ResponseRecorder { - var req *http.Request - if body != nil { - b, _ := json.Marshal(body) - req = httptest.NewRequestWithContext(ctx, method, path, bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - } else { - req = httptest.NewRequestWithContext(ctx, method, path, nil) - } - if bearer != "" { - req.Header.Set("Authorization", "Bearer "+bearer) - } - return serve(router, req) - } - - creds := func(user, pw string) map[string]any { - return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"} - } - - decode := func(w *httptest.ResponseRecorder, v any) { - ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String()) - } - - setup := func() GrantCreated { - w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String()) - var gc GrantCreated - decode(w, &gc) - return gc - } - - mint := func(secret string, body any) AccessToken { - w := call(http.MethodPost, "/api/v1/auth/token", secret, body) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String()) - var at AccessToken - decode(w, &at) - return at - } + var api testClient BeforeEach(func() { ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 0 resetDB() - router = New(realDS) + api = testClient{ctx: ctx, router: New(realDS)} }) It("lets exactly one of a v1 setup and a v0 first-admin creation win", func() { @@ -76,7 +35,7 @@ var _ = Describe("auth endpoints", func() { go func() { defer GinkgoRecover() defer wg.Done() - v1Code = call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code + v1Code = api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code }() go func() { defer GinkgoRecover() @@ -90,149 +49,149 @@ var _ = Describe("auth endpoints", func() { }) It("sets up the first admin once, then answers 409 setup_complete", func() { - gc := setup() + gc := api.setup() Expect(gc.Secret).To(HavePrefix("ndg_")) Expect(gc.User.IsAdmin).To(BeTrue()) Expect(gc.Grant.Provider).To(Equal("setup")) Expect(gc.Grant.Current).To(BeTrue()) - w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw")) + w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw")) Expect(w.Code).To(Equal(http.StatusConflict)) Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete)) }) It("logs in, mints a token, and uses it on a scoped endpoint", func() { - setup() - w := call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw")) + api.setup() + w := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw")) Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) var gc GrantCreated - decode(w, &gc) + decodeJSON(w, &gc) Expect(gc.User.PasswordChangeable).To(BeTrue()) - at := mint(gc.Secret, nil) + at := api.mint(gc.Secret, nil) Expect(at.TokenType).To(Equal(AccessTokenTokenTypeBearer)) Expect(at.ExpiresIn).To(Equal(3600)) - w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) var list GrantList - decode(w, &list) + decodeJSON(w, &list) Expect(list.Total).To(Equal(2)) Expect(list.Limit).To(Equal(100)) }) It("fails login the same way for an unknown user and a wrong password, with a Bearer challenge", func() { - setup() - a := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong")) - b := call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw")) + api.setup() + a := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong")) + b := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw")) Expect(a.Code).To(Equal(http.StatusUnauthorized)) Expect(a.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) Expect(a.Body.String()).To(Equal(b.Body.String())) }) It("treats no body and {} as all scopes, and [] as no scopes", func() { - gc := setup() - all := mint(gc.Secret, nil) + gc := api.setup() + all := api.mint(gc.Secret, nil) Expect(all.Scopes).To(ConsistOf(ScopeRead, ScopePassword)) - Expect(mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword)) + Expect(api.mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword)) - none := mint(gc.Secret, map[string]any{"scopes": []string{}}) + none := api.mint(gc.Secret, map[string]any{"scopes": []string{}}) Expect(none.Scopes).To(BeEmpty()) - w := call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil) + w := api.call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil) Expect(w.Code).To(Equal(http.StatusForbidden)) Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) }) It("drops unknown requested scopes instead of rejecting them", func() { - gc := setup() - at := mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}}) + gc := api.setup() + at := api.mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}}) Expect(at.Scopes).To(ConsistOf(ScopeRead)) }) It("does not let a token without read log out or revoke grants", func() { - gc := setup() - narrow := mint(gc.Secret, map[string]any{"scopes": []string{"password"}}) - Expect(call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) - Expect(call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) + gc := api.setup() + narrow := api.mint(gc.Secret, map[string]any{"scopes": []string{"password"}}) + Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) + Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) }) It("logs out: the token stops at once and logoutUrl is null", func() { - gc := setup() - at := mint(gc.Secret, nil) - w := call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) + gc := api.setup() + at := api.mint(gc.Secret, nil) + w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) - w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + Expect(api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) }) It("logs out with 200 when another node already revoked the grant", func() { - gc := setup() - at := mint(gc.Secret, nil) - Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant + gc := api.setup() + at := api.mint(gc.Secret, nil) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed()) - w := call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) + w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) }) It("challenges with invalid_token when the grant is revoked while a password change runs", func() { - gc := setup() - at := mint(gc.Secret, nil) - Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant + gc := api.setup() + at := api.mint(gc.Secret, nil) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed()) - w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String()) Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) }) It("marks grant and token responses no-store", func() { - w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) + w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) Expect(w.Code).To(Equal(http.StatusCreated)) Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) var gc GrantCreated - decode(w, &gc) + decodeJSON(w, &gc) - w = call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) + w = api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - w = call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil) + w = api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) }) It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() { - gc := setup() - tok := mint(gc.Secret, nil).AccessToken - Expect(call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound)) - w := call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, nil) + gc := api.setup() + tok := api.mint(gc.Secret, nil).AccessToken + Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound)) + w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, nil) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"})) }) It("changes the password, keeping the caller and revoking the rest", func() { - gc := setup() - otherLogin := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) + gc := api.setup() + otherLogin := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) var other GrantCreated - decode(otherLogin, &other) - at := mint(gc.Secret, nil) + decodeJSON(otherLogin, &other) + at := api.mint(gc.Secret, nil) - w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String()) - Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) - Expect(call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) + Expect(api.call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) }) It("reports a wrong current password as a field error", func() { - gc := setup() - at := mint(gc.Secret, nil) - w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "nope", "newPassword": "pw2"}) + gc := api.setup() + at := api.mint(gc.Secret, nil) + w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "nope", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusBadRequest)) p := decodeProblem(w) Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"})) @@ -240,7 +199,7 @@ var _ = Describe("auth endpoints", func() { DescribeTable("rejects bad credential bodies with a field error and no echo", func(body map[string]any, field string) { - w := call(http.MethodPost, "/api/v1/auth/setup", "", body) + w := api.call(http.MethodPost, "/api/v1/auth/setup", "", body) Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) p := decodeProblem(w) Expect(p.Code).To(Equal(ProblemCodeValidation)) @@ -258,7 +217,7 @@ var _ = Describe("auth endpoints", func() { big := `{"username":"a","password":"` + strings.Repeat("a", maxBodyBytes) + `","client":"c"}` req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", body(big)) req.Header.Set("Content-Type", "application/json") - w := serve(router, req) + w := serve(api.router, req) Expect(w.Code).To(Equal(http.StatusRequestEntityTooLarge)) Expect(decodeProblem(w).Code).To(Equal(ProblemCodePayloadTooLarge)) }, diff --git a/server/apiv1/capabilities_test.go b/server/apiv1/capabilities_test.go index ac1ec1b69..456d09bd5 100644 --- a/server/apiv1/capabilities_test.go +++ b/server/apiv1/capabilities_test.go @@ -1,11 +1,8 @@ package apiv1 import ( - "bytes" "context" - "encoding/json" "net/http" - "net/http/httptest" "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" @@ -14,39 +11,28 @@ import ( var _ = Describe("GET /capabilities", func() { var ctx context.Context - var router *Router + var api testClient BeforeEach(func() { ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) resetDB() - router = New(realDS) + api = testClient{ctx: ctx, router: New(realDS)} }) It("needs a token", func() { - w := serve(router, httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil)) + w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) It("lists core and password for any valid token, even one with no scopes", func() { - body, _ := json.Marshal(map[string]any{"username": "admin", "password": "pw", "client": "c"}) - setupReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body)) - setupReq.Header.Set("Content-Type", "application/json") - var gc GrantCreated - Expect(json.Unmarshal(serve(router, setupReq).Body.Bytes(), &gc)).To(Succeed()) + gc := api.setup() + at := api.mint(gc.Secret, map[string]any{"scopes": []string{}}) - tokReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", bytes.NewReader([]byte(`{"scopes":[]}`))) - tokReq.Header.Set("Content-Type", "application/json") - tokReq.Header.Set("Authorization", "Bearer "+gc.Secret) - var at AccessToken - Expect(json.Unmarshal(serve(router, tokReq).Body.Bytes(), &at)).To(Succeed()) - - req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil) - req.Header.Set("Authorization", "Bearer "+at.AccessToken) - w := serve(router, req) + w := api.call(http.MethodGet, "/api/v1/capabilities", at.AccessToken, nil) Expect(w.Code).To(Equal(http.StatusOK)) var caps Capabilities - Expect(json.Unmarshal(w.Body.Bytes(), &caps)).To(Succeed()) + decodeJSON(w, &caps) Expect(caps.Core.Version).To(Equal(1)) Expect(caps.Password.Version).To(Equal(1)) }) diff --git a/server/apiv1/dto_test.go b/server/apiv1/dto_test.go new file mode 100644 index 000000000..49f10f067 --- /dev/null +++ b/server/apiv1/dto_test.go @@ -0,0 +1,15 @@ +package apiv1 + +import ( + "github.com/navidrome/navidrome/core/apiauth" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("toScopes", func() { + It("only produces scopes the spec's Scope enum allows", func() { + for _, s := range toScopes(append([]string{apiauth.ScopeAll}, apiauth.KnownScopes...)) { + Expect(s.Valid()).To(BeTrue(), "scope %q is missing from the spec's Scope enum", s) + } + }) +}) From 550e03976cee2a099e578d0bb2c5f5bd0a05f42e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:42:43 -0400 Subject: [PATCH 24/41] refactor(api): shorten the grant touch comment --- core/apiauth/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/apiauth/service.go b/core/apiauth/service.go index 4cbf25eea..f16fef5fb 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -232,7 +232,7 @@ func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant return g, u, nil } -// touch writes last_used at most every touchInterval (a zero lastUsed means never); the SQL condition keeps that true across nodes. +// touch writes last_used at most every touchInterval (zero lastUsed: never used); the SQL condition holds that across nodes. func (s *Service) touch(ctx context.Context, id, ip string, lastUsed time.Time) { now := s.now() if !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) { From d368eef3ce315c0a7059d6a7a7ddcf5059525c16 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:49:05 -0400 Subject: [PATCH 25/41] fix(log): redact named string types instead of panicking The redaction hook matched fields by reflect.Kind but read them with a v.(string) type assertion, so any named string type (such as an enum) panicked the log call. API v1 problem logging hit this on every error. --- log/redactrus.go | 3 ++- log/redactrus_test.go | 11 +++++++++++ server/apiv1/problem_test.go | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/log/redactrus.go b/log/redactrus.go index 6e17243e7..fc642bfc0 100755 --- a/log/redactrus.go +++ b/log/redactrus.go @@ -47,7 +47,8 @@ func (h *Hook) Fire(e *logrus.Entry) error { } switch reflect.TypeOf(v).Kind() { case reflect.String: - e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2") + // Via reflect: named string types (e.g. enums) have Kind String but fail v.(string). + e.Data[k] = re.ReplaceAllString(reflect.ValueOf(v).String(), "$1[REDACTED]$2") continue case reflect.Map: s := fmt.Sprintf("%+v", v) diff --git a/log/redactrus_test.go b/log/redactrus_test.go index 36a19e2f5..a2bd13dda 100755 --- a/log/redactrus_test.go +++ b/log/redactrus_test.go @@ -157,3 +157,14 @@ func TestEntryMessage(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message) } + +type namedString string + +func TestFireRedactsNamedStringTypes(t *testing.T) { + hook := &Hook{RedactionList: []string{"(secret=)[^&]+"}} + e := &logrus.Entry{Data: logrus.Fields{"code": namedString("not_found"), "url": namedString("/x?secret=abc")}} + + assert.NotPanics(t, func() { _ = hook.Fire(e) }) + assert.Equal(t, "not_found", e.Data["code"]) + assert.Equal(t, "/x?secret=[REDACTED]", e.Data["url"]) +} diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go index 9cc33838c..074f270b2 100644 --- a/server/apiv1/problem_test.go +++ b/server/apiv1/problem_test.go @@ -77,6 +77,13 @@ var _ = Describe("problem", func() { Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"})) }) + It("writes and logs a problem with debug logging on", func() { + logs := captureLogs() + writeProblem(w, r, model.ErrNotFound) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(logs.String()).To(ContainSubstring("code=not_found")) + }) + It("adds a Bearer challenge to every 401 unless one is already set", func() { writeProblem(w, r, model.ErrInvalidAuth) Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) From 8789561b607b8f96e2ebc3042d2e66532f91d62a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 12:52:20 -0400 Subject: [PATCH 26/41] fix(api): consolidate grant deletes and harden API v1 auth after review - GrantRepository keeps three deletes: DeleteForUser, DeleteStaleEpochs (replaces DeleteOtherEpochs and DeleteIfEpoch) and DeleteIdle. Delete(id) is gone; the idle path in ResolveGrant now calls DeleteIdle, so a grant renewed by another node between the read and the delete survives. settleEpoch deletes the user's grants below the snapshot's epoch, which is safe outside the transaction because epochs only move forward. - The "dead grants on an older epoch are only deleted when presented" policy note moves from the repository to core ListGrants. - SQL trace logging no longer prints the args of property writes (signing keys) or user password writes, both encrypted with a key that may be the public default. The SQL statement is still logged. - The spec gate rejects JSON body keys that differ from a declared property only in case. kin-openapi validates exact names while encoding/json decodes case-insensitively, so {"scopes":[],"Scopes":null} minted a token with every scope and a "Client" key skipped maxLength. It also rejects data after the first JSON value, which the handlers' decoder ignores and which let a body skip the alias check. - The liveness cache trims its eviction log on evict, not only on put, so evict-only traffic stays bounded; the floor still drops stale fills. - createAccessToken, login and setupFirstAdmin declare Cache-Control: no-store on their success responses. --- api/bundled/openapi.json | 24 ++++++ api/bundled/openapi.yaml | 15 ++++ .../headers/CacheControlNoStore.yaml | 4 + api/openapi/paths/auth.yaml | 9 ++ core/apiauth/cache.go | 8 +- core/apiauth/cache_test.go | 16 ++++ core/apiauth/service.go | 24 +++--- core/apiauth/service_session_test.go | 6 +- core/apiauth/service_test.go | 41 +++++++++- model/grant.go | 4 +- persistence/grant_repository.go | 13 +-- persistence/grant_repository_test.go | 45 ++++++---- persistence/persistence_suite_test.go | 14 ++++ persistence/property_repository.go | 2 + persistence/property_repository_test.go | 13 +++ persistence/sql_base_repository.go | 21 ++++- persistence/user_repository.go | 3 +- persistence/user_repository_test.go | 22 +++++ server/apiv1/api_gen.go | 42 ++++++++-- server/apiv1/api_test.go | 24 ++++++ server/apiv1/apiv1_suite_test.go | 15 +++- server/apiv1/auth_handlers.go | 4 +- server/apiv1/auth_test.go | 48 ++++++++++- server/apiv1/gate.go | 82 ++++++++++++++++++- server/apiv1/gate_test.go | 62 +++++++++++++- server/apiv1/password_handlers.go | 4 +- 26 files changed, 493 insertions(+), 72 deletions(-) create mode 100644 api/openapi/components/headers/CacheControlNoStore.yaml diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index c46fef1a7..f3af48f50 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -151,6 +151,11 @@ "responses": { "200": { "description": "The new access token.", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControlNoStore" + } + }, "content": { "application/json": { "schema": { @@ -372,6 +377,11 @@ "responses": { "200": { "description": "The new grant.", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControlNoStore" + } + }, "content": { "application/json": { "schema": { @@ -423,6 +433,11 @@ "responses": { "201": { "description": "The admin was created.", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControlNoStore" + } + }, "content": { "application/json": { "schema": { @@ -1137,6 +1152,15 @@ "schema": { "type": "string" } + }, + "CacheControlNoStore": { + "description": "Always `no-store`, because the response carries a secret or a token.", + "schema": { + "type": "string", + "enum": [ + "no-store" + ] + } } } } diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index 5b07e614d..2134268eb 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -108,6 +108,9 @@ paths: responses: '200': description: The new access token. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControlNoStore' content: application/json: schema: @@ -243,6 +246,9 @@ paths: responses: '200': description: The new grant. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControlNoStore' content: application/json: schema: @@ -276,6 +282,9 @@ paths: responses: '201': description: The admin was created. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControlNoStore' content: application/json: schema: @@ -793,3 +802,9 @@ components: description: Entity tag for `If-None-Match` revalidation. schema: type: string + CacheControlNoStore: + description: Always `no-store`, because the response carries a secret or a token. + schema: + type: string + enum: + - no-store diff --git a/api/openapi/components/headers/CacheControlNoStore.yaml b/api/openapi/components/headers/CacheControlNoStore.yaml new file mode 100644 index 000000000..092a03fda --- /dev/null +++ b/api/openapi/components/headers/CacheControlNoStore.yaml @@ -0,0 +1,4 @@ +description: Always `no-store`, because the response carries a secret or a token. +schema: + type: string + enum: [no-store] diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml index 5f7e3d58d..b6d8c1d9c 100644 --- a/api/openapi/paths/auth.yaml +++ b/api/openapi/paths/auth.yaml @@ -17,6 +17,9 @@ token: responses: '200': description: The new access token. + headers: + Cache-Control: + $ref: ../components/headers/CacheControlNoStore.yaml content: application/json: schema: @@ -130,6 +133,9 @@ login: responses: '200': description: The new grant. + headers: + Cache-Control: + $ref: ../components/headers/CacheControlNoStore.yaml content: application/json: schema: @@ -163,6 +169,9 @@ setup: responses: '201': description: The admin was created. + headers: + Cache-Control: + $ref: ../components/headers/CacheControlNoStore.yaml content: application/json: schema: diff --git a/core/apiauth/cache.go b/core/apiauth/cache.go index d19d53dd5..ab4aaa1e1 100644 --- a/core/apiauth/cache.go +++ b/core/apiauth/cache.go @@ -62,10 +62,6 @@ func (c *livenessCache) put(id string, e livenessEntry, now time.Time, started u break } } - if len(c.evicted) >= maxLivenessEntries { - clear(c.evicted) - c.floor = c.gen - } e.expires = now.Add(c.ttl) c.entries[id] = e } @@ -73,6 +69,10 @@ func (c *livenessCache) put(id string, e livenessEntry, now time.Time, started u func (c *livenessCache) evict(id string) { c.mu.Lock() defer c.mu.Unlock() + if len(c.evicted) >= maxLivenessEntries { + clear(c.evicted) + c.floor = c.gen + } c.gen++ c.evicted[id] = c.gen delete(c.entries, id) diff --git a/core/apiauth/cache_test.go b/core/apiauth/cache_test.go index a3429897d..fde2066f5 100644 --- a/core/apiauth/cache_test.go +++ b/core/apiauth/cache_test.go @@ -65,6 +65,22 @@ var _ = Describe("livenessCache", func() { Expect(ok).To(BeFalse()) }) + It("stays bounded under eviction-only traffic and still drops a fill started before the trim", func() { + started := c.begin() + c.evict("g1") + for i := range 3 * maxLivenessEntries { + c.evict(fmt.Sprint("other", i)) + } + Expect(len(c.evicted)).To(BeNumerically("<=", maxLivenessEntries)) + + c.put("g1", livenessEntry{userID: "u1"}, t0, started) + _, ok := c.get("g1", t0) + Expect(ok).To(BeFalse()) + c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) + _, ok = c.get("g1", t0) + Expect(ok).To(BeTrue()) + }) + It("records the last use without extending the TTL", func() { c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) c.markUsed("g1", t0.Add(10*time.Second)) diff --git a/core/apiauth/service.go b/core/apiauth/service.go index f16fef5fb..08dc53332 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -155,8 +155,8 @@ func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Princip if err != nil { return nil, err } - if !s.now().Before(g.LastActivity().Add(IdleExpiry)) { - s.dropGrant(ctx, g.ID) + if idleSince := s.now().Add(-IdleExpiry); g.LastActivity().Before(idleSince) { + s.dropIdle(ctx, g.ID, idleSince) return nil, model.ErrInvalidAuth } u, err := s.loadUser(ctx, g.UserID) @@ -194,16 +194,17 @@ func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, err return u, err } -// dropGrant evicts after deleting, so a concurrent fill cannot re-cache the dead grant. -func (s *Service) dropGrant(ctx context.Context, id string) { - if err := s.ds.Grant().Delete(ctx, id); err != nil { - log.Warn(ctx, "API v1: could not delete dead grant", "grant", id, err) +// dropIdle deletes only still-idle grants, sparing one renewed meanwhile, and evicts after deleting so a +// concurrent fill cannot re-cache the dead grant. +func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) { + if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil { + log.Warn(ctx, "API v1: could not delete idle grants", "grant", id, err) } s.cache.evict(id) } -// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password -// change and make a kept grant look dead. The delete only fires while the grant is on the epoch seen here. +// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password change +// and make a kept grant look dead. Deleting below the snapshot's epoch is safe: a later change only moves kept grants up. func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant, *model.User, error) { var g *model.Grant var u *model.User @@ -224,8 +225,8 @@ func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant } if g.UserEpoch != u.TokenEpoch { s.cache.evict(grantID) - if err := s.ds.Grant().DeleteIfEpoch(ctx, grantID, g.UserEpoch); err != nil { - log.Warn(ctx, "API v1: could not delete dead grant", "grant", grantID, err) + if err := s.ds.Grant().DeleteStaleEpochs(ctx, u.ID, u.TokenEpoch); err != nil { + log.Warn(ctx, "API v1: could not delete the user's grants from older epochs", "user", u.ID, "grant", grantID, err) } return nil, nil, model.ErrInvalidAuth } @@ -297,6 +298,7 @@ func (s *Service) liveGrant(ctx context.Context, id string, u *model.User) (live return e, u, nil } +// ListGrants shows only the current epoch: grants left on an older one are dead but only deleted when presented. func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit int) (model.Grants, int64, error) { idleSince := s.now().Add(-IdleExpiry) grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince, offset, limit) @@ -374,6 +376,6 @@ func (s *Service) ChangePassword(ctx context.Context, p *Principal, current, new if err := tx.Grant().SetEpoch(ctx, u.ID, oldEpoch, updated.TokenEpoch, keep); err != nil { return err } - return tx.Grant().DeleteOtherEpochs(ctx, u.ID, updated.TokenEpoch) + return tx.Grant().DeleteStaleEpochs(ctx, u.ID, updated.TokenEpoch) }) } diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go index 1a83d2ee4..573173402 100644 --- a/core/apiauth/service_session_test.go +++ b/core/apiauth/service_session_test.go @@ -63,7 +63,7 @@ var _ = Describe("Service: sessions", func() { _, err := svc.Authenticate(ctx, tok.Token, "") Expect(err).ToNot(HaveOccurred()) - Expect(realDS.Grant().Delete(ctx, issued.Grant.ID)).To(Succeed()) // another node + Expect(realDS.Grant().DeleteForUser(ctx, u.ID, issued.Grant.ID)).To(Succeed()) // another node _, err = svc.Authenticate(ctx, tok.Token, "") Expect(err).ToNot(HaveOccurred()) // still cached now = now.Add(cacheTTL) @@ -223,7 +223,7 @@ var _ = Describe("Service: sessions", func() { _, p, tok := login(ctx, svc, u) _, err := svc.Authenticate(ctx, tok.Token, "") Expect(err).ToNot(HaveOccurred()) - Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed()) // another node + Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) // another node Expect(svc.Logout(ctx, p)).To(Succeed()) _, err = svc.Authenticate(ctx, tok.Token, "") @@ -302,7 +302,7 @@ var _ = Describe("Service: sessions", func() { It("rejects a caller whose grant was revoked before the change ran", func() { u := createUser(ctx, "pw", false) _, p, _ := login(ctx, svc, u) - Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed()) + Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) Expect(err).To(MatchError(model.ErrInvalidAuth)) _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go index ce6622914..03469e486 100644 --- a/core/apiauth/service_test.go +++ b/core/apiauth/service_test.go @@ -38,6 +38,26 @@ func (p *flakyProps) Get(ctx context.Context, id string) (string, error) { return p.PropertyRepository.Get(ctx, id) } +// renewingDS runs renew right before DeleteIdle, as a node resolving the grant meanwhile would. +type renewingDS struct { + model.DataStore + renew func() +} + +func (d renewingDS) Grant() model.GrantRepository { + return renewingGrants{GrantRepository: d.DataStore.Grant(), renew: d.renew} +} + +type renewingGrants struct { + model.GrantRepository + renew func() +} + +func (g renewingGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) { + g.renew() + return g.GrantRepository.DeleteIdle(ctx, idleSince) +} + var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} var _ = Describe("Service: grants and tokens", func() { @@ -174,13 +194,32 @@ var _ = Describe("Service: grants and tokens", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) - It("rejects a grant whose epoch is behind the user's", func() { + It("keeps an idle grant that another node renewed before the delete ran", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + renewedAt := now.Add(IdleExpiry - time.Minute) + racing := New(renewingDS{DataStore: realDS, renew: func() { + Expect(realDS.Grant().Touch(ctx, issued.Grant.ID, "10.0.0.2", renewedAt, renewedAt)).To(Succeed()) + }}) + now = now.Add(IdleExpiry + time.Second) + racing.SetClock(func() time.Time { return now }) + + _, err := racing.ResolveGrant(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + g, err := realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(g.LastUsedIP).To(Equal("10.0.0.2")) + }) + + It("rejects and deletes a grant whose epoch is behind the user's", func() { u := createUser(ctx, "pw", false) issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) u.NewPassword = "changed-elsewhere" Expect(realDS.User().Put(ctx, &u)).To(Succeed()) _, err := svc.ResolveGrant(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) + _, err = realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(err).To(MatchError(model.ErrNotFound)) }) }) diff --git a/model/grant.go b/model/grant.go index 1e8fe46e1..ecbf30e41 100644 --- a/model/grant.go +++ b/model/grant.go @@ -59,11 +59,9 @@ type GrantRepository interface { FindBySecretHash(ctx context.Context, hash string) (*Grant, error) GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (Grants, error) CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) - Delete(ctx context.Context, id string) error DeleteForUser(ctx context.Context, userID, id string) error - DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error + DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error - DeleteIfEpoch(ctx context.Context, id string, epoch int) error Touch(ctx context.Context, id, ip string, at, notSince time.Time) error DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) } diff --git a/persistence/grant_repository.go b/persistence/grant_repository.go index 0e561315c..349099983 100644 --- a/persistence/grant_repository.go +++ b/persistence/grant_repository.go @@ -60,7 +60,6 @@ func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Gra return &g, nil } -// activeForUser skips grants left on an older epoch: they are dead but only deleted when presented. func activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer { return And{Eq{"user_id": userID, "user_epoch": epoch}, Expr(grantLastActivity+" >= ?", idleSince.UTC())} } @@ -78,10 +77,6 @@ func (r *grantRepository) CountForUser(ctx context.Context, userID string, epoch return r.count(ctx, Select().Where(activeForUser(userID, epoch, idleSince))) } -func (r *grantRepository) Delete(ctx context.Context, id string) error { - return r.delete(ctx, Eq{"id": id}) -} - func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error { n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID})) if err != nil { @@ -93,8 +88,8 @@ func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) return nil } -func (r *grantRepository) DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error { - return r.delete(ctx, And{Eq{"user_id": userID}, NotEq{"user_epoch": epoch}}) +func (r *grantRepository) DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error { + return r.delete(ctx, And{Eq{"user_id": userID}, Lt{"user_epoch": currentEpoch}}) } // SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back. @@ -107,10 +102,6 @@ func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch return err } -func (r *grantRepository) DeleteIfEpoch(ctx context.Context, id string, epoch int) error { - return r.delete(ctx, Eq{"id": id, "user_epoch": epoch}) -} - func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error { upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip). Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}}) diff --git a/persistence/grant_repository_test.go b/persistence/grant_repository_test.go index bb140ebd6..2436f8bb4 100644 --- a/persistence/grant_repository_test.go +++ b/persistence/grant_repository_test.go @@ -96,20 +96,18 @@ var _ = Describe("GrantRepository", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) - It("moves epochs forward and deletes grants left on other epochs", func() { + It("moves epochs forward", func() { keep := newGrant(adminUser.ID, "h-keep") - drop := newGrant(adminUser.ID, "h-drop") + stay := newGrant(adminUser.ID, "h-stay") Expect(repo.Put(ctx, keep)).To(Succeed()) - Expect(repo.Put(ctx, drop)).To(Succeed()) + Expect(repo.Put(ctx, stay)).To(Succeed()) Expect(repo.SetEpoch(ctx, adminUser.ID, 0, 3, keep.ID)).To(Succeed()) - Expect(repo.DeleteOtherEpochs(ctx, adminUser.ID, 3)).To(Succeed()) - kept, err := repo.Get(ctx, keep.ID) Expect(err).ToNot(HaveOccurred()) Expect(kept.UserEpoch).To(Equal(3)) - _, err = repo.Get(ctx, drop.ID) - Expect(err).To(MatchError(model.ErrNotFound)) + stayed, _ := repo.Get(ctx, stay.ID) + Expect(stayed.UserEpoch).To(Equal(0)) Expect(repo.SetEpoch(ctx, adminUser.ID, 3, 4, "")).To(Succeed()) kept, _ = repo.Get(ctx, keep.ID) @@ -131,16 +129,29 @@ var _ = Describe("GrantRepository", func() { Expect(got.UserEpoch).To(Equal(3)) }) - It("deletes by epoch only while the row is still on it", func() { - g := newGrant(adminUser.ID, "h-cond") - g.UserEpoch = 5 - Expect(repo.Put(ctx, g)).To(Succeed()) - Expect(repo.DeleteIfEpoch(ctx, g.ID, 4)).To(Succeed()) - _, err := repo.Get(ctx, g.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(repo.DeleteIfEpoch(ctx, g.ID, 5)).To(Succeed()) - _, err = repo.Get(ctx, g.ID) - Expect(err).To(MatchError(model.ErrNotFound)) + It("deletes only the user's grants on an epoch before the current one", func() { + older := newGrant(adminUser.ID, "h-older") + older.UserEpoch = 1 + previous := newGrant(adminUser.ID, "h-previous") + previous.UserEpoch = 4 + current := newGrant(adminUser.ID, "h-current") + current.UserEpoch = 5 + otherUser := newGrant(regularUser.ID, "h-other-user") + otherUser.UserEpoch = 1 + for _, g := range []*model.Grant{older, previous, current, otherUser} { + Expect(repo.Put(ctx, g)).To(Succeed()) + } + + Expect(repo.DeleteStaleEpochs(ctx, adminUser.ID, 5)).To(Succeed()) + + for _, g := range []*model.Grant{older, previous} { + _, err := repo.Get(ctx, g.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + } + for _, g := range []*model.Grant{current, otherUser} { + _, err := repo.Get(ctx, g.ID) + Expect(err).ToNot(HaveOccurred()) + } }) It("touches a never-used grant, then throttles until notSince passes", func() { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ee2794454..dc6134793 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -1,7 +1,9 @@ package persistence import ( + "bytes" "context" + "os" "path/filepath" "testing" "time" @@ -348,6 +350,18 @@ var _ = BeforeSuite(func() { } }) +// captureTraceLogs sends trace logs, SQL included, to a buffer for the rest of the spec. +func captureTraceLogs() *bytes.Buffer { + buf := &bytes.Buffer{} + log.SetOutput(buf) + log.SetLevel(log.LevelTrace) + DeferCleanup(func() { + log.SetOutput(os.Stderr) + log.SetLevel(log.LevelFatal) + }) + return buf +} + func GetDBXBuilder() *dbx.DB { return dbx.NewFromDB(db.Db(), db.Dialect) } diff --git a/persistence/property_repository.go b/persistence/property_repository.go index 87d97491c..916b16430 100644 --- a/persistence/property_repository.go +++ b/persistence/property_repository.go @@ -21,6 +21,7 @@ func NewPropertyRepository(db dbx.Builder) model.PropertyRepository { } func (r propertyRepository) Put(ctx context.Context, id string, value string) error { + ctx = withSecretArgs(ctx) update := Update(r.tableName).Set("value", value).Where(Eq{"id": id}) count, err := r.executeSQL(ctx, update) if err != nil { @@ -35,6 +36,7 @@ func (r propertyRepository) Put(ctx context.Context, id string, value string) er } func (r propertyRepository) PutIfAbsent(ctx context.Context, id string, value string) error { + ctx = withSecretArgs(ctx) insert := Insert(r.tableName).Columns("id", "value").Values(id, value).Options("OR IGNORE") _, err := r.executeSQL(ctx, insert) return err diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 0f4f9ebe5..f80fa0b36 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -39,4 +39,17 @@ var _ = Describe("Property Repository", func() { Expect(pr.PutIfAbsent(ctx, "pia", "second")).To(Succeed()) Expect(pr.Get(ctx, "pia")).To(Equal("first")) }) + + It("never logs the values it writes, but still logs the SQL", func() { + logs := captureTraceLogs() + Expect(pr.Put(ctx, "secret-prop", "inserted-secret")).To(Succeed()) + Expect(pr.Put(ctx, "secret-prop", "updated-secret")).To(Succeed()) + Expect(pr.PutIfAbsent(ctx, "secret-prop-2", "absent-secret")).To(Succeed()) + + Expect(logs.String()).To(ContainSubstring("INSERT INTO property")) + Expect(logs.String()).To(ContainSubstring("UPDATE property")) + Expect(logs.String()).ToNot(ContainSubstring("inserted-secret")) + Expect(logs.String()).ToNot(ContainSubstring("updated-secret")) + Expect(logs.String()).ToNot(ContainSubstring("absent-secret")) + }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 03cc6a01b..d929e7ef4 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -658,9 +658,28 @@ func (r sqlRepository) deleteByID(ctx context.Context, id string) error { return nil } +type secretArgsKey struct{} + +// withSecretArgs hides a statement's args from the SQL log: stored secrets are encrypted with a key that may be the public default. +func withSecretArgs(ctx context.Context) context.Context { + return context.WithValue(ctx, secretArgsKey{}, true) +} + +func hasSecretArgs(ctx context.Context) bool { + if ctx == nil { + return false + } + secret, _ := ctx.Value(secretArgsKey{}).(bool) + return secret +} + func (r sqlRepository) logSQL(ctx context.Context, sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { elapsed := time.Since(start) - fields := []any{ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} + var logArgs any = args + if hasSecretArgs(ctx) { + logArgs = "[REDACTED]" + } + fields := []any{ctx, "SQL: `" + sql + "`", "args", logArgs, "rowsAffected", rowsAffected, "elapsedTime", elapsed} if err == nil || errors.Is(err, context.Canceled) { log.Trace(append(fields, err)...) return diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 20b4e5125..a750bbef4 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -118,6 +118,7 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error { } u.UpdatedAt = time.Now() if u.NewPassword != "" { + ctx = withSecretArgs(ctx) _ = r.encryptPassword(ctx, u) } values, err := toSQLArgs(*u) @@ -432,7 +433,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { u.NewPassword = u.Password if err := r.encryptPassword(ctx, &u); err == nil { upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) - _, err = r.executeSQL(ctx, upd) + _, err = r.executeSQL(withSecretArgs(ctx), upd) if err != nil { log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) } else { diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 0e776fc3a..4f907b447 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("UserRepository", func() { @@ -74,6 +75,27 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) + It("never logs the stored password, on insert or update, but still logs the SQL", func() { + logs := captureTraceLogs() + storedPassword := func(id string) string { + var enc string + Expect(GetDBXBuilder().NewQuery("select password from user where id = {:id}"). + Bind(dbx.Params{"id": id}).Row(&enc)).To(Succeed()) + return enc + } + u := model.User{ID: "u-logged", UserName: "u-logged", NewPassword: "first-secret"} + Expect(repo.Put(ctx, &u)).To(Succeed()) + inserted := storedPassword(u.ID) + u.NewPassword = "second-secret" + Expect(repo.Put(ctx, &u)).To(Succeed()) + updated := storedPassword(u.ID) + + Expect(logs.String()).To(ContainSubstring("INSERT INTO user")) + Expect(logs.String()).To(ContainSubstring("UPDATE user")) + for _, secret := range []string{inserted, updated, "first-secret", "second-secret"} { + Expect(logs.String()).ToNot(ContainSubstring(secret)) + } + }) It("persists and reads back the scrobble filter", func() { usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index ccca0efc0..e174b0b43 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -1046,15 +1046,25 @@ type LoginResponseObject interface { VisitLoginResponse(w http.ResponseWriter) error } -type Login200JSONResponse GrantCreated +type Login200ResponseHeaders struct { + CacheControl *string +} + +type Login200JSONResponse struct { + Body GrantCreated + Headers Login200ResponseHeaders +} func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { return err } w.Header().Set("Content-Type", "application/json") + if response.Headers.CacheControl != nil { + w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) + } w.WriteHeader(200) _, err := buf.WriteTo(w) return err @@ -1366,15 +1376,25 @@ type SetupFirstAdminResponseObject interface { VisitSetupFirstAdminResponse(w http.ResponseWriter) error } -type SetupFirstAdmin201JSONResponse GrantCreated +type SetupFirstAdmin201ResponseHeaders struct { + CacheControl *string +} + +type SetupFirstAdmin201JSONResponse struct { + Body GrantCreated + Headers SetupFirstAdmin201ResponseHeaders +} func (response SetupFirstAdmin201JSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { return err } w.Header().Set("Content-Type", "application/json") + if response.Headers.CacheControl != nil { + w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) + } w.WriteHeader(201) _, err := buf.WriteTo(w) return err @@ -1471,15 +1491,25 @@ type CreateAccessTokenResponseObject interface { VisitCreateAccessTokenResponse(w http.ResponseWriter) error } -type CreateAccessToken200JSONResponse AccessToken +type CreateAccessToken200ResponseHeaders struct { + CacheControl *string +} + +type CreateAccessToken200JSONResponse struct { + Body AccessToken + Headers CreateAccessToken200ResponseHeaders +} func (response CreateAccessToken200JSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { return err } w.Header().Set("Content-Type", "application/json") + if response.Headers.CacheControl != nil { + w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) + } w.WriteHeader(200) _, err := buf.WriteTo(w) return err diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go index 9e38672d1..3851db3d1 100644 --- a/server/apiv1/api_test.go +++ b/server/apiv1/api_test.go @@ -3,6 +3,7 @@ package apiv1 import ( "net/http" "net/http/httptest" + "strings" "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" @@ -30,6 +31,29 @@ var _ = Describe("Router", func() { } }) + It("declares Cache-Control no-store on the success responses of every no-store operation", func() { + doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) + Expect(err).ToNot(HaveOccurred()) + checked := map[string]bool{} + for _, item := range doc.Paths.Map() { + for _, op := range item.Operations() { + if !gateRulesV1.noStore[op.OperationID] { + continue + } + for code, resp := range op.Responses.Map() { + if !strings.HasPrefix(code, "2") { + continue + } + h := resp.Value.Headers["Cache-Control"] + Expect(h).ToNot(BeNil(), op.OperationID+" "+code) + Expect(h.Value.Schema.Value.Enum).To(ConsistOf("no-store"), op.OperationID+" "+code) + checked[op.OperationID] = true + } + } + } + Expect(checked).To(HaveLen(len(gateRulesV1.noStore))) + }) + It("returns a 404 problem for unknown paths", func() { w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)) Expect(w.Code).To(Equal(http.StatusNotFound)) diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index 69416c924..6743951de 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "github.com/getkin/kin-openapi/openapi3" @@ -66,10 +67,18 @@ type testClient struct { } func (c testClient) call(method, path, bearer string, body any) *httptest.ResponseRecorder { + if body == nil { + return c.callRaw(method, path, bearer, "") + } + b, _ := json.Marshal(body) + return c.callRaw(method, path, bearer, string(b)) +} + +// callRaw sends body verbatim, for JSON a map cannot express, like keys differing only in case. +func (c testClient) callRaw(method, path, bearer, body string) *httptest.ResponseRecorder { var req *http.Request - if body != nil { - b, _ := json.Marshal(body) - req = httptest.NewRequestWithContext(c.ctx, method, path, bytes.NewReader(b)) + if body != "" { + req = httptest.NewRequestWithContext(c.ctx, method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequestWithContext(c.ctx, method, path, nil) diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go index 1ddfaa33b..100a0e19a 100644 --- a/server/apiv1/auth_handlers.go +++ b/server/apiv1/auth_handlers.go @@ -22,12 +22,12 @@ func (rt *Router) CreateAccessToken(ctx context.Context, req CreateAccessTokenRe if err != nil { return nil, err } - return CreateAccessToken200JSONResponse{ + return CreateAccessToken200JSONResponse{Body: AccessToken{ AccessToken: tok.Token, TokenType: AccessTokenTokenTypeBearer, ExpiresIn: int(tok.ExpiresIn.Seconds()), Scopes: toScopes(tok.Scopes), - }, nil + }}, nil } func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) { diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index 884865d16..e04766e54 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -131,7 +131,7 @@ var _ = Describe("auth endpoints", func() { gc := api.setup() at := api.mint(gc.Secret, nil) Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant - Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed()) + Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed()) w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) @@ -142,13 +142,57 @@ var _ = Describe("auth endpoints", func() { gc := api.setup() at := api.mint(gc.Secret, nil) Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant - Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed()) + Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed()) w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String()) Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) }) + It("rejects a case-variant scopes key that would widen an explicit empty subset", func() { + gc := api.setup() + w := api.callRaw(http.MethodPost, "/api/v1/auth/token", gc.Secret, `{"scopes":[],"Scopes":null}`) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"})) + }) + + It("rejects a case-variant client key that would skip its length limit", func() { + api.setup() + body := `{"username":"admin","password":"pw","client":"ok","Client":"` + strings.Repeat("x", 60_000) + `"}` + w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", body) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Client", Message: "must match the field name exactly"})) + }) + + DescribeTable("rejects a body with data after its JSON value, without echoing it", + func(path string, needsSecret bool, body string) { + secret := "" + if gc := api.setup(); needsSecret { + secret = gc.Secret + } + w := api.callRaw(http.MethodPost, path, secret, body) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"})) + Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) + }, + Entry("token with a trailing byte", "/api/v1/auth/token", true, `{"scopes":[],"Scopes":null}x`), + Entry("login with a second value", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"} {}`), + ) + + It("checks the token body even when Content-Type has a repeated parameter", func() { + gc := api.setup() + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", strings.NewReader(`{"scopes":[],"Scopes":null}`)) + req.Header.Set("Content-Type", "application/json; a=1; a=2") + req.Header.Set("Authorization", "Bearer "+gc.Secret) + w := serve(api.router, req) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"})) + }) + It("marks grant and token responses no-store", func() { w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) Expect(w.Code).To(Equal(http.StatusCreated)) diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index ea4e2b2c9..ee25b15a2 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -1,13 +1,18 @@ package apiv1 import ( + "bytes" "cmp" "context" + "encoding/json" "errors" "fmt" + "io" + "maps" "net/http" "regexp" "slices" + "strconv" "strings" "github.com/getkin/kin-openapi/openapi3" @@ -265,19 +270,88 @@ func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx err := openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ Request: r, PathParams: params, Route: op.route, Options: validationOptions, }) - if err == nil { - return true - } if tooLarge(err) { writeProblem(w, r, ClientError(err, tooLargeDetail)) return false } - fields := sanitizeValidation(err) + var fields []ValidationError + if err != nil { + fields = sanitizeValidation(err) + } else if fields = jsonBodyFields(r, op.route.Operation); len(fields) == 0 { + return true + } log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id(), "errors", fields) writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "the request does not match the API schema", fields...) return false } +// jsonBodyFields checks what kin-openapi misses in a JSON body: data after the first value, which Go's decoder +// ignores, and keys that only case-fold to a declared property, which encoding/json decodes into that property. +func jsonBodyFields(r *http.Request, op *openapi3.Operation) []ValidationError { + if op.RequestBody == nil || op.RequestBody.Value == nil || r.Body == nil { + return nil + } + // Keyed on the spec, not the request's Content-Type: the handlers decode JSON whatever the header says. + media := op.RequestBody.Value.Content.Get("application/json") + if media == nil || media.Schema == nil { + return nil + } + data, err := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(data)) + if err != nil || len(bytes.TrimSpace(data)) == 0 { + return nil + } + dec := json.NewDecoder(bytes.NewReader(data)) + var body any + if err := dec.Decode(&body); err != nil { + return []ValidationError{{Field: "", Message: "must be a single JSON value"}} + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return []ValidationError{{Field: "", Message: "must be a single JSON value"}} + } + var out []ValidationError + collectCaseAliases(body, media.Schema.Value, "", &out) + return out +} + +func collectCaseAliases(v any, schema *openapi3.Schema, path string, out *[]ValidationError) { + if schema == nil { + return + } + switch v := v.(type) { + case map[string]any: + for _, key := range slices.Sorted(maps.Keys(v)) { + field := joinField(path, key) + if prop, ok := schema.Properties[key]; ok { + if prop != nil { + collectCaseAliases(v[key], prop.Value, field, out) + } + continue + } + for name := range schema.Properties { + if strings.EqualFold(key, name) { + *out = append(*out, ValidationError{Field: field, Message: "must match the field name exactly"}) + break + } + } + } + case []any: + if schema.Items == nil { + return + } + for i, item := range v { + collectCaseAliases(item, schema.Items.Value, joinField(path, strconv.Itoa(i)), out) + } + } +} + +func joinField(path, name string) string { + if path == "" { + return name + } + return path + "." + name +} + var missingProperty = regexp.MustCompile(`property "([^"]+)" is missing`) // sanitizeValidation keeps only field paths and fixed messages: kin-openapi errors can embed the submitted value. diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go index dc8f8aeb6..37cc67448 100644 --- a/server/apiv1/gate_test.go +++ b/server/apiv1/gate_test.go @@ -47,7 +47,9 @@ paths: schema: type: object required: [name] - properties: {name: {type: string, maxLength: 5}} + properties: + name: {type: string, maxLength: 5} + tags: {type: array, items: {type: object, properties: {label: {type: string, maxLength: 5}}}} responses: {'200': {description: ok}} /caps: get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}} @@ -287,6 +289,64 @@ var _ = Describe("spec gate", func() { Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is required"})) }) + DescribeTable("rejects a case variant of a declared body field, which Go would decode into it", + func(body, field string) { + fa.principal.Scopes = []string{"password"} + w := do(http.MethodPost, "/things", "Bearer x", body) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(*p.Errors).To(ConsistOf(ValidationError{Field: field, Message: "must match the field name exactly"})) + Expect(reached).To(BeEmpty()) + }, + Entry("top level", `{"name":"ok","NAME":"much-too-long"}`, "NAME"), + Entry("inside array items", `{"name":"ok","tags":[{"label":"a"},{"label":"b","Label":"much-too-long"}]}`, "tags.1.Label"), + Entry("Unicode case folding", "{\"name\":\"ok\",\"tag\u017f\":null}", "tag\u017f"), + ) + + DescribeTable("rejects data after the first JSON value, which Go's decoder would ignore", + func(body string) { + fa.principal.Scopes = []string{"password"} + w := do(http.MethodPost, "/things", "Bearer x", body) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"})) + Expect(reached).To(BeEmpty()) + }, + Entry("garbage", `{"name":"ok","NAME":"much-too-long"}x`), + Entry("a second value", `{"name":"ok"} {"NAME":"much-too-long"}`), + Entry("a stray bracket", `{"name":"ok"}]`), + ) + + DescribeTable("checks the body whatever parameters the Content-Type carries", + func(contentType string) { + fa.principal.Scopes = []string{"password"} + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/things", strings.NewReader(`{"name":"ok","NAME":"much-too-long"}`)) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Authorization", "Bearer x") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) + Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "NAME", Message: "must match the field name exactly"})) + Expect(reached).To(BeEmpty()) + }, + Entry("repeated parameter", "application/json; a=1; a=2"), + Entry("repeated charset", "application/json; charset=utf-8; CHARSET=latin1"), + ) + + It("accepts trailing whitespace after the JSON value", func() { + fa.principal.Scopes = []string{"password"} + Expect(do(http.MethodPost, "/things", "Bearer x", "{\"name\":\"ok\"}\n \t").Code).To(Equal(http.StatusOK)) + }) + + It("allows unknown body fields that do not collide with a declared one", func() { + fa.principal.Scopes = []string{"password"} + w := do(http.MethodPost, "/things", "Bearer x", `{"name":"ok","extra":{"Name":"x"},"tags":[{"label":"a","other":1}]}`) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + Expect(reached).To(Equal("createThing")) + }) + It("validates path parameters", func() { w := do(http.MethodGet, "/things/toolong", "Bearer x", "") Expect(w.Code).To(Equal(http.StatusBadRequest)) diff --git a/server/apiv1/password_handlers.go b/server/apiv1/password_handlers.go index 5eef55090..32cf09e87 100644 --- a/server/apiv1/password_handlers.go +++ b/server/apiv1/password_handlers.go @@ -14,7 +14,7 @@ func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginRespo if err != nil { return nil, err } - return Login200JSONResponse(toGrantCreated(issued)), nil + return Login200JSONResponse{Body: toGrantCreated(issued)}, nil } func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) { @@ -23,7 +23,7 @@ func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminReques if err != nil { return nil, err } - return SetupFirstAdmin201JSONResponse(toGrantCreated(issued)), nil + return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil } func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) { From c205b894aaf177ec1cb4de9680fd1321262e9a08 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 12:54:51 -0400 Subject: [PATCH 27/41] fix(api): stop sending X-RateLimit headers from API v1 Rate-limit counts are per node, so X-RateLimit-Remaining would mislead clients once API v1 runs behind more than one instance. API v1 now sends only Retry-After on 429; v0 and Jellyfin limiters keep their headers. --- server/apiv1/gate.go | 2 ++ server/apiv1/gate_test.go | 12 +++++++++++- server/middlewares_test.go | 6 ++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index ee25b15a2..35e7841dd 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -93,6 +93,8 @@ func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRule } if conf.Server.AuthRequestLimit > 0 { g.limiter = server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength, + // Counts are per node, so X-RateLimit-Remaining would mislead clients of a scaled-out server. + httprate.WithResponseHeaders(httprate.ResponseHeaders{RetryAfter: "Retry-After"}), httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { writeProblemStatus(w, r, http.StatusTooManyRequests, ProblemCodeRateLimited, "too many requests") })) diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go index 37cc67448..a2f6fc0a2 100644 --- a/server/apiv1/gate_test.go +++ b/server/apiv1/gate_test.go @@ -370,10 +370,13 @@ var _ = Describe("spec gate", func() { var err error mux, err = build(gateSpec) Expect(err).ToNot(HaveOccurred()) - Expect(do(http.MethodPost, "/limited", "", "").Code).To(Equal(http.StatusOK)) w := do(http.MethodPost, "/limited", "", "") + Expect(w.Code).To(Equal(http.StatusOK)) + expectNoXRateLimitHeaders(w) + w = do(http.MethodPost, "/limited", "", "") Expect(w.Code).To(Equal(http.StatusTooManyRequests)) Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) + expectNoXRateLimitHeaders(w) Expect(decodeProblem(w).Code).To(Equal(ProblemCodeRateLimited)) }) @@ -436,3 +439,10 @@ func captureLogs() *bytes.Buffer { }) return buf } + +func expectNoXRateLimitHeaders(w *httptest.ResponseRecorder) { + GinkgoHelper() + for _, h := range []string{"X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Increment", "X-RateLimit-Reset"} { + Expect(w.Header().Values(h)).To(BeEmpty(), h) + } +} diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 58ffa2e37..55f842914 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -549,6 +549,12 @@ var _ = Describe("middlewares", func() { Entry("True-Client-IP", "True-Client-IP"), ) + It("sends the X-RateLimit headers by default", func() { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), "POST", "/auth/login", nil)) + Expect(w.Header().Get("X-RateLimit-Limit")).To(Equal("2")) + }) + Context("behind a trusted proxy", func() { BeforeEach(func() { conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" From ddcc611af35d9d78a27cdc975647488c76698d2d Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 13:42:50 -0400 Subject: [PATCH 28/41] refactor(log): redact caller-marked secret values in the log hook Replaces the statement-wide SQL arg redaction from the previous commit, which hid every arg of property and password writes (user names, emails, scanner properties) and made troubleshooting harder. log.WithSecrets marks values on a context, log calls now pass their context to the logrus entry, and the redaction hook replaces those values in the message and fields. logSQL logs the real args again; only the encrypted password, the API v1 key and the JWT secrets are marked. --- core/apiauth/signer.go | 4 +-- core/auth/auth.go | 2 +- log/log.go | 36 ++++++++++++++++++++- log/log_test.go | 21 ++++++++++++ log/redactrus.go | 38 ++++++++++++++++++++++ log/redactrus_test.go | 43 +++++++++++++++++++++++++ persistence/property_repository.go | 2 -- persistence/property_repository_test.go | 17 +++++++--- persistence/sql_base_repository.go | 21 +----------- persistence/user_repository.go | 4 +-- persistence/user_repository_test.go | 5 +-- 11 files changed, 159 insertions(+), 34 deletions(-) diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go index 055f6a69e..b01ba273c 100644 --- a/core/apiauth/signer.go +++ b/core/apiauth/signer.go @@ -85,7 +85,7 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { if err != nil { return err } - if err := tx.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { + if err := tx.Property().Put(log.WithSecrets(ctx, enc), consts.JWTAPIv1SecretKey, enc); err != nil { return err } key = k @@ -103,7 +103,7 @@ func createKey(ctx context.Context, ds model.DataStore) (string, error) { if err != nil { return "", err } - if err := ds.Property().PutIfAbsent(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { + if err := ds.Property().PutIfAbsent(log.WithSecrets(ctx, enc), consts.JWTAPIv1SecretKey, enc); err != nil { return "", fmt.Errorf("storing API v1 key: %w", err) } return ds.Property().Get(ctx, consts.JWTAPIv1SecretKey) diff --git a/core/auth/auth.go b/core/auth/auth.go index 43314a7c1..8ecda9b75 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -176,7 +176,7 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string log.Error(ctx, "Could not encrypt JWT secret", err) return secret } - if err := ds.Property().Put(ctx, key, encSecret); err != nil { + if err := ds.Property().Put(log.WithSecrets(ctx, encSecret), key, encSecret); err != nil { log.Error(ctx, "Could not save JWT secret in DB", err) } return secret diff --git a/log/log.go b/log/log.go index da1d7622e..7f9cc42a8 100644 --- a/log/log.go +++ b/log/log.go @@ -72,7 +72,10 @@ const ( type contextKey string -const loggerCtxKey = contextKey("logger") +const ( + loggerCtxKey = contextKey("logger") + secretsCtxKey = contextKey("secrets") +) type levelPath struct { path string @@ -188,6 +191,31 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { return ctx } +// WithSecrets returns a context whose log entries have every occurrence of values replaced by +// [REDACTED], when redacting is enabled. +func WithSecrets(ctx context.Context, values ...string) context.Context { + if ctx == nil { + ctx = context.Background() + } + secrets := slices.Clone(secretsFrom(ctx)) + for _, v := range values { + if v != "" { + secrets = append(secrets, v) + } + } + // Longest first, so a secret containing another is not left partly visible. + slices.SortStableFunc(secrets, func(a, b string) int { return cmp.Compare(len(b), len(a)) }) + return context.WithValue(ctx, secretsCtxKey, secrets) +} + +func secretsFrom(ctx context.Context) []string { + if ctx == nil { + return nil + } + secrets, _ := ctx.Value(secretsCtxKey).([]string) + return secrets +} + // SetDefaultLogger swaps the process-wide logger and returns the previous one, // so tests can restore the original (with its hooks and formatter) on cleanup. func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { @@ -289,6 +317,12 @@ func parseArgs(args []any) (*logrus.Entry, string) { if err != nil { l = createNewLogger() } else { + switch ctx := args[0].(type) { + case context.Context: + l = l.WithContext(ctx) + case *http.Request: + l = l.WithContext(ctx.Context()) + } args = args[1:] } } diff --git a/log/log_test.go b/log/log_test.go index 184ff57db..d3dbe6c7d 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -1,6 +1,7 @@ package log import ( + "bytes" "context" "encoding/json" "errors" @@ -109,6 +110,26 @@ var _ = Describe("Logger", func() { Error("Simple Message", "key1", t) Expect(hook.LastEntry().Data["key1"]).To(Equal("nil")) }) + + It("passes the call's context to hooks", func() { + ctx := WithSecrets(GinkgoT().Context(), "s3cr3t") + Error(ctx, "Simple Message") + Expect(hook.LastEntry().Context).To(Equal(ctx)) + + Error(httptest.NewRequest("get", "/", nil).WithContext(ctx), "Simple Message") + Expect(hook.LastEntry().Context).To(Equal(ctx)) + }) + + It("redacts the context's secrets when redacting is on", func() { + l.AddHook(redacted) + ctx := WithSecrets(NewContext(GinkgoT().Context(), "user", "admin"), "s3cr3t") + + var buf bytes.Buffer + l.SetOutput(&buf) + Error(ctx, "Saving s3cr3t", "args", map[string]any{"value": "s3cr3t"}) + Expect(buf.String()).ToNot(ContainSubstring("s3cr3t")) + Expect(buf.String()).To(ContainSubstring("user=admin")) + }) }) Describe("Levels", func() { diff --git a/log/redactrus.go b/log/redactrus.go index fc642bfc0..a9cf5a981 100755 --- a/log/redactrus.go +++ b/log/redactrus.go @@ -7,6 +7,7 @@ import ( "fmt" "reflect" "regexp" + "strings" "github.com/sirupsen/logrus" ) @@ -35,6 +36,7 @@ func (h *Hook) Fire(e *logrus.Entry) error { if err := h.initRedaction(); err != nil { return err } + redactSecrets(e) for _, re := range h.redactionKeys { // Redact based on key matching in Data fields for k, v := range e.Data { @@ -64,6 +66,42 @@ func (h *Hook) Fire(e *logrus.Entry) error { return nil } +// redactSecrets hides the values marked with WithSecrets in the context the entry was logged with. +func redactSecrets(e *logrus.Entry) { + secrets := secretsFrom(e.Context) + if len(secrets) == 0 { + return + } + hide := func(s string) string { + for _, secret := range secrets { + s = strings.ReplaceAll(s, secret, "[REDACTED]") + } + return s + } + e.Message = hide(e.Message) + for k, v := range e.Data { + if v == nil { + continue + } + var s string + if err, ok := v.(error); ok { + s = err.Error() + } else { + switch reflect.TypeOf(v).Kind() { + case reflect.String: + s = reflect.ValueOf(v).String() + case reflect.Map: + s = fmt.Sprintf("%+v", v) + default: + continue + } + } + if hidden := hide(s); hidden != s { + e.Data[k] = hidden + } + } +} + func (h *Hook) initRedaction() error { if len(h.redactionKeys) == 0 { for _, redactionKey := range h.RedactionList { diff --git a/log/redactrus_test.go b/log/redactrus_test.go index a2bd13dda..b8cdfe879 100755 --- a/log/redactrus_test.go +++ b/log/redactrus_test.go @@ -1,6 +1,7 @@ package log import ( + "errors" "testing" "github.com/sirupsen/logrus" @@ -168,3 +169,45 @@ func TestFireRedactsNamedStringTypes(t *testing.T) { assert.Equal(t, "not_found", e.Data["code"]) assert.Equal(t, "/x?secret=[REDACTED]", e.Data["url"]) } + +func TestFireRedactsContextSecrets(t *testing.T) { + ctx := WithSecrets(t.Context(), "s3cr3t") + ctx = WithSecrets(ctx, "", "other-secret") + e := &logrus.Entry{ + Context: ctx, + Message: "value s3cr3t in message", + Data: logrus.Fields{ + "str": "has s3cr3t", + "named": namedString("named other-secret"), + "args": map[string]any{"p0": "s3cr3t", "p1": "plain"}, + "error": errors.New("failed with other-secret"), + "num": 42, + "clean": namedString("untouched"), + }, + } + + assert.Nil(t, (&Hook{}).Fire(e)) + assert.Equal(t, "value [REDACTED] in message", e.Message) + assert.Equal(t, "has [REDACTED]", e.Data["str"]) + assert.Equal(t, "named [REDACTED]", e.Data["named"]) + assert.Equal(t, "map[p0:[REDACTED] p1:plain]", e.Data["args"]) + assert.Equal(t, "failed with [REDACTED]", e.Data["error"]) + assert.Equal(t, 42, e.Data["num"]) + assert.Equal(t, namedString("untouched"), e.Data["clean"]) +} + +func TestFireWithoutContextSecretsLeavesEntryUnchanged(t *testing.T) { + args := map[string]any{"p0": "value"} + e := &logrus.Entry{Context: t.Context(), Message: "value", Data: logrus.Fields{"str": "value", "args": args}} + + assert.Nil(t, (&Hook{}).Fire(e)) + assert.Equal(t, "value", e.Message) + assert.Equal(t, logrus.Fields{"str": "value", "args": args}, e.Data) +} + +func TestFireRedactsLongerSecretsFirst(t *testing.T) { + e := &logrus.Entry{Context: WithSecrets(t.Context(), "abc", "abcdef"), Message: "abcdef"} + + assert.Nil(t, (&Hook{}).Fire(e)) + assert.Equal(t, "[REDACTED]", e.Message) +} diff --git a/persistence/property_repository.go b/persistence/property_repository.go index 916b16430..87d97491c 100644 --- a/persistence/property_repository.go +++ b/persistence/property_repository.go @@ -21,7 +21,6 @@ func NewPropertyRepository(db dbx.Builder) model.PropertyRepository { } func (r propertyRepository) Put(ctx context.Context, id string, value string) error { - ctx = withSecretArgs(ctx) update := Update(r.tableName).Set("value", value).Where(Eq{"id": id}) count, err := r.executeSQL(ctx, update) if err != nil { @@ -36,7 +35,6 @@ func (r propertyRepository) Put(ctx context.Context, id string, value string) er } func (r propertyRepository) PutIfAbsent(ctx context.Context, id string, value string) error { - ctx = withSecretArgs(ctx) insert := Insert(r.tableName).Columns("id", "value").Values(id, value).Options("OR IGNORE") _, err := r.executeSQL(ctx, insert) return err diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index f80fa0b36..01814f41f 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -40,16 +40,25 @@ var _ = Describe("Property Repository", func() { Expect(pr.Get(ctx, "pia")).To(Equal("first")) }) - It("never logs the values it writes, but still logs the SQL", func() { + It("hides values marked as secrets from the SQL log, but still logs the property id", func() { logs := captureTraceLogs() - Expect(pr.Put(ctx, "secret-prop", "inserted-secret")).To(Succeed()) - Expect(pr.Put(ctx, "secret-prop", "updated-secret")).To(Succeed()) - Expect(pr.PutIfAbsent(ctx, "secret-prop-2", "absent-secret")).To(Succeed()) + Expect(pr.Put(log.WithSecrets(ctx, "inserted-secret"), "secret-prop", "inserted-secret")).To(Succeed()) + Expect(pr.Put(log.WithSecrets(ctx, "updated-secret"), "secret-prop", "updated-secret")).To(Succeed()) + Expect(pr.PutIfAbsent(log.WithSecrets(ctx, "absent-secret"), "secret-prop-2", "absent-secret")).To(Succeed()) Expect(logs.String()).To(ContainSubstring("INSERT INTO property")) Expect(logs.String()).To(ContainSubstring("UPDATE property")) + Expect(logs.String()).To(ContainSubstring("secret-prop-2")) Expect(logs.String()).ToNot(ContainSubstring("inserted-secret")) Expect(logs.String()).ToNot(ContainSubstring("updated-secret")) Expect(logs.String()).ToNot(ContainSubstring("absent-secret")) }) + + It("logs the values of unmarked property writes", func() { + logs := captureTraceLogs() + Expect(pr.Put(ctx, "plain-prop", "plain-value")).To(Succeed()) + + Expect(logs.String()).To(ContainSubstring("plain-prop")) + Expect(logs.String()).To(ContainSubstring("plain-value")) + }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index d929e7ef4..03cc6a01b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -658,28 +658,9 @@ func (r sqlRepository) deleteByID(ctx context.Context, id string) error { return nil } -type secretArgsKey struct{} - -// withSecretArgs hides a statement's args from the SQL log: stored secrets are encrypted with a key that may be the public default. -func withSecretArgs(ctx context.Context) context.Context { - return context.WithValue(ctx, secretArgsKey{}, true) -} - -func hasSecretArgs(ctx context.Context) bool { - if ctx == nil { - return false - } - secret, _ := ctx.Value(secretArgsKey{}).(bool) - return secret -} - func (r sqlRepository) logSQL(ctx context.Context, sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { elapsed := time.Since(start) - var logArgs any = args - if hasSecretArgs(ctx) { - logArgs = "[REDACTED]" - } - fields := []any{ctx, "SQL: `" + sql + "`", "args", logArgs, "rowsAffected", rowsAffected, "elapsedTime", elapsed} + fields := []any{ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} if err == nil || errors.Is(err, context.Canceled) { log.Trace(append(fields, err)...) return diff --git a/persistence/user_repository.go b/persistence/user_repository.go index a750bbef4..f53682bd5 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -118,8 +118,8 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error { } u.UpdatedAt = time.Now() if u.NewPassword != "" { - ctx = withSecretArgs(ctx) _ = r.encryptPassword(ctx, u) + ctx = log.WithSecrets(ctx, u.NewPassword) } values, err := toSQLArgs(*u) if err != nil { @@ -433,7 +433,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { u.NewPassword = u.Password if err := r.encryptPassword(ctx, &u); err == nil { upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) - _, err = r.executeSQL(withSecretArgs(ctx), upd) + _, err = r.executeSQL(log.WithSecrets(ctx, u.NewPassword), upd) if err != nil { log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) } else { diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 4f907b447..f90da0db9 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -75,7 +75,7 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) - It("never logs the stored password, on insert or update, but still logs the SQL", func() { + It("never logs the stored password, on insert or update, but still logs the user name", func() { logs := captureTraceLogs() storedPassword := func(id string) string { var enc string @@ -83,7 +83,7 @@ var _ = Describe("UserRepository", func() { Bind(dbx.Params{"id": id}).Row(&enc)).To(Succeed()) return enc } - u := model.User{ID: "u-logged", UserName: "u-logged", NewPassword: "first-secret"} + u := model.User{ID: "u-logged", UserName: "logged-user-name", NewPassword: "first-secret"} Expect(repo.Put(ctx, &u)).To(Succeed()) inserted := storedPassword(u.ID) u.NewPassword = "second-secret" @@ -92,6 +92,7 @@ var _ = Describe("UserRepository", func() { Expect(logs.String()).To(ContainSubstring("INSERT INTO user")) Expect(logs.String()).To(ContainSubstring("UPDATE user")) + Expect(logs.String()).To(ContainSubstring("logged-user-name")) for _, secret := range []string{inserted, updated, "first-secret", "second-secret"} { Expect(logs.String()).ToNot(ContainSubstring(secret)) } From 1b5c68a20347a072996506a7772f7b9e0a1ef98f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 13:55:28 -0400 Subject: [PATCH 29/41] refactor(log): mark secrets on their own line and cover session keys Mark secret values with log.WithSecrets on a separate line instead of nesting the call in argument lists. Also mark Last.fm/ListenBrainz session keys written through SessionKeys.Put and the PasswordEncryptionKey checksum, which still reached trace logs, and ignore values shorter than 8 characters so a short plaintext marked after a failed encryption cannot mangle SQL text or the [REDACTED] marker. --- core/agents/session_keys.go | 2 ++ core/agents/session_keys_test.go | 42 ++++++++++++++++++++++++- core/apiauth/signer.go | 6 ++-- core/auth/auth.go | 3 +- log/log.go | 7 +++-- log/log_test.go | 8 ++--- log/redactrus_test.go | 19 ++++++++--- persistence/property_repository_test.go | 9 ++++-- persistence/user_repository.go | 4 ++- persistence/user_repository_test.go | 31 ++++++++++++++++++ 10 files changed, 112 insertions(+), 19 deletions(-) diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go index 1eb414b15..400c54fc7 100644 --- a/core/agents/session_keys.go +++ b/core/agents/session_keys.go @@ -3,6 +3,7 @@ package agents import ( "context" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -13,6 +14,7 @@ type SessionKeys struct { } func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { + ctx = log.WithSecrets(ctx, sessionKey) return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey) } diff --git a/core/agents/session_keys_test.go b/core/agents/session_keys_test.go index e0232c08e..66eaf3a57 100644 --- a/core/agents/session_keys_test.go +++ b/core/agents/session_keys_test.go @@ -1,21 +1,31 @@ package agents import ( + "bytes" "context" + "database/sql" + "os" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/tests" + "github.com/pocketbase/dbx" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("SessionKeys", func() { - ctx := context.Background() + var ctx context.Context user := model.User{ID: "u-1"} ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}} sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"} + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + It("uses the assigned key name", func() { Expect(sk.KeyName).To(Equal("fakeSessionKey")) }) @@ -34,4 +44,34 @@ var _ = Describe("SessionKeys", func() { _, err := sk.Get(ctx, "u-2") Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("never logs the session key, but still logs the user id and key name", func() { + conn, err := sql.Open("sqlite3", ":memory:") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(conn.Close) + conn.SetMaxOpenConns(1) + _, err = conn.ExecContext(ctx, "create table user_props (user_id varchar, key varchar, value varchar)") + Expect(err).ToNot(HaveOccurred()) + props := persistence.NewUserPropsRepository(dbx.NewFromDB(conn, "sqlite3")) + dbKeys := SessionKeys{DataStore: &tests.MockDataStore{MockedUserProps: props}, KeyName: "LastFMSessionKey"} + + logs := &bytes.Buffer{} + log.SetOutput(logs) + log.SetLevel(log.LevelTrace) + DeferCleanup(func() { + log.SetOutput(os.Stderr) + log.SetLevel(log.LevelFatal) + }) + + Expect(dbKeys.Put(ctx, "logged-user-id", "inserted-session-key")).To(Succeed()) + Expect(dbKeys.Put(ctx, "logged-user-id", "updated-session-key")).To(Succeed()) + + Expect(dbKeys.Get(ctx, "logged-user-id")).To(Equal("updated-session-key")) + Expect(logs.String()).To(ContainSubstring("INSERT INTO user_props")) + Expect(logs.String()).To(ContainSubstring("UPDATE user_props")) + Expect(logs.String()).To(ContainSubstring("logged-user-id")) + Expect(logs.String()).To(ContainSubstring("LastFMSessionKey")) + Expect(logs.String()).ToNot(ContainSubstring("inserted-session-key")) + Expect(logs.String()).ToNot(ContainSubstring("updated-session-key")) + }) }) diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go index b01ba273c..a7f3b44d5 100644 --- a/core/apiauth/signer.go +++ b/core/apiauth/signer.go @@ -85,7 +85,8 @@ func loadKey(ctx context.Context, ds model.DataStore) (string, error) { if err != nil { return err } - if err := tx.Property().Put(log.WithSecrets(ctx, enc), consts.JWTAPIv1SecretKey, enc); err != nil { + ctx = log.WithSecrets(ctx, enc) + if err := tx.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { return err } key = k @@ -103,7 +104,8 @@ func createKey(ctx context.Context, ds model.DataStore) (string, error) { if err != nil { return "", err } - if err := ds.Property().PutIfAbsent(log.WithSecrets(ctx, enc), consts.JWTAPIv1SecretKey, enc); err != nil { + ctx = log.WithSecrets(ctx, enc) + if err := ds.Property().PutIfAbsent(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { return "", fmt.Errorf("storing API v1 key: %w", err) } return ds.Property().Get(ctx, consts.JWTAPIv1SecretKey) diff --git a/core/auth/auth.go b/core/auth/auth.go index 8ecda9b75..1d521451a 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -176,7 +176,8 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string log.Error(ctx, "Could not encrypt JWT secret", err) return secret } - if err := ds.Property().Put(log.WithSecrets(ctx, encSecret), key, encSecret); err != nil { + ctx = log.WithSecrets(ctx, encSecret) + if err := ds.Property().Put(ctx, key, encSecret); err != nil { log.Error(ctx, "Could not save JWT secret in DB", err) } return secret diff --git a/log/log.go b/log/log.go index 7f9cc42a8..eb6f81cb8 100644 --- a/log/log.go +++ b/log/log.go @@ -191,15 +191,18 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { return ctx } +// Shorter values could match unrelated log text, or the [REDACTED] marker itself. +const minSecretLen = 8 + // WithSecrets returns a context whose log entries have every occurrence of values replaced by -// [REDACTED], when redacting is enabled. +// [REDACTED], when redacting is enabled. Values shorter than minSecretLen are ignored. func WithSecrets(ctx context.Context, values ...string) context.Context { if ctx == nil { ctx = context.Background() } secrets := slices.Clone(secretsFrom(ctx)) for _, v := range values { - if v != "" { + if len(v) >= minSecretLen { secrets = append(secrets, v) } } diff --git a/log/log_test.go b/log/log_test.go index d3dbe6c7d..389658df0 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -112,7 +112,7 @@ var _ = Describe("Logger", func() { }) It("passes the call's context to hooks", func() { - ctx := WithSecrets(GinkgoT().Context(), "s3cr3t") + ctx := WithSecrets(GinkgoT().Context(), "s3cr3t-value") Error(ctx, "Simple Message") Expect(hook.LastEntry().Context).To(Equal(ctx)) @@ -122,12 +122,12 @@ var _ = Describe("Logger", func() { It("redacts the context's secrets when redacting is on", func() { l.AddHook(redacted) - ctx := WithSecrets(NewContext(GinkgoT().Context(), "user", "admin"), "s3cr3t") + ctx := WithSecrets(NewContext(GinkgoT().Context(), "user", "admin"), "s3cr3t-value") var buf bytes.Buffer l.SetOutput(&buf) - Error(ctx, "Saving s3cr3t", "args", map[string]any{"value": "s3cr3t"}) - Expect(buf.String()).ToNot(ContainSubstring("s3cr3t")) + Error(ctx, "Saving s3cr3t-value", "args", map[string]any{"value": "s3cr3t-value"}) + Expect(buf.String()).ToNot(ContainSubstring("s3cr3t-value")) Expect(buf.String()).To(ContainSubstring("user=admin")) }) }) diff --git a/log/redactrus_test.go b/log/redactrus_test.go index b8cdfe879..6b8a0e5fe 100755 --- a/log/redactrus_test.go +++ b/log/redactrus_test.go @@ -171,15 +171,15 @@ func TestFireRedactsNamedStringTypes(t *testing.T) { } func TestFireRedactsContextSecrets(t *testing.T) { - ctx := WithSecrets(t.Context(), "s3cr3t") + ctx := WithSecrets(t.Context(), "s3cr3t-value") ctx = WithSecrets(ctx, "", "other-secret") e := &logrus.Entry{ Context: ctx, - Message: "value s3cr3t in message", + Message: "value s3cr3t-value in message", Data: logrus.Fields{ - "str": "has s3cr3t", + "str": "has s3cr3t-value", "named": namedString("named other-secret"), - "args": map[string]any{"p0": "s3cr3t", "p1": "plain"}, + "args": map[string]any{"p0": "s3cr3t-value", "p1": "plain"}, "error": errors.New("failed with other-secret"), "num": 42, "clean": namedString("untouched"), @@ -206,8 +206,17 @@ func TestFireWithoutContextSecretsLeavesEntryUnchanged(t *testing.T) { } func TestFireRedactsLongerSecretsFirst(t *testing.T) { - e := &logrus.Entry{Context: WithSecrets(t.Context(), "abc", "abcdef"), Message: "abcdef"} + ctx := WithSecrets(t.Context(), "abcdefgh", "abcdefghijkl") + e := &logrus.Entry{Context: ctx, Message: "abcdefghijkl"} assert.Nil(t, (&Hook{}).Fire(e)) assert.Equal(t, "[REDACTED]", e.Message) } + +func TestFireIgnoresShortSecrets(t *testing.T) { + ctx := WithSecrets(t.Context(), "abc") + e := &logrus.Entry{Context: ctx, Message: "abc in UPDATE ... abc"} + + assert.Nil(t, (&Hook{}).Fire(e)) + assert.Equal(t, "abc in UPDATE ... abc", e.Message) +} diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 01814f41f..658a75b48 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -42,9 +42,12 @@ var _ = Describe("Property Repository", func() { It("hides values marked as secrets from the SQL log, but still logs the property id", func() { logs := captureTraceLogs() - Expect(pr.Put(log.WithSecrets(ctx, "inserted-secret"), "secret-prop", "inserted-secret")).To(Succeed()) - Expect(pr.Put(log.WithSecrets(ctx, "updated-secret"), "secret-prop", "updated-secret")).To(Succeed()) - Expect(pr.PutIfAbsent(log.WithSecrets(ctx, "absent-secret"), "secret-prop-2", "absent-secret")).To(Succeed()) + insertCtx := log.WithSecrets(ctx, "inserted-secret") + Expect(pr.Put(insertCtx, "secret-prop", "inserted-secret")).To(Succeed()) + updateCtx := log.WithSecrets(ctx, "updated-secret") + Expect(pr.Put(updateCtx, "secret-prop", "updated-secret")).To(Succeed()) + absentCtx := log.WithSecrets(ctx, "absent-secret") + Expect(pr.PutIfAbsent(absentCtx, "secret-prop-2", "absent-secret")).To(Succeed()) Expect(logs.String()).To(ContainSubstring("INSERT INTO property")) Expect(logs.String()).To(ContainSubstring("UPDATE property")) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index f53682bd5..26f7b19d4 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -400,6 +400,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { key := keyTo32Bytes(conf.Server.PasswordEncryptionKey) keySum := fmt.Sprintf("%x", sha256.Sum256(key)) + ctx = log.WithSecrets(ctx, keySum) props := NewPropertyRepository(r.db) savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey) @@ -433,7 +434,8 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { u.NewPassword = u.Password if err := r.encryptPassword(ctx, &u); err == nil { upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) - _, err = r.executeSQL(log.WithSecrets(ctx, u.NewPassword), upd) + userCtx := log.WithSecrets(ctx, u.NewPassword) + _, err = r.executeSQL(userCtx, upd) if err != nil { log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) } else { diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index f90da0db9..156d43253 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -2,12 +2,16 @@ package persistence import ( "context" + "crypto/sha256" "errors" + "fmt" "slices" "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -119,6 +123,33 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("initPasswordEncryptionKey", func() { + It("never logs the encryption key checksum, but still logs its property id", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PasswordEncryptionKey = "a-new-password-encryption-key" + keySum := fmt.Sprintf("%x", sha256.Sum256(keyTo32Bytes(conf.Server.PasswordEncryptionKey))) + previousKey := encKey + DeferCleanup(func() { encKey = previousKey }) + tx, err := GetDBXBuilder().Begin() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = tx.Rollback() }) + _, err = tx.NewQuery("delete from user").Execute() + Expect(err).ToNot(HaveOccurred()) + txRepo := NewUserRepository(tx).(*userRepository) + Expect(txRepo.Put(ctx, &model.User{ID: "u-rekey", UserName: "rekeyed-user", NewPassword: "rekeyed-password"})).To(Succeed()) + + logs := captureTraceLogs() + Expect(txRepo.initPasswordEncryptionKey(ctx)).To(Succeed()) + + Expect(logs.String()).To(ContainSubstring("UPDATE user")) + Expect(logs.String()).To(ContainSubstring(consts.PasswordsEncryptedKey)) + Expect(logs.String()).ToNot(ContainSubstring(keySum)) + var rekeyed string + Expect(tx.NewQuery("select password from user where id = 'u-rekey'").Row(&rekeyed)).To(Succeed()) + Expect(logs.String()).ToNot(ContainSubstring(rekeyed)) + }) + }) + Describe("validatePasswordChange", func() { var loggedUser *model.User From 052be800a7b3a24d9b393497ab4248325f4510d7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 28 Sep 2026 21:05:56 -0400 Subject: [PATCH 30/41] test(log): fix the source line assertion after the import change Signed-off-by: Deluan --- log/log_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/log/log_test.go b/log/log_test.go index 389658df0..631b7c1aa 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -96,7 +96,7 @@ var _ = Describe("Logger", func() { SetLogSourceLine(true) Error("A crash happened") // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:97")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) From d1b876097f36e7b0365b516177dffe9a4020c4b7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 28 Sep 2026 21:05:57 -0400 Subject: [PATCH 31/41] refactor(api): use the grant secret as the API v1 bearer credential API v1 no longer mints short-lived JWT access tokens. Clients send the grant secret from POST /auth/login or /auth/setup as `Authorization: Bearer` on every request. Every request already looked the grant up in the database, so the JWT gave no speed or revocation benefit and only added a refresh loop, which early client authors pushed back on. The grant already is an API key: one per client sign-in, scoped and revocable. Revocation is now immediate on every node; the contract promises "within one minute". Removed: POST /auth/token, the grantAuth scheme, the TokenRequest and AccessToken schemas, the token_expired problem code, the API v1 JWT signer and its signing key, the grant liveness cache, and PropertyRepository.PutIfAbsent. ResolveGrant is now Authenticate. Short-lived tokens return later only as narrow media tokens for ?access_token= on media URLs, together with the media endpoints. Signed-off-by: Deluan --- api/bundled/openapi.json | 247 +++++------------- api/bundled/openapi.yaml | 180 ++++--------- .../headers/CacheControlNoStore.yaml | 2 +- .../components/schemas/AccessToken.yaml | 19 -- .../components/schemas/Capabilities.yaml | 2 +- api/openapi/components/schemas/Grant.yaml | 2 +- .../components/schemas/GrantCreated.yaml | 2 +- api/openapi/components/schemas/Problem.yaml | 1 - .../components/schemas/TokenRequest.yaml | 9 - api/openapi/openapi.yaml | 16 +- api/openapi/paths/auth.yaml | 36 +-- api/openapi/paths/capabilities.yaml | 2 +- consts/consts.go | 1 - core/apiauth/cache.go | 94 ------- core/apiauth/cache_test.go | 113 -------- core/apiauth/db_test.go | 16 +- core/apiauth/scopes.go | 16 +- core/apiauth/scopes_test.go | 16 -- core/apiauth/service.go | 118 +-------- core/apiauth/service_session_test.go | 218 +++++++--------- core/apiauth/service_test.go | 101 +++---- core/apiauth/signer.go | 159 ----------- core/apiauth/signer_test.go | 153 ----------- model/properties.go | 1 - persistence/property_repository.go | 6 - persistence/property_repository_test.go | 11 +- server/apiv1/api.go | 7 +- server/apiv1/api_gen.go | 214 +-------------- server/apiv1/apiv1_suite_test.go | 15 +- server/apiv1/auth_handlers.go | 21 -- server/apiv1/auth_test.go | 127 +++++---- server/apiv1/capabilities_test.go | 11 +- server/apiv1/gate.go | 37 +-- server/apiv1/gate_test.go | 100 ++----- server/apiv1/problem.go | 2 - server/apiv1/problem_test.go | 1 - tests/mock_property_repo.go | 11 - 37 files changed, 401 insertions(+), 1686 deletions(-) delete mode 100644 api/openapi/components/schemas/AccessToken.yaml delete mode 100644 api/openapi/components/schemas/TokenRequest.yaml delete mode 100644 core/apiauth/cache.go delete mode 100644 core/apiauth/cache_test.go delete mode 100644 core/apiauth/signer.go delete mode 100644 core/apiauth/signer_test.go diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index f3af48f50..5755ee75b 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Navidrome API", "version": "1.0.0", - "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n", + "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as\n`Authorization: Bearer \u003csecret\u003e`. A revoked grant stops working within one minute at most.\n", "license": { "name": "GPL-3.0", "url": "https://www.gnu.org/licenses/gpl-3.0.html" @@ -21,7 +21,7 @@ }, { "name": "auth", - "description": "Grants, access tokens, and login methods." + "description": "Grants and login methods." } ], "paths": { @@ -62,7 +62,7 @@ "server" ], "summary": "List implemented capability modules", - "description": "The capability modules this server implements. Any valid access token may read it, whatever its scopes.", + "description": "The capability modules this server implements. Any valid grant may read it, whatever its scopes.", "security": [ { "bearerAuth": [] @@ -122,97 +122,6 @@ } } }, - "/auth/token": { - "post": { - "operationId": "createAccessToken", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "auth" - ], - "summary": "Mint an access token", - "description": "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential.", - "security": [ - { - "grantAuth": [] - } - ], - "requestBody": { - "description": "Scopes to narrow the token to. Optional.", - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenRequest" - } - } - } - }, - "responses": { - "200": { - "description": "The new access token.", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControlNoStore" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccessToken" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/openapi.yaml": { - "get": { - "operationId": "getOpenAPISpecYAML", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "security": [], - "summary": "Get the OpenAPI document (YAML)", - "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", - "responses": { - "200": { - "description": "The OpenAPI document.", - "headers": { - "ETag": { - "$ref": "#/components/headers/ETag" - } - }, - "content": { - "application/yaml": { - "schema": { - "type": "object", - "description": "OpenAPI 3.0 document." - } - } - } - }, - "304": { - "$ref": "#/components/responses/NotModified" - } - } - } - }, "/auth/grants": { "get": { "operationId": "listGrants", @@ -263,6 +172,40 @@ } } }, + "/openapi.yaml": { + "get": { + "operationId": "getOpenAPISpecYAML", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "server" + ], + "security": [], + "summary": "Get the OpenAPI document (YAML)", + "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", + "responses": { + "200": { + "description": "The OpenAPI document.", + "headers": { + "ETag": { + "$ref": "#/components/headers/ETag" + } + }, + "content": { + "application/yaml": { + "schema": { + "type": "object", + "description": "OpenAPI 3.0 document." + } + } + } + }, + "304": { + "$ref": "#/components/responses/NotModified" + } + } + } + }, "/auth/grants/{id}": { "delete": { "operationId": "revokeGrant", @@ -273,7 +216,7 @@ "auth" ], "summary": "Revoke one of my grants", - "description": "Revokes the grant and every token minted from it. Another user's grant id answers 404.", + "description": "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404.", "security": [ { "bearerAuth": [] @@ -525,12 +468,7 @@ "bearerAuth": { "type": "http", "scheme": "bearer", - "description": "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." - }, - "grantAuth": { - "type": "http", - "scheme": "bearer", - "description": "Long-lived grant secret. Accepted only by `POST /auth/token`." + "description": "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." } }, "schemas": { @@ -610,7 +548,6 @@ "enum": [ "validation", "unauthorized", - "token_expired", "forbidden", "insufficient_scope", "not_found", @@ -656,7 +593,7 @@ }, "Capabilities": { "type": "object", - "description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling token may use.\n", + "description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling grant may use.\n", "properties": { "core": { "$ref": "#/components/schemas/CoreCapability" @@ -692,54 +629,6 @@ } } }, - "TokenRequest": { - "type": "object", - "description": "Optional narrowing of a new access token.", - "properties": { - "scopes": { - "type": "array", - "maxItems": 32, - "description": "Subset of the grant's scopes. Omit for all of them; an empty list asks for none.", - "items": { - "$ref": "#/components/schemas/ScopeRequest" - } - } - } - }, - "AccessToken": { - "type": "object", - "description": "A short-lived access token. Opaque; clients must not decode it.", - "required": [ - "accessToken", - "tokenType", - "expiresIn", - "scopes" - ], - "properties": { - "accessToken": { - "type": "string", - "description": "The token. Send it as `Authorization: Bearer \u003ctoken\u003e`." - }, - "tokenType": { - "type": "string", - "enum": [ - "Bearer" - ], - "description": "Always `Bearer`." - }, - "expiresIn": { - "type": "integer", - "description": "Seconds until the token expires." - }, - "scopes": { - "type": "array", - "description": "Scopes the token actually carries, which may be fewer than requested.", - "items": { - "$ref": "#/components/schemas/Scope" - } - } - } - }, "GrantList": { "type": "object", "description": "A page of the caller's grants.", @@ -845,7 +734,7 @@ "secret": { "type": "string", "maxLength": 512, - "description": "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`." + "description": "Opaque grant secret. Send it as `Authorization: Bearer \u003csecret\u003e`." }, "grant": { "description": "The new grant.", @@ -892,21 +781,6 @@ } } }, - "ScopeRequest": { - "type": "string", - "description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.", - "pattern": "^[a-z][a-z-]*(:write)?$", - "maxLength": 64 - }, - "Scope": { - "type": "string", - "description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n", - "enum": [ - "all", - "read", - "password" - ] - }, "Grant": { "type": "object", "description": "A long-lived grant held by one client of one user.", @@ -942,7 +816,7 @@ }, "scopes": { "type": "array", - "description": "Scopes this grant may mint tokens for.", + "description": "Scopes this grant carries.", "items": { "$ref": "#/components/schemas/Scope" } @@ -973,6 +847,21 @@ } } }, + "Scope": { + "type": "string", + "description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n", + "enum": [ + "all", + "read", + "password" + ] + }, + "ScopeRequest": { + "type": "string", + "description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.", + "pattern": "^[a-z][a-z-]*(:write)?$", + "maxLength": 64 + }, "AuthUser": { "type": "object", "description": "The user a grant belongs to.", @@ -1051,16 +940,6 @@ } } }, - "PayloadTooLarge": { - "description": "The request body is too large (`payload_too_large`).", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, "Forbidden": { "description": "The caller is authenticated but not allowed to do this.", "headers": { @@ -1086,6 +965,16 @@ } } }, + "PayloadTooLarge": { + "description": "The request body is too large (`payload_too_large`).", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, "TooManyRequests": { "description": "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds.", "headers": { @@ -1154,7 +1043,7 @@ } }, "CacheControlNoStore": { - "description": "Always `no-store`, because the response carries a secret or a token.", + "description": "Always `no-store`, because the response carries a secret.", "schema": { "type": "string", "enum": [ diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index 2134268eb..8b430b629 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -16,9 +16,9 @@ info: `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods in its `Allow` header. - Operations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in - `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted - from it, stops working within one access-token lifetime at most. + Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in + `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as + `Authorization: Bearer `. A revoked grant stops working within one minute at most. license: name: GPL-3.0 url: https://www.gnu.org/licenses/gpl-3.0.html @@ -28,7 +28,7 @@ tags: - name: server description: Server discovery and the published OpenAPI document. - name: auth - description: Grants, access tokens, and login methods. + description: Grants and login methods. paths: /server: get: @@ -57,7 +57,7 @@ paths: x-stability-level: alpha tags: [server] summary: List implemented capability modules - description: The capability modules this server implements. Any valid access token may read it, whatever its scopes. + description: The capability modules this server implements. Any valid grant may read it, whatever its scopes. security: [{bearerAuth: []}] responses: '200': @@ -89,62 +89,6 @@ paths: description: OpenAPI 3.0 document. '304': $ref: '#/components/responses/NotModified' - /auth/token: - post: - operationId: createAccessToken - x-module: core - x-stability-level: alpha - tags: [auth] - summary: Mint an access token - description: "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential." - security: [{grantAuth: []}] - requestBody: - description: Scopes to narrow the token to. Optional. - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRequest' - responses: - '200': - description: The new access token. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControlNoStore' - content: - application/json: - schema: - $ref: '#/components/schemas/AccessToken' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '500': - $ref: '#/components/responses/InternalError' - /openapi.yaml: - get: - operationId: getOpenAPISpecYAML - x-module: core - x-stability-level: alpha - tags: [server] - security: [] - summary: Get the OpenAPI document (YAML) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: '#/components/headers/ETag' - content: - application/yaml: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: '#/components/responses/NotModified' /auth/grants: get: operationId: listGrants @@ -173,6 +117,28 @@ paths: $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/InternalError' + /openapi.yaml: + get: + operationId: getOpenAPISpecYAML + x-module: core + x-stability-level: alpha + tags: [server] + security: [] + summary: Get the OpenAPI document (YAML) + description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. + responses: + '200': + description: The OpenAPI document. + headers: + ETag: + $ref: '#/components/headers/ETag' + content: + application/yaml: + schema: + type: object + description: OpenAPI 3.0 document. + '304': + $ref: '#/components/responses/NotModified' /auth/grants/{id}: delete: operationId: revokeGrant @@ -181,7 +147,7 @@ paths: x-stability-level: alpha tags: [auth] summary: Revoke one of my grants - description: "Revokes the grant and every token minted from it. Another user's grant id answers 404." + description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404." security: [{bearerAuth: []}] parameters: - name: id @@ -338,11 +304,7 @@ components: bearerAuth: type: http scheme: bearer - description: "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." - grantAuth: - type: http - scheme: bearer - description: "Long-lived grant secret. Accepted only by `POST /auth/token`." + description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." schemas: ServerInfo: type: object @@ -409,7 +371,6 @@ components: enum: - validation - unauthorized - - token_expired - forbidden - insufficient_scope - not_found @@ -446,7 +407,7 @@ components: description: | Capability modules this server implements, keyed by module. Keys are optional; a missing key means the module is not implemented. New modules are added as new optional keys. These are server facts, not what - the calling token may use. + the calling grant may use. properties: core: $ref: '#/components/schemas/CoreCapability' @@ -470,41 +431,6 @@ components: version: type: integer description: Module version. Bumped only on semantic change. - TokenRequest: - type: object - description: Optional narrowing of a new access token. - properties: - scopes: - type: array - maxItems: 32 - description: "Subset of the grant's scopes. Omit for all of them; an empty list asks for none." - items: - $ref: '#/components/schemas/ScopeRequest' - AccessToken: - type: object - description: "A short-lived access token. Opaque; clients must not decode it." - required: - - accessToken - - tokenType - - expiresIn - - scopes - properties: - accessToken: - type: string - description: "The token. Send it as `Authorization: Bearer `." - tokenType: - type: string - enum: - - Bearer - description: "Always `Bearer`." - expiresIn: - type: integer - description: Seconds until the token expires. - scopes: - type: array - description: "Scopes the token actually carries, which may be fewer than requested." - items: - $ref: '#/components/schemas/Scope' GrantList: type: object description: "A page of the caller's grants." @@ -587,7 +513,7 @@ components: secret: type: string maxLength: 512 - description: "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`." + description: "Opaque grant secret. Send it as `Authorization: Bearer `." grant: description: The new grant. allOf: @@ -617,20 +543,6 @@ components: type: boolean default: true description: "Revoke every other grant of the user. The calling grant always survives. Default true." - ScopeRequest: - type: string - description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working." - pattern: '^[a-z][a-z-]*(:write)?$' - maxLength: 64 - Scope: - type: string - description: | - A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on - grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. - enum: - - all - - read - - password Grant: type: object description: A long-lived grant held by one client of one user. @@ -661,7 +573,7 @@ components: description: "Version of the client app, when it sent one." scopes: type: array - description: Scopes this grant may mint tokens for. + description: Scopes this grant carries. items: $ref: '#/components/schemas/Scope' provider: @@ -683,6 +595,20 @@ components: current: type: boolean description: True for the grant that made this request. + Scope: + type: string + description: | + A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on + grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. + enum: + - all + - read + - password + ScopeRequest: + type: string + description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working." + pattern: '^[a-z][a-z-]*(:write)?$' + maxLength: 64 AuthUser: type: object description: The user a grant belongs to. @@ -735,12 +661,6 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' - PayloadTooLarge: - description: "The request body is too large (`payload_too_large`)." - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' Forbidden: description: The caller is authenticated but not allowed to do this. headers: @@ -756,6 +676,12 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + PayloadTooLarge: + description: "The request body is too large (`payload_too_large`)." + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' TooManyRequests: description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds." headers: @@ -803,7 +729,7 @@ components: schema: type: string CacheControlNoStore: - description: Always `no-store`, because the response carries a secret or a token. + description: Always `no-store`, because the response carries a secret. schema: type: string enum: diff --git a/api/openapi/components/headers/CacheControlNoStore.yaml b/api/openapi/components/headers/CacheControlNoStore.yaml index 092a03fda..07250936b 100644 --- a/api/openapi/components/headers/CacheControlNoStore.yaml +++ b/api/openapi/components/headers/CacheControlNoStore.yaml @@ -1,4 +1,4 @@ -description: Always `no-store`, because the response carries a secret or a token. +description: Always `no-store`, because the response carries a secret. schema: type: string enum: [no-store] diff --git a/api/openapi/components/schemas/AccessToken.yaml b/api/openapi/components/schemas/AccessToken.yaml deleted file mode 100644 index 5a692aa13..000000000 --- a/api/openapi/components/schemas/AccessToken.yaml +++ /dev/null @@ -1,19 +0,0 @@ -type: object -description: "A short-lived access token. Opaque; clients must not decode it." -required: [accessToken, tokenType, expiresIn, scopes] -properties: - accessToken: - type: string - description: "The token. Send it as `Authorization: Bearer `." - tokenType: - type: string - enum: [Bearer] - description: "Always `Bearer`." - expiresIn: - type: integer - description: Seconds until the token expires. - scopes: - type: array - description: "Scopes the token actually carries, which may be fewer than requested." - items: - $ref: ./Scope.yaml diff --git a/api/openapi/components/schemas/Capabilities.yaml b/api/openapi/components/schemas/Capabilities.yaml index 26445a312..aa760e8e5 100644 --- a/api/openapi/components/schemas/Capabilities.yaml +++ b/api/openapi/components/schemas/Capabilities.yaml @@ -2,7 +2,7 @@ type: object description: | Capability modules this server implements, keyed by module. Keys are optional; a missing key means the module is not implemented. New modules are added as new optional keys. These are server facts, not what - the calling token may use. + the calling grant may use. properties: core: $ref: ./CoreCapability.yaml diff --git a/api/openapi/components/schemas/Grant.yaml b/api/openapi/components/schemas/Grant.yaml index 54d5a3473..95b4aae19 100644 --- a/api/openapi/components/schemas/Grant.yaml +++ b/api/openapi/components/schemas/Grant.yaml @@ -17,7 +17,7 @@ properties: description: "Version of the client app, when it sent one." scopes: type: array - description: Scopes this grant may mint tokens for. + description: Scopes this grant carries. items: $ref: ./Scope.yaml provider: diff --git a/api/openapi/components/schemas/GrantCreated.yaml b/api/openapi/components/schemas/GrantCreated.yaml index eba757e90..d385ba335 100644 --- a/api/openapi/components/schemas/GrantCreated.yaml +++ b/api/openapi/components/schemas/GrantCreated.yaml @@ -5,7 +5,7 @@ properties: secret: type: string maxLength: 512 - description: "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`." + description: "Opaque grant secret. Send it as `Authorization: Bearer `." grant: description: The new grant. allOf: diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml index c9030e7b0..fb966f914 100644 --- a/api/openapi/components/schemas/Problem.yaml +++ b/api/openapi/components/schemas/Problem.yaml @@ -23,7 +23,6 @@ properties: enum: - validation - unauthorized - - token_expired - forbidden - insufficient_scope - not_found diff --git a/api/openapi/components/schemas/TokenRequest.yaml b/api/openapi/components/schemas/TokenRequest.yaml deleted file mode 100644 index 12a0b9c73..000000000 --- a/api/openapi/components/schemas/TokenRequest.yaml +++ /dev/null @@ -1,9 +0,0 @@ -type: object -description: Optional narrowing of a new access token. -properties: - scopes: - type: array - maxItems: 32 - description: "Subset of the grant's scopes. Omit for all of them; an empty list asks for none." - items: - $ref: ./ScopeRequest.yaml diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 5e55ef2ea..02a0bda8d 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -16,9 +16,9 @@ info: `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods in its `Allow` header. - Operations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in - `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted - from it, stops working within one access-token lifetime at most. + Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in + `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as + `Authorization: Bearer `. A revoked grant stops working within one minute at most. license: name: GPL-3.0 url: https://www.gnu.org/licenses/gpl-3.0.html @@ -28,7 +28,7 @@ tags: - name: server description: Server discovery and the published OpenAPI document. - name: auth - description: Grants, access tokens, and login methods. + description: Grants and login methods. paths: /server: $ref: ./paths/server.yaml @@ -38,8 +38,6 @@ paths: $ref: ./paths/openapi.yaml#/json /openapi.yaml: $ref: ./paths/openapi.yaml#/yaml - /auth/token: - $ref: ./paths/auth.yaml#/token /auth/grants: $ref: ./paths/auth.yaml#/grants /auth/grants/{id}: @@ -57,8 +55,4 @@ components: bearerAuth: type: http scheme: bearer - description: "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." - grantAuth: - type: http - scheme: bearer - description: "Long-lived grant secret. Accepted only by `POST /auth/token`." + description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml index b6d8c1d9c..83c28ea15 100644 --- a/api/openapi/paths/auth.yaml +++ b/api/openapi/paths/auth.yaml @@ -1,37 +1,3 @@ -token: - post: - operationId: createAccessToken - x-module: core - x-stability-level: alpha - tags: [auth] - summary: Mint an access token - description: "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential." - security: [{grantAuth: []}] - requestBody: - description: Scopes to narrow the token to. Optional. - required: false - content: - application/json: - schema: - $ref: ../components/schemas/TokenRequest.yaml - responses: - '200': - description: The new access token. - headers: - Cache-Control: - $ref: ../components/headers/CacheControlNoStore.yaml - content: - application/json: - schema: - $ref: ../components/schemas/AccessToken.yaml - '400': - $ref: ../components/responses/BadRequest.yaml - '401': - $ref: ../components/responses/Unauthorized.yaml - '413': - $ref: ../components/responses/PayloadTooLarge.yaml - '500': - $ref: ../components/responses/InternalError.yaml grants: get: operationId: listGrants @@ -68,7 +34,7 @@ grant: x-stability-level: alpha tags: [auth] summary: Revoke one of my grants - description: "Revokes the grant and every token minted from it. Another user's grant id answers 404." + description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404." security: [{bearerAuth: []}] parameters: - name: id diff --git a/api/openapi/paths/capabilities.yaml b/api/openapi/paths/capabilities.yaml index 111ba723d..dc0d9d7ca 100644 --- a/api/openapi/paths/capabilities.yaml +++ b/api/openapi/paths/capabilities.yaml @@ -4,7 +4,7 @@ get: x-stability-level: alpha tags: [server] summary: List implemented capability modules - description: The capability modules this server implements. Any valid access token may read it, whatever its scopes. + description: The capability modules this server implements. Any valid grant may read it, whatever its scopes. security: [{bearerAuth: []}] responses: '200': diff --git a/consts/consts.go b/consts/consts.go index 0808bff79..8faffdbd7 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -32,7 +32,6 @@ const ( UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" JWTSecretKey = "JWTSecret" JWTPublicSecretKey = "JWTPublicSecret" - JWTAPIv1SecretKey = "JWTAPIv1Secret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour APIv1GrantIdleExpiry = 90 * 24 * time.Hour diff --git a/core/apiauth/cache.go b/core/apiauth/cache.go deleted file mode 100644 index ab4aaa1e1..000000000 --- a/core/apiauth/cache.go +++ /dev/null @@ -1,94 +0,0 @@ -package apiauth - -import ( - "maps" - "sync" - "time" -) - -const maxLivenessEntries = 1024 - -type livenessEntry struct { - userID string - epoch int - lastUsedAt time.Time - expires time.Time -} - -// livenessCache bounds how long a node trusts "this grant exists" without asking the DB. -type livenessCache struct { - mu sync.Mutex - ttl time.Duration - gen uint64 - entries map[string]livenessEntry - evicted map[string]uint64 // grant id -> generation of its last eviction - floor uint64 // fills started before the last trim of evicted are dropped -} - -func newLivenessCache(ttl time.Duration) *livenessCache { - return &livenessCache{ttl: ttl, entries: map[string]livenessEntry{}, evicted: map[string]uint64{}} -} - -func (c *livenessCache) begin() uint64 { - c.mu.Lock() - defer c.mu.Unlock() - return c.gen -} - -func (c *livenessCache) get(id string, now time.Time) (livenessEntry, bool) { - c.mu.Lock() - defer c.mu.Unlock() - e, ok := c.entries[id] - if !ok || !now.Before(e.expires) { - return livenessEntry{}, false - } - return e, true -} - -// put ignores a fill whose DB read started before the grant was last evicted. -func (c *livenessCache) put(id string, e livenessEntry, now time.Time, started uint64) { - c.mu.Lock() - defer c.mu.Unlock() - if started < c.floor || c.evicted[id] > started { - return - } - if len(c.entries) >= maxLivenessEntries { - maps.DeleteFunc(c.entries, func(_ string, v livenessEntry) bool { return !now.Before(v.expires) }) - } - if _, refresh := c.entries[id]; !refresh && len(c.entries) >= maxLivenessEntries { - // Dropping an arbitrary live entry only costs that grant one extra DB read. - for k := range c.entries { - delete(c.entries, k) - break - } - } - e.expires = now.Add(c.ttl) - c.entries[id] = e -} - -func (c *livenessCache) evict(id string) { - c.mu.Lock() - defer c.mu.Unlock() - if len(c.evicted) >= maxLivenessEntries { - clear(c.evicted) - c.floor = c.gen - } - c.gen++ - c.evicted[id] = c.gen - delete(c.entries, id) -} - -func (c *livenessCache) markUsed(id string, at time.Time) { - c.mu.Lock() - defer c.mu.Unlock() - if e, ok := c.entries[id]; ok { - e.lastUsedAt = at - c.entries[id] = e - } -} - -func (c *livenessCache) len() int { - c.mu.Lock() - defer c.mu.Unlock() - return len(c.entries) -} diff --git a/core/apiauth/cache_test.go b/core/apiauth/cache_test.go deleted file mode 100644 index fde2066f5..000000000 --- a/core/apiauth/cache_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package apiauth - -import ( - "fmt" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("livenessCache", func() { - var c *livenessCache - var t0 time.Time - - BeforeEach(func() { - c = newLivenessCache(30 * time.Second) - t0 = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) - }) - - It("returns an entry until its TTL passes", func() { - c.put("g1", livenessEntry{userID: "u1", epoch: 2}, t0, c.begin()) - e, ok := c.get("g1", t0.Add(29*time.Second)) - Expect(ok).To(BeTrue()) - Expect(e.userID).To(Equal("u1")) - Expect(e.epoch).To(Equal(2)) - _, ok = c.get("g1", t0.Add(30*time.Second)) - Expect(ok).To(BeFalse()) - }) - - It("forgets evicted entries", func() { - c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) - c.evict("g1") - _, ok := c.get("g1", t0) - Expect(ok).To(BeFalse()) - }) - - It("drops a fill that started before an eviction of the same grant", func() { - started := c.begin() // a request reads the grant from the DB... - c.evict("g1") // ...a logout deletes and evicts it... - c.put("g1", livenessEntry{userID: "u1"}, t0, started) // ...then the slow request tries to cache it - _, ok := c.get("g1", t0) - Expect(ok).To(BeFalse()) - }) - - It("still accepts fills of other grants and later fills of the same grant", func() { - started := c.begin() - c.evict("g1") - c.put("g2", livenessEntry{userID: "u2"}, t0, started) - _, ok := c.get("g2", t0) - Expect(ok).To(BeTrue()) - c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) - _, ok = c.get("g1", t0) - Expect(ok).To(BeTrue()) - }) - - It("still drops a racing fill after the eviction log is trimmed", func() { - started := c.begin() - c.evict("g1") - for i := range maxLivenessEntries { - c.evict(fmt.Sprint("other", i)) - } - c.put("fresh", livenessEntry{}, t0, c.begin()) - c.put("g1", livenessEntry{userID: "u1"}, t0, started) - _, ok := c.get("g1", t0) - Expect(ok).To(BeFalse()) - }) - - It("stays bounded under eviction-only traffic and still drops a fill started before the trim", func() { - started := c.begin() - c.evict("g1") - for i := range 3 * maxLivenessEntries { - c.evict(fmt.Sprint("other", i)) - } - Expect(len(c.evicted)).To(BeNumerically("<=", maxLivenessEntries)) - - c.put("g1", livenessEntry{userID: "u1"}, t0, started) - _, ok := c.get("g1", t0) - Expect(ok).To(BeFalse()) - c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) - _, ok = c.get("g1", t0) - Expect(ok).To(BeTrue()) - }) - - It("records the last use without extending the TTL", func() { - c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) - c.markUsed("g1", t0.Add(10*time.Second)) - e, _ := c.get("g1", t0.Add(11*time.Second)) - Expect(e.lastUsedAt).To(Equal(t0.Add(10 * time.Second))) - _, ok := c.get("g1", t0.Add(30*time.Second)) - Expect(ok).To(BeFalse()) - }) - - It("drops expired entries when it grows", func() { - for i := range maxLivenessEntries { - c.put(fmt.Sprint(i), livenessEntry{}, t0, c.begin()) - } - c.put("fresh", livenessEntry{}, t0.Add(time.Minute), c.begin()) - Expect(c.len()).To(Equal(1)) - }) - - It("never grows past its cap, even when every entry is live", func() { - for i := range maxLivenessEntries { - c.put(fmt.Sprint(i), livenessEntry{}, t0, c.begin()) - } - c.put("fresh", livenessEntry{}, t0, c.begin()) - Expect(c.len()).To(Equal(maxLivenessEntries)) - _, ok := c.get("fresh", t0) - Expect(ok).To(BeTrue()) - - c.put("fresh", livenessEntry{userID: "u1"}, t0, c.begin()) - Expect(c.len()).To(Equal(maxLivenessEntries)) - }) -}) diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go index 6fdd20459..15b6cb115 100644 --- a/core/apiauth/db_test.go +++ b/core/apiauth/db_test.go @@ -33,19 +33,11 @@ func createUser(ctx context.Context, password string, admin bool) model.User { return *stored } -// login runs the full client flow for a user whose password is "pw": grant, resolve, then mint. -func login(ctx context.Context, svc *Service, u model.User) (*Issued, *Principal, *AccessToken) { +// login signs in a user whose password is "pw" and authenticates with the new grant secret. +func login(ctx context.Context, svc *Service, u model.User) (*Issued, *Principal) { issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) ExpectWithOffset(1, err).ToNot(HaveOccurred()) - p, err := svc.ResolveGrant(ctx, issued.Secret, "") + p, err := svc.Authenticate(ctx, issued.Secret, "") ExpectWithOffset(1, err).ToNot(HaveOccurred()) - tok, err := svc.Mint(ctx, p, nil) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - return issued, p, tok -} - -func mustMint(ctx context.Context, svc *Service, p *Principal) string { - tok, err := svc.Mint(ctx, p, nil) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - return tok.Token + return issued, p } diff --git a/core/apiauth/scopes.go b/core/apiauth/scopes.go index 367c19519..a32c2f9f9 100644 --- a/core/apiauth/scopes.go +++ b/core/apiauth/scopes.go @@ -43,7 +43,7 @@ func Entitled(requested []string, isAdmin bool) []string { return normalize(out) } -// Expand turns a grant's stored scopes into the concrete scopes a token may carry right now. +// Expand turns a grant's stored scopes into the concrete scopes it carries right now. func Expand(granted []string, isAdmin bool) []string { var out []string for _, s := range granted { @@ -62,20 +62,6 @@ func Allowed(scopes []string, isAdmin bool) []string { return normalize(out) } -// Attenuate returns the requested subset of available; a nil request means "everything available". -func Attenuate(available, requested []string) []string { - if requested == nil { - return normalize(available) - } - out := []string{} - for _, s := range requested { - if Satisfies(available, s) { - out = append(out, s) - } - } - return normalize(out) -} - func Satisfies(scopes []string, required string) bool { return slices.Contains(scopes, required) || (!strings.HasSuffix(required, ":write") && slices.Contains(scopes, required+":write")) diff --git a/core/apiauth/scopes_test.go b/core/apiauth/scopes_test.go index 23826abd3..8b350f121 100644 --- a/core/apiauth/scopes_test.go +++ b/core/apiauth/scopes_test.go @@ -49,22 +49,6 @@ var _ = Describe("scopes", func() { }) }) - Describe("Attenuate", func() { - available := []string{"playlists:write", "read"} - It("returns everything when no subset is asked", func() { - Expect(Attenuate(available, nil)).To(Equal([]string{"playlists:write", "read"})) - }) - It("returns nothing for an explicit empty request", func() { - Expect(Attenuate(available, []string{})).To(BeEmpty()) - }) - It("returns the overlap and drops unknown scopes", func() { - Expect(Attenuate(available, []string{"read", "sync"})).To(Equal([]string{"read"})) - }) - It("grants the base scope when only its :write form is available", func() { - Expect(Attenuate(available, []string{"playlists"})).To(Equal([]string{"playlists"})) - }) - }) - Describe("Satisfies", func() { It("accepts the exact scope or its :write form", func() { Expect(Satisfies([]string{"read"}, "read")).To(BeTrue()) diff --git a/core/apiauth/service.go b/core/apiauth/service.go index 08dc53332..c62005964 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -5,9 +5,6 @@ import ( "context" "errors" "fmt" - "slices" - "sync" - "sync/atomic" "time" "github.com/navidrome/navidrome/conf" @@ -19,9 +16,7 @@ import ( ) const ( - TokenTTL = time.Hour IdleExpiry = consts.APIv1GrantIdleExpiry - cacheTTL = 30 * time.Second touchInterval = 5 * time.Minute ) @@ -43,12 +38,6 @@ type Issued struct { User model.User } -type AccessToken struct { - Token string - ExpiresIn time.Duration - Scopes []string -} - type Principal struct { User model.User GrantID string @@ -58,10 +47,7 @@ type Principal struct { type Service struct { ds model.DataStore checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction - cache *livenessCache now func() time.Time - signerMu sync.Mutex - sg atomic.Pointer[signer] } func New(ds model.DataStore) *Service { @@ -70,30 +56,11 @@ func New(ds model.DataStore) *Service { checkers: func(ds model.DataStore) []CredentialChecker { return []CredentialChecker{dbChecker{ds: ds}} }, - cache: newLivenessCache(cacheTTL), - now: time.Now, + now: time.Now, } return s } -// signer loads the key on first use, so building the router never touches the database; only a success is kept. -func (s *Service) signer() (*signer, error) { - if sg := s.sg.Load(); sg != nil { - return sg, nil - } - s.signerMu.Lock() - defer s.signerMu.Unlock() - if sg := s.sg.Load(); sg != nil { - return sg, nil - } - sg, err := loadSigner(context.Background(), s.ds, func() time.Time { return s.now() }) - if err != nil { - return nil, err - } - s.sg.Store(sg) - return sg, nil -} - func PasswordChangeable(u model.User) bool { return u.IsAdmin || conf.Server.EnableUserEditing } @@ -147,7 +114,7 @@ func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, p return &Issued{Secret: secret, Grant: g, User: u}, nil } -func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Principal, error) { +func (s *Service) Authenticate(ctx context.Context, secret, ip string) (*Principal, error) { g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret)) if errors.Is(err, model.ErrNotFound) { return nil, model.ErrInvalidAuth @@ -172,20 +139,6 @@ func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Princip return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil } -func (s *Service) Mint(ctx context.Context, p *Principal, requested []string) (*AccessToken, error) { - sg, err := s.signer() - if err != nil { - return nil, err - } - now := s.now() - scopes := Attenuate(p.Scopes, requested) - tok, err := sg.sign(claims{UserID: p.User.ID, GrantID: p.GrantID, Scopes: scopes, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) - if err != nil { - return nil, fmt.Errorf("signing access token: %w", err) - } - return &AccessToken{Token: tok, ExpiresIn: TokenTTL, Scopes: scopes}, nil -} - func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, error) { u, err := s.ds.User().Get(ctx, userID) if errors.Is(err, model.ErrNotFound) { @@ -194,13 +147,11 @@ func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, err return u, err } -// dropIdle deletes only still-idle grants, sparing one renewed meanwhile, and evicts after deleting so a -// concurrent fill cannot re-cache the dead grant. +// dropIdle deletes only still-idle grants, sparing one renewed meanwhile. func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) { if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil { log.Warn(ctx, "API v1: could not delete idle grants", "grant", id, err) } - s.cache.evict(id) } // settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password change @@ -217,14 +168,12 @@ func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant return err }) if errors.Is(err, model.ErrNotFound) { - s.cache.evict(grantID) return nil, nil, model.ErrInvalidAuth } if err != nil { return nil, nil, err } if g.UserEpoch != u.TokenEpoch { - s.cache.evict(grantID) if err := s.ds.Grant().DeleteStaleEpochs(ctx, u.ID, u.TokenEpoch); err != nil { log.Warn(ctx, "API v1: could not delete the user's grants from older epochs", "user", u.ID, "grant", grantID, err) } @@ -241,61 +190,7 @@ func (s *Service) touch(ctx context.Context, id, ip string, lastUsed time.Time) } if err := s.ds.Grant().Touch(ctx, id, ip, now, now.Add(-touchInterval)); err != nil { log.Warn(ctx, "API v1: could not record grant use", "grant", id, err) - return } - s.cache.markUsed(id, now) -} - -func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principal, error) { - sg, err := s.signer() - if err != nil { - return nil, err - } - c, err := sg.parse(token) - if err != nil { - return nil, err - } - u, err := s.loadUser(ctx, c.UserID) - if err != nil { - return nil, err - } - entry, u, err := s.liveGrant(ctx, c.GrantID, u) - if err != nil { - return nil, err - } - if entry.userID != c.UserID { - return nil, model.ErrInvalidAuth - } - if slices.Contains(c.Scopes, ScopeAdmin) && !u.IsAdmin { - return nil, ErrInsufficientScope - } - s.touch(ctx, c.GrantID, ip, entry.lastUsedAt) - return &Principal{User: *u, GrantID: c.GrantID, Scopes: Allowed(c.Scopes, u.IsAdmin)}, nil -} - -// liveGrant trusts the cache only while its epoch matches; a mismatch is settled from one consistent read. -func (s *Service) liveGrant(ctx context.Context, id string, u *model.User) (livenessEntry, *model.User, error) { - now := s.now() - if e, ok := s.cache.get(id, now); ok && e.epoch == u.TokenEpoch { - return e, u, nil - } - started := s.cache.begin() - g, err := s.ds.Grant().Get(ctx, id) - if errors.Is(err, model.ErrNotFound) { - s.cache.evict(id) - return livenessEntry{}, nil, model.ErrInvalidAuth - } - if err != nil { - return livenessEntry{}, nil, err - } - if g.UserEpoch != u.TokenEpoch { - if g, u, err = s.settleEpoch(ctx, id); err != nil { - return livenessEntry{}, nil, err - } - } - e := livenessEntry{userID: g.UserID, epoch: g.UserEpoch, lastUsedAt: gg.V(g.LastUsedAt)} - s.cache.put(id, e, now, started) - return e, u, nil } // ListGrants shows only the current epoch: grants left on an older one are dead but only deleted when presented. @@ -310,18 +205,13 @@ func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit in } func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) error { - if err := s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID); err != nil { - return err - } - s.cache.evict(grantID) - return nil + return s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID) } // Logout succeeds when the grant is already gone, e.g. revoked by another node or a concurrent logout. func (s *Service) Logout(ctx context.Context, p *Principal) error { err := s.RevokeGrant(ctx, p, p.GrantID) if errors.Is(err, model.ErrNotFound) { - s.cache.evict(p.GrantID) return nil } return err diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go index 573173402..cbd0f483e 100644 --- a/core/apiauth/service_session_test.go +++ b/core/apiauth/service_session_test.go @@ -27,142 +27,82 @@ var _ = Describe("Service: sessions", func() { }) Describe("Authenticate", func() { - It("returns the token's scopes and marks the grant used", func() { + It("rejects the secret at once after logout", func() { u := createUser(ctx, "pw", false) - issued, _, tok := login(ctx, svc, u) - now = now.Add(10 * time.Minute) - p, err := svc.Authenticate(ctx, tok.Token, "10.1.1.1") - Expect(err).ToNot(HaveOccurred()) - Expect(p.GrantID).To(Equal(issued.Grant.ID)) - Expect(p.Scopes).To(Equal(tok.Scopes)) - g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(g.LastUsedIP).To(Equal("10.1.1.1")) - }) - - It("reports an expired token as ErrTokenExpired", func() { - u := createUser(ctx, "pw", false) - _, _, tok := login(ctx, svc, u) - now = now.Add(TokenTTL + clockSkew + time.Second) - _, err := svc.Authenticate(ctx, tok.Token, "") - Expect(err).To(MatchError(ErrTokenExpired)) - }) - - It("rejects a token at once on the node that revoked its grant", func() { - u := createUser(ctx, "pw", false) - _, p, tok := login(ctx, svc, u) - _, err := svc.Authenticate(ctx, tok.Token, "") - Expect(err).ToNot(HaveOccurred()) + issued, p := login(ctx, svc, u) Expect(svc.Logout(ctx, p)).To(Succeed()) - _, err = svc.Authenticate(ctx, tok.Token, "") + _, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) - It("stops a token revoked on another node within the cache time", func() { + It("rejects the secret at once when another node revoked its grant", func() { u := createUser(ctx, "pw", false) - issued, _, tok := login(ctx, svc, u) - _, err := svc.Authenticate(ctx, tok.Token, "") - Expect(err).ToNot(HaveOccurred()) - - Expect(realDS.Grant().DeleteForUser(ctx, u.ID, issued.Grant.ID)).To(Succeed()) // another node - _, err = svc.Authenticate(ctx, tok.Token, "") - Expect(err).ToNot(HaveOccurred()) // still cached - now = now.Add(cacheTTL) - _, err = svc.Authenticate(ctx, tok.Token, "") + issued, _ := login(ctx, svc, u) + Expect(realDS.Grant().DeleteForUser(ctx, u.ID, issued.Grant.ID)).To(Succeed()) + _, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) It("kills grants when the password changes anywhere else", func() { u := createUser(ctx, "pw", false) - _, _, tok := login(ctx, svc, u) + issued, _ := login(ctx, svc, u) u.NewPassword = "reset-by-admin" Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - _, err := svc.Authenticate(ctx, tok.Token, "") + _, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) It("does not kill a grant kept by a password change made through another node", func() { u := createUser(ctx, "pw", false) - _, p, tok := login(ctx, svc, u) - _, err := svc.Authenticate(ctx, tok.Token, "") // caches the old epoch - Expect(err).ToNot(HaveOccurred()) + issued, p := login(ctx, svc, u) other := New(realDS) // another node other.SetClock(func() time.Time { return now }) Expect(other.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - _, err = svc.Authenticate(ctx, tok.Token, "") + _, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).ToNot(HaveOccurred()) }) - It("rejects a token carrying admin once the user is no longer an admin", func() { + It("does not delete a kept grant when the password changed between reading the grant and the user", func() { + u := createUser(ctx, "pw", false) + issued, p := login(ctx, svc, u) + racing := New(afterFindDS{DataStore: realDS, after: func() { + Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) + }}) + racing.SetClock(func() time.Time { return now }) + + _, err := racing.Authenticate(ctx, issued.Secret, "") + Expect(err).ToNot(HaveOccurred()) + _, err = realDS.Grant().Get(ctx, p.GrantID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("drops admin from a grant once its user is no longer an admin", func() { saved := KnownScopes KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin} DeferCleanup(func() { KnownScopes = saved }) u := createUser(ctx, "pw", true) - _, p, tok := login(ctx, svc, u) - Expect(tok.Scopes).To(ContainElement(ScopeAdmin)) + issued, p := login(ctx, svc, u) + Expect(p.Scopes).To(ContainElement(ScopeAdmin)) u.IsAdmin = false Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - _, err := svc.Authenticate(ctx, tok.Token, "") - Expect(err).To(MatchError(ErrInsufficientScope)) - - fresh, err := svc.Mint(ctx, &Principal{User: u, GrantID: p.GrantID, Scopes: Expand(model.Scopes{ScopeAll}, false)}, nil) + demoted, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).ToNot(HaveOccurred()) - Expect(fresh.Scopes).ToNot(ContainElement(ScopeAdmin)) + Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) }) - It("rejects a live token after its user is deleted, and the grant row is gone", func() { + It("rejects the secret after its user is deleted, and the grant row is gone", func() { u := createUser(ctx, "pw", false) - issued, _, tok := login(ctx, svc, u) + issued, _ := login(ctx, svc, u) Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed()) _, err := realDS.Grant().Get(ctx, issued.Grant.ID) Expect(err).To(MatchError(model.ErrNotFound)) - now = now.Add(cacheTTL) - _, err = svc.Authenticate(ctx, tok.Token, "") + _, err = svc.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) - It("grants no scopes to a signed token claiming all", func() { - u := createUser(ctx, "pw", true) - _, p, _ := login(ctx, svc, u) - sg, err := svc.signer() - Expect(err).ToNot(HaveOccurred()) - tok, err := sg.sign(claims{UserID: u.ID, GrantID: p.GrantID, Scopes: []string{ScopeAll, "unknown"}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) - Expect(err).ToNot(HaveOccurred()) - got, err := svc.Authenticate(ctx, tok, "") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Scopes).To(BeEmpty()) - }) - - It("rejects a token whose grant belongs to another user, even across an epoch change", func() { - alice := createUser(ctx, "pw", false) - bob := createUser(ctx, "pw", false) - bobGrant, _, _ := login(ctx, svc, bob) - alice.NewPassword = "bumped" - Expect(realDS.User().Put(ctx, &alice)).To(Succeed()) - - sg, err := svc.signer() - Expect(err).ToNot(HaveOccurred()) - tok, err := sg.sign(claims{UserID: alice.ID, GrantID: bobGrant.Grant.ID, Scopes: []string{ScopeRead}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) - Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, tok, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("does not delete a kept grant when the user was read before a password change", func() { - u := createUser(ctx, "pw", false) - _, p, _ := login(ctx, svc, u) - stale, err := realDS.User().Get(ctx, u.ID) // read before the change lands - Expect(err).ToNot(HaveOccurred()) - Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - - _, _, err = svc.liveGrant(ctx, p.GrantID, stale) - Expect(err).ToNot(HaveOccurred()) - _, err = realDS.Grant().Get(ctx, p.GrantID) - Expect(err).ToNot(HaveOccurred()) - }) - It("leaves a login that raced a password change with a dead grant", func() { u := createUser(ctx, "pw", false) reached, release := make(chan struct{}), make(chan struct{}) @@ -184,7 +124,7 @@ var _ = Describe("Service: sessions", func() { <-done Expect(loginErr).ToNot(HaveOccurred()) - _, err := New(realDS).ResolveGrant(ctx, issued.Secret, "") + _, err := New(realDS).Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) @@ -192,8 +132,8 @@ var _ = Describe("Service: sessions", func() { Describe("grant management", func() { It("lists the user's grants and marks the current one", func() { u := createUser(ctx, "pw", false) - first, _, _ := login(ctx, svc, u) - _, p, _ := login(ctx, svc, u) + first, _ := login(ctx, svc, u) + _, p := login(ctx, svc, u) grants, total, err := svc.ListGrants(ctx, p, 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(total).To(Equal(int64(2))) @@ -208,7 +148,7 @@ var _ = Describe("Service: sessions", func() { Expect(realDS.User().Put(ctx, &u)).To(Succeed()) issued, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil) Expect(err).ToNot(HaveOccurred()) - p, err := svc.ResolveGrant(ctx, issued.Secret, "") + p, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).ToNot(HaveOccurred()) grants, total, err := svc.ListGrants(ctx, p, 0, 10) @@ -220,36 +160,44 @@ var _ = Describe("Service: sessions", func() { It("logs out successfully when the grant is already gone", func() { u := createUser(ctx, "pw", false) - _, p, tok := login(ctx, svc, u) - _, err := svc.Authenticate(ctx, tok.Token, "") - Expect(err).ToNot(HaveOccurred()) + issued, p := login(ctx, svc, u) Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) // another node Expect(svc.Logout(ctx, p)).To(Succeed()) - _, err = svc.Authenticate(ctx, tok.Token, "") + _, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) It("refuses to revoke another user's grant", func() { alice := createUser(ctx, "pw", false) bob := createUser(ctx, "pw", false) - aliceGrant, _, _ := login(ctx, svc, alice) - _, bobP, _ := login(ctx, svc, bob) + aliceGrant, _ := login(ctx, svc, alice) + _, bobP := login(ctx, svc, bob) Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound)) + _, err := svc.Authenticate(ctx, aliceGrant.Secret, "") + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects the secret at once after its grant is revoked", func() { + u := createUser(ctx, "pw", false) + other, _ := login(ctx, svc, u) + _, p := login(ctx, svc, u) + Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed()) + _, err := svc.Authenticate(ctx, other.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) Describe("ChangePassword", func() { It("revokes other grants by default and keeps the caller's", func() { u := createUser(ctx, "pw", false) - _, _, otherTok := login(ctx, svc, u) - _, p, myTok := login(ctx, svc, u) + other, _ := login(ctx, svc, u) + mine, p := login(ctx, svc, u) Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed()) - _, err := svc.Authenticate(ctx, myTok.Token, "") + _, err := svc.Authenticate(ctx, mine.Secret, "") Expect(err).ToNot(HaveOccurred()) - now = now.Add(cacheTTL) - _, err = svc.Authenticate(ctx, otherTok.Token, "") + _, err = svc.Authenticate(ctx, other.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) _, err = svc.Login(ctx, u.UserName, "pw2", meta, nil) @@ -258,17 +206,16 @@ var _ = Describe("Service: sessions", func() { It("keeps every grant when revokeOthers is false", func() { u := createUser(ctx, "pw", false) - _, _, otherTok := login(ctx, svc, u) - _, p, _ := login(ctx, svc, u) + other, _ := login(ctx, svc, u) + _, p := login(ctx, svc, u) Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - now = now.Add(cacheTTL) - _, err := svc.Authenticate(ctx, otherTok.Token, "") + _, err := svc.Authenticate(ctx, other.Secret, "") Expect(err).ToNot(HaveOccurred()) }) It("rejects a wrong current password without changing anything", func() { u := createUser(ctx, "pw", false) - _, p, _ := login(ctx, svc, u) + _, p := login(ctx, svc, u) err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true) Expect(err).To(MatchError(ErrCurrentPasswordMismatch)) _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) @@ -278,30 +225,30 @@ var _ = Describe("Service: sessions", func() { It("is forbidden for non-admins when user editing is off", func() { conf.Server.EnableUserEditing = false u := createUser(ctx, "pw", false) - _, p, _ := login(ctx, svc, u) + _, p := login(ctx, svc, u) err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("does not revive grants killed by an earlier reset when keeping grants", func() { u := createUser(ctx, "pw", false) - killed, _, _ := login(ctx, svc, u) + killed, _ := login(ctx, svc, u) u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented Expect(realDS.User().Put(ctx, &u)).To(Succeed()) issued2, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil) Expect(err).ToNot(HaveOccurred()) - p2, err := svc.ResolveGrant(ctx, issued2.Secret, "") + p2, err := svc.Authenticate(ctx, issued2.Secret, "") Expect(err).ToNot(HaveOccurred()) Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed()) - _, err = svc.ResolveGrant(ctx, killed.Secret, "") + _, err = svc.Authenticate(ctx, killed.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) It("rejects a caller whose grant was revoked before the change ran", func() { u := createUser(ctx, "pw", false) - _, p, _ := login(ctx, svc, u) + _, p := login(ctx, svc, u) Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) Expect(err).To(MatchError(model.ErrInvalidAuth)) @@ -309,9 +256,19 @@ var _ = Describe("Service: sessions", func() { Expect(err).ToNot(HaveOccurred()) }) + It("rejects a caller naming another user's grant", func() { + alice := createUser(ctx, "pw", false) + bob := createUser(ctx, "pw", false) + _, aliceP := login(ctx, svc, alice) + bobGrant, _ := login(ctx, svc, bob) + forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID} + err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + It("rolls back the password and epoch when a grant update fails", func() { u := createUser(ctx, "pw", false) - _, p, _ := login(ctx, svc, u) + issued, p := login(ctx, svc, u) failing := New(failingEpochDS{realDS}) failing.SetClock(func() time.Time { return now }) @@ -322,12 +279,33 @@ var _ = Describe("Service: sessions", func() { Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch)) _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, mustMint(ctx, svc, p), "") + _, err = svc.Authenticate(ctx, issued.Secret, "") Expect(err).ToNot(HaveOccurred()) }) }) }) +// afterFindDS calls after between finding the grant by its secret and reading its user. +type afterFindDS struct { + model.DataStore + after func() +} + +func (d afterFindDS) Grant() model.GrantRepository { + return afterFindGrants{GrantRepository: d.DataStore.Grant(), after: d.after} +} + +type afterFindGrants struct { + model.GrantRepository + after func() +} + +func (g afterFindGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) { + found, err := g.GrantRepository.FindBySecretHash(ctx, hash) + g.after() + return found, err +} + type pausingChecker struct { inner CredentialChecker reached, release chan struct{} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go index 03469e486..ca86cafb1 100644 --- a/core/apiauth/service_test.go +++ b/core/apiauth/service_test.go @@ -2,7 +2,6 @@ package apiauth import ( "context" - "errors" "strings" "time" @@ -14,30 +13,6 @@ import ( . "github.com/onsi/gomega" ) -var errFlakyProps = errors.New("database is locked") - -type flakyPropsDS struct { - model.DataStore - failures int -} - -func (d *flakyPropsDS) Property() model.PropertyRepository { - return &flakyProps{PropertyRepository: d.DataStore.Property(), ds: d} -} - -type flakyProps struct { - model.PropertyRepository - ds *flakyPropsDS -} - -func (p *flakyProps) Get(ctx context.Context, id string) (string, error) { - if p.ds.failures > 0 { - p.ds.failures-- - return "", errFlakyProps - } - return p.PropertyRepository.Get(ctx, id) -} - // renewingDS runs renew right before DeleteIdle, as a node resolving the grant meanwhile would. type renewingDS struct { model.DataStore @@ -60,7 +35,7 @@ func (g renewingGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (in var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} -var _ = Describe("Service: grants and tokens", func() { +var _ = Describe("Service: grants", func() { var ctx context.Context var svc *Service var now time.Time @@ -128,59 +103,59 @@ var _ = Describe("Service: grants and tokens", func() { // The empty-database path is covered end to end in server/apiv1, which owns a fresh DB. }) - Describe("ResolveGrant and Mint", func() { - It("mints a token with the grant's expanded scopes and a 1h lifetime", func() { - u := createUser(ctx, "pw", false) - issued, p, tok := login(ctx, svc, u) - Expect(p.GrantID).To(Equal(issued.Grant.ID)) - Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) - Expect(tok.ExpiresIn).To(Equal(time.Hour)) - Expect(tok.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) - - principal, err := svc.Authenticate(ctx, tok.Token, "10.0.0.9") - Expect(err).ToNot(HaveOccurred()) - Expect(principal.User.ID).To(Equal(u.ID)) - }) - - It("retries loading the signing key after a failed load", func() { - flaky := &flakyPropsDS{DataStore: realDS, failures: 1} - svc = New(flaky) - svc.SetClock(func() time.Time { return now }) + Describe("Authenticate", func() { + It("resolves the secret to its user and the grant's expanded scopes", func() { u := createUser(ctx, "pw", false) issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) Expect(err).ToNot(HaveOccurred()) - p, err := svc.ResolveGrant(ctx, issued.Secret, "") + p, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9") Expect(err).ToNot(HaveOccurred()) - - _, err = svc.Mint(ctx, p, nil) - Expect(err).To(MatchError(errFlakyProps)) - tok, err := svc.Mint(ctx, p, nil) - Expect(err).ToNot(HaveOccurred()) - Expect(tok.Token).ToNot(BeEmpty()) + Expect(p.User.ID).To(Equal(u.ID)) + Expect(p.GrantID).To(Equal(issued.Grant.ID)) + Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) }) - It("attenuates to the requested subset", func() { + It("carries only the scopes stored on a narrow grant", func() { u := createUser(ctx, "pw", false) - issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - p, _ := svc.ResolveGrant(ctx, issued.Secret, "") - tok, err := svc.Mint(ctx, p, []string{"read", "sync"}) + issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{ScopePassword}) Expect(err).ToNot(HaveOccurred()) - Expect(tok.Scopes).To(Equal([]string{ScopeRead})) + p, err := svc.Authenticate(ctx, issued.Secret, "") + Expect(err).ToNot(HaveOccurred()) + Expect(p.Scopes).To(Equal([]string{ScopePassword})) }) - It("counts minting as use", func() { + It("records the first use with the client IP", func() { u := createUser(ctx, "pw", false) issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - p, _ := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9") - _, err := svc.Mint(ctx, p, nil) + _, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9") Expect(err).ToNot(HaveOccurred()) g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) Expect(g.LastUsedAt).ToNot(BeNil()) Expect(g.LastUsedIP).To(Equal("10.0.0.9")) }) + It("records use again only after the touch interval", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + _, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.1") + Expect(err).ToNot(HaveOccurred()) + + now = now.Add(touchInterval - time.Second) + _, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.2") + Expect(err).ToNot(HaveOccurred()) + g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(g.LastUsedIP).To(Equal("10.0.0.1")) + + now = now.Add(2 * time.Second) + _, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.3") + Expect(err).ToNot(HaveOccurred()) + g, _ = realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(g.LastUsedIP).To(Equal("10.0.0.3")) + Expect(g.LastUsedAt.Equal(now)).To(BeTrue()) + }) + It("rejects unknown secrets", func() { - _, err := svc.ResolveGrant(ctx, "ndg_unknown", "") + _, err := svc.Authenticate(ctx, "ndg_unknown", "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) @@ -188,7 +163,7 @@ var _ = Describe("Service: grants and tokens", func() { u := createUser(ctx, "pw", false) issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) now = now.Add(IdleExpiry + time.Second) - _, err := svc.ResolveGrant(ctx, issued.Secret, "") + _, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) _, err = realDS.Grant().Get(ctx, issued.Grant.ID) Expect(err).To(MatchError(model.ErrNotFound)) @@ -204,7 +179,7 @@ var _ = Describe("Service: grants and tokens", func() { now = now.Add(IdleExpiry + time.Second) racing.SetClock(func() time.Time { return now }) - _, err := racing.ResolveGrant(ctx, issued.Secret, "") + _, err := racing.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) g, err := realDS.Grant().Get(ctx, issued.Grant.ID) Expect(err).ToNot(HaveOccurred()) @@ -216,7 +191,7 @@ var _ = Describe("Service: grants and tokens", func() { issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) u.NewPassword = "changed-elsewhere" Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - _, err := svc.ResolveGrant(ctx, issued.Secret, "") + _, err := svc.Authenticate(ctx, issued.Secret, "") Expect(err).To(MatchError(model.ErrInvalidAuth)) _, err = realDS.Grant().Get(ctx, issued.Grant.ID) Expect(err).To(MatchError(model.ErrNotFound)) diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go deleted file mode 100644 index a7f3b44d5..000000000 --- a/core/apiauth/signer.go +++ /dev/null @@ -1,159 +0,0 @@ -package apiauth - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" - "time" - - "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" -) - -const Audience = "navidrome-api-v1" - -// Tokens minted on one node are verified on others, whose clocks may differ slightly. -const clockSkew = 30 * time.Second - -var ErrTokenExpired = errors.New("access token expired") - -type claims struct { - UserID string - GrantID string - Scopes []string - IssuedAt time.Time - ExpiresAt time.Time -} - -type signer struct { - auth *jwtauth.JWTAuth -} - -func newJWTAuth(key []byte, now func() time.Time) *jwtauth.JWTAuth { - return jwtauth.New("HS256", key, nil, - jwt.WithAudience(Audience), - jwt.WithClock(jwt.ClockFunc(now)), - jwt.WithAcceptableSkew(clockSkew), - // jwx accepts a token with no exp at all unless the claim is required. - jwt.WithRequiredClaim(jwt.ExpirationKey), - jwt.WithRequiredClaim(jwt.SubjectKey), - ) -} - -// loadSigner reads the shared v1 key, creating it insert-if-absent so concurrent nodes agree on one key. -func loadSigner(ctx context.Context, ds model.DataStore, now func() time.Time) (*signer, error) { - key, err := loadKey(ctx, ds) - if err != nil { - return nil, err - } - return &signer{auth: newJWTAuth([]byte(key), now)}, nil -} - -func loadKey(ctx context.Context, ds model.DataStore) (string, error) { - stored, err := ds.Property().Get(ctx, consts.JWTAPIv1SecretKey) - if errors.Is(err, model.ErrNotFound) { - stored, err = createKey(ctx, ds) - } - if err != nil { - return "", fmt.Errorf("reading API v1 key: %w", err) - } - if key, err := utils.Decrypt(ctx, auth.EncryptionKey(), stored); err == nil { - return key, nil - } - // A changed PasswordEncryptionKey makes the old key unreadable; replacing it only ends current access tokens. - // The lock and re-read make concurrent nodes agree on one replacement. - var key string - err = ds.WithTxImmediate(func(tx model.DataStore) error { - current, err := tx.Property().Get(ctx, consts.JWTAPIv1SecretKey) - if err != nil { - return err - } - if k, err := utils.Decrypt(ctx, auth.EncryptionKey(), current); err == nil { - key = k - return nil - } - log.Warn(ctx, "Could not decrypt API v1 key, replacing it") - k, enc, err := newEncryptedKey(ctx) - if err != nil { - return err - } - ctx = log.WithSecrets(ctx, enc) - if err := tx.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { - return err - } - key = k - return nil - }) - if err != nil { - return "", fmt.Errorf("replacing API v1 key: %w", err) - } - return key, nil -} - -// createKey re-reads after the insert-if-absent, so nodes racing to create the key agree on the winner. -func createKey(ctx context.Context, ds model.DataStore) (string, error) { - _, enc, err := newEncryptedKey(ctx) - if err != nil { - return "", err - } - ctx = log.WithSecrets(ctx, enc) - if err := ds.Property().PutIfAbsent(ctx, consts.JWTAPIv1SecretKey, enc); err != nil { - return "", fmt.Errorf("storing API v1 key: %w", err) - } - return ds.Property().Get(ctx, consts.JWTAPIv1SecretKey) -} - -func newEncryptedKey(ctx context.Context) (key, enc string, err error) { - key = newKey() - if enc, err = utils.Encrypt(ctx, auth.EncryptionKey(), key); err != nil { - return "", "", fmt.Errorf("encrypting API v1 key: %w", err) - } - return key, enc, nil -} - -// newKey returns 256 random bits, the minimum RFC 7518 asks of an HS256 key. -func newKey() string { - b := make([]byte, 32) - _, _ = rand.Read(b) // never fails since Go 1.24 - return hex.EncodeToString(b) -} - -func (s *signer) sign(c claims) (string, error) { - _, tok, err := s.auth.Encode(map[string]any{ - jwt.SubjectKey: c.UserID, - jwt.AudienceKey: []string{Audience}, - jwt.IssuedAtKey: c.IssuedAt, - jwt.ExpirationKey: c.ExpiresAt, - "gid": c.GrantID, - "scope": strings.Join(c.Scopes, " "), - }) - return tok, err -} - -func (s *signer) parse(token string) (claims, error) { - tok, err := jwtauth.VerifyToken(s.auth, token) - if errors.Is(err, jwtauth.ErrExpired) { - return claims{}, ErrTokenExpired - } - if err != nil { - return claims{}, model.ErrInvalidAuth - } - var c claims - c.UserID, _ = tok.Subject() - c.IssuedAt, _ = tok.IssuedAt() - c.ExpiresAt, _ = tok.Expiration() - var scope string - if tok.Get("gid", &c.GrantID) != nil || tok.Get("scope", &scope) != nil || c.UserID == "" || c.GrantID == "" { - return claims{}, model.ErrInvalidAuth - } - c.Scopes = strings.Fields(scope) - return c, nil -} diff --git a/core/apiauth/signer_test.go b/core/apiauth/signer_test.go deleted file mode 100644 index 7d3d622b9..000000000 --- a/core/apiauth/signer_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package apiauth - -import ( - "context" - "encoding/hex" - "sync" - "time" - - "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("signer", func() { - var ctx context.Context - var now time.Time - clock := func() time.Time { return now } - - BeforeEach(func() { - ctx = GinkgoT().Context() - now = time.Now().UTC().Truncate(time.Second) - Expect(realDS.Property().Delete(ctx, consts.JWTAPIv1SecretKey)).To(Or(Succeed(), MatchError(model.ErrNotFound))) - }) - - It("creates a 256-bit key", func() { - key, err := loadKey(ctx, realDS) - Expect(err).ToNot(HaveOccurred()) - raw, err := hex.DecodeString(key) - Expect(err).ToNot(HaveOccurred()) - Expect(raw).To(HaveLen(32)) - }) - - It("keeps using a stored key created in the older format", func() { - enc, err := utils.Encrypt(ctx, auth.EncryptionKey(), "legacy22charskeyABCDEF") - Expect(err).ToNot(HaveOccurred()) - Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc)).To(Succeed()) - Expect(loadKey(ctx, realDS)).To(Equal("legacy22charskeyABCDEF")) - }) - - It("round-trips claims", func() { - s, err := loadSigner(ctx, realDS, clock) - Expect(err).ToNot(HaveOccurred()) - tok, err := s.sign(claims{UserID: "u1", GrantID: "g1", Scopes: []string{"read", "password"}, - IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) - Expect(err).ToNot(HaveOccurred()) - - c, err := s.parse(tok) - Expect(err).ToNot(HaveOccurred()) - Expect(c.UserID).To(Equal("u1")) - Expect(c.GrantID).To(Equal("g1")) - Expect(c.Scopes).To(Equal([]string{"read", "password"})) - Expect(c.ExpiresAt).To(BeTemporally("==", now.Add(time.Hour))) - }) - - It("round-trips a token with no scopes", func() { - s, _ := loadSigner(ctx, realDS, clock) - tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) - - c, err := s.parse(tok) - Expect(err).ToNot(HaveOccurred()) - Expect(c.Scopes).To(BeEmpty()) - }) - - It("reports expiry distinctly", func() { - s, _ := loadSigner(ctx, realDS, clock) - tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) - now = now.Add(time.Hour + clockSkew + time.Second) - _, err := s.parse(tok) - Expect(err).To(MatchError(ErrTokenExpired)) - }) - - It("accepts a token issued slightly ahead of the verifier's clock", func() { - s, _ := loadSigner(ctx, realDS, clock) - tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now.Add(5 * time.Second), ExpiresAt: now.Add(time.Hour)}) - _, err := s.parse(tok) - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects a token issued further ahead than the allowed skew", func() { - s, _ := loadSigner(ctx, realDS, clock) - tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now.Add(clockSkew + time.Second), ExpiresAt: now.Add(time.Hour)}) - _, err := s.parse(tok) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rejects garbage and tokens signed with another key", func() { - s, _ := loadSigner(ctx, realDS, clock) - _, err := s.parse("not-a-token") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - - other := &signer{} - *other = *s - other.auth = newJWTAuth([]byte("another-key"), clock) - tok, _ := other.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) - _, err = s.parse(tok) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rejects a correctly signed token with no exp, or with another audience", func() { - s, _ := loadSigner(ctx, realDS, clock) - _, noExp, err := s.auth.Encode(map[string]any{ - jwt.SubjectKey: "u1", jwt.AudienceKey: []string{Audience}, "gid": "g1", "scope": "read", - }) - Expect(err).ToNot(HaveOccurred()) - _, err = s.parse(noExp) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - - _, otherAud, _ := s.auth.Encode(map[string]any{ - jwt.SubjectKey: "u1", jwt.AudienceKey: []string{"navidrome-other"}, jwt.ExpirationKey: now.Add(time.Hour), - "gid": "g1", "scope": "read", - }) - _, err = s.parse(otherAud) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - loadConcurrently := func() []*signer { - var wg sync.WaitGroup - signers := make([]*signer, 4) - for i := range signers { - wg.Add(1) - go func() { - defer GinkgoRecover() - defer wg.Done() - var err error - signers[i], err = loadSigner(ctx, realDS, clock) - Expect(err).ToNot(HaveOccurred()) - }() - } - wg.Wait() - return signers - } - - expectSameKey := func(signers []*signer) { - tok, _ := signers[0].sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)}) - for _, s := range signers[1:] { - _, err := s.parse(tok) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - } - } - - It("gives every concurrent loader the same key", func() { - expectSameKey(loadConcurrently()) - }) - - It("gives every concurrent loader the same replacement for an unreadable key", func() { - Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, "not-decryptable")).To(Succeed()) - expectSameKey(loadConcurrently()) - }) -}) diff --git a/model/properties.go b/model/properties.go index 11edf8a20..24b56db26 100644 --- a/model/properties.go +++ b/model/properties.go @@ -4,7 +4,6 @@ import "context" type PropertyRepository interface { Put(ctx context.Context, id string, value string) error - PutIfAbsent(ctx context.Context, id string, value string) error Get(ctx context.Context, id string) (string, error) Delete(ctx context.Context, id string) error DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) diff --git a/persistence/property_repository.go b/persistence/property_repository.go index 87d97491c..29bb2b564 100644 --- a/persistence/property_repository.go +++ b/persistence/property_repository.go @@ -34,12 +34,6 @@ func (r propertyRepository) Put(ctx context.Context, id string, value string) er return err } -func (r propertyRepository) PutIfAbsent(ctx context.Context, id string, value string) error { - insert := Insert(r.tableName).Columns("id", "value").Values(id, value).Options("OR IGNORE") - _, err := r.executeSQL(ctx, insert) - return err -} - func (r propertyRepository) Get(ctx context.Context, id string) (string, error) { sel := Select("value").From(r.tableName).Where(Eq{"id": id}) resp := struct { diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 658a75b48..eac174c5b 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -34,27 +34,18 @@ var _ = Describe("Property Repository", func() { Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default")) }) - It("PutIfAbsent inserts once and never overwrites", func() { - Expect(pr.PutIfAbsent(ctx, "pia", "first")).To(Succeed()) - Expect(pr.PutIfAbsent(ctx, "pia", "second")).To(Succeed()) - Expect(pr.Get(ctx, "pia")).To(Equal("first")) - }) - It("hides values marked as secrets from the SQL log, but still logs the property id", func() { logs := captureTraceLogs() insertCtx := log.WithSecrets(ctx, "inserted-secret") Expect(pr.Put(insertCtx, "secret-prop", "inserted-secret")).To(Succeed()) updateCtx := log.WithSecrets(ctx, "updated-secret") Expect(pr.Put(updateCtx, "secret-prop", "updated-secret")).To(Succeed()) - absentCtx := log.WithSecrets(ctx, "absent-secret") - Expect(pr.PutIfAbsent(absentCtx, "secret-prop-2", "absent-secret")).To(Succeed()) Expect(logs.String()).To(ContainSubstring("INSERT INTO property")) Expect(logs.String()).To(ContainSubstring("UPDATE property")) - Expect(logs.String()).To(ContainSubstring("secret-prop-2")) + Expect(logs.String()).To(ContainSubstring("secret-prop")) Expect(logs.String()).ToNot(ContainSubstring("inserted-secret")) Expect(logs.String()).ToNot(ContainSubstring("updated-secret")) - Expect(logs.String()).ToNot(ContainSubstring("absent-secret")) }) It("logs the values of unmarked property writes", func() { diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 70c6a7bc5..6acd95cfe 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -32,10 +32,9 @@ func New(ds model.DataStore) *Router { } var gateRulesV1 = gateRules{ - limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true}, - noScope: map[string]bool{"getCapabilities": true}, - grantOps: map[string]bool{"createAccessToken": true}, - noStore: map[string]bool{"login": true, "setupFirstAdmin": true, "createAccessToken": true}, + limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true}, + noScope: map[string]bool{"getCapabilities": true}, + noStore: map[string]bool{"login": true, "setupFirstAdmin": true}, } func (rt *Router) routes() http.Handler { diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index e174b0b43..9afd22465 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -9,7 +9,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "time" @@ -17,21 +16,6 @@ import ( "github.com/oapi-codegen/runtime" ) -// Defines values for AccessTokenTokenType. -const ( - AccessTokenTokenTypeBearer AccessTokenTokenType = "Bearer" -) - -// Valid indicates whether the value is a known member of the AccessTokenTokenType enum. -func (e AccessTokenTokenType) Valid() bool { - switch e { - case AccessTokenTokenTypeBearer: - return true - default: - return false - } -} - // Defines values for ProblemCode. const ( ProblemCodeForbidden ProblemCode = "forbidden" @@ -43,7 +27,6 @@ const ( ProblemCodePayloadTooLarge ProblemCode = "payload_too_large" ProblemCodeRateLimited ProblemCode = "rate_limited" ProblemCodeSetupComplete ProblemCode = "setup_complete" - ProblemCodeTokenExpired ProblemCode = "token_expired" ProblemCodeUnauthorized ProblemCode = "unauthorized" ProblemCodeUnavailable ProblemCode = "unavailable" ProblemCodeValidation ProblemCode = "validation" @@ -70,8 +53,6 @@ func (e ProblemCode) Valid() bool { return true case ProblemCodeSetupComplete: return true - case ProblemCodeTokenExpired: - return true case ProblemCodeUnauthorized: return true case ProblemCodeUnavailable: @@ -104,24 +85,6 @@ func (e Scope) Valid() bool { } } -// AccessToken A short-lived access token. Opaque; clients must not decode it. -type AccessToken struct { - // AccessToken The token. Send it as `Authorization: Bearer `. - AccessToken string `json:"accessToken"` - - // ExpiresIn Seconds until the token expires. - ExpiresIn int `json:"expiresIn"` - - // Scopes Scopes the token actually carries, which may be fewer than requested. - Scopes []Scope `json:"scopes"` - - // TokenType Always `Bearer`. - TokenType AccessTokenTokenType `json:"tokenType"` -} - -// AccessTokenTokenType Always `Bearer`. -type AccessTokenTokenType string - // AuthUser The user a grant belongs to. type AuthUser struct { // Id User id. @@ -142,7 +105,7 @@ type AuthUser struct { // Capabilities Capability modules this server implements, keyed by module. Keys are optional; a missing key means the // module is not implemented. New modules are added as new optional keys. These are server facts, not what -// the calling token may use. +// the calling grant may use. type Capabilities struct { // Core The mandatory core module. Core *CoreCapability `json:"core,omitempty"` @@ -207,7 +170,7 @@ type Grant struct { // Provider How the grant was created, for example `password` or `setup`. Free-form; new values may appear. Provider string `json:"provider"` - // Scopes Scopes this grant may mint tokens for. + // Scopes Scopes this grant carries. Scopes []Scope `json:"scopes"` } @@ -216,7 +179,7 @@ type GrantCreated struct { // Grant The new grant. Grant Grant `json:"grant"` - // Secret Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`. + // Secret Opaque grant secret. Send it as `Authorization: Bearer `. Secret string `json:"secret"` // User The user the grant belongs to. @@ -329,12 +292,6 @@ type ServerInfo struct { SpecVersion string `json:"specVersion"` } -// TokenRequest Optional narrowing of a new access token. -type TokenRequest struct { - // Scopes Subset of the grant's scopes. Omit for all of them; an empty list asks for none. - Scopes *[]ScopeRequest `json:"scopes,omitempty"` -} - // ValidationError One field-level validation failure. type ValidationError struct { // Field Name of the offending query parameter, path parameter, or body field (dotted for nested). @@ -392,9 +349,6 @@ type ChangePasswordJSONRequestBody = PasswordChangeRequest // SetupFirstAdminJSONRequestBody defines body for SetupFirstAdmin for application/json ContentType. type SetupFirstAdminJSONRequestBody = CredentialsRequest -// CreateAccessTokenJSONRequestBody defines body for CreateAccessToken for application/json ContentType. -type CreateAccessTokenJSONRequestBody = TokenRequest - // ServerInterface represents all server handlers. type ServerInterface interface { // ListGrants List my grants @@ -415,9 +369,6 @@ type ServerInterface interface { // SetupFirstAdmin Create the first admin // (POST /auth/setup) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) - // CreateAccessToken Mint an access token - // (POST /auth/token) - CreateAccessToken(w http.ResponseWriter, r *http.Request) // GetCapabilities List implemented capability modules // (GET /capabilities) GetCapabilities(w http.ResponseWriter, r *http.Request) @@ -466,12 +417,6 @@ func (_ Unimplemented) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// CreateAccessToken Mint an access token -// (POST /auth/token) -func (_ Unimplemented) CreateAccessToken(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - // GetCapabilities List implemented capability modules // (GET /capabilities) func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) { @@ -621,20 +566,6 @@ func (siw *ServerInterfaceWrapper) SetupFirstAdmin(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } -// CreateAccessToken operation middleware -func (siw *ServerInterfaceWrapper) CreateAccessToken(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateAccessToken(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - // GetCapabilities operation middleware func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) { @@ -794,9 +725,6 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/auth/setup", wrapper.SetupFirstAdmin) }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/auth/token", wrapper.CreateAccessToken) - }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities) }) @@ -1483,105 +1411,6 @@ func (response SetupFirstAdmin500ApplicationProblemPlusJSONResponse) VisitSetupF return err } -type CreateAccessTokenRequestObject struct { - Body *CreateAccessTokenJSONRequestBody -} - -type CreateAccessTokenResponseObject interface { - VisitCreateAccessTokenResponse(w http.ResponseWriter) error -} - -type CreateAccessToken200ResponseHeaders struct { - CacheControl *string -} - -type CreateAccessToken200JSONResponse struct { - Body AccessToken - Headers CreateAccessToken200ResponseHeaders -} - -func (response CreateAccessToken200JSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - if response.Headers.CacheControl != nil { - w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) - } - w.WriteHeader(200) - _, err := buf.WriteTo(w) - return err -} - -type CreateAccessToken400ApplicationProblemPlusJSONResponse struct { - BadRequestApplicationProblemPlusJSONResponse -} - -func (response CreateAccessToken400ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - _, err := buf.WriteTo(w) - return err -} - -type CreateAccessToken401ApplicationProblemPlusJSONResponse struct { - UnauthorizedApplicationProblemPlusJSONResponse -} - -func (response CreateAccessToken401ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(401) - _, err := buf.WriteTo(w) - return err -} - -type CreateAccessToken413ApplicationProblemPlusJSONResponse struct { - PayloadTooLargeApplicationProblemPlusJSONResponse -} - -func (response CreateAccessToken413ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(413) - _, err := buf.WriteTo(w) - return err -} - -type CreateAccessToken500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response CreateAccessToken500ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - type GetCapabilitiesRequestObject struct { } @@ -1695,9 +1524,6 @@ type StrictServerInterface interface { // SetupFirstAdmin Create the first admin // (POST /auth/setup) SetupFirstAdmin(ctx context.Context, request SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) - // CreateAccessToken Mint an access token - // (POST /auth/token) - CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) // GetCapabilities List implemented capability modules // (GET /capabilities) GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) @@ -1914,40 +1740,6 @@ func (sh *strictHandler) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) } } -// CreateAccessToken operation middleware -func (sh *strictHandler) CreateAccessToken(w http.ResponseWriter, r *http.Request) { - var request CreateAccessTokenRequestObject - - var body CreateAccessTokenJSONRequestBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - if !errors.Is(err, io.EOF) { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) - return - } - } else { - request.Body = &body - } - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.CreateAccessToken(ctx, request.(CreateAccessTokenRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "CreateAccessToken") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(CreateAccessTokenResponseObject); ok { - if err := validResponse.VisitCreateAccessTokenResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - // GetCapabilities operation middleware func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) { var request GetCapabilitiesRequestObject diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index 6743951de..00b26abc4 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -97,12 +97,17 @@ func (c testClient) setup() GrantCreated { return gc } -func (c testClient) mint(secret string, body any) AccessToken { - w := c.call(http.MethodPost, "/api/v1/auth/token", secret, body) +// login signs in as the admin created by setup; nil scopes asks for all of them. +func (c testClient) login(scopes []string) GrantCreated { + body := creds("admin", "pw") + if scopes != nil { + body["scopes"] = scopes + } + w := c.call(http.MethodPost, "/api/v1/auth/login", "", body) ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String()) - var at AccessToken - decodeJSON(w, &at) - return at + var gc GrantCreated + decodeJSON(w, &gc) + return gc } func creds(user, pw string) map[string]any { diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go index 100a0e19a..768cda508 100644 --- a/server/apiv1/auth_handlers.go +++ b/server/apiv1/auth_handlers.go @@ -9,27 +9,6 @@ import ( const defaultPageSize = 100 -func (rt *Router) CreateAccessToken(ctx context.Context, req CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) { - p, err := principalFrom(ctx) - if err != nil { - return nil, err - } - var requested []string - if req.Body != nil { - requested = fromScopeRequests(req.Body.Scopes) - } - tok, err := rt.auth.Mint(ctx, p, requested) - if err != nil { - return nil, err - } - return CreateAccessToken200JSONResponse{Body: AccessToken{ - AccessToken: tok.Token, - TokenType: AccessTokenTokenTypeBearer, - ExpiresIn: int(tok.ExpiresIn.Seconds()), - Scopes: toScopes(tok.Scopes), - }}, nil -} - func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) { p, err := principalFrom(ctx) if err != nil { diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index e04766e54..b41f5fb73 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -60,7 +60,7 @@ var _ = Describe("auth endpoints", func() { Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete)) }) - It("logs in, mints a token, and uses it on a scoped endpoint", func() { + It("logs in and uses the grant secret on a scoped endpoint", func() { api.setup() w := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw")) Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) @@ -68,11 +68,7 @@ var _ = Describe("auth endpoints", func() { decodeJSON(w, &gc) Expect(gc.User.PasswordChangeable).To(BeTrue()) - at := api.mint(gc.Secret, nil) - Expect(at.TokenType).To(Equal(AccessTokenTokenTypeBearer)) - Expect(at.ExpiresIn).To(Equal(3600)) - - w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) var list GrantList decodeJSON(w, &list) @@ -89,69 +85,64 @@ var _ = Describe("auth endpoints", func() { Expect(a.Body.String()).To(Equal(b.Body.String())) }) - It("treats no body and {} as all scopes, and [] as no scopes", func() { - gc := api.setup() - all := api.mint(gc.Secret, nil) - Expect(all.Scopes).To(ConsistOf(ScopeRead, ScopePassword)) - Expect(api.mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword)) + It("treats missing scopes as all scopes, and [] as no scopes", func() { + api.setup() + all := api.login(nil) + Expect(all.Grant.Scopes).To(ConsistOf(ScopeAll)) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", all.Secret, nil).Code).To(Equal(http.StatusOK)) - none := api.mint(gc.Secret, map[string]any{"scopes": []string{}}) - Expect(none.Scopes).To(BeEmpty()) - w := api.call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil) + none := api.login([]string{}) + Expect(none.Grant.Scopes).To(BeEmpty()) + w := api.call(http.MethodGet, "/api/v1/auth/grants", none.Secret, nil) Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`)) Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) }) It("drops unknown requested scopes instead of rejecting them", func() { - gc := api.setup() - at := api.mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}}) - Expect(at.Scopes).To(ConsistOf(ScopeRead)) + api.setup() + gc := api.login([]string{"read", "playlists:write"}) + Expect(gc.Grant.Scopes).To(ConsistOf(ScopeRead)) }) - It("does not let a token without read log out or revoke grants", func() { + It("does not let a grant without read log out or revoke grants", func() { gc := api.setup() - narrow := api.mint(gc.Secret, map[string]any{"scopes": []string{"password"}}) - Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) - Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) + narrow := api.login([]string{"password"}) + Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.Secret, nil).Code).To(Equal(http.StatusForbidden)) + Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.Secret, nil).Code).To(Equal(http.StatusForbidden)) }) - It("logs out: the token stops at once and logoutUrl is null", func() { + It("logs out: the secret stops at once and logoutUrl is null", func() { gc := api.setup() - at := api.mint(gc.Secret, nil) - w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) + w := api.call(http.MethodPost, "/api/v1/auth/logout", gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) - w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) }) - It("logs out with 200 when another node already revoked the grant", func() { + It("revokes another grant of the caller, whose secret then stops at once", func() { gc := api.setup() - at := api.mint(gc.Secret, nil) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant - Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed()) - - w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) - Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) - Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) + other := api.login(nil) + Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+other.Grant.Id, gc.Secret, nil).Code).To(Equal(http.StatusNoContent)) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK)) }) It("challenges with invalid_token when the grant is revoked while a password change runs", func() { gc := api.setup() - at := api.mint(gc.Secret, nil) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed()) - w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String()) Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) }) It("rejects a case-variant scopes key that would widen an explicit empty subset", func() { - gc := api.setup() - w := api.callRaw(http.MethodPost, "/api/v1/auth/token", gc.Secret, `{"scopes":[],"Scopes":null}`) + api.setup() + w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`) Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) p := decodeProblem(w) Expect(p.Code).To(Equal(ProblemCodeValidation)) @@ -167,33 +158,30 @@ var _ = Describe("auth endpoints", func() { }) DescribeTable("rejects a body with data after its JSON value, without echoing it", - func(path string, needsSecret bool, body string) { - secret := "" - if gc := api.setup(); needsSecret { - secret = gc.Secret - } - w := api.callRaw(http.MethodPost, path, secret, body) + func(body string) { + api.setup() + w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", body) Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) p := decodeProblem(w) Expect(p.Code).To(Equal(ProblemCodeValidation)) Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"})) Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) }, - Entry("token with a trailing byte", "/api/v1/auth/token", true, `{"scopes":[],"Scopes":null}x`), - Entry("login with a second value", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"} {}`), + Entry("a trailing byte", `{"username":"a","password":"hunter2","client":"c"}x`), + Entry("a second value", `{"username":"a","password":"hunter2","client":"c"} {}`), ) - It("checks the token body even when Content-Type has a repeated parameter", func() { - gc := api.setup() - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", strings.NewReader(`{"scopes":[],"Scopes":null}`)) + It("checks the login body even when Content-Type has a repeated parameter", func() { + api.setup() + body := `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}` + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json; a=1; a=2") - req.Header.Set("Authorization", "Bearer "+gc.Secret) w := serve(api.router, req) Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"})) }) - It("marks grant and token responses no-store", func() { + It("marks responses carrying a grant secret no-store", func() { w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) Expect(w.Code).To(Equal(http.StatusCreated)) Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) @@ -204,38 +192,45 @@ var _ = Describe("auth endpoints", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - w = api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil) + w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(w.Header().Get("Cache-Control")).To(BeEmpty()) }) It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() { gc := api.setup() - tok := api.mint(gc.Secret, nil).AccessToken - Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound)) - w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, nil) + Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", gc.Secret, nil).Code).To(Equal(http.StatusNotFound)) + w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"})) }) It("changes the password, keeping the caller and revoking the rest", func() { gc := api.setup() - otherLogin := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) - var other GrantCreated - decodeJSON(otherLogin, &other) - at := api.mint(gc.Secret, nil) + other := api.login(nil) - w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String()) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) - Expect(api.call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK)) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("keeps every grant when revokeOtherGrants is false", func() { + gc := api.setup() + other := api.login(nil) + + body := map[string]any{"currentPassword": "pw", "newPassword": "pw2", "revokeOtherGrants": false} + w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, body) + Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String()) + + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK)) + Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusOK)) }) It("reports a wrong current password as a field error", func() { gc := api.setup() - at := api.mint(gc.Secret, nil) - w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "nope", "newPassword": "pw2"}) + w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "nope", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusBadRequest)) p := decodeProblem(w) Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"})) diff --git a/server/apiv1/capabilities_test.go b/server/apiv1/capabilities_test.go index 456d09bd5..e14248513 100644 --- a/server/apiv1/capabilities_test.go +++ b/server/apiv1/capabilities_test.go @@ -20,16 +20,17 @@ var _ = Describe("GET /capabilities", func() { api = testClient{ctx: ctx, router: New(realDS)} }) - It("needs a token", func() { + It("needs a grant", func() { w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) - It("lists core and password for any valid token, even one with no scopes", func() { - gc := api.setup() - at := api.mint(gc.Secret, map[string]any{"scopes": []string{}}) + It("lists core and password for any valid grant, even one with no scopes", func() { + api.setup() + gc := api.login([]string{}) + Expect(gc.Grant.Scopes).To(BeEmpty()) - w := api.call(http.MethodGet, "/api/v1/capabilities", at.AccessToken, nil) + w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusOK)) var caps Capabilities decodeJSON(w, &caps) diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index 35e7841dd..fabafee85 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -28,16 +28,14 @@ import ( ) type authenticator interface { - Authenticate(ctx context.Context, token, ip string) (*apiauth.Principal, error) - ResolveGrant(ctx context.Context, secret, ip string) (*apiauth.Principal, error) + Authenticate(ctx context.Context, secret, ip string) (*apiauth.Principal, error) } type authKind int const ( authPublic authKind = iota - authToken - authGrant + authBearer ) type gateOp struct { @@ -69,10 +67,9 @@ var moduleScope = map[string]string{ } type gateRules struct { - limited map[string]bool // login-type operations, throttled per client IP - noScope map[string]bool // the only token operations allowed without x-scope - grantOps map[string]bool // the only operations allowed to use grantAuth - noStore map[string]bool // operations whose responses carry a secret or token + limited map[string]bool // login-type operations, throttled per client IP + noScope map[string]bool // the only bearerAuth operations allowed without x-scope + noStore map[string]bool // operations whose responses carry a secret } func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) { @@ -104,7 +101,7 @@ func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRule // check fails on a rule naming an operation the spec lacks, so a typo cannot silently disable the rule. func (rules gateRules) check(ids map[string]bool) error { - sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "grantOps": rules.grantOps, "noStore": rules.noStore} + sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "noStore": rules.noStore} for name, set := range sets { for id := range set { if !ids[id] { @@ -136,17 +133,15 @@ func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method s case len(reqs) == 0: gop.kind = authPublic case len(reqs) == 1 && isScheme(reqs[0], "bearerAuth"): - gop.kind = authToken - case len(reqs) == 1 && isScheme(reqs[0], "grantAuth") && rules.grantOps[id]: - gop.kind = authGrant + gop.kind = authBearer default: return nil, fmt.Errorf("operation %s has a security requirement outside the allowed forms", id) } - if gop.kind == authToken && scope == "" && !rules.noScope[id] { + if gop.kind == authBearer && scope == "" && !rules.noScope[id] { return nil, fmt.Errorf("operation %s: bearerAuth needs x-scope", id) } if scope != "" { - if gop.kind != authToken { + if gop.kind != authBearer { return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id) } base := cmp.Or(moduleScope[module], module) @@ -226,23 +221,13 @@ func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*h if op.kind == authPublic { return r, true } - token, ok := bearerToken(r) + secret, ok := bearerToken(r) if !ok { writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "") return r, false } - ip := server.ClientAddr(r) - var p *apiauth.Principal - var err error - if op.kind == authGrant { - p, err = g.auth.ResolveGrant(r.Context(), token, ip) - } else { - p, err = g.auth.Authenticate(r.Context(), token, ip) - } + p, err := g.auth.Authenticate(r.Context(), secret, server.ClientAddr(r)) if err == nil && op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { - err = apiauth.ErrInsufficientScope - } - if errors.Is(err, apiauth.ErrInsufficientScope) { err = &scopeError{scope: op.scope} } if err != nil { diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go index a2f6fc0a2..f748957fc 100644 --- a/server/apiv1/gate_test.go +++ b/server/apiv1/gate_test.go @@ -53,39 +53,29 @@ paths: responses: {'200': {description: ok}} /caps: get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}} - /mint: - post: {operationId: mint, x-module: core, security: [{grantAuth: []}], responses: {'200': {description: ok}}} /limited: post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}} components: securitySchemes: bearerAuth: {type: http, scheme: bearer} - grantAuth: {type: http, scheme: bearer} ` type fakeAuth struct { principal *apiauth.Principal err error - gotToken string gotSecret string gotIP string } -func (f *fakeAuth) Authenticate(_ context.Context, token, ip string) (*apiauth.Principal, error) { - f.gotToken, f.gotIP = token, ip - return f.principal, f.err -} - -func (f *fakeAuth) ResolveGrant(_ context.Context, secret, ip string) (*apiauth.Principal, error) { +func (f *fakeAuth) Authenticate(_ context.Context, secret, ip string) (*apiauth.Principal, error) { f.gotSecret, f.gotIP = secret, ip return f.principal, f.err } var testGateRules = gateRules{ - limited: map[string]bool{"limited": true}, - noScope: map[string]bool{"caps": true}, - grantOps: map[string]bool{"mint": true}, - noStore: map[string]bool{"mint": true}, + limited: map[string]bool{"limited": true}, + noScope: map[string]bool{"caps": true}, + noStore: map[string]bool{"caps": true}, } var _ = Describe("spec gate", func() { @@ -117,7 +107,6 @@ var _ = Describe("spec gate", func() { m.Get("/things/{id}", ok("getThing")) m.Post("/things", ok("createThing")) m.Get("/caps", ok("caps")) - m.Post("/mint", ok("mint")) m.Post("/limited", ok("limited")) return m, nil } @@ -147,12 +136,12 @@ var _ = Describe("spec gate", func() { Expect(err).ToNot(HaveOccurred()) }) - It("lets public operations through without a token", func() { + It("lets public operations through without a credential", func() { Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK)) Expect(reached).To(Equal("open")) }) - It("requires a token, with a Bearer challenge", func() { + It("requires a grant secret, with a Bearer challenge", func() { w := do(http.MethodGet, "/things/1", "", "") Expect(w.Code).To(Equal(http.StatusUnauthorized)) Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) @@ -161,21 +150,13 @@ var _ = Describe("spec gate", func() { }) It("accepts the Bearer scheme in any case and trims spaces", func() { - w := do(http.MethodGet, "/things/1", "bearer tok-1 ", "") + w := do(http.MethodGet, "/things/1", "bearer ndg_secret ", "") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.gotToken).To(Equal("tok-1")) + Expect(fa.gotSecret).To(Equal("ndg_secret")) Expect(w.Header().Get("X-User")).To(Equal("u1")) }) - It("maps an expired token to token_expired", func() { - fa.err = apiauth.ErrTokenExpired - w := do(http.MethodGet, "/things/1", "Bearer x", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeTokenExpired)) - }) - - It("maps other auth failures to unauthorized with invalid_token", func() { + It("maps auth failures to unauthorized with invalid_token", func() { fa.err = model.ErrInvalidAuth w := do(http.MethodGet, "/things/1", "Bearer x", "") Expect(w.Code).To(Equal(http.StatusUnauthorized)) @@ -183,44 +164,34 @@ var _ = Describe("spec gate", func() { Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized)) }) - It("rejects a token without the operation's scope", func() { + It("rejects a grant without the operation's scope", func() { w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`) Expect(w.Code).To(Equal(http.StatusForbidden)) Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`)) Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) }) - It("lets any valid token through an operation with no x-scope", func() { + It("lets any valid grant through an operation with no x-scope", func() { fa.principal.Scopes = nil Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK)) }) - DescribeTable("passes the full client address to the authenticator, not the rate-limit /64", - func(method, path string) { - req := httptest.NewRequestWithContext(ctx, method, path, nil) - req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321" - req.Header.Set("Authorization", "Bearer x") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6")) - }, - Entry("access token", http.MethodGet, "/things/1"), - Entry("grant", http.MethodPost, "/mint"), - ) - - It("marks only the listed operations' responses no-store, errors included", func() { - Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) - fa.err = model.ErrInvalidAuth - Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) - fa.err = nil - Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty()) + It("passes the full client address to the authenticator, not the rate-limit /64", func() { + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/things/1", nil) + req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321" + req.Header.Set("Authorization", "Bearer x") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6")) }) - It("uses ResolveGrant for grantAuth operations", func() { - Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Code).To(Equal(http.StatusOK)) - Expect(fa.gotSecret).To(Equal("ndg_secret")) - Expect(fa.gotToken).To(BeEmpty()) + It("marks only the listed operations' responses no-store, errors included", func() { + Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) + fa.err = model.ErrInvalidAuth + Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) + fa.err = nil + Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty()) }) It("checks HEAD on a protected GET", func() { @@ -228,21 +199,6 @@ var _ = Describe("spec gate", func() { Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) - It("turns an insufficient-scope error from Authenticate into a 403 challenge", func() { - fa.err = apiauth.ErrInsufficientScope // e.g. a token carrying admin after demotion - w := do(http.MethodGet, "/caps", "Bearer x", "") - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope"`)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) - }) - - It("names the operation's scope when Authenticate reports an insufficient scope", func() { - fa.err = apiauth.ErrInsufficientScope - w := do(http.MethodGet, "/things/1", "Bearer x", "") - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`)) - }) - It("looks routes up on the raw path, as chi dispatches them", func() { w := do(http.MethodGet, "/things/a%2Fb", "", "") Expect(w.Code).To(Equal(http.StatusUnauthorized)) @@ -389,7 +345,10 @@ var _ = Describe("spec gate", func() { Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)), Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)), Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)), - Entry("grantAuth outside the allowlist", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)), + Entry("a scheme other than bearerAuth", strings.Replace( + strings.Replace(gateSpec, " bearerAuth: {type: http, scheme: bearer}\n", " bearerAuth: {type: http, scheme: bearer}\n grantAuth: {type: http, scheme: bearer}\n", 1), + "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{grantAuth: []}]", 1)), + Entry("an undeclared scheme", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)), Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)), Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)), Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)), @@ -409,7 +368,6 @@ var _ = Describe("spec gate", func() { }, Entry("limited", func(r *gateRules) *map[string]bool { return &r.limited }), Entry("noScope", func(r *gateRules) *map[string]bool { return &r.noScope }), - Entry("grantOps", func(r *gateRules) *map[string]bool { return &r.grantOps }), Entry("noStore", func(r *gateRules) *map[string]bool { return &r.noStore }), ) diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go index 197a7dde6..11dddf1f4 100644 --- a/server/apiv1/problem.go +++ b/server/apiv1/problem.go @@ -89,8 +89,6 @@ func classifyError(err error) (int, ProblemCode) { switch { case tooLarge(err): return http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge - case errors.Is(err, apiauth.ErrTokenExpired): - return http.StatusUnauthorized, ProblemCodeTokenExpired case errors.Is(err, apiauth.ErrInsufficientScope): return http.StatusForbidden, ProblemCodeInsufficientScope case errors.Is(err, auth.ErrSetupComplete): diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go index 074f270b2..e0e501989 100644 --- a/server/apiv1/problem_test.go +++ b/server/apiv1/problem_test.go @@ -51,7 +51,6 @@ var _ = Describe("problem", func() { Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized), Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation), Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable), - Entry("token expired", apiauth.ErrTokenExpired, http.StatusUnauthorized, ProblemCodeTokenExpired), Entry("insufficient scope", apiauth.ErrInsufficientScope, http.StatusForbidden, ProblemCodeInsufficientScope), Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete), Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally), diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go index f86aa434d..949f894c1 100644 --- a/tests/mock_property_repo.go +++ b/tests/mock_property_repo.go @@ -27,17 +27,6 @@ func (p *MockedPropertyRepo) Put(_ context.Context, id string, value string) err return nil } -func (p *MockedPropertyRepo) PutIfAbsent(_ context.Context, id string, value string) error { - if p.Error != nil { - return p.Error - } - p.init() - if _, ok := p.Data[id]; !ok { - p.Data[id] = value - } - return nil -} - func (p *MockedPropertyRepo) Get(_ context.Context, id string) (string, error) { if p.Error != nil { return "", p.Error From 04b9e97eb2a467d57eaa63183ac3443e4807c95b Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 28 Sep 2026 21:06:31 -0400 Subject: [PATCH 32/41] fix(api): stop the API v1 request validator from rewriting bodies Filling schema defaults made kin-openapi re-encode the body, so trailing data after the JSON value of POST /auth/password was silently dropped instead of answering 400 like the other endpoints. The handler already applies the revokeOtherGrants default itself. Signed-off-by: Deluan --- server/apiv1/auth_test.go | 15 ++++++++++----- server/apiv1/gate.go | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index b41f5fb73..bc3b735f4 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -158,17 +158,22 @@ var _ = Describe("auth endpoints", func() { }) DescribeTable("rejects a body with data after its JSON value, without echoing it", - func(body string) { - api.setup() - w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", body) + func(path string, needsSecret bool, body string) { + secret := "" + if gc := api.setup(); needsSecret { + secret = gc.Secret + } + w := api.callRaw(http.MethodPost, path, secret, body) Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) p := decodeProblem(w) Expect(p.Code).To(Equal(ProblemCodeValidation)) Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"})) Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) }, - Entry("a trailing byte", `{"username":"a","password":"hunter2","client":"c"}x`), - Entry("a second value", `{"username":"a","password":"hunter2","client":"c"} {}`), + Entry("login with a trailing byte", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"}x`), + Entry("login with a second value", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"} {}`), + // This schema has a default, which the validator must not fill in by rewriting the body. + Entry("password change with a trailing byte", "/api/v1/auth/password", true, `{"currentPassword":"hunter2","newPassword":"pw2"}x`), ) It("checks the login body even when Content-Type has a repeated parameter", func() { diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index fabafee85..550705c2c 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -247,7 +247,8 @@ func bearerToken(r *http.Request) (string, bool) { return token, true } -var validationOptions = &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true} +// SkipSettingDefaults: filling defaults re-encodes the body, which hides trailing data from jsonBodyFields. +var validationOptions = &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true, SkipSettingDefaults: true} func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx *chi.Context) bool { params := make(map[string]string, len(rctx.URLParams.Keys)) From 9655c97b8432b298be0660dc7b94730ef628cd14 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 28 Sep 2026 21:47:16 -0400 Subject: [PATCH 33/41] test(log): compute the expected source line instead of hard-coding it Signed-off-by: Deluan --- log/log_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/log/log_test.go b/log/log_test.go index 631b7c1aa..0e6628adb 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "runtime" "testing" "time" @@ -94,9 +96,9 @@ var _ = Describe("Logger", func() { It("logs source file and line number, if requested", func() { SetLogSourceLine(true) + _, _, line, _ := runtime.Caller(0) Error("A crash happened") - // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:97")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring(fmt.Sprintf("/log/log_test.go:%d", line+1))) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) From 062872100607e923e03c9426dd5befe89153e1cc Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 28 Sep 2026 21:47:16 -0400 Subject: [PATCH 34/41] refactor(api): simplify API v1 auth after dropping access tokens - Fold Allowed into Expand and drop the ErrInsufficientScope sentinel; the gate's scopeError is now the only source of insufficient_scope. - Replace the two-value authKind with a public flag, inline loadUser, and pass the grant to touch. - Read a declared request body once before validation, so the JSON checks and the handler no longer depend on kin-openapi restoring the exact bytes. - Merge the Service tests into one file and drop specs that only covered the removed liveness cache. The revoked-during-password-change spec now revokes after the gate authenticates, so it reaches ChangePassword again. Signed-off-by: Deluan --- core/apiauth/db_test.go | 6 +- core/apiauth/scopes.go | 7 +- core/apiauth/scopes_test.go | 10 - core/apiauth/service.go | 27 +-- core/apiauth/service_session_test.go | 338 --------------------------- core/apiauth/service_test.go | 315 ++++++++++++++++++++++--- server/apiv1/auth_handlers.go | 7 +- server/apiv1/auth_test.go | 23 +- server/apiv1/gate.go | 63 ++--- server/apiv1/problem.go | 19 +- server/apiv1/problem_test.go | 2 +- 11 files changed, 361 insertions(+), 456 deletions(-) delete mode 100644 core/apiauth/service_session_test.go diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go index 15b6cb115..5f54e6007 100644 --- a/core/apiauth/db_test.go +++ b/core/apiauth/db_test.go @@ -33,9 +33,9 @@ func createUser(ctx context.Context, password string, admin bool) model.User { return *stored } -// login signs in a user whose password is "pw" and authenticates with the new grant secret. -func login(ctx context.Context, svc *Service, u model.User) (*Issued, *Principal) { - issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) +// login signs u in (nil scopes asks for all) and authenticates with the new grant secret. +func login(ctx context.Context, svc *Service, u model.User, password string, scopes []string) (*Issued, *Principal) { + issued, err := svc.Login(ctx, u.UserName, password, meta, scopes) ExpectWithOffset(1, err).ToNot(HaveOccurred()) p, err := svc.Authenticate(ctx, issued.Secret, "") ExpectWithOffset(1, err).ToNot(HaveOccurred()) diff --git a/core/apiauth/scopes.go b/core/apiauth/scopes.go index a32c2f9f9..319269a00 100644 --- a/core/apiauth/scopes.go +++ b/core/apiauth/scopes.go @@ -53,12 +53,7 @@ func Expand(granted []string, isAdmin bool) []string { } out = append(out, s) } - return Allowed(out, isAdmin) -} - -// Allowed keeps the concrete scopes the user may hold now; unlike Expand it never widens `all`. -func Allowed(scopes []string, isAdmin bool) []string { - out := slices.DeleteFunc(slices.Clone(scopes), func(s string) bool { return !grantable(s, isAdmin) }) + out = slices.DeleteFunc(out, func(s string) bool { return !grantable(s, isAdmin) }) return normalize(out) } diff --git a/core/apiauth/scopes_test.go b/core/apiauth/scopes_test.go index 8b350f121..351f935e2 100644 --- a/core/apiauth/scopes_test.go +++ b/core/apiauth/scopes_test.go @@ -39,16 +39,6 @@ var _ = Describe("scopes", func() { }) }) - Describe("Allowed", func() { - It("never widens all", func() { - Expect(Allowed([]string{ScopeAll}, true)).To(BeEmpty()) - }) - It("keeps known scopes, and admin only for admins", func() { - Expect(Allowed([]string{"read", "retired", "admin"}, false)).To(Equal([]string{"read"})) - Expect(Allowed([]string{"read", "admin"}, true)).To(Equal([]string{"admin", "read"})) - }) - }) - Describe("Satisfies", func() { It("accepts the exact scope or its :write form", func() { Expect(Satisfies([]string{"read"}, "read")).To(BeTrue()) diff --git a/core/apiauth/service.go b/core/apiauth/service.go index c62005964..3ec70476f 100644 --- a/core/apiauth/service.go +++ b/core/apiauth/service.go @@ -21,7 +21,6 @@ const ( ) var ( - ErrInsufficientScope = errors.New("insufficient scope") ErrPasswordManagedExternally = errors.New("password is managed externally") ErrCurrentPasswordMismatch = errors.New("current password does not match") ) @@ -51,14 +50,13 @@ type Service struct { } func New(ds model.DataStore) *Service { - s := &Service{ + return &Service{ ds: ds, checkers: func(ds model.DataStore) []CredentialChecker { return []CredentialChecker{dbChecker{ds: ds}} }, now: time.Now, } - return s } func PasswordChangeable(u model.User) bool { @@ -126,7 +124,10 @@ func (s *Service) Authenticate(ctx context.Context, secret, ip string) (*Princip s.dropIdle(ctx, g.ID, idleSince) return nil, model.ErrInvalidAuth } - u, err := s.loadUser(ctx, g.UserID) + u, err := s.ds.User().Get(ctx, g.UserID) + if errors.Is(err, model.ErrNotFound) { + return nil, model.ErrInvalidAuth + } if err != nil { return nil, err } @@ -135,18 +136,10 @@ func (s *Service) Authenticate(ctx context.Context, secret, ip string) (*Princip return nil, err } } - s.touch(ctx, g.ID, ip, gg.V(g.LastUsedAt)) + s.touch(ctx, g, ip) return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil } -func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, error) { - u, err := s.ds.User().Get(ctx, userID) - if errors.Is(err, model.ErrNotFound) { - return nil, model.ErrInvalidAuth - } - return u, err -} - // dropIdle deletes only still-idle grants, sparing one renewed meanwhile. func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) { if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil { @@ -183,13 +176,13 @@ func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant } // touch writes last_used at most every touchInterval (zero lastUsed: never used); the SQL condition holds that across nodes. -func (s *Service) touch(ctx context.Context, id, ip string, lastUsed time.Time) { +func (s *Service) touch(ctx context.Context, g *model.Grant, ip string) { now := s.now() - if !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) { + if lastUsed := gg.V(g.LastUsedAt); !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) { return } - if err := s.ds.Grant().Touch(ctx, id, ip, now, now.Add(-touchInterval)); err != nil { - log.Warn(ctx, "API v1: could not record grant use", "grant", id, err) + if err := s.ds.Grant().Touch(ctx, g.ID, ip, now, now.Add(-touchInterval)); err != nil { + log.Warn(ctx, "API v1: could not record grant use", "grant", g.ID, err) } } diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go deleted file mode 100644 index cbd0f483e..000000000 --- a/core/apiauth/service_session_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package apiauth - -import ( - "context" - "errors" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Service: sessions", func() { - var ctx context.Context - var svc *Service - var now time.Time - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - now = time.Now().UTC().Truncate(time.Second) - svc = New(realDS) - svc.SetClock(func() time.Time { return now }) - }) - - Describe("Authenticate", func() { - It("rejects the secret at once after logout", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u) - Expect(svc.Logout(ctx, p)).To(Succeed()) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rejects the secret at once when another node revoked its grant", func() { - u := createUser(ctx, "pw", false) - issued, _ := login(ctx, svc, u) - Expect(realDS.Grant().DeleteForUser(ctx, u.ID, issued.Grant.ID)).To(Succeed()) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("kills grants when the password changes anywhere else", func() { - u := createUser(ctx, "pw", false) - issued, _ := login(ctx, svc, u) - u.NewPassword = "reset-by-admin" - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("does not kill a grant kept by a password change made through another node", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u) - - other := New(realDS) // another node - other.SetClock(func() time.Time { return now }) - Expect(other.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - }) - - It("does not delete a kept grant when the password changed between reading the grant and the user", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u) - racing := New(afterFindDS{DataStore: realDS, after: func() { - Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - }}) - racing.SetClock(func() time.Time { return now }) - - _, err := racing.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - _, err = realDS.Grant().Get(ctx, p.GrantID) - Expect(err).ToNot(HaveOccurred()) - }) - - It("drops admin from a grant once its user is no longer an admin", func() { - saved := KnownScopes - KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin} - DeferCleanup(func() { KnownScopes = saved }) - u := createUser(ctx, "pw", true) - issued, p := login(ctx, svc, u) - Expect(p.Scopes).To(ContainElement(ScopeAdmin)) - - u.IsAdmin = false - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - demoted, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) - }) - - It("rejects the secret after its user is deleted, and the grant row is gone", func() { - u := createUser(ctx, "pw", false) - issued, _ := login(ctx, svc, u) - Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed()) - _, err := realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("leaves a login that raced a password change with a dead grant", func() { - u := createUser(ctx, "pw", false) - reached, release := make(chan struct{}), make(chan struct{}) - svc.SetCheckers(func(ds model.DataStore) []CredentialChecker { - return []CredentialChecker{pausingChecker{inner: dbChecker{ds: ds}, reached: reached, release: release}} - }) - var issued *Issued - var loginErr error - done := make(chan struct{}) - go func() { - defer GinkgoRecover() - defer close(done) - issued, loginErr = svc.Login(ctx, u.UserName, "pw", meta, nil) - }() - <-reached // credentials (and the old epoch) were read - u.NewPassword = "changed-meanwhile" - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - close(release) - <-done - - Expect(loginErr).ToNot(HaveOccurred()) - _, err := New(realDS).Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - }) - - Describe("grant management", func() { - It("lists the user's grants and marks the current one", func() { - u := createUser(ctx, "pw", false) - first, _ := login(ctx, svc, u) - _, p := login(ctx, svc, u) - grants, total, err := svc.ListGrants(ctx, p, 0, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(Equal(int64(2))) - Expect(grants).To(HaveLen(2)) - Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID)) - }) - - It("lists only grants on the user's current epoch", func() { - u := createUser(ctx, "pw", false) - login(ctx, svc, u) - u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - issued, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil) - Expect(err).ToNot(HaveOccurred()) - p, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - - grants, total, err := svc.ListGrants(ctx, p, 0, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(Equal(int64(1))) - Expect(grants).To(HaveLen(1)) - Expect(grants[0].ID).To(Equal(issued.Grant.ID)) - }) - - It("logs out successfully when the grant is already gone", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u) - Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) // another node - - Expect(svc.Logout(ctx, p)).To(Succeed()) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("refuses to revoke another user's grant", func() { - alice := createUser(ctx, "pw", false) - bob := createUser(ctx, "pw", false) - aliceGrant, _ := login(ctx, svc, alice) - _, bobP := login(ctx, svc, bob) - Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound)) - _, err := svc.Authenticate(ctx, aliceGrant.Secret, "") - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects the secret at once after its grant is revoked", func() { - u := createUser(ctx, "pw", false) - other, _ := login(ctx, svc, u) - _, p := login(ctx, svc, u) - Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed()) - _, err := svc.Authenticate(ctx, other.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - }) - - Describe("ChangePassword", func() { - It("revokes other grants by default and keeps the caller's", func() { - u := createUser(ctx, "pw", false) - other, _ := login(ctx, svc, u) - mine, p := login(ctx, svc, u) - Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed()) - - _, err := svc.Authenticate(ctx, mine.Secret, "") - Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, other.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - - _, err = svc.Login(ctx, u.UserName, "pw2", meta, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("keeps every grant when revokeOthers is false", func() { - u := createUser(ctx, "pw", false) - other, _ := login(ctx, svc, u) - _, p := login(ctx, svc, u) - Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - _, err := svc.Authenticate(ctx, other.Secret, "") - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects a wrong current password without changing anything", func() { - u := createUser(ctx, "pw", false) - _, p := login(ctx, svc, u) - err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true) - Expect(err).To(MatchError(ErrCurrentPasswordMismatch)) - _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("is forbidden for non-admins when user editing is off", func() { - conf.Server.EnableUserEditing = false - u := createUser(ctx, "pw", false) - _, p := login(ctx, svc, u) - err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - - It("does not revive grants killed by an earlier reset when keeping grants", func() { - u := createUser(ctx, "pw", false) - killed, _ := login(ctx, svc, u) - u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - - issued2, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil) - Expect(err).ToNot(HaveOccurred()) - p2, err := svc.Authenticate(ctx, issued2.Secret, "") - Expect(err).ToNot(HaveOccurred()) - Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed()) - - _, err = svc.Authenticate(ctx, killed.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rejects a caller whose grant was revoked before the change ran", func() { - u := createUser(ctx, "pw", false) - _, p := login(ctx, svc, u) - Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) - err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects a caller naming another user's grant", func() { - alice := createUser(ctx, "pw", false) - bob := createUser(ctx, "pw", false) - _, aliceP := login(ctx, svc, alice) - bobGrant, _ := login(ctx, svc, bob) - forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID} - err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rolls back the password and epoch when a grant update fails", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u) - failing := New(failingEpochDS{realDS}) - failing.SetClock(func() time.Time { return now }) - - err := failing.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) - Expect(err).To(MatchError(ContainSubstring("boom"))) - - reloaded, _ := realDS.User().Get(ctx, u.ID) - Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch)) - _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) - -// afterFindDS calls after between finding the grant by its secret and reading its user. -type afterFindDS struct { - model.DataStore - after func() -} - -func (d afterFindDS) Grant() model.GrantRepository { - return afterFindGrants{GrantRepository: d.DataStore.Grant(), after: d.after} -} - -type afterFindGrants struct { - model.GrantRepository - after func() -} - -func (g afterFindGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) { - found, err := g.GrantRepository.FindBySecretHash(ctx, hash) - g.after() - return found, err -} - -type pausingChecker struct { - inner CredentialChecker - reached, release chan struct{} -} - -func (c pausingChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) { - res, err := c.inner.Check(ctx, username, password) - close(c.reached) - <-c.release - return res, err -} - -// failingEpochDS makes SetEpoch fail inside WithTxImmediate, to prove the whole change rolls back. -type failingEpochDS struct{ model.DataStore } - -func (f failingEpochDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { - return f.DataStore.WithTxImmediate(func(tx model.DataStore) error { - return block(failingEpochTx{tx}) - }, scope...) -} - -type failingEpochTx struct{ model.DataStore } - -func (f failingEpochTx) Grant() model.GrantRepository { return failingGrants{f.DataStore.Grant()} } - -type failingGrants struct{ model.GrantRepository } - -func (failingGrants) SetEpoch(context.Context, string, int, int, string) error { - return errors.New("boom") -} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go index ca86cafb1..6482122c7 100644 --- a/core/apiauth/service_test.go +++ b/core/apiauth/service_test.go @@ -2,6 +2,7 @@ package apiauth import ( "context" + "errors" "strings" "time" @@ -9,33 +10,14 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// renewingDS runs renew right before DeleteIdle, as a node resolving the grant meanwhile would. -type renewingDS struct { - model.DataStore - renew func() -} - -func (d renewingDS) Grant() model.GrantRepository { - return renewingGrants{GrantRepository: d.DataStore.Grant(), renew: d.renew} -} - -type renewingGrants struct { - model.GrantRepository - renew func() -} - -func (g renewingGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) { - g.renew() - return g.GrantRepository.DeleteIdle(ctx, idleSince) -} - var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} -var _ = Describe("Service: grants", func() { +var _ = Describe("Service", func() { var ctx context.Context var svc *Service var now time.Time @@ -106,10 +88,7 @@ var _ = Describe("Service: grants", func() { Describe("Authenticate", func() { It("resolves the secret to its user and the grant's expanded scopes", func() { u := createUser(ctx, "pw", false) - issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - p, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9") - Expect(err).ToNot(HaveOccurred()) + issued, p := login(ctx, svc, u, "pw", nil) Expect(p.User.ID).To(Equal(u.ID)) Expect(p.GrantID).To(Equal(issued.Grant.ID)) Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) @@ -117,10 +96,7 @@ var _ = Describe("Service: grants", func() { It("carries only the scopes stored on a narrow grant", func() { u := createUser(ctx, "pw", false) - issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{ScopePassword}) - Expect(err).ToNot(HaveOccurred()) - p, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) + _, p := login(ctx, svc, u, "pw", []string{ScopePassword}) Expect(p.Scopes).To(Equal([]string{ScopePassword})) }) @@ -169,11 +145,11 @@ var _ = Describe("Service: grants", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) - It("keeps an idle grant that another node renewed before the delete ran", func() { + It("keeps an idle grant that a concurrent request renewed before the delete ran", func() { u := createUser(ctx, "pw", false) issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) renewedAt := now.Add(IdleExpiry - time.Minute) - racing := New(renewingDS{DataStore: realDS, renew: func() { + racing := New(hookDS{DataStore: realDS, beforeDeleteIdle: func() { Expect(realDS.Grant().Touch(ctx, issued.Grant.ID, "10.0.0.2", renewedAt, renewedAt)).To(Succeed()) }}) now = now.Add(IdleExpiry + time.Second) @@ -196,6 +172,221 @@ var _ = Describe("Service: grants", func() { _, err = realDS.Grant().Get(ctx, issued.Grant.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("does not delete a kept grant when the password changed between reading the grant and the user", func() { + u := createUser(ctx, "pw", false) + issued, p := login(ctx, svc, u, "pw", nil) + racing := New(hookDS{DataStore: realDS, afterFind: func() { + Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) + }}) + racing.SetClock(func() time.Time { return now }) + + _, err := racing.Authenticate(ctx, issued.Secret, "") + Expect(err).ToNot(HaveOccurred()) + _, err = realDS.Grant().Get(ctx, p.GrantID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("drops admin from a grant once its user is no longer an admin", func() { + saved := KnownScopes + KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin} + DeferCleanup(func() { KnownScopes = saved }) + u := createUser(ctx, "pw", true) + issued, p := login(ctx, svc, u, "pw", nil) + Expect(p.Scopes).To(ContainElement(ScopeAdmin)) + + u.IsAdmin = false + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + demoted, err := svc.Authenticate(ctx, issued.Secret, "") + Expect(err).ToNot(HaveOccurred()) + Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) + }) + + It("rejects the secret after its user is deleted, and the grant row is gone", func() { + u := createUser(ctx, "pw", false) + issued, _ := login(ctx, svc, u, "pw", nil) + Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed()) + _, err := realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = svc.Authenticate(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("leaves a login that raced a password change with a dead grant", func() { + u := createUser(ctx, "pw", false) + reached, release := make(chan struct{}), make(chan struct{}) + svc.SetCheckers(func(ds model.DataStore) []CredentialChecker { + return []CredentialChecker{pausingChecker{inner: dbChecker{ds: ds}, reached: reached, release: release}} + }) + var issued *Issued + var loginErr error + done := make(chan struct{}) + go func() { + defer GinkgoRecover() + defer close(done) + issued, loginErr = svc.Login(ctx, u.UserName, "pw", meta, nil) + }() + <-reached // credentials (and the old epoch) were read + u.NewPassword = "changed-meanwhile" + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + close(release) + <-done + + Expect(loginErr).ToNot(HaveOccurred()) + _, err := svc.Authenticate(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Describe("grant management", func() { + It("lists the user's grants and marks the current one", func() { + u := createUser(ctx, "pw", false) + first, _ := login(ctx, svc, u, "pw", nil) + _, p := login(ctx, svc, u, "pw", nil) + grants, total, err := svc.ListGrants(ctx, p, 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(Equal(int64(2))) + Expect(grants).To(HaveLen(2)) + Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID)) + }) + + It("lists only grants on the user's current epoch", func() { + u := createUser(ctx, "pw", false) + login(ctx, svc, u, "pw", nil) + u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + issued, p := login(ctx, svc, u, "reset-by-admin", nil) + + grants, total, err := svc.ListGrants(ctx, p, 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(Equal(int64(1))) + Expect(grants).To(HaveLen(1)) + Expect(grants[0].ID).To(Equal(issued.Grant.ID)) + }) + + It("logs out, and succeeds again when the grant is already gone", func() { + u := createUser(ctx, "pw", false) + issued, p := login(ctx, svc, u, "pw", nil) + Expect(svc.Logout(ctx, p)).To(Succeed()) + _, err := svc.Authenticate(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + + Expect(svc.Logout(ctx, p)).To(Succeed()) + }) + + It("refuses to revoke another user's grant", func() { + alice := createUser(ctx, "pw", false) + bob := createUser(ctx, "pw", false) + aliceGrant, _ := login(ctx, svc, alice, "pw", nil) + _, bobP := login(ctx, svc, bob, "pw", nil) + Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound)) + _, err := svc.Authenticate(ctx, aliceGrant.Secret, "") + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects the secret after its grant is revoked", func() { + u := createUser(ctx, "pw", false) + other, _ := login(ctx, svc, u, "pw", nil) + _, p := login(ctx, svc, u, "pw", nil) + Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed()) + _, err := svc.Authenticate(ctx, other.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Describe("ChangePassword", func() { + It("revokes other grants by default and keeps the caller's", func() { + u := createUser(ctx, "pw", false) + other, _ := login(ctx, svc, u, "pw", nil) + mine, p := login(ctx, svc, u, "pw", nil) + Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed()) + + _, err := svc.Authenticate(ctx, mine.Secret, "") + Expect(err).ToNot(HaveOccurred()) + _, err = svc.Authenticate(ctx, other.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + + _, err = svc.Login(ctx, u.UserName, "pw2", meta, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("keeps every grant, the caller's included, when revokeOthers is false", func() { + u := createUser(ctx, "pw", false) + other, _ := login(ctx, svc, u, "pw", nil) + mine, p := login(ctx, svc, u, "pw", nil) + Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) + _, err := svc.Authenticate(ctx, other.Secret, "") + Expect(err).ToNot(HaveOccurred()) + _, err = svc.Authenticate(ctx, mine.Secret, "") + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects a wrong current password without changing anything", func() { + u := createUser(ctx, "pw", false) + _, p := login(ctx, svc, u, "pw", nil) + err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true) + Expect(err).To(MatchError(ErrCurrentPasswordMismatch)) + _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("is forbidden for non-admins when user editing is off", func() { + conf.Server.EnableUserEditing = false + u := createUser(ctx, "pw", false) + _, p := login(ctx, svc, u, "pw", nil) + err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("does not revive grants killed by an earlier reset when keeping grants", func() { + u := createUser(ctx, "pw", false) + killed, _ := login(ctx, svc, u, "pw", nil) + u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + + _, p2 := login(ctx, svc, u, "reset-by-admin", nil) + Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed()) + + _, err := svc.Authenticate(ctx, killed.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rejects a caller whose grant was revoked before the change ran", func() { + u := createUser(ctx, "pw", false) + _, p := login(ctx, svc, u, "pw", nil) + Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) + err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects a caller naming another user's grant", func() { + alice := createUser(ctx, "pw", false) + bob := createUser(ctx, "pw", false) + _, aliceP := login(ctx, svc, alice, "pw", nil) + bobGrant, _ := login(ctx, svc, bob, "pw", nil) + forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID} + err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rolls back the password and epoch when a grant update fails", func() { + u := createUser(ctx, "pw", false) + issued, p := login(ctx, svc, u, "pw", nil) + failing := New(failingEpochDS{realDS}) + failing.SetClock(func() time.Time { return now }) + + err := failing.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) + Expect(err).To(MatchError(ContainSubstring("boom"))) + + reloaded, _ := realDS.User().Get(ctx, u.ID) + Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch)) + _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + _, err = svc.Authenticate(ctx, issued.Secret, "") + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("PasswordChangeable", func() { @@ -208,3 +399,65 @@ var _ = Describe("Service: grants", func() { }) }) }) + +// hookDS runs its optional callbacks inside grant lookups, to land a concurrent change mid-Authenticate. +type hookDS struct { + model.DataStore + afterFind func() + beforeDeleteIdle func() +} + +func (d hookDS) Grant() model.GrantRepository { + return hookGrants{GrantRepository: d.DataStore.Grant(), hooks: d} +} + +type hookGrants struct { + model.GrantRepository + hooks hookDS +} + +func (g hookGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) { + found, err := g.GrantRepository.FindBySecretHash(ctx, hash) + if g.hooks.afterFind != nil { + g.hooks.afterFind() + } + return found, err +} + +func (g hookGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) { + if g.hooks.beforeDeleteIdle != nil { + g.hooks.beforeDeleteIdle() + } + return g.GrantRepository.DeleteIdle(ctx, idleSince) +} + +type pausingChecker struct { + inner CredentialChecker + reached, release chan struct{} +} + +func (c pausingChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) { + res, err := c.inner.Check(ctx, username, password) + close(c.reached) + <-c.release + return res, err +} + +// failingEpochDS makes SetEpoch fail inside WithTxImmediate, to prove the whole change rolls back. +type failingEpochDS struct{ model.DataStore } + +func (f failingEpochDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { + return f.DataStore.WithTxImmediate(func(tx model.DataStore) error { + return block(failingEpochTx{tx}) + }, scope...) +} + +type failingEpochTx struct{ model.DataStore } + +func (f failingEpochTx) Grant() model.GrantRepository { return failingGrants{f.DataStore.Grant()} } + +type failingGrants struct{ model.GrantRepository } + +func (failingGrants) SetEpoch(context.Context, string, int, int, string) error { + return errors.New("boom") +} diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go index 768cda508..cf07eb385 100644 --- a/server/apiv1/auth_handlers.go +++ b/server/apiv1/auth_handlers.go @@ -4,7 +4,9 @@ import ( "cmp" "context" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/slice" ) const defaultPageSize = 100 @@ -20,10 +22,7 @@ func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) ( if err != nil { return nil, err } - items := make([]Grant, len(grants)) - for i, g := range grants { - items[i] = toGrant(g, p.GrantID) - } + items := slice.Map(grants, func(g model.Grant) Grant { return toGrant(g, p.GrantID) }) return ListGrants200JSONResponse{Items: items, Total: int(total), Offset: offset, Limit: limit}, nil } diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index bc3b735f4..48c2748ad 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -112,7 +113,7 @@ var _ = Describe("auth endpoints", func() { Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.Secret, nil).Code).To(Equal(http.StatusForbidden)) }) - It("logs out: the secret stops at once and logoutUrl is null", func() { + It("logs out: the secret stops working and logoutUrl is null", func() { gc := api.setup() w := api.call(http.MethodPost, "/api/v1/auth/logout", gc.Secret, nil) Expect(w.Code).To(Equal(http.StatusOK)) @@ -123,7 +124,7 @@ var _ = Describe("auth endpoints", func() { Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) }) - It("revokes another grant of the caller, whose secret then stops at once", func() { + It("revokes another grant of the caller, whose secret then stops working", func() { gc := api.setup() other := api.login(nil) Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+other.Grant.Id, gc.Secret, nil).Code).To(Equal(http.StatusNoContent)) @@ -133,11 +134,14 @@ var _ = Describe("auth endpoints", func() { It("challenges with invalid_token when the grant is revoked while a password change runs", func() { gc := api.setup() - Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed()) + revoking := testClient{ctx: ctx, router: New(beforeTxDS{DataStore: realDS, before: func() { + Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed()) + }})} - w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + w := revoking.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String()) Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) + api.login(nil) }) It("rejects a case-variant scopes key that would widen an explicit empty subset", func() { @@ -270,3 +274,14 @@ var _ = Describe("auth endpoints", func() { Entry("with no declared length", func(s string) io.Reader { return io.MultiReader(strings.NewReader(s)) }), ) }) + +// beforeTxDS calls before as each immediate transaction starts; authentication opens none, so it lands after the gate. +type beforeTxDS struct { + model.DataStore + before func() +} + +func (d beforeTxDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { + d.before() + return d.DataStore.WithTxImmediate(block, scope...) +} diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go index 550705c2c..9d6c20716 100644 --- a/server/apiv1/gate.go +++ b/server/apiv1/gate.go @@ -31,16 +31,9 @@ type authenticator interface { Authenticate(ctx context.Context, secret, ip string) (*apiauth.Principal, error) } -type authKind int - -const ( - authPublic authKind = iota - authBearer -) - type gateOp struct { route *routers.Route - kind authKind + public bool scope string limited bool noStore bool @@ -131,17 +124,16 @@ func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method s module, _ := op.Extensions["x-module"].(string) switch reqs := *op.Security; { case len(reqs) == 0: - gop.kind = authPublic + gop.public = true case len(reqs) == 1 && isScheme(reqs[0], "bearerAuth"): - gop.kind = authBearer default: return nil, fmt.Errorf("operation %s has a security requirement outside the allowed forms", id) } - if gop.kind == authBearer && scope == "" && !rules.noScope[id] { + if !gop.public && scope == "" && !rules.noScope[id] { return nil, fmt.Errorf("operation %s: bearerAuth needs x-scope", id) } if scope != "" { - if gop.kind != authBearer { + if gop.public { return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id) } base := cmp.Or(moduleScope[module], module) @@ -218,7 +210,7 @@ func (g *gate) handler(next http.Handler) http.Handler { } func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*http.Request, bool) { - if op.kind == authPublic { + if op.public { return r, true } secret, ok := bearerToken(r) @@ -227,13 +219,14 @@ func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*h return r, false } p, err := g.auth.Authenticate(r.Context(), secret, server.ClientAddr(r)) - if err == nil && op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { - err = &scopeError{scope: op.scope} - } if err != nil { writeProblem(w, r, err) return r, false } + if op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { + writeProblem(w, r, &scopeError{scope: op.scope}) + return r, false + } ctx := apiauth.WithPrincipal(request.WithUser(r.Context(), p.User), p) return r.WithContext(ctx), true } @@ -247,7 +240,7 @@ func bearerToken(r *http.Request) (string, bool) { return token, true } -// SkipSettingDefaults: filling defaults re-encodes the body, which hides trailing data from jsonBodyFields. +// SkipSettingDefaults: the handlers apply defaults themselves, and the validator must not rewrite the body. var validationOptions = &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true, SkipSettingDefaults: true} func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx *chi.Context) bool { @@ -255,9 +248,15 @@ func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx for i, k := range rctx.URLParams.Keys { params[k] = rctx.URLParams.Values[i] } - err := openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ - Request: r, PathParams: params, Route: op.route, Options: validationOptions, - }) + body, err := readBody(r, op.route.Operation) + if err == nil { + err = openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ + Request: r, PathParams: params, Route: op.route, Options: validationOptions, + }) + if body != nil { + r.Body = io.NopCloser(bytes.NewReader(body)) + } + } if tooLarge(err) { writeProblem(w, r, ClientError(err, tooLargeDetail)) return false @@ -265,7 +264,7 @@ func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx var fields []ValidationError if err != nil { fields = sanitizeValidation(err) - } else if fields = jsonBodyFields(r, op.route.Operation); len(fields) == 0 { + } else if fields = jsonBodyFields(body, op.route.Operation); len(fields) == 0 { return true } log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id(), "errors", fields) @@ -273,10 +272,23 @@ func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx return false } +// readBody reads a declared body once, so the validator, the JSON checks and the handler all see the same bytes. +func readBody(r *http.Request, op *openapi3.Operation) ([]byte, error) { + if op.RequestBody == nil || r.Body == nil || r.Body == http.NoBody { + return nil, nil + } + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewReader(data)) + return data, nil +} + // jsonBodyFields checks what kin-openapi misses in a JSON body: data after the first value, which Go's decoder // ignores, and keys that only case-fold to a declared property, which encoding/json decodes into that property. -func jsonBodyFields(r *http.Request, op *openapi3.Operation) []ValidationError { - if op.RequestBody == nil || op.RequestBody.Value == nil || r.Body == nil { +func jsonBodyFields(data []byte, op *openapi3.Operation) []ValidationError { + if op.RequestBody == nil || op.RequestBody.Value == nil || len(bytes.TrimSpace(data)) == 0 { return nil } // Keyed on the spec, not the request's Content-Type: the handlers decode JSON whatever the header says. @@ -284,11 +296,6 @@ func jsonBodyFields(r *http.Request, op *openapi3.Operation) []ValidationError { if media == nil || media.Schema == nil { return nil } - data, err := io.ReadAll(r.Body) - r.Body = io.NopCloser(bytes.NewReader(data)) - if err != nil || len(bytes.TrimSpace(data)) == 0 { - return nil - } dec := json.NewDecoder(bytes.NewReader(data)) var body any if err := dec.Decode(&body); err != nil { diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go index 11dddf1f4..fe5c8169b 100644 --- a/server/apiv1/problem.go +++ b/server/apiv1/problem.go @@ -32,8 +32,7 @@ type scopeError struct { scope string } -func (e *scopeError) Error() string { return apiauth.ErrInsufficientScope.Error() } -func (e *scopeError) Unwrap() error { return apiauth.ErrInsufficientScope } +func (e *scopeError) Error() string { return "insufficient scope" } const tooLargeDetail = "request body too large" @@ -60,8 +59,9 @@ func writeProblem(w http.ResponseWriter, r *http.Request, err error) { return } log.Debug(r.Context(), "API v1: request failed", "path", r.URL.Path, "status", status, "code", code, err) - if code == ProblemCodeInsufficientScope { - w.Header().Set("WWW-Authenticate", scopeChallenge(err)) + var se *scopeError + if errors.As(err, &se) { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q`, se.scope)) } var detail string var ce *clientError @@ -76,20 +76,11 @@ func writeProblem(w http.ResponseWriter, r *http.Request, err error) { writeProblemStatus(w, r, status, code, detail) } -func scopeChallenge(err error) string { - challenge := `Bearer error="insufficient_scope"` - var se *scopeError - if errors.As(err, &se) && se.scope != "" { - challenge += fmt.Sprintf(`, scope=%q`, se.scope) - } - return challenge -} - func classifyError(err error) (int, ProblemCode) { switch { case tooLarge(err): return http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge - case errors.Is(err, apiauth.ErrInsufficientScope): + case errors.As(err, new(*scopeError)): return http.StatusForbidden, ProblemCodeInsufficientScope case errors.Is(err, auth.ErrSetupComplete): return http.StatusConflict, ProblemCodeSetupComplete diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go index e0e501989..95ee46668 100644 --- a/server/apiv1/problem_test.go +++ b/server/apiv1/problem_test.go @@ -51,7 +51,7 @@ var _ = Describe("problem", func() { Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized), Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation), Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable), - Entry("insufficient scope", apiauth.ErrInsufficientScope, http.StatusForbidden, ProblemCodeInsufficientScope), + Entry("insufficient scope", &scopeError{scope: "read"}, http.StatusForbidden, ProblemCodeInsufficientScope), Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete), Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally), Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal), From cbc22b09cbdb3048cb685d4e320e155830ade797 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 28 Sep 2026 22:08:01 -0400 Subject: [PATCH 35/41] refactor(api): group API v1 handlers into one file per OpenAPI tag Handlers live in _handlers.go, so the package grows by tag rather than by endpoint, and shared convention helpers keep plain names without clashing with tag files. Signed-off-by: Deluan --- server/apiv1/auth_handlers.go | 41 ++++++++++++++++ .../{auth_test.go => auth_handlers_test.go} | 0 server/apiv1/capabilities.go | 10 ---- server/apiv1/capabilities_test.go | 40 ---------------- server/apiv1/password_handlers.go | 47 ------------------- .../{server_info.go => server_handlers.go} | 7 +++ ...r_info_test.go => server_handlers_test.go} | 31 ++++++++++++ 7 files changed, 79 insertions(+), 97 deletions(-) rename server/apiv1/{auth_test.go => auth_handlers_test.go} (100%) delete mode 100644 server/apiv1/capabilities.go delete mode 100644 server/apiv1/capabilities_test.go delete mode 100644 server/apiv1/password_handlers.go rename server/apiv1/{server_info.go => server_handlers.go} (69%) rename server/apiv1/{server_info_test.go => server_handlers_test.go} (68%) diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go index cf07eb385..eec4a3dae 100644 --- a/server/apiv1/auth_handlers.go +++ b/server/apiv1/auth_handlers.go @@ -3,7 +3,9 @@ package apiv1 import ( "cmp" "context" + "errors" + "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" @@ -11,6 +13,45 @@ import ( const defaultPageSize = 100 +// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same. +func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) { + b := *req.Body + issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) + if err != nil { + return nil, err + } + return Login200JSONResponse{Body: toGrantCreated(issued)}, nil +} + +func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) { + b := *req.Body + issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) + if err != nil { + return nil, err + } + return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil +} + +func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) { + p, err := principalFrom(ctx) + if err != nil { + return nil, err + } + b := *req.Body + revoke := true + if b.RevokeOtherGrants != nil { + revoke = *b.RevokeOtherGrants + } + err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke) + if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) { + return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}) + } + if err != nil { + return nil, err + } + return ChangePassword204Response{}, nil +} + func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) { p, err := principalFrom(ctx) if err != nil { diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_handlers_test.go similarity index 100% rename from server/apiv1/auth_test.go rename to server/apiv1/auth_handlers_test.go diff --git a/server/apiv1/capabilities.go b/server/apiv1/capabilities.go deleted file mode 100644 index f3b8c78c7..000000000 --- a/server/apiv1/capabilities.go +++ /dev/null @@ -1,10 +0,0 @@ -package apiv1 - -import "context" - -func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) { - return GetCapabilities200JSONResponse{ - Core: &CoreCapability{Version: 1}, - Password: &PasswordCapability{Version: 1}, - }, nil -} diff --git a/server/apiv1/capabilities_test.go b/server/apiv1/capabilities_test.go deleted file mode 100644 index e14248513..000000000 --- a/server/apiv1/capabilities_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package apiv1 - -import ( - "context" - "net/http" - - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("GET /capabilities", func() { - var ctx context.Context - var api testClient - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - resetDB() - api = testClient{ctx: ctx, router: New(realDS)} - }) - - It("needs a grant", func() { - w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("lists core and password for any valid grant, even one with no scopes", func() { - api.setup() - gc := api.login([]string{}) - Expect(gc.Grant.Scopes).To(BeEmpty()) - - w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - var caps Capabilities - decodeJSON(w, &caps) - Expect(caps.Core.Version).To(Equal(1)) - Expect(caps.Password.Version).To(Equal(1)) - }) -}) diff --git a/server/apiv1/password_handlers.go b/server/apiv1/password_handlers.go deleted file mode 100644 index 32cf09e87..000000000 --- a/server/apiv1/password_handlers.go +++ /dev/null @@ -1,47 +0,0 @@ -package apiv1 - -import ( - "context" - "errors" - - "github.com/navidrome/navidrome/core/apiauth" -) - -// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same. -func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) { - b := *req.Body - issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) - if err != nil { - return nil, err - } - return Login200JSONResponse{Body: toGrantCreated(issued)}, nil -} - -func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) { - b := *req.Body - issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) - if err != nil { - return nil, err - } - return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil -} - -func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) { - p, err := principalFrom(ctx) - if err != nil { - return nil, err - } - b := *req.Body - revoke := true - if b.RevokeOtherGrants != nil { - revoke = *b.RevokeOtherGrants - } - err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke) - if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) { - return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}) - } - if err != nil { - return nil, err - } - return ChangePassword204Response{}, nil -} diff --git a/server/apiv1/server_info.go b/server/apiv1/server_handlers.go similarity index 69% rename from server/apiv1/server_info.go rename to server/apiv1/server_handlers.go index d7d46175a..f5c159629 100644 --- a/server/apiv1/server_info.go +++ b/server/apiv1/server_handlers.go @@ -21,3 +21,10 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}}, }, nil } + +func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) { + return GetCapabilities200JSONResponse{ + Core: &CoreCapability{Version: 1}, + Password: &PasswordCapability{Version: 1}, + }, nil +} diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_handlers_test.go similarity index 68% rename from server/apiv1/server_info_test.go rename to server/apiv1/server_handlers_test.go index 3d6c938d9..59d0dcb3d 100644 --- a/server/apiv1/server_info_test.go +++ b/server/apiv1/server_handlers_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -60,3 +61,33 @@ var _ = Describe("GET /server", func() { Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal)) }) }) + +var _ = Describe("GET /capabilities", func() { + var ctx context.Context + var api testClient + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + resetDB() + api = testClient{ctx: ctx, router: New(realDS)} + }) + + It("needs a grant", func() { + w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("lists core and password for any valid grant, even one with no scopes", func() { + api.setup() + gc := api.login([]string{}) + Expect(gc.Grant.Scopes).To(BeEmpty()) + + w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil) + Expect(w.Code).To(Equal(http.StatusOK)) + var caps Capabilities + decodeJSON(w, &caps) + Expect(caps.Core.Version).To(Equal(1)) + Expect(caps.Password.Version).To(Equal(1)) + }) +}) From 58090b42ec7568fdf1c36feeb4c56d8327d32232 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 29 Sep 2026 10:37:53 -0400 Subject: [PATCH 36/41] fix(log): redact marked secrets in every field type redactSecrets only looked at strings, maps and errors, so a marked secret inside a slice, struct or []byte field was logged as is, and a typed-nil error field made it panic. It now renders each field with fmt.Sprint, as the text formatter does (which also survives typed-nil errors), and writes []byte raw. Signed-off-by: Deluan --- log/redactrus.go | 14 ++++---------- log/redactrus_test.go | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/log/redactrus.go b/log/redactrus.go index a9cf5a981..b8ad0ddb4 100755 --- a/log/redactrus.go +++ b/log/redactrus.go @@ -83,18 +83,12 @@ func redactSecrets(e *logrus.Entry) { if v == nil { continue } + // fmt.Sprint renders like the text formatter and survives typed-nil errors; []byte is written raw. var s string - if err, ok := v.(error); ok { - s = err.Error() + if b, ok := v.([]byte); ok { + s = string(b) } else { - switch reflect.TypeOf(v).Kind() { - case reflect.String: - s = reflect.ValueOf(v).String() - case reflect.Map: - s = fmt.Sprintf("%+v", v) - default: - continue - } + s = fmt.Sprint(v) } if hidden := hide(s); hidden != s { e.Data[k] = hidden diff --git a/log/redactrus_test.go b/log/redactrus_test.go index 6b8a0e5fe..6b9d71f89 100755 --- a/log/redactrus_test.go +++ b/log/redactrus_test.go @@ -2,6 +2,7 @@ package log import ( "errors" + "net/url" "testing" "github.com/sirupsen/logrus" @@ -196,6 +197,26 @@ func TestFireRedactsContextSecrets(t *testing.T) { assert.Equal(t, namedString("untouched"), e.Data["clean"]) } +func TestFireRedactsContextSecretsInAnyValueType(t *testing.T) { + ctx := WithSecrets(t.Context(), "s3cr3t-value") + var nilErr *url.Error + e := &logrus.Entry{ + Context: ctx, + Data: logrus.Fields{ + "slice": []any{"s3cr3t-value", 1}, + "struct": struct{ A string }{"s3cr3t-value"}, + "bytes": []byte("has s3cr3t-value"), + "nilErr": nilErr, + }, + } + + assert.NotPanics(t, func() { _ = (&Hook{}).Fire(e) }) + assert.Equal(t, "[[REDACTED] 1]", e.Data["slice"]) + assert.Equal(t, "{[REDACTED]}", e.Data["struct"]) + assert.Equal(t, "has [REDACTED]", e.Data["bytes"]) + assert.Equal(t, nilErr, e.Data["nilErr"]) +} + func TestFireWithoutContextSecretsLeavesEntryUnchanged(t *testing.T) { args := map[string]any{"p0": "value"} e := &logrus.Entry{Context: t.Context(), Message: "value", Data: logrus.Fields{"str": "value", "args": args}} From 0e1893530b844898cbf87825fdefd4b13e1ff3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 29 Sep 2026 13:23:36 -0400 Subject: [PATCH 37/41] fix(ui): don't crash the playlist list when rows lose their record (#6250) * fix(ui): don't crash playlist list rows that lost their record react-admin 3 evicts records fetched more than 10 minutes ago whenever another getList for the same resource completes, but the list keeps its cached ids. The Datagrid then renders those rows with an undefined record, and the Public and Auto-import switches crashed reading record.id. This happened when the playlist list was left open and the sidebar or the add to playlist dialog reloaded a smaller set of playlists. Both switches now render nothing when the row has no record; the next list refresh fills the row in again. * refactor(ui): merge playlist list toggles into one ToggleField The Public and Auto-import switches were copies that differed only in the field they flip. ToggleField now flips its source field, and ToggleAutoImport just shows it for playlists that have a file path. The tests render inside TestContext, so they use react-admin's real hooks instead of mocks. --- ui/src/playlist/PlaylistList.jsx | 46 ++++++--------------------- ui/src/playlist/PlaylistList.test.jsx | 25 ++++++++++++++- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index d2b17b108..14d819a4e 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -67,15 +67,15 @@ const PlaylistFilter = (props) => { ) } -const TogglePublicInput = ({ resource, source }) => { +export const ToggleField = ({ resource, source }) => { const record = useRecordContext() const notify = useNotify() - const [togglePublic] = useUpdate( + const [toggle] = useUpdate( resource, - record.id, + record?.id, { ...record, - public: !record.public, + [source]: !record?.[source], }, { undoable: false, @@ -86,10 +86,12 @@ const TogglePublicInput = ({ resource, source }) => { ) const handleClick = (e) => { - togglePublic() + toggle() e.stopPropagation() } + if (!record) return null + return ( { ) } -const ToggleAutoImport = ({ resource, source }) => { +export const ToggleAutoImport = (props) => { const record = useRecordContext() - const notify = useNotify() - const [ToggleAutoImport] = useUpdate( - resource, - record.id, - { - ...record, - sync: !record.sync, - }, - { - undoable: false, - onFailure: (error) => { - notify('ra.page.error', 'warning') - }, - }, - ) - const handleClick = (e) => { - ToggleAutoImport() - e.stopPropagation() - } - - return record.path ? ( - - ) : null + return record?.path ? : null } const PlaylistListBulkActions = (props) => { @@ -169,9 +145,7 @@ const PlaylistList = (props) => { updatedAt: isDesktop && ( ), - public: !isXsmall && ( - - ), + public: !isXsmall && , comment: , sync: !isXsmall && ( diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx index 4fbc6d516..6c714b827 100644 --- a/ui/src/playlist/PlaylistList.test.jsx +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -1,7 +1,8 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' -import { PlaylistLove } from './PlaylistList' +import { TestContext } from 'ra-test' +import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' vi.mock('../config', () => ({ default: { enableFavourites: true }, @@ -32,3 +33,25 @@ describe('', () => { }) }) }) + +// react-admin evicts records older than 10 minutes while the list still holds +// their ids, so rows can render with no record. +describe('playlist toggles without a record', () => { + it(' renders nothing', () => { + const { container } = render( + + + , + ) + expect(container.innerHTML).toBe('') + }) + + it(' renders nothing', () => { + const { container } = render( + + + , + ) + expect(container.innerHTML).toBe('') + }) +}) From 758e64c9996e6aa01e8428fb587292873f9f181f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 2 Oct 2026 05:05:32 -0400 Subject: [PATCH 38/41] feat(scanner): per-library PID configuration (#6252) * feat(model): add per-library PID config columns * refactor(metadata): pass PID config to ToMediaFile and add spec validation * feat(scanner): rescan only libraries whose PID config changed * feat(server): validate library PID config and rescan on change * feat(ui): edit per-library PID config * fix(ui): label the PID mode selects * fix: tighten per-library PID rescan edge cases An interrupted PID rescan no longer upgrades every library to a full scan, a save that loses the race for the scanner logs at debug, the confirm dialog only shows when the effective PID spec changes, and it now gets translation keys. * refactor(metadata): pass the library to ToMediaFile ToMediaFile and core.Inspect took the library ID and its PID config as separate arguments, so a caller could mix values from two libraries. They now take the model.Library and resolve the effective PID config from it. * chore: tidy per-library PID comments, PropTypes and migration Trim comments that restated the code, add PropTypes to the new UI components, and recreate the migration with make migration-sql. * fix(ui): show the PID spec help under its input * feat(cmd): make inspect use the file's library PID config inspect always used the global PID config, so it showed different IDs than the scanner for files in a library with an override. It now finds the file's library in the DB and uses its effective config, falling back to the global config when there is no DB or the file is outside every library. It never creates a DB. The library path matcher moves from core/playlists to model so both can use it. * refactor: simplify per-library PID code Share the DB-file check between CLI commands, move ErrAlreadyScanning to model so core no longer imports scanner, read the libraries once for insights, and let ValidatePIDSpec accept an empty spec and look tags up directly. In the scanner, use FullScanInProgress instead of a second flag, and skip recomputing album IDs when the album spec did not change. In the UI, share the PID inputs between Create and Edit, and use docsUrl. * feat(ui): add section titles to Library Create and pre-fill Custom PID specs Custom now starts from the global spec, so admins edit a working spec instead of typing one from scratch. * fix(inspect): map files with the library-relative path the scanner uses Inspect gave metadata the file's directory as typed, so folder-based PIDs never matched the DB. It now uses the path relative to the library root, through the scanner's helper, which moves to model. * fix(scanner): say when a PID rescan only covers target folders * fix: reject tag aliases in album PID specs and match root libraries Tags are stored under canonical names, so an alias in a spec always reads as empty. In an album spec that gives every album the same ID, so album specs now require the tag name. Track specs keep accepting aliases, since the default one uses them. LibraryMatcher now matches paths under a library at the filesystem root. * refactor(model): move the tag alias lookup to tag_mappings.go * test: run the library matcher and inspect tests on Windows Build test paths with filepath instead of Unix literals, so they use the OS separator like filepath.Abs output, and drop the Windows skips. * feat(ui): add pt-BR translations for per-library PID settings --- adapters/gotaglib/end_to_end_test.go | 2 +- cmd/inspect.go | 46 +++- cmd/inspect_test.go | 61 +++++ cmd/root.go | 28 ++- cmd/root_test.go | 30 +++ cmd/utils.go | 13 +- consts/consts.go | 2 - core/inspect.go | 16 +- core/inspect_test.go | 45 ++++ core/library.go | 54 ++++- core/library_test.go | 73 ++++++ core/metrics/insights.go | 9 +- core/playlists/import.go | 4 +- core/playlists/parse_m3u.go | 70 +----- core/playlists/parse_m3u_test.go | 181 -------------- ...20260929221042_add_library_pid_columns.sql | 18 ++ model/library.go | 37 +++ model/library_matcher.go | 57 +++++ model/library_matcher_test.go | 91 +++++++ model/library_test.go | 73 ++++++ model/metadata/map_mediafile.go | 10 +- model/metadata/map_mediafile_test.go | 16 +- model/metadata/map_participants_test.go | 2 +- model/metadata/metadata_test.go | 2 +- model/metadata/persistent_ids.go | 55 ++++- model/metadata/persistent_ids_test.go | 54 ++++- model/scanner.go | 3 + model/tag_mappings.go | 22 ++ model/tag_mappings_test.go | 19 ++ persistence/library_repository.go | 11 + persistence/library_repository_test.go | 32 +++ resources/i18n/pt-br.json | 19 +- scanner/controller.go | 8 +- scanner/controller_test.go | 12 +- scanner/phase_1_folders.go | 73 +++--- scanner/scanner.go | 55 ++--- scanner/scanner_internal_test.go | 39 --- scanner/scanner_multilibrary_test.go | 166 +++++++++++++ server/nativeapi/inspect.go | 7 +- server/serve_index.go | 2 + server/serve_index_test.go | 2 + tests/mock_library_repo.go | 11 + ui/src/config.js | 2 + ui/src/i18n/en.json | 19 +- ui/src/library/LibraryCreate.jsx | 31 +++ ui/src/library/LibraryEdit.jsx | 222 +++++++++++------- ui/src/library/LibraryEdit.test.jsx | 125 ++++++++++ ui/src/library/PIDInput.jsx | 114 +++++++++ ui/src/library/pidPresets.js | 33 +++ ui/src/library/pidPresets.test.js | 65 +++++ 50 files changed, 1626 insertions(+), 515 deletions(-) create mode 100644 cmd/inspect_test.go create mode 100644 core/inspect_test.go create mode 100644 db/migrations/20260929221042_add_library_pid_columns.sql create mode 100644 model/library_matcher.go create mode 100644 model/library_matcher_test.go create mode 100644 model/library_test.go create mode 100644 ui/src/library/LibraryEdit.test.jsx create mode 100644 ui/src/library/PIDInput.jsx create mode 100644 ui/src/library/pidPresets.js create mode 100644 ui/src/library/pidPresets.test.js diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index e7dd18ac1..0f9a90d94 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - return new(metadata.ToMediaFile(1, "folderID")) + return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID")) } BeforeEach(func() { diff --git a/cmd/inspect.go b/cmd/inspect.go index 5e88793cc..05f569f3e 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -1,13 +1,17 @@ package cmd import ( + "context" "encoding/json" "fmt" + "path/filepath" "strings" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -28,7 +32,7 @@ var inspectCmd = &cobra.Command{ Long: "Show file tags as seen by Navidrome", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - runInspector(args) + runInspector(cmd.Context(), args) }, } @@ -55,18 +59,24 @@ func prettyMarshal(v any) ([]byte, error) { return []byte(res.String()), nil } -func runInspector(args []string) { +func runInspector(ctx context.Context, args []string) { marshal := marshalers[format] if marshal == nil { log.Fatal("Invalid format", "format", format) } + libs := loadLibraries(ctx) + matcher := model.NewLibraryMatcher(libs) var out []core.InspectOutput for _, filePath := range args { if !model.IsAudioFile(filePath) { log.Warn("Not an audio file", "file", filePath) continue } - output, err := core.Inspect(filePath, 1, "") + lib, ok := libraryForFile(matcher, filePath) + if !ok && len(libs) > 0 { + log.Warn("File is not in any library, using the global PID config", "file", filePath) + } + output, err := core.Inspect(filePath, lib, "") if err != nil { log.Warn("Unable to process file", "file", filePath, "error", err) continue @@ -77,3 +87,33 @@ func runInspector(args []string) { data, _ := marshal(out) fmt.Println(string(data)) } + +// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB. +func loadLibraries(ctx context.Context) model.Libraries { + if dbFile, ok := existingDBFile(); !ok { + log.Warn(ctx, "No database found, using the global PID config", "path", dbFile) + return nil + } + defer db.Init(ctx)() + libs, err := persistence.New(db.Db()).Library().GetAll(ctx) + if err != nil { + log.Warn(ctx, "Could not load libraries, using the global PID config", err) + return nil + } + for i := range libs { + if absPath, err := filepath.Abs(libs[i].Path); err == nil { + libs[i].Path = absPath + } + } + return libs +} + +// libraryForFile falls back to the default library with no overrides, which uses the global PID config. +func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) { + if absPath, err := filepath.Abs(filePath); err == nil { + if lib, ok := matcher.FindLibrary(absPath); ok { + return lib, true + } + } + return model.Library{ID: model.DefaultLibraryID}, false +} diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go new file mode 100644 index 000000000..728dc8770 --- /dev/null +++ b/cmd/inspect_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("inspect", func() { + Describe("libraryForFile", func() { + var matcher *model.LibraryMatcher + var root string + + BeforeEach(func() { + root = GinkgoT().TempDir() + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + matcher = model.NewLibraryMatcher(model.Libraries{ + {ID: 1, Path: filepath.Join(root, "music")}, + {ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"}, + }) + }) + + It("returns the library that contains an absolute path", func() { + lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3")) + Expect(ok).To(BeTrue()) + Expect(lib.ID).To(Equal(1)) + }) + + It("resolves a relative path against the working directory", func() { + lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3")) + Expect(ok).To(BeTrue()) + Expect(lib.PIDAlbum).To(Equal("folder")) + }) + + It("falls back to the default library without overrides", func() { + lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3")) + Expect(ok).To(BeFalse()) + Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID})) + }) + }) + + Describe("loadLibraries", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does not create a database when there is none", func() { + dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db") + conf.Server.DbPath = dbFile + "?_journal_mode=WAL" + + Expect(loadLibraries(GinkgoT().Context())).To(BeNil()) + Expect(dbFile).ToNot(BeAnExistingFile()) + }) + }) +}) diff --git a/cmd/root.go b/cmd/root.go index 089f09472..e39c55365 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -190,16 +190,20 @@ func schedulePeriodicScan(ctx context.Context) func() error { } } -func pidHashChanged(ds model.DataStore) (bool, error) { - pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "") +// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from +// the one used by their last finished scan +func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) { + libs, err := ds.Library().GetAll(ctx) if err != nil { - return false, err + return nil, err } - pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "") - if err != nil { - return false, err + var names []string + for _, lib := range libs { + if lib.PIDChanged() { + names = append(names, lib.Name) + } } - return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil + return names, nil } // runInitialScan runs an initial scan of the music library if needed. @@ -214,12 +218,12 @@ func runInitialScan(ctx context.Context) func() error { if err != nil { return err } - pidHasChanged, err := pidHashChanged(ds) + pidChangedLibs, err := librariesWithChangedPID(ctx, ds) if err != nil { return err } scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup - scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged + scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0 time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan if scanNeeded { s := CreateScanner(ctx) @@ -227,9 +231,9 @@ func runInitialScan(ctx context.Context) func() error { case fullScanRequired == "1": log.Warn(ctx, "Full scan required after migration") _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) - case pidHasChanged: - log.Warn(ctx, "PID config changed, performing full scan") - fullScanRequired = "1" + case len(pidChangedLibs) > 0: + // Includes never-scanned libraries. The scanner rescans in full only the ones that need it + log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs) case inProgress: log.Warn(ctx, "Resuming interrupted scan") default: diff --git a/cmd/root_test.go b/cmd/root_test.go index af8d44e7e..423cd2a8f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "net/http" "net/http/httptest" "path" @@ -9,6 +10,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -44,3 +47,30 @@ var _ = Describe("profilerHandler", func() { Entry("with a trailing-slash BasePath", "/music/"), ) }) + +var _ = Describe("librariesWithChangedPID", func() { + var ds *tests.MockDataStore + var libs *tests.MockLibraryRepo + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + libs = &tests.MockLibraryRepo{} + ds = &tests.MockDataStore{MockedLibrary: libs} + }) + + It("returns only the libraries whose PID config changed", func() { + pid := model.Library{}.EffectivePID() + libs.SetData(model.Libraries{ + {ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 3, Name: "Never scanned"}, + }) + Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned")) + }) + + It("returns the error from the repository", func() { + libs.Err = errors.New("db down") + _, err := librariesWithChangedPID(GinkgoT().Context(), ds) + Expect(err).To(MatchError("db down")) + }) +}) diff --git a/cmd/utils.go b/cmd/utils.go index 72ec67f90..f35a31fb1 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -18,11 +18,16 @@ import ( "github.com/navidrome/navidrome/persistence" ) -// requireExistingDB aborts the command when the database file (DbPath minus DSN -// params) does not exist. -func requireExistingDB() { +// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists. +func existingDBFile() (string, bool) { path, _, _ := strings.Cut(conf.Server.DbPath, "?") - if _, err := os.Stat(path); os.IsNotExist(err) { + _, err := os.Stat(path) + return path, err == nil +} + +// requireExistingDB aborts the command when the database file does not exist. +func requireExistingDB() { + if path, ok := existingDBFile(); !ok { log.Fatal("No existing database", "path", path) } } diff --git a/consts/consts.go b/consts/consts.go index 9bdac9125..42e9ec42f 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -156,8 +156,6 @@ const ( //DefaultAlbumPID = "album_legacy" DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" - PIDAlbumKey = "PIDAlbum" - PIDTrackKey = "PIDTrack" ) const ( diff --git a/core/inspect.go b/core/inspect.go index 01ec33760..c60459b88 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -15,7 +15,7 @@ type InspectOutput struct { MappedTags *model.MediaFile `json:"mappedTags,omitempty"` } -func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) { +func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) { path, file := filepath.Split(filePath) s, err := storage.For(path) @@ -39,12 +39,22 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e return nil, model.ErrNotFound } - md := metadata.New(path, tag) + md := metadata.New(scannerPath(lib, filePath), tag) result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: new(md.ToMediaFile(libraryId, folderId)), + MappedTags: new(md.ToMediaFile(lib, folderId)), } return result, nil } + +// scannerPath returns the path the scanner uses for the file (relative to its library), so +// folder-based PIDs match the DB. Files outside the library keep their absolute path. +func scannerPath(lib model.Library, filePath string) string { + absPath, err := filepath.Abs(filePath) + if err != nil || lib.Path == "" { + return filePath + } + return model.LibraryRelativePath(lib.Path, absPath) +} diff --git a/core/inspect_test.go b/core/inspect_test.go new file mode 100644 index 000000000..0ac90990c --- /dev/null +++ b/core/inspect_test.go @@ -0,0 +1,45 @@ +package core_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Inspect", func() { + var fixtures string + + BeforeEach(func() { + var err error + fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures")) + Expect(err).ToNot(HaveOccurred()) + }) + + It("maps the file with the library-relative path the scanner uses", func() { + lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} + out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3")) + Expect(out.MappedTags.LibraryID).To(Equal(2)) + }) + + It("gives the same IDs for relative and absolute paths", func() { + lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} + abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID)) + Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID)) + }) + + It("keeps the given path for a file outside the library", func() { + filePath := filepath.Join(fixtures, "test.mp3") + out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "") + Expect(err).ToNot(HaveOccurred()) + Expect(out.MappedTags.Path).To(Equal(filePath)) + }) +}) diff --git a/core/library.go b/core/library.go index 628ee4b7b..f1153da26 100644 --- a/core/library.go +++ b/core/library.go @@ -2,10 +2,12 @@ package core import ( "context" + "errors" "fmt" "io/fs" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/slice" @@ -200,23 +203,22 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity } pathChanged := originalLib.Path != lib.Path + pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) || + (updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack) err = r.LibraryRepository.Put(ctx, lib, cols...) if err != nil { return r.mapError(err) } - // Restart watcher and trigger scan if path was updated - if pathChanged { - if r.watcher != nil { - if err := r.watcher.Watch(ctx, lib); err != nil { - log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) - } + if pathChanged && r.watcher != nil { + if err := r.watcher.Watch(ctx, lib); err != nil { + log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) } + } - if r.scanner != nil { - go r.triggerScan(ctx, lib, "updated") - } + if (pathChanged || pidChanged) && r.scanner != nil { + go r.triggerScan(ctx, lib, "updated") } // Send library refresh event to all clients @@ -325,6 +327,15 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library } } + library.PIDAlbum = strings.TrimSpace(library.PIDAlbum) + library.PIDTrack = strings.TrimSpace(library.PIDTrack) + if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil { + validationErrors["pidAlbum"] = err.Error() + } + if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil { + validationErrors["pidTrack"] = err.Error() + } + if len(validationErrors) > 0 { return &rest.ValidationError{Errors: validationErrors} } @@ -332,6 +343,11 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library return nil } +// updatesColumn reports whether an update with these columns writes col. No columns means all of them. +func updatesColumn(cols []string, col string) bool { + return len(cols) == 0 || slices.Contains(cols, col) +} + func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { // Validate path format if !filepath.IsAbs(library.Path) { @@ -399,11 +415,27 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in return nil } +var scanWaitInterval = time.Second + func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { + // Runs in its own goroutine and outlives the HTTP request + ctx = context.WithoutCancel(ctx) + + // A running scan loaded the libraries before this change, and would reject a new request + for { + status, err := r.scanner.Status(ctx) + if err != nil || !status.Scanning { + break + } + time.Sleep(scanWaitInterval) + } + log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) start := time.Now() - warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library - if err != nil { + warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full + if errors.Is(err, model.ErrAlreadyScanning) { + log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name) + } else if err != nil { log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) } else { log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) diff --git a/core/library_test.go b/core/library_test.go index 5402eac22..e6ebb1974 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -322,6 +322,37 @@ var _ = Describe("Library Service", func() { }) }) + Describe("PID validation", func() { + pidError := func(err error, field string) string { + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + return validationErr.Errors[field] + } + + It("rejects an unknown attribute in the album PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"}) + Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`)) + }) + + It("rejects albumid in the album PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"}) + Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid")) + }) + + It("rejects an unknown attribute in the track PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"}) + Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`)) + }) + + It("trims spaces", func() { + library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "} + _, err := repo.Save(ctx, library) + Expect(err).ToNot(HaveOccurred()) + Expect(library.PIDAlbum).To(Equal("folder")) + Expect(library.PIDTrack).To(BeEmpty()) + }) + }) + Describe("Path Validation", func() { Context("Create operation", func() { It("fails when path is not absolute", func() { @@ -679,6 +710,48 @@ var _ = Describe("Library Service", func() { }, "100ms", "10ms").Should(Equal(0)) }) + It("triggers scan when updating the library PID config", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) + + library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} + Expect(repo.Update(ctx, "1", library)).To(Succeed()) + + Eventually(func() int { + return scanner.GetScanAllCallCount() + }, "1s", "10ms").Should(Equal(1)) + // A quick scan: the scanner itself rescans this library in full + Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse()) + }) + + It("does not trigger scan when the PID fields were not sent", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}}) + + // The REST layer decodes a missing pidAlbum as "". Only the sent fields count. + library := model.Library{ID: 1, Name: "Renamed", Path: tempDir} + Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed()) + + Consistently(func() int { + return scanner.GetScanAllCallCount() + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("waits for a running scan before triggering a new one", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) + scanner.SetScanning(true) + + library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} + Expect(repo.Update(ctx, "1", library)).To(Succeed()) + + Consistently(func() int { + return scanner.GetScanAllCallCount() + }, "200ms", "20ms").Should(Equal(0)) + + scanner.SetScanning(false) + Eventually(func() int { + return scanner.GetScanAllCallCount() + }, "3s", "20ms").Should(Equal(1)) + }) + It("does not trigger scan when library creation fails", func() { // Try to create library with invalid data (empty name) library := &model.Library{Path: tempDir} diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 4a78a7f3f..1d2ff5df4 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "runtime/debug" + "slices" "strings" "sync" "sync/atomic" @@ -269,9 +270,13 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { if err != nil { log.Trace(ctx, "Error reading radios count", err) } - data.Library.Libraries, err = c.ds.Library().CountAll(ctx) + libs, err := c.ds.Library().GetAll(ctx) if err != nil { - log.Trace(ctx, "Error reading libraries count", err) + log.Trace(ctx, "Error reading libraries", err) + } + data.Library.Libraries = int64(len(libs)) + if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) { + data.Config.HasCustomPID = true } data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, diff --git a/core/playlists/import.go b/core/playlists/import.go index 658bd92dc..b5991b095 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -78,8 +78,8 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde if err != nil { return nil, err } - matcher := newLibraryMatcher(libs) - lib, ok := matcher.findLibrary(dir) + matcher := model.NewLibraryMatcher(libs) + lib, ok := matcher.FindLibrary(dir) if !ok { return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) } diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index 9610e9dbb..ab95b8850 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -1,13 +1,11 @@ package playlists import ( - "cmp" "context" "fmt" "io" "net/url" "path/filepath" - "slices" "strings" "time" @@ -156,61 +154,9 @@ func (r pathResolution) ToQualifiedString() (string, error) { return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil } -// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching. -type libraryMatcher struct { - libraries model.Libraries - cleanedPaths []string -} - -// findLibraryForPath finds which library contains the given absolute path. -// Returns library ID and path, or 0 and empty string if not found. -func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { - lib, ok := lm.findLibrary(absolutePath) - if !ok { - return 0, "" - } - return lib.ID, filepath.Clean(lib.Path) -} - -// findLibrary checks if the absolute path is under any of the library paths. -func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { - // Check sorted libraries (longest path first) to find the best match - for i, cleanLibPath := range lm.cleanedPaths { - // Check if absolutePath is under this library path - if strings.HasPrefix(absolutePath, cleanLibPath) { - // Ensure it's a proper path boundary (not just a prefix) - if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { - return lm.libraries[i], true - } - } - } - return model.Library{}, false -} - -// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). -// This ensures correct matching when library paths are prefixes of each other. -// Example: /music-classical must be checked before /music -// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical -func newLibraryMatcher(libs model.Libraries) *libraryMatcher { - // Sort libraries by path length (descending) to ensure longest paths match first. - slices.SortFunc(libs, func(i, j model.Library) int { - return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending - }) - - // Pre-clean all library paths once for efficient matching - cleanedPaths := make([]string, len(libs)) - for i, lib := range libs { - cleanedPaths[i] = filepath.Clean(lib.Path) - } - return &libraryMatcher{ - libraries: libs, - cleanedPaths: cleanedPaths, - } -} - // pathResolver handles path resolution logic for playlist imports. type pathResolver struct { - matcher *libraryMatcher + matcher *model.LibraryMatcher } // newPathResolver creates a pathResolver with libraries loaded from the datastore. @@ -219,7 +165,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er if err != nil { return nil, err } - matcher := newLibraryMatcher(libs) + matcher := model.NewLibraryMatcher(libs) return &pathResolver{matcher: matcher}, nil } @@ -246,14 +192,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu // a pathResolution with the library information. Returns an invalid resolution if // the path is not found in any library. func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { - libID, libPath := r.matcher.findLibraryForPath(absolutePath) - if libID == 0 { + lib, ok := r.matcher.FindLibrary(absolutePath) + if !ok { return pathResolution{valid: false} } return pathResolution{ absolutePath: absolutePath, - libraryPath: libPath, - libraryID: libID, + libraryPath: filepath.Clean(lib.Path), + libraryID: lib.ID, valid: true, } } @@ -288,7 +234,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // Local paths (file://, absolute, or relative) are resolved to an absolute path // and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string { +func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -308,7 +254,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher return "" } - lib, ok := matcher.findLibrary(localPath) + lib, ok := matcher.FindLibrary(localPath) // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { return "" diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go index b6a3a96f9..ced6c2b16 100644 --- a/core/playlists/parse_m3u_test.go +++ b/core/playlists/parse_m3u_test.go @@ -9,187 +9,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("libraryMatcher", func() { - var ds *tests.MockDataStore - var mockLibRepo *tests.MockLibraryRepo - ctx := context.Background() - - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") - mockLibRepo = &tests.MockLibraryRepo{} - ds = &tests.MockDataStore{ - MockedLibrary: mockLibRepo, - } - }) - - // Helper function to create a libraryMatcher from the mock datastore - createMatcher := func(ds model.DataStore) *libraryMatcher { - libs, err := ds.Library().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - return newLibraryMatcher(libs) - } - - Describe("Longest library path matching", func() { - It("matches the longest library path when multiple libraries share a prefix", func() { - // Setup libraries with prefix conflicts - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - {ID: 2, Path: "/music-classical"}, - {ID: 3, Path: "/music-classical/opera"}, - }) - - matcher := createMatcher(ds) - - // Test that longest path matches first and returns correct library ID - testCases := []struct { - path string - expectedLibID int - expectedLibPath string - }{ - {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"}, - {"/music-classical/track.mp3", 2, "/music-classical"}, - {"/music/track.mp3", 1, "/music"}, - {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"}, - } - - for _, tc := range testCases { - libID, libPath := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID) - Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath) - } - }) - - It("handles libraries with similar prefixes but different structures", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/home/user/music"}, - {ID: 2, Path: "/home/user/music-backup"}, - }) - - matcher := createMatcher(ds) - - // Test that music-backup library is matched correctly - libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3") - Expect(libID).To(Equal(2)) - Expect(libPath).To(Equal("/home/user/music-backup")) - - // Test that music library is still matched correctly - libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/home/user/music")) - }) - - It("matches path that is exactly the library root", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - {ID: 2, Path: "/music-classical"}, - }) - - matcher := createMatcher(ds) - - // Exact library path should match - libID, libPath := matcher.findLibraryForPath("/music-classical") - Expect(libID).To(Equal(2)) - Expect(libPath).To(Equal("/music-classical")) - }) - - It("handles complex nested library structures", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/media"}, - {ID: 2, Path: "/media/audio"}, - {ID: 3, Path: "/media/audio/classical"}, - {ID: 4, Path: "/media/audio/classical/baroque"}, - }) - - matcher := createMatcher(ds) - - testCases := []struct { - path string - expectedLibID int - expectedLibPath string - }{ - {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"}, - {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"}, - {"/media/audio/rock/track.mp3", 2, "/media/audio"}, - {"/media/video/movie.mp4", 1, "/media"}, - } - - for _, tc := range testCases { - libID, libPath := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) - Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath) - } - }) - }) - - Describe("Edge cases", func() { - It("handles empty library list", func() { - mockLibRepo.SetData([]model.Library{}) - - matcher := createMatcher(ds) - Expect(matcher).ToNot(BeNil()) - - // Should not match anything - libID, libPath := matcher.findLibraryForPath("/music/track.mp3") - Expect(libID).To(Equal(0)) - Expect(libPath).To(BeEmpty()) - }) - - It("handles single library", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - }) - - matcher := createMatcher(ds) - - libID, libPath := matcher.findLibraryForPath("/music/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/music")) - }) - - It("handles libraries with special characters in paths", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music[test]"}, - {ID: 2, Path: "/music(backup)"}, - }) - - matcher := createMatcher(ds) - Expect(matcher).ToNot(BeNil()) - - // Special characters should match literally - libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/music[test]")) - }) - }) - - Describe("Path matching order", func() { - It("ensures longest paths match first", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/a"}, - {ID: 2, Path: "/ab"}, - {ID: 3, Path: "/abc"}, - }) - - matcher := createMatcher(ds) - - // Verify that longer paths match correctly (not cut off by shorter prefix) - testCases := []struct { - path string - expectedLibID int - }{ - {"/abc/file.mp3", 3}, - {"/ab/file.mp3", 2}, - {"/a/file.mp3", 1}, - } - - for _, tc := range testCases { - libID, _ := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) - } - }) - }) -}) - var _ = Describe("pathResolver", func() { var ds *tests.MockDataStore var mockLibRepo *tests.MockLibraryRepo diff --git a/db/migrations/20260929221042_add_library_pid_columns.sql b/db/migrations/20260929221042_add_library_pid_columns.sql new file mode 100644 index 000000000..487512287 --- /dev/null +++ b/db/migrations/20260929221042_add_library_pid_columns.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +alter table library add column pid_album varchar default '' not null; +alter table library add column pid_track varchar default '' not null; +alter table library add column scanned_pid_album varchar default '' not null; +alter table library add column scanned_pid_track varchar default '' not null; + +-- Every library was scanned with the global PID config, so seed it as their scanned config. +-- This way the upgrade does not trigger a full rescan. +update library set + scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''), + scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), ''); + +delete from property where id in ('PIDAlbum', 'PIDTrack'); +-- +goose StatementEnd + +-- +goose Down +SELECT 1; diff --git a/model/library.go b/model/library.go index 1e33222ac..e80d22c89 100644 --- a/model/library.go +++ b/model/library.go @@ -1,10 +1,13 @@ package model import ( + "cmp" "context" + "strings" "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/utils/slice" ) @@ -27,6 +30,38 @@ type Library struct { TotalSize int64 `json:"totalSize" db:"total_size"` TotalDuration float64 `json:"totalDuration" db:"total_duration"` DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` + PIDAlbum string `json:"pidAlbum" db:"pid_album"` + PIDTrack string `json:"pidTrack" db:"pid_track"` + ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"` + ScannedPIDTrack string `json:"-" db:"scanned_pid_track"` +} + +// PIDConfig holds the persistent ID specs used to compute track and album IDs. +type PIDConfig struct { + Track string + Album string +} + +// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to +// the global config. +func (l Library) EffectivePID() PIDConfig { + return PIDConfig{ + Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track), + Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album), + } +} + +// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished +// scan of this library. A library that was never scanned counts as changed. +func (l Library) PIDChanged() bool { + pid := l.EffectivePID() + return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track) +} + +// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be +// rescanned in full. A library that never finished a scan has nothing to regroup. +func (l Library) NeedsPIDRescan() bool { + return !l.LastScanAt.IsZero() && l.PIDChanged() } const ( @@ -59,6 +94,8 @@ type LibraryRepository interface { // TODO These methods should be moved to a core service ScanBegin(ctx context.Context, id int, fullScan bool) error ScanEnd(ctx context.Context, id int) error + // SetScannedPID records the PID specs used by the last finished scan of the library + SetScannedPID(ctx context.Context, id int, pid PIDConfig) error ScanInProgress(ctx context.Context) (bool, error) RefreshStats(ctx context.Context, id int) error } diff --git a/model/library_matcher.go b/model/library_matcher.go new file mode 100644 index 000000000..83af96f9f --- /dev/null +++ b/model/library_matcher.go @@ -0,0 +1,57 @@ +package model + +import ( + "cmp" + "path/filepath" + "slices" + "strings" +) + +// LibraryMatcher finds the library that contains an absolute path. +type LibraryMatcher struct { + libraries Libraries + cleanedPaths []string +} + +// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music. +func NewLibraryMatcher(libs Libraries) *LibraryMatcher { + libs = slices.Clone(libs) + slices.SortFunc(libs, func(i, j Library) int { + return cmp.Compare(len(j.Path), len(i.Path)) + }) + cleanedPaths := make([]string, len(libs)) + for i, lib := range libs { + cleanedPaths[i] = filepath.Clean(lib.Path) + } + return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths} +} + +// FindLibrary returns the library whose path contains absolutePath. +func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) { + for i, libPath := range lm.cleanedPaths { + // A cleaned path only ends with a separator when it is a filesystem root + if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) || + absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) { + return lm.libraries[i], true + } + } + return Library{}, false +} + +// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it +// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged. +func LibraryRelativePath(libPath, path string) string { + if !filepath.IsAbs(path) { + return path + } + // The library root may be relative (e.g. the default "./music"); it resolves against the same cwd + absLib, err := filepath.Abs(libPath) + if err != nil { + return path + } + rel, err := filepath.Rel(absLib, path) + if err != nil || !filepath.IsLocal(rel) { + return path + } + return filepath.ToSlash(rel) +} diff --git a/model/library_matcher_test.go b/model/library_matcher_test.go new file mode 100644 index 000000000..09e6f7e33 --- /dev/null +++ b/model/library_matcher_test.go @@ -0,0 +1,91 @@ +package model_test + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LibraryMatcher", func() { + // Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does + find := func(libs model.Libraries, path string) int { + for i := range libs { + libs[i].Path = filepath.FromSlash(libs[i].Path) + } + lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path)) + if !ok { + return 0 + } + return lib.ID + } + + DescribeTable("matches the longest library path", + func(libs model.Libraries, path string, expectedID int) { + Expect(find(libs, path)).To(Equal(expectedID)) + }, + Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3), + Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2), + Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1), + Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2), + Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3), + Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2), + Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1), + Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1), + Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1), + Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2), + ) + + It("does not match a path outside every library", func() { + Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero()) + }) + + It("does not match anything without libraries", func() { + Expect(find(nil, "/music/track.mp3")).To(BeZero()) + }) + + It("does not reorder the caller's libraries", func() { + libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}} + model.NewLibraryMatcher(libs) + Expect(libs.IDs()).To(Equal([]int{1, 2})) + }) +}) + +var _ = Describe("LibraryRelativePath", func() { + // Paths are built with filepath so the "absolute" cases stay absolute on every OS + // (a Unix-style "/foo" is not absolute on Windows). + libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) + outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) + + It("returns a relative path unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) + }) + + It("rebases an absolute target when the library root is relative", func() { + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) + }) + + It("rebases an absolute path that equals the library root to '.'", func() { + Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal(".")) + }) + + It("rebases an absolute path under the library root", func() { + Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("handles a trailing slash on the library path", func() { + Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("leaves an absolute path outside the library root unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside)) + }) + + It("returns an empty path unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, "")).To(Equal("")) + }) +}) diff --git a/model/library_test.go b/model/library_test.go new file mode 100644 index 000000000..4e799e13f --- /dev/null +++ b/model/library_test.go @@ -0,0 +1,73 @@ +package model_test + +import ( + "encoding/json" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Library PID config", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "global_album" + conf.Server.PID.Track = "global_track" + }) + + Describe("EffectivePID", func() { + It("falls back to the global config", func() { + Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"})) + }) + It("uses the library overrides", func() { + lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"} + Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"})) + }) + }) + + Describe("PIDChanged", func() { + It("is false when the scanned specs match, ignoring case", func() { + lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeFalse()) + }) + It("is true when the album override differs from the scanned spec", func() { + lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true when only the track spec changed", func() { + lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true when the global config changed for a library without overrides", func() { + lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true for a library that was never scanned", func() { + Expect(model.Library{}.PIDChanged()).To(BeTrue()) + }) + }) + + Describe("NeedsPIDRescan", func() { + It("is false for a library that never finished a scan", func() { + Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse()) + }) + It("is true for a scanned library whose PID config changed", func() { + lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} + Expect(lib.NeedsPIDRescan()).To(BeTrue()) + }) + It("is false for a scanned library whose PID config did not change", func() { + lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} + Expect(lib.NeedsPIDRescan()).To(BeFalse()) + }) + }) + + It("does not expose the scanned specs in JSON", func() { + data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"}) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`)) + Expect(string(data)).ToNot(ContainSubstring("secret_")) + }) +}) diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 6d12feba9..2135824d2 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -8,15 +8,14 @@ import ( "math" "strconv" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" ) -func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { +func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile { mf := model.MediaFile{ - LibraryID: libID, + LibraryID: lib.ID, FolderID: folderID, Tags: maps.Clone(md.tags), } @@ -84,8 +83,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.AlbumArtist = md.mapDisplayAlbumArtist(mf) // Persistent IDs - mf.PID = md.trackPID(mf) - mf.AlbumID = md.albumID(mf, conf.Server.PID.Album) + pid := lib.EffectivePID() + mf.PID = md.trackPID(mf, pid) + mf.AlbumID = md.albumID(mf, pid.Album) // BFR These IDs will go away once the UI handle multiple participants. // BFR For Legacy Subsonic compatibility, we will set them in the API handlers diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index baaf8fab5..c19398841 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -30,9 +30,23 @@ var _ = Describe("ToMediaFile", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(1, "folderID") + return md.ToMediaFile(model.Library{ID: 1}, "folderID") } + Describe("Persistent IDs", func() { + It("uses the library PID config for the album ID and for albumid in the track spec", func() { + props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}} + md = metadata.New("Jazz/Loose/01.mp3", props) + + byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID") + byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID") + + Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID)) + Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder"))) + Expect(byFolder.PID).ToNot(Equal(byTags.PID)) + }) + }) + Describe("Dates", func() { It("should parse properly tagged dates ", func() { mf = toMediaFile(model.RawTags{ diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index ec66e12b9..db652fb8b 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -38,7 +38,7 @@ var _ = Describe("Participants", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(1, "folderID") + return md.ToMediaFile(model.Library{ID: 1}, "folderID") } Describe("ARTIST(S) tags", func() { diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index c84d93981..a1a675006 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -323,7 +323,7 @@ var _ = Describe("Metadata", func() { tag: {tagValue}, } md = metadata.New(filePath, props) - return md.ToMediaFile(0, "0") + return md.ToMediaFile(model.Library{}, "0") } DescribeTable("Gain", diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index db315dc6b..b66ce824a 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -2,11 +2,11 @@ package metadata import ( "cmp" + "errors" "fmt" "path/filepath" "strings" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -22,12 +22,13 @@ type hashFunc = func(...string) string // attributes. Attributes can be either tags or processed values like folder, // albumid, albumartistid, etc. For each field, it gets all its attribute values // and concatenates them, then hashes the result. If a field is empty, it is -// skipped and the function looks for the next field. +// skipped and the function looks for the next field. albumSpec is the album PID +// spec used to resolve the `albumid` attribute. // // Taking hash as a parameter (instead of closing over it in a factory) keeps // mf on the stack: closing over mf would force the whole ~1KB MediaFile to the // heap on every call. -func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string { +func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string { switch spec { case "track_legacy": return legacyTrackID(mf, prependLibId) @@ -41,7 +42,7 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, values := make([]string, len(attributes)) hasValue := false for i, attr := range attributes { - v := getPIDAttr(mf, md, attr, prependLibId, spec, hash) + v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash) if v != "" { hasValue = true } @@ -58,15 +59,15 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, return hash(pid) } -func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string { +func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string { attr = strings.TrimSpace(strings.ToLower(attr)) switch attr { case "albumid": - if spec == conf.Server.PID.Album { + if spec == albumSpec { log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) return "" } - return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash) + return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash) case "folder": return filepath.Dir(mf.Path) case "albumartistid": @@ -79,18 +80,50 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, return md.String(model.TagName(attr)) } -func (md Metadata) trackPID(mf model.MediaFile) string { - return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash) +// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config". +// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them. +func ValidatePIDSpec(spec string, isAlbum bool) error { + switch { + case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy": + return nil + } + for field := range strings.SplitSeq(spec, "|") { + for attr := range strings.SplitSeq(field, ",") { + attr = strings.TrimSpace(strings.ToLower(attr)) + switch attr { + case "": + return fmt.Errorf("empty attribute in %q", spec) + case "albumid": + if isAlbum { + return errors.New("albumid cannot be used in an album PID") + } + case "folder", "albumartistid": + default: + name, ok := model.CanonicalTagName(attr) + if !ok { + return fmt.Errorf("unknown attribute %q", attr) + } + if isAlbum && string(name) != attr { + return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr) + } + } + } + } + return nil +} + +func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string { + return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash) } func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { - return computePID(mf, md, pidConf, true, id.NewHash) + return computePID(mf, md, pidConf, pidConf, true, id.NewHash) } // BFR Must be configurable? func (md Metadata) artistID(name string) string { mf := model.MediaFile{AlbumArtist: name} - return computePID(mf, md, "albumartistid", false, id.NewHash) + return computePID(mf, md, "albumartistid", "", false, id.NewHash) } func (md Metadata) mapTrackTitle() string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 8e38bbd42..9f6eaf1f4 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -3,8 +3,7 @@ package metadata import ( "strings" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -13,16 +12,18 @@ import ( var _ = Describe("getPID", func() { var ( - md Metadata - mf model.MediaFile - sum hashFunc + md Metadata + mf model.MediaFile + sum hashFunc + albumSpec string ) getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - return computePID(mf, md, spec, prependLibId, sum) + return computePID(mf, md, spec, albumSpec, prependLibId, sum) } BeforeEach(func() { sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } + albumSpec = consts.DefaultAlbumPID }) Context("attributes are tags", func() { @@ -66,8 +67,7 @@ var _ = Describe("getPID", func() { Context("calculated attributes", func() { BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" + albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" }) When("field is title", func() { It("should return the pid", func() { @@ -121,8 +121,8 @@ var _ = Describe("getPID", func() { When("albumid configuration refers to albumid recursively", func() { It("should avoid infinite recursion", func() { // Reproduce the issue from #4920 - conf.Server.PID.Album = "albumid,album,albumversion,releasedate" - spec := conf.Server.PID.Album + albumSpec = "albumid,album,albumversion,releasedate" + spec := albumSpec md.tags = map[model.TagName][]string{ "album": {"Album Name"}, "albumversion": {"Version"}, @@ -205,8 +205,7 @@ var _ = Describe("getPID", func() { }) When("prependLibId is true with nested albumid", func() { It("should handle nested albumid calls correctly", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "album" + albumSpec = "album" spec := "albumid" md.tags = map[model.TagName][]string{"album": {"Test Album"}} mf.AlbumArtist = "Test Artist" @@ -306,3 +305,34 @@ var _ = Describe("getPID", func() { }) }) }) + +var _ = Describe("ValidatePIDSpec", func() { + DescribeTable("accepts valid specs", + func(spec string, isAlbum bool) { + Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed()) + }, + Entry("empty, meaning the global config", "", true), + Entry("default album spec", consts.DefaultAlbumPID, true), + Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false), + Entry("folder", "folder", true), + Entry("album legacy", "album_legacy", true), + Entry("track legacy", "track_legacy", false), + Entry("computed attributes", "albumartistid,album|title", true), + Entry("albumid in a track spec", "albumid,title", false), + Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true), + ) + + DescribeTable("rejects invalid specs", + func(spec string, isAlbum bool, msg string) { + Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg))) + }, + Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`), + Entry("empty field", "album||title", true, "empty attribute"), + Entry("empty attribute", "album,,title", true, "empty attribute"), + Entry("trailing separator", "album|", true, "empty attribute"), + Entry("albumid in an album spec", "albumid,album", true, "albumid"), + Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`), + Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`), + Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`), + ) +}) diff --git a/model/scanner.go b/model/scanner.go index 36c9007fb..d22c3d0d6 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -2,12 +2,15 @@ package model import ( "context" + "errors" "fmt" "strconv" "strings" "time" ) +var ErrAlreadyScanning = errors.New("already scanning") + // ScanTarget represents a specific folder within a library to be scanned. // NOTE: This struct is used as a map key, so it should only contain comparable types. type ScanTarget struct { diff --git a/model/tag_mappings.go b/model/tag_mappings.go index ce7d2f37b..5a8168754 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -195,6 +195,28 @@ func TagMappings() map[TagName]TagConf { return mappings } +// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name. +func CanonicalTagName(name string) (TagName, bool) { + tagName, ok := tagNameIndex()[TagName(name).ToLower()] + return tagName, ok +} + +// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases +// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid). +var tagNameIndex = sync.OnceValue(func() map[TagName]TagName { + mappings := TagMappings() + index := make(map[TagName]TagName, len(mappings)) + for name, tag := range mappings { + for _, alias := range tag.Aliases { + index[TagName(alias)] = name + } + } + for name := range mappings { + index[name] = name + } + return index +}) + func TagRolesConf() TagConf { _, cfg := parseMappings() return cfg.Roles diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go index e582c3f2f..91e54e5d4 100644 --- a/model/tag_mappings_test.go +++ b/model/tag_mappings_test.go @@ -192,3 +192,22 @@ var _ = Describe("TagConf", func() { }) }) }) + +var _ = Describe("CanonicalTagName", func() { + DescribeTable("resolves tag names and aliases", + func(name string, expected TagName) { + tagName, ok := CanonicalTagName(name) + Expect(ok).To(BeTrue()) + Expect(tagName).To(Equal(expected)) + }, + Entry("tag name", "album", TagAlbum), + Entry("alias", "talb", TagAlbum), + Entry("mixed case alias", "TALB", TagAlbum), + Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID), + ) + + It("does not resolve an unknown name", func() { + _, ok := CanonicalTagName("nosuchtag") + Expect(ok).To(BeFalse()) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index bf6b8995e..85da65cbc 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -93,6 +93,8 @@ func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpd "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, + "pid_album": l.PIDAlbum, + "pid_track": l.PIDTrack, }, colsToUpdate...) cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) @@ -176,6 +178,15 @@ func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error { return err } +func (r *libraryRepository) SetScannedPID(ctx context.Context, id int, pid model.PIDConfig) error { + sq := Update(r.tableName). + Set("scanned_pid_album", pid.Album). + Set("scanned_pid_track", pid.Track). + Where(Eq{"id": id}) + _, err := r.executeSQL(ctx, sq) + return err +} + func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) { query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}}) count, err := r.count(ctx, query) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 0ff470861..bf485a06f 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -270,6 +270,38 @@ var _ = Describe("LibraryRepository", func() { }) }) + Describe("PID config", func() { + It("stores the overrides, and Put never touches the scanned specs", func() { + lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"} + Expect(repo.Put(ctx, lib)).To(Succeed()) + Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed()) + + // An update coming from the REST API has no scanned specs. It must not clear them + update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"} + Expect(repo.Put(ctx, update)).To(Succeed()) + + saved, err := repo.Get(ctx, lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.PIDAlbum).To(BeEmpty()) + Expect(saved.PIDTrack).To(Equal("title")) + Expect(saved.ScannedPIDAlbum).To(Equal("folder")) + Expect(saved.ScannedPIDTrack).To(Equal("title")) + }) + + It("keeps the overrides when a partial update does not send them", func() { + lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"} + Expect(repo.Put(ctx, lib)).To(Succeed()) + + Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed()) + + saved, err := repo.Get(ctx, lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Name).To(Equal("Renamed")) + Expect(saved.PIDAlbum).To(Equal("folder")) + Expect(saved.PIDTrack).To(Equal("title")) + }) + }) + Describe("Delete", func() { var adminRepo model.LibraryRepository var artistRepo model.ArtistRepository diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index f0d8a0e06..238919259 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -328,11 +328,22 @@ "totalDuration": "Duração", "defaultNewUsers": "Padrão para Novos Usuários", "createdAt": "Data de Criação", - "updatedAt": "Últ. Atualização" + "updatedAt": "Últ. Atualização", + "pidAlbum": "Agrupamento de álbuns", + "pidTrack": "Identificação das faixas" }, "sections": { "basic": "Informações Básicas", - "statistics": "Estatísticas" + "statistics": "Estatísticas", + "pid": "IDs Persistentes" + }, + "pid": { + "global": "Usar configuração global (%{value})", + "folder": "Pasta (um álbum por pasta)", + "custom": "Personalizado", + "spec": "Especificação do PID", + "help": "Tags e atributos que identificam um item. Consulte a sintaxe na documentação:", + "docs": "IDs Persistentes" }, "actions": { "scan": "Scanear Biblioteca", @@ -362,7 +373,9 @@ "messages": { "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.", "scanInProgress": "Scan em progresso...", - "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário" + "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário", + "pidChangeTitle": "Alterar os IDs persistentes?", + "pidChangeConfirm": "Ao salvar, os álbuns desta biblioteca serão reagrupados e as faixas serão identificadas novamente. Um scan completo da biblioteca começará imediatamente. As marcações como favoritas, as classificações e as contagens de reprodução das faixas serão mantidas. Os favoritos e as classificações dos álbuns serão transferidos para os novos álbuns quando um álbum antigo corresponder a um novo." } }, "plugin": { diff --git a/scanner/controller.go b/scanner/controller.go index 5eed6c58d..1b13c1846 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -25,7 +25,7 @@ import ( ) var ( - ErrAlreadyScanning = errors.New("already scanning") + ErrAlreadyScanning = model.ErrAlreadyScanning ) func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, @@ -304,14 +304,14 @@ func LockForMaintenance() (func(), bool) { return scanMaintenanceMux.Unlock, true } -// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted -// full scan in one of the included libraries. +// EffectiveFullScan reports whether a scan was requested as full, will resume an interrupted full scan, +// or will rescan a library in full because its PID config changed, in one of the included libraries. func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { if fullScan { return true } return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { - return library.FullScanInProgress + return library.FullScanInProgress || library.NeedsPIDRescan() }) } diff --git a/scanner/controller_test.go b/scanner/controller_test.go index bdcb99eda..974540e32 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -2,6 +2,7 @@ package scanner_test import ( "context" + "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -70,14 +71,21 @@ var _ = Describe("EffectiveFullScan", func() { var ds *tests.MockDataStore BeforeEach(func() { + pid := model.Library{}.EffectivePID() libraries := &tests.MockLibraryRepo{} libraries.SetData(model.Libraries{ - {ID: 1, FullScanInProgress: true}, - {ID: 2}, + {ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, }) ds = &tests.MockDataStore{MockedLibrary: libraries} }) + It("detects a library that needs a full rescan for a PID change", func() { + targets := []model.ScanTarget{{LibraryID: 3, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(GinkgoT().Context(), ds, false, targets)).To(BeTrue()) + }) + It("detects an interrupted full scan in a targeted library", func() { targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 7b6a6b097..4edaecacd 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -40,6 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) + state.markFailed(lib.ID) continue } jobs = append(jobs, job) @@ -51,12 +52,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor } type scanJob struct { - lib model.Library - fs storage.MusicFS - lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library - targetFolders []string // Specific folders to scan (including all descendants) - lock sync.Mutex - numFolders atomic.Int64 + lib model.Library + fs storage.MusicFS + lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library + targetFolders []string // Specific folders to scan (including all descendants) + prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one + lock sync.Mutex + numFolders atomic.Int64 } func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { @@ -77,16 +79,32 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full return nil, fmt.Errorf("getting fs for library: %w", err) } + pid := lib.EffectivePID() + if lib.NeedsPIDRescan() { + msg := "Scanner: PID config changed, rescanning library in full" + if len(targetFolders) > 0 { + msg = "Scanner: PID config changed, rescanning target folders in full" + } + log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders, + "album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack) + fullScan = true + } + var prevAlbumPIDConf string + if lib.ScannedPIDAlbum != pid.Album { + prevAlbumPIDConf = lib.ScannedPIDAlbum + } + // Ensure FullScanInProgress reflects the current scan request. // This is important when resuming an interrupted quick scan as a full scan: // the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly. lib.FullScanInProgress = lib.FullScanInProgress || fullScan return &scanJob{ - lib: lib, - fs: fsys, - lastUpdates: lastUpdates, - targetFolders: targetFolders, + lib: lib, + fs: fsys, + lastUpdates: lastUpdates, + targetFolders: targetFolders, + prevAlbumPIDConf: prevAlbumPIDConf, }, nil } @@ -122,14 +140,13 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry { // The phaseFolders struct implements the phase interface, providing methods to produce // folder entries, process folders, persist changes to the database, and log the results. type phaseFolders struct { - jobs []*scanJob - ds model.DataStore - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early - stopWalk context.CancelCauseFunc - state *scanState - prevAlbumPIDConf string - imageChanges *imageChangeCollector + jobs []*scanJob + ds model.DataStore + ctx context.Context //nolint:containedctx // phase runs under a single scan ctx + walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early + stopWalk context.CancelCauseFunc + state *scanState + imageChanges *imageChangeCollector } func (p *phaseFolders) description() string { @@ -138,12 +155,6 @@ func (p *phaseFolders) description() string { func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { return ppl.NewProducer(func(put func(entry *folderEntry)) error { - var err error - p.prevAlbumPIDConf, err = p.ds.Property().DefaultGet(p.ctx, consts.PIDAlbumKey, "") - if err != nil { - return fmt.Errorf("getting album PID conf: %w", err) - } - // TODO Parallelize multiple job when we have multiple libraries var total int64 var totalChanged int64 @@ -173,7 +184,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { // Check if folder is outdated if folder.isOutdated() { - if !p.state.fullScan { + if !folder.job.lib.FullScanInProgress { // Ancestor folders need a row even with no files of their own: artwork // resolution climbs them, and an image added later needs a state to diff. if folder.isEmpty() && folder.isNew() { @@ -239,7 +250,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { for afPath, af := range entry.audioFiles { fullPath := path.Join(entry.path, afPath) dbTrack, foundInDB := dbTracks[fullPath] - if !foundInDB || p.state.fullScan { + if !foundInDB || entry.job.lib.FullScanInProgress { filesToImport[fullPath] = dbTrack } else { info, err := af.Info() @@ -289,18 +300,18 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string } for filePath, info := range allInfo { md := metadata.New(filePath, info) - track := md.ToMediaFile(entry.job.lib.ID, entry.id) + track := md.ToMediaFile(entry.job.lib, entry.id) tracks = append(tracks, track) for _, t := range track.Tags.FlattenAll() { uniqueTags[t.ID] = t } // Keep track of any album ID changes, to reassign annotations later - prevAlbumID := "" + prevAlbumID := track.AlbumID if prev := toImport[filePath]; prev != nil { prevAlbumID = prev.AlbumID - } else { - prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf) + } else if entry.job.prevAlbumPIDConf != "" { + prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf) } _, ok := entry.albumIDMap[track.AlbumID] if prevAlbumID != track.AlbumID && !ok { @@ -453,7 +464,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en if len(queueItems) > 0 { queue := tx.ArtworkQueue() enqueue := queue.Enqueue - if p.state.fullScan { + if entry.job.lib.FullScanInProgress { enqueue = queue.EnqueueIfMissing } if err := enqueue(ctx, queueItems...); err != nil { diff --git a/scanner/scanner.go b/scanner/scanner.go index cd2fe3c8d..305c443f4 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -4,13 +4,11 @@ import ( "context" "fmt" "maps" - "path/filepath" "slices" "sync/atomic" "time" ppl "github.com/google/go-pipeline/pkg/pipeline" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -32,6 +30,7 @@ type scanState struct { libraries model.Libraries // Store libraries list for consistency across phases targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection + failedLibs map[int]bool // Libraries that could not be scanned in this run } func (s *scanState) sendProgress(info *ProgressInfo) { @@ -48,29 +47,15 @@ func (s *scanState) sendWarning(msg string) { s.sendProgress(&ProgressInfo{Warning: msg}) } -func (s *scanState) sendError(err error) { - s.sendProgress(&ProgressInfo{Error: err.Error()}) +func (s *scanState) markFailed(libID int) { + if s.failedLibs == nil { + s.failedLibs = map[int]bool{} + } + s.failedLibs[libID] = true } -// libraryRelativePath rebases an absolute scan target path onto the library root, since the -// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside -// the library root, are returned unchanged. -func libraryRelativePath(libPath, folderPath string) string { - if !filepath.IsAbs(folderPath) { - return folderPath - } - // The library root may be relative (e.g. the default "./music"); it must be made absolute - // to match against an absolute target, and it resolves against the same cwd as the scanner's fs. - absLib, err := filepath.Abs(libPath) - if err != nil { - return folderPath - } - rel, err := filepath.Rel(absLib, folderPath) - if err != nil || !filepath.IsLocal(rel) { - return folderPath - } - // The scanner's fs.FS is an io/fs, which always uses forward slashes. - return filepath.ToSlash(rel) +func (s *scanState) sendError(err error) { + s.sendProgress(&ProgressInfo{Error: err.Error()}) } func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { @@ -104,7 +89,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] }) for _, target := range targets { - folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath) + folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath) if folderPath == "" { folderPath = "." } @@ -137,6 +122,10 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // if there was a full scan in progress, force a full scan if !state.fullScan { for _, lib := range state.libraries { + // A pending PID rescan already restarts in full through its own job + if lib.NeedsPIDRescan() { + continue + } if lib.FullScanInProgress { log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) state.fullScan = true @@ -215,10 +204,13 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt var successfulLibs []model.Library for _, lib := range state.libraries { - if lib.LastScanStartedAt.IsZero() { + // A library with a changed PID config restarts its scan: resuming would skip the folders that + // the interrupted scan already processed with the old config + pidRescan := lib.NeedsPIDRescan() + if lib.LastScanStartedAt.IsZero() || pidRescan { // This is a new scan - mark it as started err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan) + return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan || pidRescan) }, "scanner: begin library scan") if err != nil { log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) @@ -340,11 +332,12 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil { return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) } - if err := tx.Property().Put(ctx, consts.PIDTrackKey, conf.Server.PID.Track); err != nil { - return fmt.Errorf("updating track PID conf: %w", err) - } - if err := tx.Property().Put(ctx, consts.PIDAlbumKey, conf.Server.PID.Album); err != nil { - return fmt.Errorf("updating album PID conf: %w", err) + // A selective scan covers only part of the library, so the rest may still use the old PID + // config. A library that could not be scanned did not apply it either. + if !state.isSelectiveScan() && !state.failedLibs[lib.ID] { + if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil { + return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err) + } } if state.changesDetected.Load() { log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go index 0778bd6ec..e8abb7c7d 100644 --- a/scanner/scanner_internal_test.go +++ b/scanner/scanner_internal_test.go @@ -4,8 +4,6 @@ package scanner import ( "context" "errors" - "os" - "path/filepath" "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" @@ -13,43 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("libraryRelativePath", func() { - // Paths are built with filepath so the "absolute" cases stay absolute on every OS - // (a Unix-style "/foo" is not absolute on Windows). - libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) - outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) - - It("returns a relative path unchanged", func() { - Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) - }) - - It("rebases an absolute target when the library root is relative", func() { - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) - }) - - It("rebases an absolute path that equals the library root to '.'", func() { - Expect(libraryRelativePath(libRoot, libRoot)).To(Equal(".")) - }) - - It("rebases an absolute path under the library root", func() { - Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("handles a trailing slash on the library path", func() { - Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("leaves an absolute path outside the library root unchanged", func() { - Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside)) - }) - - It("returns an empty path unchanged", func() { - Expect(libraryRelativePath(libRoot, "")).To(Equal("")) - }) -}) - type mockPhase struct { num int produceFunc func() ppl.Producer[int] diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 546baf756..f6634c875 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -835,4 +835,170 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(lastError).To(BeEmpty()) }) }) + + Context("Per-library PID config", func() { + albumsOf := func(libID int) model.Albums { + // The mock datastore's GC is a no-op, so run the real one to purge the albums left + // empty by a regroup, as the scanner does in production + Expect(ds.RealDS.GC(ctx)).To(Succeed()) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + Filters: squirrel.Eq{"library_id": libID, "missing": false}, + Sort: "name", + }) + Expect(err).ToNot(HaveOccurred()) + return albums + } + trackByTitle := func(libID int, title string) model.MediaFile { + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + Filters: squirrel.Eq{"library_id": libID, "title": title}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(1)) + return mfs[0] + } + rockTitles := func() []string { + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib1.ID}}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(mfs, func(mf model.MediaFile) string { return mf.Title }) + } + // changeRockInDB edits the rock track in the DB only. A full rescan of the rock library would + // restore the title from the file tags, a quick scan leaves it alone. + changeRockInDB := func() { + _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Changed In DB' where library_id = ?", lib1.ID) + Expect(err).ToNot(HaveOccurred()) + } + // changeBlueTrainInDB does the same for one jazz track + changeBlueTrainInDB := func() { + _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Blue Train In DB' where library_id = ? and title = 'Blue Train'", lib2.ID) + Expect(err).ToNot(HaveOccurred()) + } + + BeforeEach(func() { + beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969}) + _ = createFS("rock", fstest.MapFS{ + "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), + }) + + miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959}) + coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960}) + blueTrain := template(_t{"albumartist": "John Coltrane", "album": "Blue Train", "year": 1957}) + _ = createFS("jazz", fstest.MapFS{ + "Loose/01 - So What.mp3": miles(track(1, "So What")), + "Loose/02 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), + "Coltrane/Blue Train/01 - Blue Train.mp3": blueTrain(track(1, "Blue Train")), + }) + }) + + It("regroups only the library whose PID config changed, keeping annotations", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + Expect(albumsOf(lib2.ID)).To(HaveLen(3)) + + // Star Blue Train, to check the star follows the album to its new ID + oldBlueTrain := trackByTitle(lib2.ID, "Blue Train") + Expect(ds.Album().SetStar(ctx, true, oldBlueTrain.AlbumID)).To(Succeed()) + changeRockInDB() + + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Jazz is grouped by folder now: "Loose" is one album + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + Expect(trackByTitle(lib2.ID, "So What").AlbumID).To(Equal(trackByTitle(lib2.ID, "Giant Steps").AlbumID)) + + newBlueTrain := trackByTitle(lib2.ID, "Blue Train") + Expect(newBlueTrain.AlbumID).ToNot(Equal(oldBlueTrain.AlbumID)) + album, err := ds.Album().Get(ctx, newBlueTrain.AlbumID) + Expect(err).ToNot(HaveOccurred()) + Expect(album.Starred).To(BeTrue()) + + // Rock only got a quick scan + Expect(rockTitles()).To(ConsistOf("Changed In DB")) + + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) + Expect(jazz.PIDChanged()).To(BeFalse()) + rock, err := ds.Library().Get(ctx, lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rock.PIDChanged()).To(BeFalse()) + }) + + It("rescans only libraries that follow the global config", func() { + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, true)).To(Succeed()) + changeRockInDB() + changeBlueTrainInDB() + + conf.Server.PID.Album = "album" + Expect(runScanner(ctx, false)).To(Succeed()) + + // Rock follows the global config, so it was rescanned in full and its title restored + Expect(rockTitles()).To(ConsistOf("Come Together")) + // Jazz has its own override, so it only got a quick scan + trackByTitle(lib2.ID, "Blue Train In DB") + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) + }) + + It("restarts an interrupted scan when the PID config changed meanwhile", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Simulate a quick scan of jazz that was interrupted after it had processed every folder: + // the folders were updated after the (old) scan start time + _, err := db.Db().ExecContext(ctx, "update library set last_scan_started_at = ?, full_scan_in_progress = false where id = ?", + time.Now().Add(-time.Hour), lib2.ID) + Expect(err).ToNot(HaveOccurred()) + + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Every folder was revisited with the new config + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + }) + + It("does not turn an interrupted PID rescan into a full scan of every library", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + changeRockInDB() + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + + // Simulate a PID full scan of jazz that was interrupted + Expect(ds.Library().ScanBegin(ctx, lib2.ID, true)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Rock only got a quick scan, jazz was rescanned with the new config + Expect(rockTitles()).To(ConsistOf("Changed In DB")) + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + }) + + It("does not record the PID config for a library that could not be scanned", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + broken := model.Library{Name: "Broken", Path: "unregistered:///music", PIDAlbum: "folder"} + Expect(ds.Library().Put(ctx, &broken)).To(Succeed()) + + // The scan reports an error for the broken library, and still finishes the others + _ = runScanner(ctx, false) + + reloaded, err := ds.Library().Get(ctx, broken.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.PIDChanged()).To(BeTrue()) + }) + + It("does not record the PID config after a selective scan", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: lib2.ID, FolderPath: "Loose"}}) + Expect(err).ToNot(HaveOccurred()) + + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.PIDChanged()).To(BeTrue()) + }) + }) }) diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index f1e6c4539..61013dd5a 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -22,7 +22,12 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec return nil, model.ErrNotFound } - return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID) + lib, err := ds.Library().Get(ctx, file.LibraryID) + if err != nil { + return nil, err + } + + return core.Inspect(file.AbsolutePath(), *lib, file.FolderID) } func inspect(ds model.DataStore) http.HandlerFunc { diff --git a/server/serve_index.go b/server/serve_index.go index 4b093b953..167197403 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, + "pidAlbum": conf.Server.PID.Album, + "pidTrack": conf.Server.PID.Track, "enableNowPlaying": conf.Server.EnableNowPlaying, "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e2df55c4b..277513768 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -89,6 +89,8 @@ var _ = Describe("serveIndex", func() { Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), + Entry("pidAlbum", func() { conf.Server.PID.Album = "folder" }, "pidAlbum", "folder"), + Entry("pidTrack", func() { conf.Server.PID.Track = "title" }, "pidTrack", "title"), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true), diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index e21dcccce..6de2b9265 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -145,6 +145,17 @@ func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error { return nil } +func (m *MockLibraryRepo) SetScannedPID(_ context.Context, id int, pid model.PIDConfig) error { + if m.Err != nil { + return m.Err + } + if lib, ok := m.Data[id]; ok { + lib.ScannedPIDAlbum, lib.ScannedPIDTrack = pid.Album, pid.Track + m.Data[id] = lib + } + return nil +} + func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) { if m.Err != nil { return false, m.Err diff --git a/ui/src/config.js b/ui/src/config.js index e406e47cf..62b3cb822 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -32,6 +32,8 @@ const defaultConfig = { listenBrainzEnabled: true, enableExternalServices: true, enableCoverAnimation: true, + pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID + pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID enableNowPlaying: true, playbackReportIntervalMs: 60000, devShowArtistPage: true, diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index a04b6e311..f694ea75f 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -331,11 +331,22 @@ "totalDuration": "Duration", "defaultNewUsers": "Default for New Users", "createdAt": "Created", - "updatedAt": "Updated" + "updatedAt": "Updated", + "pidAlbum": "Album grouping", + "pidTrack": "Track identity" }, "sections": { "basic": "Basic Information", - "statistics": "Statistics" + "statistics": "Statistics", + "pid": "Persistent IDs" + }, + "pid": { + "global": "Use global setting (%{value})", + "folder": "Folder (one album per folder)", + "custom": "Custom", + "spec": "PID spec", + "help": "Tags and attributes that identify an item. See the documentation for the syntax:", + "docs": "Persistent IDs" }, "actions": { "scan": "Scan Library", @@ -365,7 +376,9 @@ "messages": { "deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.", "scanInProgress": "Scan in progress...", - "noLibrariesAssigned": "No libraries assigned to this user" + "noLibrariesAssigned": "No libraries assigned to this user", + "pidChangeTitle": "Change persistent IDs?", + "pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one." } }, "plugin": { diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx index 0e69964b6..8166bb2f3 100644 --- a/ui/src/library/LibraryCreate.jsx +++ b/ui/src/library/LibraryCreate.jsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react' +import PropTypes from 'prop-types' import { Create, SimpleForm, @@ -10,7 +11,34 @@ import { useNotify, useRedirect, } from 'react-admin' +import { Typography } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' import { Title } from '../common' +import { PIDInputs } from './PIDInput' + +const useStyles = makeStyles((theme) => ({ + spaced: { marginTop: theme.spacing(3) }, +})) + +// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly +const SectionTitle = ({ label, spaced }) => { + const translate = useTranslate() + const classes = useStyles() + return ( + + {translate(label)} + + ) +} + +SectionTitle.propTypes = { + label: PropTypes.string.isRequired, + spaced: PropTypes.bool, +} const LibraryCreate = (props) => { const translate = useTranslate() @@ -73,9 +101,12 @@ const LibraryCreate = (props) => { return ( } {...props}> + + + ) diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index 53d17ac7f..c42c7ac4b 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -1,9 +1,11 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' +import PropTypes from 'prop-types' import { Edit, FormWithRedirect, TextInput, BooleanInput, + Confirm, required, SaveButton, useTranslate, @@ -22,6 +24,9 @@ import { ReadOnlySizeField, Title, } from '../common' +import config from '../config' +import { PIDInputs } from './PIDInput' +import { pidConfigChanged } from './pidPresets' const useStyles = makeStyles({ toolbar: { @@ -53,8 +58,131 @@ const CustomToolbar = ({ showDelete, ...props }) => ( ) -const LibraryEdit = (props) => { +export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => { const translate = useTranslate() + const [confirmOpen, setConfirmOpen] = useState(false) + + // Every submit path (Save button and Enter key) goes through here, so a PID change always asks first + const submit = () => { + if ( + pidConfigChanged( + formProps.form.getState().values, + formProps.record, + config, + ) + ) { + setConfirmOpen(true) + return + } + formProps.handleSubmit() + } + + const handleConfirm = () => { + setConfirmOpen(false) + formProps.handleSubmit() + } + + return ( +
{ + event.preventDefault() + submit() + }} + > + + + + {/* Basic Information */} + + {translate('resources.library.sections.basic')} + + + + + + + + + {translate('resources.library.sections.pid')} + + + + + + {/* Statistics - Two Column Layout */} + + {translate('resources.library.sections.statistics')} + + + + + + + + + + + + + + + + + + + + + setConfirmOpen(false)} + /> + + ) +} + +LibraryEditForm.propTypes = { + formProps: PropTypes.object.isRequired, + canEditPath: PropTypes.bool, + canDelete: PropTypes.bool, +} + +const LibraryEdit = (props) => { const [mutate] = useMutation() const notify = useNotify() const redirect = useRedirect() @@ -93,91 +221,11 @@ const LibraryEdit = (props) => { {...props} save={save} render={(formProps) => ( -
- - - - {/* Basic Information */} - - {translate('resources.library.sections.basic')} - - - - - - - - - {/* Statistics - Two Column Layout */} - - {translate('resources.library.sections.statistics')} - - - - - - - - - - - - - - - - - - - - - + )} /> diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx new file mode 100644 index 000000000..926adc839 --- /dev/null +++ b/ui/src/library/LibraryEdit.test.jsx @@ -0,0 +1,125 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { + FormWithRedirect, + RecordContextProvider, + SaveContextProvider, +} from 'react-admin' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { LibraryEditForm } from './LibraryEdit' +import config from '../config' + +const record = { + id: '2', + name: 'Jazz', + path: '/music/jazz', + pidAlbum: '', + pidTrack: '', +} + +// Edit provides a save context in the app. SaveButton only reads these setters from it +const saveContext = { + save: vi.fn(), + setOnSuccess: vi.fn(), + setOnFailure: vi.fn(), + setTransform: vi.fn(), +} + +const renderForm = (save) => + render( + + + + ( + + )} + /> + + + , + ) + +const chooseAlbumGrouping = (optionText) => { + fireEvent.mouseDown( + screen.getByLabelText('resources.library.fields.pidAlbum'), + ) + fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText)) +} + +const dialogTitle = 'resources.library.messages.pidChangeTitle' + +describe('LibraryEditForm', () => { + afterEach(cleanup) + + it('saves directly when the PID config did not change', async () => { + const save = vi.fn() + renderForm(save) + fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), { + target: { value: 'Jazz Renamed' }, + }) + fireEvent.click(screen.getByText('ra.action.save')) + await waitFor(() => expect(save).toHaveBeenCalled()) + expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument() + }) + + it('asks before saving a PID change, and Cancel keeps the edits', async () => { + const save = vi.fn() + renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.click(screen.getByText('ra.action.save')) + + expect(await screen.findByText(dialogTitle)).toBeInTheDocument() + expect(save).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('ra.action.cancel')) + await waitFor(() => + expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(), + ) + expect(save).not.toHaveBeenCalled() + expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument() + }) + + it('saves the PID change after Confirm', async () => { + const save = vi.fn() + renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.click(screen.getByText('ra.action.save')) + fireEvent.click(await screen.findByText('ra.action.confirm')) + + await waitFor(() => expect(save).toHaveBeenCalled()) + expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' }) + }) + + it('pre-fills a Custom spec with the global spec', () => { + renderForm(vi.fn()) + chooseAlbumGrouping('resources.library.pid.custom') + expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue( + config.pidAlbum, + ) + }) + + it('asks before saving when the form is submitted with Enter', async () => { + const save = vi.fn() + const { container } = renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.submit(container.querySelector('form')) + + expect(await screen.findByText(dialogTitle)).toBeInTheDocument() + expect(save).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/library/PIDInput.jsx b/ui/src/library/PIDInput.jsx new file mode 100644 index 000000000..6481dc392 --- /dev/null +++ b/ui/src/library/PIDInput.jsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { TextInput, required, useTranslate } from 'react-admin' +import { useField } from 'react-final-form' +import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { + PID_CUSTOM, + PID_FOLDER, + PID_GLOBAL, + pidModeFromValue, + pidValueForMode, +} from './pidPresets' +import config from '../config' +import { docsUrl } from '../utils' + +const PID_DOCS_URL = docsUrl('/docs/usage/pids/') + +const useStyles = makeStyles((theme) => ({ + help: { marginBottom: theme.spacing(1) }, +})) + +// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec +export const PIDInput = ({ source, label, globalValue, allowFolder }) => { + const translate = useTranslate() + const classes = useStyles() + const { input } = useField(source) + // Local state, so choosing Custom shows the text box before anything is typed + const [mode, setMode] = useState(() => + pidModeFromValue(input.value, allowFolder), + ) + + const choices = [ + { + id: PID_GLOBAL, + name: translate('resources.library.pid.global', { value: globalValue }), + }, + ...(allowFolder + ? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }] + : []), + { id: PID_CUSTOM, name: translate('resources.library.pid.custom') }, + ] + + const handleModeChange = (event) => { + const newMode = event.target.value + setMode(newMode) + input.onChange(pidValueForMode(newMode, globalValue)) + } + + return ( + <> + + {choices.map((choice) => ( + + {choice.name} + + ))} + + {mode === PID_CUSTOM && ( + <> + + + {translate('resources.library.pid.help')}{' '} + + {translate('resources.library.pid.docs')} + + + + )} + + ) +} + +PIDInput.propTypes = { + source: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + globalValue: PropTypes.string, + allowFolder: PropTypes.bool, +} + +export const PIDInputs = () => { + const translate = useTranslate() + return ( + <> + + + + ) +} diff --git a/ui/src/library/pidPresets.js b/ui/src/library/pidPresets.js new file mode 100644 index 000000000..0483fc691 --- /dev/null +++ b/ui/src/library/pidPresets.js @@ -0,0 +1,33 @@ +export const PID_GLOBAL = 'global' +export const PID_FOLDER = 'folder' +export const PID_CUSTOM = 'custom' + +export const pidModeFromValue = (value, allowFolder) => { + const v = (value || '').trim() + if (v === '') return PID_GLOBAL + if (allowFolder && v === PID_FOLDER) return PID_FOLDER + return PID_CUSTOM +} + +// Returns the value to store for a dropdown choice. Custom starts from the global spec +export const pidValueForMode = (mode, globalValue) => { + switch (mode) { + case PID_GLOBAL: + return '' + case PID_FOLDER: + return PID_FOLDER + default: + return globalValue || '' + } +} + +// Reports whether the form values change the effective PID spec of the saved record. Like the +// server, it trims, treats empty as the global value and compares case-insensitively +export const pidConfigChanged = (values, record, globals) => { + const effective = (value, field) => + ((value || '').trim() || globals[field] || '').toLowerCase() + return ['pidAlbum', 'pidTrack'].some( + (field) => + effective(values[field], field) !== effective(record[field], field), + ) +} diff --git a/ui/src/library/pidPresets.test.js b/ui/src/library/pidPresets.test.js new file mode 100644 index 000000000..dab1c82e2 --- /dev/null +++ b/ui/src/library/pidPresets.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { + PID_CUSTOM, + PID_FOLDER, + PID_GLOBAL, + pidConfigChanged, + pidModeFromValue, + pidValueForMode, +} from './pidPresets' + +describe('pidModeFromValue', () => { + it('maps an empty value to the global setting', () => { + expect(pidModeFromValue('', true)).toBe(PID_GLOBAL) + expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL) + }) + it('maps folder to the Folder preset when allowed', () => { + expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER) + }) + it('maps folder to Custom when the Folder preset is not offered', () => { + expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM) + }) + it('maps any other value to Custom', () => { + expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM) + }) +}) + +describe('pidValueForMode', () => { + it('stores an empty value for the global setting', () => { + expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('') + }) + it('stores folder for the Folder preset', () => { + expect(pidValueForMode(PID_FOLDER, '')).toBe('folder') + }) + it('starts Custom from the global spec', () => { + expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title') + expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('') + }) +}) + +describe('pidConfigChanged', () => { + const record = { pidAlbum: 'folder', pidTrack: '' } + const globals = { + pidAlbum: 'musicbrainz_albumid|albumartistid,album', + pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', + } + it.each([ + ['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false], + ['a missing value equals an empty one', { pidAlbum: 'folder' }, false], + [ + 'Custom set to the global value', + { pidAlbum: 'folder', pidTrack: globals.pidTrack }, + false, + ], + ['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false], + [ + 'a whitespace-only change', + { pidAlbum: ' folder ', pidTrack: ' ' }, + false, + ], + ['the album PID changed', { pidAlbum: '', pidTrack: '' }, true], + ['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true], + ])('%s', (_, values, expected) => { + expect(pidConfigChanged(values, record, globals)).toBe(expected) + }) +}) From 95f67d2c4ef391967327f0c7dccd5d0d8b02f62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 3 Oct 2026 08:58:54 -0700 Subject: [PATCH 39/41] fix(share): reuse cached transcodes for share streams and zip downloads (#6262) * fix(share): reuse cached transcodes when streaming from share links Public share streams built the stream request with only the share's format and bit rate, leaving sample rate, bit depth and channels at zero. Regular playback resolves those through the transcode decider (e.g. 48000 Hz for Opus), and they are part of the transcoding cache key, so a track already transcoded during normal playback was transcoded again into a separate, identical cache entry when played through a share link. The public router now resolves share stream requests with the same TranscodeDecider.ResolveRequest used by the Subsonic stream endpoint, so both paths produce the same request and share cache entries. Fixes #6261 * fix(archiver): reuse cached transcodes when zipping downloads Zip downloads (album, artist, playlist and share) built the stream request with only the format and bit rate, leaving sample rate, bit depth and channels at zero. Those are part of the transcoding cache key, so a track already transcoded for playback was transcoded again into a separate cache entry when downloaded in a zip, and vice versa. The archiver now resolves each request with TranscodeDecider.ResolveRequest, the same as single-song downloads and streams. This also applies the decider's defaults, so a zip requested without a bit rate uses the target format's default bit rate instead of leaving it to ffmpeg. * fix(archiver): name zip entries after the resolved transcoding format The transcode decider can pick a different format than the one requested (for example a player's forced transcoding, or a fallback to the default downsampling format when the requested one can't be produced). Zip entry names and the playlist M3U were still built from the requested format, so an entry could end in .mp3 or .flac while holding Opus data. Each track's request is now resolved before its entry name is built, and the name uses the resolved format. --- cmd/wire_gen.go | 9 +-- core/archiver.go | 30 ++++++---- core/archiver_test.go | 58 +++++++++++++++++++- server/public/handle_streams.go | 5 +- server/public/handle_streams_test.go | 17 ++++-- server/public/public.go | 5 +- server/subsonic/e2e/subsonic_artwork_test.go | 2 +- 7 files changed, 101 insertions(+), 25 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 19f92d9d5..2a396689d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -95,8 +95,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) + archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) @@ -110,7 +111,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router @@ -159,9 +159,10 @@ func CreatePublicRouter() *public.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) - router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) + archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) + router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver) return router } diff --git a/core/archiver.go b/core/archiver.go index 6f362322a..60eb44858 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -35,13 +35,14 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { - return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork} +func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { + return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork} } type archiver struct { ds model.DataStore ms stream.MediaStreamer + decider stream.TranscodeDecider shares Share artwork artwork.Artwork } @@ -78,8 +79,9 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { - file := a.albumFilename(mf, format, isMultiDisc, folder) - if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + req := a.resolveRequest(ctx, &mf, format, bitrate) + file := a.albumFilename(mf, req.Format, isMultiDisc, folder) + if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { // Stop iterating: continuing would just rack up more // rejections from the limiter. Close finalises whatever // tracks were already written; the rejected one is not @@ -204,8 +206,9 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { - file := a.playlistFilename(mf, format, idx) - if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + req := a.resolveRequest(ctx, &mf, format, bitrate) + file := a.playlistFilename(mf, req.Format, idx) + if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { // Abort the whole archive: continuing would silently emit // empty zip entries since the headers are already written. _ = z.Close() @@ -251,7 +254,14 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } -func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { +func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request { + if format == "" || format == "raw" { + return stream.Request{Format: "raw"} + } + return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0) +} + +func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error { path := mf.AbsolutePath() // Open the source before writing the zip entry header so a rejection @@ -259,13 +269,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med // archive. var r io.ReadCloser var err error - if format != "raw" && format != "" { - r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + if req.Format != "raw" { + r, err = a.ms.NewStream(ctx, &mf, req) } else { r, err = os.Open(path) } if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err) return err } defer func() { diff --git a/core/archiver_test.go b/core/archiver_test.go index 4e00ce78c..178d1b6b9 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -26,6 +26,7 @@ var _ = Describe("Archiver", func() { var ( arch core.Archiver ms *mockMediaStreamer + dc *fakeDecider ds *mockDataStore sh *mockShare ca *mockCoverArt @@ -33,10 +34,11 @@ var _ = Describe("Archiver", func() { BeforeEach(func() { ms = &mockMediaStreamer{} + dc = &fakeDecider{} sh = &mockShare{} ds = &mockDataStore{} ca = &mockCoverArt{images: map[string][]byte{}} - arch = core.NewArchiver(ms, ds, sh, ca) + arch = core.NewArchiver(ms, dc, ds, sh, ca) }) Context("ZipAlbum", func() { @@ -66,6 +68,23 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3")) }) + + It("streams the request resolved by the transcode decider and names the entry after its format", func() { + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil) + ds.On("MediaFile").Return(mfRepo) + resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2} + dc.resolved = &resolved + ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once() + + out := new(bytes.Buffer) + Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) + ms.AssertExpectations(GinkgoT()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus")) + }) }) Context("ZipArtist", func() { @@ -296,6 +315,30 @@ var _ = Describe("Archiver", func() { }) Context("ZipPlaylist", func() { + It("names the entries and the M3U lines after the resolved format", func() { + pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{ + {MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}}, + }} + plRepo := &mockPlaylistRepository{} + plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) + ds.On("Playlist").Return(plRepo) + dc.resolved = &stream.Request{Format: "opus", BitRate: 128} + ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil) + + out := new(bytes.Buffer) + Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus")) + m3u, err := zr.File[1].Open() + Expect(err).ToNot(HaveOccurred()) + defer m3u.Close() + content, err := io.ReadAll(m3u) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus")) + }) + It("zips a playlist correctly", func() { tracks := []model.PlaylistTrack{ {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}}, @@ -571,6 +614,19 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } +// fakeDecider echoes the legacy format/bitrate unless a resolved request is set. +type fakeDecider struct { + stream.TranscodeDecider + resolved *stream.Request +} + +func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { + if f.resolved != nil { + return *f.resolved + } + return stream.Request{Format: format, BitRate: bitRate, Offset: offset} +} + type mockShare struct { mock.Mock core.Share diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 37ae56c2b..46c7ca210 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -60,9 +60,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ - Format: info.format, BitRate: info.bitrate, - }) + streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0) + stream, err := pub.streamer.NewStream(ctx, mf, streamReq) if err != nil { if errors.Is(err, streampkg.ErrTooManyTranscodes) { w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 4b4a3545b..965bc7e05 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -116,11 +116,11 @@ var _ = Describe("handleStream", func() { BeforeEach(func() { ctx = GinkgoT().Context() auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - ds = &tests.MockDataStore{} + ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo streamer = &mockStreamer{} - pub = &Router{ds: ds, streamer: streamer} + pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))} }) makeRequest := func(token string) *httptest.ResponseRecorder { @@ -152,8 +152,17 @@ var _ = Describe("handleStream", func() { makeRequest(token) Expect(streamer.called).To(BeTrue()) - Expect(streamer.req.Format).To(Equal("mp3")) - Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() { + mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2} + shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf) + + claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2})) }) It("returns 404 when the track is outside the share owner's libraries", func() { diff --git a/server/public/public.go b/server/public/public.go index 142c474bd..8239ef927 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -21,14 +21,15 @@ type Router struct { http.Handler artwork artwork.Artwork streamer stream.MediaStreamer + decider stream.TranscodeDecider archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { - p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router { + p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) p.Handler = p.routes() diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 9394c8830..530588759 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -137,7 +137,7 @@ var _ = Describe("Artwork Serving", Ordered, func() { artRouter = buildArtworkRouter(artSvc) router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router - pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + pubRouter = public.New(ds, artSvc, streamerSpy, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{}) }) It("emits a bare optimistic coverArt id before the queue is drained", func() { From caa2f8a0c012328efb9d5685d67b8dc45d24a635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 6 Oct 2026 06:50:11 -0700 Subject: [PATCH 40/41] fix(ui): make playlist toggle switches visible in all themes (#6277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): make playlist toggle switches visible in all themes The Public and Auto-import switches in the playlist list did not set a color, so Material-UI used the theme's secondary color. Many themes use secondary as a surface color close to the table background, which made checked switches nearly invisible (Catppuccin, Rosé Pine, Monokai, Moonbase and others). Set color="primary" on the playlist switch, like every other switch in the app, and make primary the default MuiSwitch color in useCurrentTheme so future switches cannot regress. Fixes #6272. * refactor(ui): drop secondary switch overrides from themes Dracula, Gruvbox Dark, Tokyo Night and Tokyo Night Light styled checked MuiSwitch colorSecondary to work around the same invisible-switch problem (Gruvbox in #5064). With primary as the default switch color and every switch in the app using it, no switch renders with colorSecondary anymore, so these overrides are dead code. --- ui/src/playlist/PlaylistList.jsx | 1 + ui/src/playlist/PlaylistList.test.jsx | 27 ++++++++++++++++++++++++++ ui/src/themes/dracula.js | 10 ---------- ui/src/themes/gruvboxDark.js | 10 ---------- ui/src/themes/tokyoNight.js | 10 ---------- ui/src/themes/tokyoNightLight.js | 10 ---------- ui/src/themes/useCurrentTheme.js | 2 ++ ui/src/themes/useCurrentTheme.test.jsx | 27 ++++++++++++++++++++++++++ 8 files changed, 57 insertions(+), 40 deletions(-) diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 14d819a4e..e1695e980 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -95,6 +95,7 @@ export const ToggleField = ({ resource, source }) => { return ( diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx index 6c714b827..c05833166 100644 --- a/ui/src/playlist/PlaylistList.test.jsx +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -2,6 +2,7 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { TestContext } from 'ra-test' +import { RecordContextProvider } from 'react-admin' import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' vi.mock('../config', () => ({ @@ -14,6 +15,7 @@ vi.mock('../common', () => ({ {record?.starred ? 'starred' : 'not-starred'} ), + isWritable: (ownerId) => ownerId === 'me', })) describe('', () => { @@ -55,3 +57,28 @@ describe('playlist toggles without a record', () => { expect(container.innerHTML).toBe('') }) }) + +// Secondary is a surface color in many themes, so these toggles must use primary +describe('', () => { + const renderToggle = (record) => + render( + + + + + , + ) + + it.each([ + ['owner', 'me', false], + ['non-owner', 'someone-else', true], + ])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => { + renderToggle({ id: 'pl-1', public: true, ownerId }) + const input = screen.getByRole('checkbox') + const switchBase = input.closest('.MuiSwitch-switchBase') + expect(input.checked).toBe(true) + expect(input.disabled).toBe(disabled) + expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') + expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary') + }) +}) diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js index 2e4ae38e5..45559c3af 100644 --- a/ui/src/themes/dracula.js +++ b/ui/src/themes/dracula.js @@ -185,16 +185,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: green, - }, - '&$checked + $track': { - backgroundColor: green, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 0f4cbd7c4..3e2955dcd 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -121,16 +121,6 @@ export default { boxShadow: '3px 3px 5px #3c3836', }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: '#458588', - }, - '&$checked + $track': { - backgroundColor: '#458588', - }, - }, - }, NDMobileArtistDetails: { bgContainer: { background: diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js index 07d372a6b..9f6424b77 100644 --- a/ui/src/themes/tokyoNight.js +++ b/ui/src/themes/tokyoNight.js @@ -184,16 +184,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: blue, - }, - '&$checked + $track': { - backgroundColor: blue, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js index f84cd0be9..a61c0fe87 100644 --- a/ui/src/themes/tokyoNightLight.js +++ b/ui/src/themes/tokyoNightLight.js @@ -184,16 +184,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: blue, - }, - '&$checked + $track': { - backgroundColor: blue, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index 4ccefe820..fbb5e9bc8 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -63,6 +63,8 @@ const useCurrentTheme = () => { ...theme.props, MuiUseMediaQuery: { noSsr: true }, MuiPopover: { disableScrollLock: true }, + // MUI defaults to secondary, which many themes use as a surface color + MuiSwitch: { color: 'primary' }, }, }), [theme], diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx index 65c3be8c6..6553d9866 100644 --- a/ui/src/themes/useCurrentTheme.test.jsx +++ b/ui/src/themes/useCurrentTheme.test.jsx @@ -3,6 +3,10 @@ import { Provider } from 'react-redux' import { createStore } from 'redux' import mediaQuery from 'css-mediaquery' import { renderHook } from '@testing-library/react-hooks' +import { render, screen } from '@testing-library/react' +import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles' +import Switch from '@material-ui/core/Switch' +import themes from './index' import useCurrentTheme from './useCurrentTheme' import { themeReducer } from '../reducers/themeReducer' import { AUTO_THEME_ID } from '../consts' @@ -161,4 +165,27 @@ describe('useCurrentTheme', () => { expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)') }) }) + describe('switch color', () => { + it.each(Object.keys(themes))( + 'renders switches with the primary color in %s', + (theme) => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + render( + + {}} /> + , + ) + const switchBase = screen + .getByRole('checkbox') + .closest('.MuiSwitch-switchBase') + expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') + }, + ) + }) }) From 52135913d4747c8f8ddcfd8e7004202b8f53b594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 6 Oct 2026 07:57:07 -0700 Subject: [PATCH 41/41] fix(artwork): don't crash the server when a playlist's tracks can't be loaded (#6267) Playlist().Tracks returns nil when its internal Get fails (for example when the context is canceled at shutdown), and resolvePlaylist called GetAlbumIDs on it, panicking with a nil pointer dereference. The artwork drain runs on a bare goroutine, so the panic killed the whole server. resolvePlaylist now returns an error when Tracks is nil, and the worker recovers panics per item: it logs the panic with the item details and stack, and marks the item as a failed attempt so the rest of the batch still runs. Fixes #6266 --- core/artwork/resolve.go | 7 ++++-- core/artwork/resolve_test.go | 10 +++++++++ core/artwork/worker.go | 18 +++++++++++++++- core/artwork/worker_test.go | 42 ++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 40baa2495..fb07332fe 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } } - albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false). - GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) + tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false) + if tracks == nil { + return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID) + } + albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if err != nil { return resolution{}, err } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index da144d8e2..2a36531bb 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -707,6 +707,16 @@ var _ = Describe("resolveItem", func() { Expect(err).To(HaveOccurred()) Expect(res).To(Equal(resolution{})) }) + + It("returns an error when the playlist tracks cannot be loaded", func() { + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}}) + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"}) + Expect(err).To(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) }) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 28e51958c..4be99f92e 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -4,9 +4,11 @@ import ( "bytes" "cmp" "context" + "fmt" "io" "math" "math/rand/v2" + "runtime/debug" "sync" "time" @@ -244,7 +246,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) trace := &ChainTrace{} ctx = withTrace(ctx, trace) - out, got, retryIn := w.proc.acquire(ctx, item) + out, got, retryIn := w.safeAcquire(ctx, item) queue := w.proc.ds.ArtworkQueue() switch out { @@ -286,6 +288,20 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc return out, got } +// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an +// unrecovered panic would crash the server, and the still-queued row would crash it again on restart. +func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { + defer func() { + if r := recover(); r != nil { + log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID, + "imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack())) + traceStage(ctx, "panic", fmt.Errorf("%v", r)) + out, got, retryIn = outcomeFailed, nil, 0 + } + }() + return w.proc.acquire(ctx, item) +} + // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item // that never resolved has no row to update, and creating one would settle it absent. func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index a6c07b763..80ca68bc3 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -142,6 +142,18 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla return v.MockPlaylistRepo.Get(ctx, id) } +type panickingAlbumRepo struct { + *tests.MockAlbumRepo + panicID string +} + +func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) { + if id == r.panicID { + panic("boom") + } + return r.MockAlbumRepo.Get(ctx, id) +} + func adminUserRepo() *tests.MockedUserRepo { repo := tests.CreateMockUserRepo() Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) @@ -278,6 +290,36 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("fails an item that panics, without stopping the rest of the batch", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + albums := tests.CreateMockAlbumRepo() + albums.SetData(model.Albums{ + {ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}}, + {ID: "alok", Name: "Album", FolderIDs: []string{"f1"}}, + }) + ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"} + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(2)) + + it := findQueued(queueRepo, "al", "alboom") + Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped") + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + Expect(it.Trace).To(ContainSubstring("boom")) + + Expect(findQueued(queueRepo, "al", "alok")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + }) + It("reschedules past the provider's requested delay when it exceeds the backoff", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})