diff --git a/db/migrations/20250415111500_add_api_key_table.go b/db/migrations/20250415111500_add_api_key_table.go
new file mode 100644
index 000000000..d75ddd19f
--- /dev/null
+++ b/db/migrations/20250415111500_add_api_key_table.go
@@ -0,0 +1,39 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddApiKeyTable, downAddApiKeyTable)
+}
+
+func upAddApiKeyTable(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `
+ create table if not exists api_key (
+ id text not null primary key,
+ user_id text not null,
+ name text not null,
+ key text not null unique,
+ created_at datetime not null,
+
+ foreign key (user_id)
+ references user(id)
+ on delete cascade
+ );
+
+ create index if not exists api_key_key on api_key(key);
+ create index if not exists api_key_user_id on api_key(user_id);
+`)
+ return err
+}
+
+func downAddApiKeyTable(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `
+ drop table api_key;
+`)
+ return err
+}
diff --git a/model/api_key.go b/model/api_key.go
new file mode 100644
index 000000000..6d238f422
--- /dev/null
+++ b/model/api_key.go
@@ -0,0 +1,26 @@
+package model
+
+import (
+ "github.com/deluan/rest"
+ "time"
+)
+
+type APIKey struct {
+ ID string `structs:"id" json:"id"`
+ UserID string `structs:"user_id" json:"userId"`
+ Name string `structs:"name" json:"name"`
+ Key string `structs:"key" json:"key"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt"`
+}
+
+type APIKeys []APIKey
+
+type APIKeyRepository interface {
+ ResourceRepository
+ rest.Persistable
+ CountAll(...QueryOptions) (int64, error)
+ Get(id string) (*APIKey, error)
+ GetAll(options ...QueryOptions) (APIKeys, error)
+ Put(*APIKey) error
+ FindByKey(key string) (*APIKey, error)
+}
diff --git a/model/datastore.go b/model/datastore.go
index 4290e2134..ebd99e25b 100644
--- a/model/datastore.go
+++ b/model/datastore.go
@@ -38,6 +38,7 @@ type DataStore interface {
User(ctx context.Context) UserRepository
UserProps(ctx context.Context) UserPropsRepository
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
+ APIKey(ctx context.Context) APIKeyRepository
Resource(ctx context.Context, model interface{}) ResourceRepository
diff --git a/model/user.go b/model/user.go
index aabedc096..042ac8d60 100644
--- a/model/user.go
+++ b/model/user.go
@@ -56,4 +56,6 @@ type UserRepository interface {
// Library association methods
GetUserLibraries(userID string) (Libraries, error)
SetUserLibraries(userID string, libraryIDs []int) error
+ // FindByAPIKey finds a user by the provided API key
+ FindByAPIKey(key string) (*User, error)
}
diff --git a/persistence/api_key_repository.go b/persistence/api_key_repository.go
new file mode 100644
index 000000000..583578d3c
--- /dev/null
+++ b/persistence/api_key_repository.go
@@ -0,0 +1,158 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "github.com/deluan/rest"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/pocketbase/dbx"
+)
+
+type apiKeyRepository struct {
+ sqlRepository
+}
+
+func NewAPIKeyRepository(ctx context.Context, db dbx.Builder) model.APIKeyRepository {
+ r := &apiKeyRepository{}
+ r.ctx = ctx
+ r.db = db
+ r.registerModel(&model.APIKey{}, nil)
+ return r
+}
+
+func (r *apiKeyRepository) userFilter() Sqlizer {
+ user := loggedUser(r.ctx)
+ if user.IsAdmin {
+ return And{}
+ }
+ return Eq{"user_id": user.ID}
+}
+
+func (r *apiKeyRepository) CountAll(options ...model.QueryOptions) (int64, error) {
+ sq := Select().From(r.tableName).Where(r.userFilter())
+ return r.count(sq, options...)
+}
+
+func (r *apiKeyRepository) Get(id string) (*model.APIKey, error) {
+ sel := r.newSelect().Columns("*").Where(And{Eq{"id": id}})
+ var res model.APIKey
+ err := r.queryOne(sel, &res)
+ if err != nil {
+ return nil, err
+ }
+ return &res, err
+}
+
+func (r *apiKeyRepository) GetAll(options ...model.QueryOptions) (model.APIKeys, error) {
+ sel := r.newSelect(options...).Columns("*").Where(r.userFilter())
+ res := model.APIKeys{}
+ err := r.queryAll(sel, &res)
+ if err != nil {
+ return nil, err
+ }
+ return res, err
+}
+
+func (r *apiKeyRepository) Put(ak *model.APIKey) error {
+ if ak.ID == "" {
+ ak.ID = id.NewRandom()
+ }
+ ak.CreatedAt = time.Now()
+ values, err := toSQLArgs(*ak)
+ if err != nil {
+ return err
+ }
+ insert := Insert(r.tableName).SetMap(values)
+ _, err = r.executeSQL(insert)
+ return err
+}
+
+func (r *apiKeyRepository) Count(options ...rest.QueryOptions) (int64, error) {
+ return r.CountAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *apiKeyRepository) Read(id string) (interface{}, error) {
+ user := loggedUser(r.ctx)
+ apiKey, err := r.Get(id)
+ if err != nil {
+ return nil, err
+ }
+ if !user.IsAdmin && apiKey.UserID != user.ID {
+ return nil, rest.ErrPermissionDenied
+ }
+ return apiKey, err
+}
+
+func (r *apiKeyRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *apiKeyRepository) EntityName() string {
+ return "apikey"
+}
+
+func (r *apiKeyRepository) NewInstance() interface{} {
+ return &model.APIKey{}
+}
+
+func (r *apiKeyRepository) Save(entity interface{}) (string, error) {
+ ak := entity.(*model.APIKey)
+ user := loggedUser(r.ctx)
+ ak.UserID = user.ID
+ // prefix API keys with nav_
+ ak.Key = "nav_" + id.NewRandom()
+ err := r.Put(ak)
+ if err != nil {
+ return "", err
+ }
+ return ak.ID, err
+}
+
+func (r *apiKeyRepository) Update(id string, entity interface{}, _ ...string) error {
+ ak := entity.(*model.APIKey)
+ current, err := r.Get(id)
+ if err != nil {
+ return err
+ }
+ user := loggedUser(r.ctx)
+ if !user.IsAdmin && current.UserID != user.ID {
+ return rest.ErrPermissionDenied
+ }
+
+ // Only allow updating name
+ update := Update(r.tableName).
+ Set("name", ak.Name).
+ Where(Eq{"id": id})
+ _, err = r.executeSQL(update)
+ return err
+}
+
+func (r *apiKeyRepository) Delete(id string) error {
+ user := loggedUser(r.ctx)
+ apiKey, err := r.Get(id)
+ if err != nil {
+ return err
+ }
+ if !user.IsAdmin && apiKey.UserID != user.ID {
+ return rest.ErrPermissionDenied
+ }
+ return r.delete(Eq{"id": id})
+}
+
+func (r *apiKeyRepository) FindByKey(key string) (*model.APIKey, error) {
+ sel := r.newSelect().Columns("*").Where(Eq{"key": key})
+ var res model.APIKey
+ err := r.queryOne(sel, &res)
+ if err != nil {
+ return nil, err
+ }
+ return &res, err
+}
+
+var _ model.APIKeyRepository = (*apiKeyRepository)(nil)
+var _ rest.Repository = (*apiKeyRepository)(nil)
+var _ rest.Persistable = (*apiKeyRepository)(nil)
diff --git a/persistence/api_key_repository_test.go b/persistence/api_key_repository_test.go
new file mode 100644
index 000000000..44ab6f0cc
--- /dev/null
+++ b/persistence/api_key_repository_test.go
@@ -0,0 +1,216 @@
+package persistence
+
+import (
+ "context"
+ "github.com/deluan/rest"
+ "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("APIKeyRepository", func() {
+ var repo model.APIKeyRepository
+
+ BeforeEach(func() {
+ ctx := log.NewContext(context.TODO())
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
+ repo = NewAPIKeyRepository(ctx, GetDBXBuilder())
+ })
+
+ Describe("Put", func() {
+ It("sets an ID if it is not set", func() {
+ apiKey := &model.APIKey{
+ UserID: "userid",
+ Name: "Test API Key",
+ Key: "test-key",
+ }
+
+ err := repo.Put(apiKey)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(apiKey.ID).ToNot(BeEmpty())
+ Expect(apiKey.CreatedAt).ToNot(BeZero())
+ })
+
+ It("keeps existing values", func() {
+ apiKey := &model.APIKey{
+ ID: "existing-id",
+ UserID: "userid",
+ Name: "Test API Key 2",
+ Key: "test-key-2",
+ }
+
+ err := repo.Put(apiKey)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(apiKey.ID).To(Equal("existing-id"))
+ Expect(apiKey.CreatedAt).ToNot(BeZero())
+ })
+ })
+
+ Describe("FindByKey", func() {
+ It("returns the API key with matching key", func() {
+ apiKey := &model.APIKey{
+ UserID: "userid",
+ Name: "Unique API Key",
+ Key: "unique-test-key",
+ }
+
+ err := repo.Put(apiKey)
+ Expect(err).ToNot(HaveOccurred())
+
+ result, err := repo.FindByKey("unique-test-key")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.ID).To(Equal(apiKey.ID))
+ Expect(result.Key).To(Equal("unique-test-key"))
+ })
+
+ It("returns error when key not found", func() {
+ _, err := repo.FindByKey("non-existent-key")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ Describe("Save", func() {
+ It("creates a new API key with a generated key", func() {
+ apiKey := &model.APIKey{
+ Name: "Test API Key Save",
+ }
+
+ id, err := repo.Save(apiKey)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(id).ToNot(BeEmpty())
+ Expect(apiKey.Key).To(HavePrefix("nav_"))
+ Expect(apiKey.UserID).To(Equal("userid"))
+ })
+ })
+
+ Describe("Update", func() {
+ It("only updates the name field", func() {
+ apiKey := &model.APIKey{
+ UserID: "userid",
+ Name: "Original Name",
+ Key: "test-key-for-update",
+ }
+
+ err := repo.Put(apiKey)
+ Expect(err).ToNot(HaveOccurred())
+
+ updateKey := &model.APIKey{
+ Name: "Updated Name",
+ Key: "should-not-change",
+ UserID: "2222",
+ }
+
+ err = repo.Update(apiKey.ID, updateKey)
+ Expect(err).ToNot(HaveOccurred())
+
+ result, err := repo.Get(apiKey.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Name).To(Equal("Updated Name"))
+ Expect(result.Key).To(Equal("test-key-for-update"))
+ Expect(result.UserID).To(Equal("userid"))
+ })
+
+ It("returns error when attempting to update non-existent key", func() {
+ err := repo.Update("non-existent-id", &model.APIKey{Name: "Updated Name"})
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ Describe("Delete", func() {
+ It("deletes an existing API key", func() {
+ apiKey := &model.APIKey{
+ UserID: "userid",
+ Name: "API Key to Delete",
+ Key: "key-to-delete",
+ }
+
+ err := repo.Put(apiKey)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.Delete(apiKey.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = repo.Get(apiKey.ID)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ Describe("User permissions", func() {
+ var nonAdminCtx context.Context
+ var nonAdminRepo model.APIKeyRepository
+ var adminKey model.APIKey
+
+ BeforeEach(func() {
+ nonAdminCtx = log.NewContext(context.TODO())
+ nonAdminCtx = context.WithValue(nonAdminCtx, "user", model.User{ID: "2222", UserName: "user", IsAdmin: false})
+ nonAdminRepo = NewAPIKeyRepository(nonAdminCtx, GetDBXBuilder())
+
+ cleanupKeys := func(key string) {
+ foundKey, err := repo.FindByKey(key)
+ if err == nil {
+ _ = repo.Delete(foundKey.ID)
+ }
+ }
+ cleanupKeys("admin-key")
+ cleanupKeys("user-key")
+
+ tmpAdminKey := &model.APIKey{
+ UserID: "userid",
+ Name: "Admin's API Key",
+ Key: "admin-key",
+ }
+ err := repo.Put(tmpAdminKey)
+ Expect(err).ToNot(HaveOccurred())
+ adminKey = *tmpAdminKey
+
+ userKey := &model.APIKey{
+ UserID: "2222",
+ Name: "User's API Key",
+ Key: "user-key",
+ }
+ err = repo.Put(userKey)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("non-admin users can only see their own API keys", func() {
+ results, err := nonAdminRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ for _, key := range results {
+ Expect(key.UserID).To(Equal("2222"))
+ }
+ })
+
+ It("admin users can see all API keys", func() {
+ results, err := repo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ userIds := make(map[string]bool)
+ for _, key := range results {
+ userIds[key.UserID] = true
+ }
+
+ Expect(userIds).To(HaveKey("userid"))
+ Expect(userIds).To(HaveKey("2222"))
+ })
+
+ It("a user cannot view/delete/update another user's key", func() {
+ result, err := nonAdminRepo.Read(adminKey.ID)
+ Expect(result).To(BeNil())
+ Expect(err).To(MatchError(rest.ErrPermissionDenied))
+
+ updatedKey := &model.APIKey{Name: "new admin key name"}
+ err = nonAdminRepo.Update(adminKey.ID, updatedKey)
+ Expect(err).To(MatchError(rest.ErrPermissionDenied))
+
+ err = nonAdminRepo.Delete(adminKey.ID)
+ Expect(err).To(MatchError(rest.ErrPermissionDenied))
+ })
+ })
+})
diff --git a/persistence/persistence.go b/persistence/persistence.go
index ac607f85f..76f89e7ec 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
}
+func (s *SQLStore) APIKey(ctx context.Context) model.APIKeyRepository {
+ return NewAPIKeyRepository(ctx, s.getDBXBuilder())
+}
+
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
@@ -113,6 +117,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
return s.Share(ctx).(model.ResourceRepository)
case model.Tag:
return s.Tag(ctx).(model.ResourceRepository)
+ case model.APIKey:
+ return s.APIKey(ctx).(model.ResourceRepository)
}
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
return nil
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
index a7181b1a7..c675228d3 100644
--- a/persistence/user_repository.go
+++ b/persistence/user_repository.go
@@ -193,6 +193,18 @@ func (r *userRepository) FindByUsernameWithPassword(username string) (*model.Use
return usr, nil
}
+func (r *userRepository) FindByAPIKey(key string) (*model.User, error) {
+ // find the API key in the database
+ apiKeyRepo := NewAPIKeyRepository(r.ctx, r.db)
+ apiKey, err := apiKeyRepo.FindByKey(key)
+ if err != nil {
+ return nil, err
+ }
+
+ // Then get the user associated with this API key
+ return r.Get(apiKey.UserID)
+}
+
func (r *userRepository) UpdateLastLoginAt(id string) error {
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
_, err := r.executeSQL(upd)
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index 370bdbd1e..c6d6e9c64 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -55,6 +55,7 @@ func (n *Router) routes() http.Handler {
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
n.R(r, "/radio", model.Radio{}, true)
n.R(r, "/tag", model.Tag{}, true)
+ n.R(r, "/apikey", model.APIKey{}, true)
if conf.Server.EnableSharing {
n.RX(r, "/share", n.share.NewRepository, true)
}
diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go
index af1ba448f..a39063273 100644
--- a/server/subsonic/middlewares.go
+++ b/server/subsonic/middlewares.go
@@ -66,11 +66,12 @@ func checkRequiredParameters(next http.Handler) http.Handler {
username, _ := fromInternalOrProxyAuth(r)
if username != "" {
requiredParameters = []string{"v", "c"}
+ } else if apiKey, _ := p.String("apiKey"); apiKey != "" {
+ requiredParameters = []string{"v", "c"}
} else {
requiredParameters = []string{"u", "v", "c"}
}
- p := req.Params(r)
for _, param := range requiredParameters {
if _, err := p.String(param); err != nil {
log.Warn(r, err)
@@ -123,21 +124,62 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
token, _ := p.String("t")
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
+ apiKey, _ := p.String("apiKey")
- usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
- if errors.Is(err, context.Canceled) {
- log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ // When an API key is provided, username should not be provided
+ if apiKey != "" && username != "" {
+ log.Warn(ctx, "API: Invalid login - username provided with API key", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
+ sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
return
}
- switch {
- case errors.Is(err, model.ErrNotFound):
- log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
- case err != nil:
- log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
- default:
- err = validateCredentials(usr, pass, token, salt, jwt)
- if err != nil {
+
+ // Check for conflicting authentication mechanisms
+ authMechanismsCount := 0
+ if apiKey != "" {
+ authMechanismsCount++
+ }
+ if pass != "" {
+ authMechanismsCount++
+ }
+ if token != "" && salt != "" {
+ authMechanismsCount++
+ }
+ if jwt != "" {
+ authMechanismsCount++
+ }
+ if authMechanismsCount > 1 {
+ log.Warn(ctx, "API: Invalid login - multiple authentication mechanisms", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
+ sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
+ return
+ }
+
+ if apiKey != "" {
+ usr, err = ds.User(ctx).FindByAPIKey(apiKey)
+ if errors.Is(err, context.Canceled) {
+ log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr, err)
+ return
+ }
+ if errors.Is(err, model.ErrNotFound) {
+ log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr)
+ } else if err != nil {
+ log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr, err)
+ }
+ } else {
+ usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
+ if errors.Is(err, context.Canceled) {
+ log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ return
+ }
+ switch {
+ case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ case err != nil:
+ log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ default:
+ err = validateCredentials(usr, pass, token, salt, jwt)
+ if err != nil {
+ log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ }
}
}
}
diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go
index a30d5b3af..e9f1c0177 100644
--- a/server/subsonic/middlewares_test.go
+++ b/server/subsonic/middlewares_test.go
@@ -306,6 +306,65 @@ var _ = Describe("Middlewares", func() {
Expect(next.called).To(BeFalse())
})
})
+
+ When("using api key authentication", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ ur := ds.User(context.TODO())
+ user := &model.User{
+ UserName: "user-api",
+ NewPassword: "wordpass-api",
+ }
+ _ = ur.Put(user)
+
+ ar := ds.APIKey(context.TODO())
+ apiKey := &model.APIKey{
+ ID: "api-key-id",
+ UserID: user.ID,
+ Name: "API Key",
+ Key: "api-key",
+ }
+ _ = ar.Put(apiKey)
+ })
+
+ It("passes authentication with correct api key", func() {
+ r := newGetRequest("apiKey=api-key")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(next.called).To(BeTrue())
+ user, _ := request.UserFrom(next.req.Context())
+ Expect(user.UserName).To(Equal("user-api"))
+ })
+
+ It("fails authentication with invalid api key", func() {
+ r := newGetRequest("apiKey=invalid-api-key")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
+ Expect(next.called).To(BeFalse())
+ })
+
+ It("fails authentication with empty api key", func() {
+ r := newGetRequest("apiKey=")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
+ Expect(next.called).To(BeFalse())
+ })
+
+ It("fails authentication if both api key and username are provided", func() {
+ r := newGetRequest("apiKey=api-key", "u=user-api")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
+ Expect(next.called).To(BeFalse())
+ })
+ })
})
Describe("GetPlayer", func() {
diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go
index 42e5427b3..f869c763e 100644
--- a/server/subsonic/responses/errors.go
+++ b/server/subsonic/responses/errors.go
@@ -1,25 +1,31 @@
package responses
const (
- ErrorGeneric int32 = 0
- ErrorMissingParameter int32 = 10
- ErrorClientTooOld int32 = 20
- ErrorServerTooOld int32 = 30
- ErrorAuthenticationFail int32 = 40
- ErrorAuthorizationFail int32 = 50
- ErrorTrialExpired int32 = 60
- ErrorDataNotFound int32 = 70
+ ErrorGeneric int32 = 0
+ ErrorMissingParameter int32 = 10
+ ErrorClientTooOld int32 = 20
+ ErrorServerTooOld int32 = 30
+ ErrorAuthenticationFail int32 = 40
+ ErrorTokenAuthNotSupported int32 = 41
+ ErrorAuthMechanismNotSupported int32 = 42
+ ErrorMultipleAuthMechanismsProvided int32 = 43
+ ErrorAuthorizationFail int32 = 50
+ ErrorTrialExpired int32 = 60
+ ErrorDataNotFound int32 = 70
)
var errors = map[int32]string{
- ErrorGeneric: "A generic error",
- ErrorMissingParameter: "Required parameter is missing",
- ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
- ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
- ErrorAuthenticationFail: "Wrong username or password",
- ErrorAuthorizationFail: "User is not authorized for the given operation",
- ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
- ErrorDataNotFound: "The requested data was not found",
+ ErrorGeneric: "A generic error",
+ ErrorMissingParameter: "Required parameter is missing",
+ ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
+ ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
+ ErrorAuthenticationFail: "Wrong username or password or api key",
+ ErrorTokenAuthNotSupported: "Token authentication not supported",
+ ErrorAuthMechanismNotSupported: "Provided authentication mechanism not supported",
+ ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided",
+ ErrorAuthorizationFail: "User is not authorized for the given operation",
+ ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
+ ErrorDataNotFound: "The requested data was not found",
}
func ErrorMsg(code int32) string {
diff --git a/tests/mock_apikey_repo.go b/tests/mock_apikey_repo.go
new file mode 100644
index 000000000..240281a13
--- /dev/null
+++ b/tests/mock_apikey_repo.go
@@ -0,0 +1,57 @@
+package tests
+
+import (
+ "github.com/navidrome/navidrome/model"
+ "strings"
+)
+
+func CreateMockApiKeyRepo() *MockedAPIKeyRepo {
+ return &MockedAPIKeyRepo{
+ Data: map[string]*model.APIKey{},
+ }
+}
+
+type MockedAPIKeyRepo struct {
+ model.APIKeyRepository
+ Error error
+ Data map[string]*model.APIKey
+}
+
+func (m *MockedAPIKeyRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
+ if m.Error != nil {
+ return 0, m.Error
+ }
+ return int64(len(m.Data)), nil
+}
+
+func (m *MockedAPIKeyRepo) Put(apiKey *model.APIKey) error {
+ if m.Error != nil {
+ return m.Error
+ }
+ m.Data[strings.ToLower(apiKey.Key)] = apiKey
+ return nil
+}
+
+func (m *MockedAPIKeyRepo) FindByKey(key string) (*model.APIKey, error) {
+ if m.Error != nil {
+ return nil, m.Error
+ }
+ apiKey, exists := m.Data[strings.ToLower(key)]
+ if !exists {
+ return nil, model.ErrNotFound
+ }
+ return apiKey, nil
+}
+
+func (m *MockedAPIKeyRepo) Get(id string) (*model.APIKey, error) {
+ if m.Error != nil {
+ return nil, m.Error
+ }
+
+ for _, apiKey := range m.Data {
+ if apiKey.ID == id {
+ return apiKey, nil
+ }
+ }
+ return nil, model.ErrNotFound
+}
diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go
index 56f68a74b..93f2d8a68 100644
--- a/tests/mock_data_store.go
+++ b/tests/mock_data_store.go
@@ -28,6 +28,7 @@ type MockDataStore struct {
MockedRadio model.RadioRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
+ MockedAPIKey model.APIKeyRepository
}
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
@@ -169,7 +170,8 @@ func (db *MockDataStore) User(ctx context.Context) model.UserRepository {
if db.RealDS != nil {
db.MockedUser = db.RealDS.User(ctx)
} else {
- db.MockedUser = CreateMockUserRepo()
+ apiKeyRepo := db.APIKey(ctx).(*MockedAPIKeyRepo)
+ db.MockedUser = CreateMockUserRepo(apiKeyRepo)
}
}
return db.MockedUser
@@ -221,6 +223,17 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
return db.MockedRadio
}
+func (db *MockDataStore) APIKey(ctx context.Context) model.APIKeyRepository {
+ if db.MockedAPIKey == nil {
+ if db.RealDS != nil {
+ db.MockedAPIKey = db.RealDS.APIKey(ctx)
+ } else {
+ db.MockedAPIKey = CreateMockApiKeyRepo()
+ }
+ }
+ return db.MockedAPIKey
+}
+
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}
diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go
index 9f3dd672e..5cfd32cf1 100644
--- a/tests/mock_user_repo.go
+++ b/tests/mock_user_repo.go
@@ -10,10 +10,15 @@ import (
"github.com/navidrome/navidrome/utils/gg"
)
-func CreateMockUserRepo() *MockedUserRepo {
+func CreateMockUserRepo(apiKeyRepo ...*MockedAPIKeyRepo) *MockedUserRepo {
+ var repo *MockedAPIKeyRepo
+ if len(apiKeyRepo) > 0 {
+ repo = apiKeyRepo[0]
+ }
return &MockedUserRepo{
Data: map[string]*model.User{},
UserLibraries: map[string][]int{},
+ APIKeyRepo: repo,
}
}
@@ -22,9 +27,10 @@ type MockedUserRepo struct {
Error error
Data map[string]*model.User
UserLibraries map[string][]int // userID -> libraryIDs
+ APIKeyRepo *MockedAPIKeyRepo
}
-func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
+func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
if u.Error != nil {
return 0, u.Error
}
@@ -123,3 +129,22 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error
u.UserLibraries[userID] = libraryIDs
return nil
}
+
+func (u *MockedUserRepo) FindByAPIKey(key string) (*model.User, error) {
+ if u.Error != nil {
+ return nil, u.Error
+ }
+
+ apiKey, err := u.APIKeyRepo.FindByKey(key)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, usr := range u.Data {
+ if usr.ID == apiKey.UserID {
+ return usr, nil
+ }
+ }
+
+ return nil, model.ErrNotFound
+}
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index dc4fe9b53..7a5899045 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -42,6 +42,7 @@ import SharePlayer from './share/SharePlayer'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
import missing from './missing/index.js'
+import apikey from './apikey/index.js'
const history = createHashHistory()
@@ -111,6 +112,11 @@ const Admin = (props) => {
options={{ subMenu: 'playlist' }}
/>,
,
+ ,
{
+ const translate = useTranslate()
+ const [mutate] = useMutation()
+ const notify = useNotify()
+ const redirect = useRedirect()
+ const resourceName = translate('resources.apikey.name', { smart_count: 1 })
+ const title = translate('ra.page.create', {
+ name: `${resourceName}`,
+ })
+
+ const save = useCallback(
+ async (values) => {
+ try {
+ await mutate(
+ {
+ type: 'create',
+ resource: 'apikey',
+ payload: { data: values },
+ },
+ { returnPromise: true },
+ )
+ notify('resources.apikey.notifications.created', 'info', {
+ smart_count: 1,
+ })
+ redirect('/apikey')
+ } catch (error) {
+ if (error.body.errors) {
+ return error.body.errors
+ }
+ }
+ },
+ [mutate, notify, redirect],
+ )
+
+ return (
+ } {...props}>
+
+
+
+
+ )
+}
+
+export default ApiKeyCreate
diff --git a/ui/src/apikey/ApiKeyEdit.jsx b/ui/src/apikey/ApiKeyEdit.jsx
new file mode 100644
index 000000000..cbcfa7396
--- /dev/null
+++ b/ui/src/apikey/ApiKeyEdit.jsx
@@ -0,0 +1,75 @@
+import React, { useCallback } from 'react'
+import {
+ Edit,
+ SimpleForm,
+ TextInput,
+ required,
+ Toolbar,
+ SaveButton,
+ DeleteButton,
+ useTranslate,
+ useMutation,
+ useNotify,
+ useRefresh,
+ DateField,
+} from 'react-admin'
+import { Title } from '../common'
+
+const ApiKeyTitle = ({ record }) => {
+ const translate = useTranslate()
+ const resourceName = translate('resources.apikey.name', { smart_count: 1 })
+ return
+}
+
+const ApiKeyEditToolbar = (props) => (
+
+
+
+
+)
+
+const ApiKeyEdit = (props) => {
+ const [mutate] = useMutation()
+ const notify = useNotify()
+ const refresh = useRefresh()
+
+ const save = useCallback(
+ async (values) => {
+ try {
+ await mutate(
+ {
+ type: 'update',
+ resource: 'apikey',
+ payload: { id: values.id, data: values },
+ },
+ { returnPromise: true },
+ )
+ notify('resources.apikey.notifications.updated', 'info', {
+ smart_count: 1,
+ })
+ refresh()
+ } catch (error) {
+ if (error.body.errors) {
+ return error.body.errors
+ }
+ }
+ },
+ [mutate, notify, refresh],
+ )
+
+ return (
+ } {...props}>
+ }
+ save={save}
+ variant={'outlined'}
+ >
+
+
+
+
+
+ )
+}
+
+export default ApiKeyEdit
diff --git a/ui/src/apikey/ApiKeyList.jsx b/ui/src/apikey/ApiKeyList.jsx
new file mode 100644
index 000000000..42a43aa15
--- /dev/null
+++ b/ui/src/apikey/ApiKeyList.jsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import {
+ Datagrid,
+ DateField,
+ Filter,
+ List,
+ SearchInput,
+ TextField,
+ useTranslate,
+} from 'react-admin'
+import { useMediaQuery } from '@material-ui/core'
+import { SimpleList } from '../common'
+import AddIcon from '@material-ui/icons/Add'
+import { CreateButton } from 'react-admin'
+
+const ApiKeyFilter = (props) => (
+
+
+
+)
+
+const ApiKeyList = (props) => {
+ const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+ const translate = useTranslate()
+ return (
+
}
+ label={translate('resources.apikey.actions.add')}
+ />
+ }
+ sort={{ field: 'createdAt', order: 'DESC' }}
+ exporter={false}
+ bulkActionButtons={false}
+ filters={}
+ >
+ {isXsmall ? (
+ r.name}
+ secondaryText={(r) => r.key}
+ tertiaryText={(r) => }
+ linkType={'edit'}
+ />
+ ) : (
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default ApiKeyList
diff --git a/ui/src/apikey/index.js b/ui/src/apikey/index.js
new file mode 100644
index 000000000..9c5c411c1
--- /dev/null
+++ b/ui/src/apikey/index.js
@@ -0,0 +1,11 @@
+import VpnKeyIcon from '@material-ui/icons/VpnKey'
+import ApiKeyList from './ApiKeyList'
+import ApiKeyCreate from './ApiKeyCreate'
+import ApiKeyEdit from './ApiKeyEdit'
+
+export default {
+ list: ApiKeyList,
+ create: ApiKeyCreate,
+ edit: ApiKeyEdit,
+ icon: VpnKeyIcon,
+}
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 4a9039a67..48a8bc819 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -1,6 +1,22 @@
{
"languageName": "English",
"resources": {
+ "apikey": {
+ "name": "API Key |||| API Keys",
+ "fields": {
+ "name": "Name",
+ "key": "Key",
+ "createdAt": "Created At",
+ "key_help_text": "This key will be used for API authentication"
+ },
+ "actions": {
+ "add": "Add API Key"
+ },
+ "notifications": {
+ "created": "API Key created",
+ "updated": "API Key updated"
+ }
+ },
"song": {
"name": "Song |||| Songs",
"fields": {
diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx
index 561701dce..9a8e7017b 100644
--- a/ui/src/layout/AppBar.jsx
+++ b/ui/src/layout/AppBar.jsx
@@ -63,6 +63,7 @@ AboutMenuItem.displayName = 'AboutMenuItem'
const settingsResources = (resource) =>
resource.name !== 'user' &&
+ resource.name !== 'apikey' &&
resource.hasList &&
resource.options &&
resource.options.subMenu === 'settings'
@@ -95,6 +96,14 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
)
}
+ const renderApiKeyMenuItemLink = () => {
+ const apiKeyResource = resourceDefinition('apikey')
+ if (!apiKeyResource) {
+ return null
+ }
+ return renderSettingsMenuItemLink(apiKeyResource)
+ }
+
const renderSettingsMenuItemLink = (resource, id) => {
const label = translate(`resources.${resource.name}.name`, {
smart_count: id ? 1 : 2,
@@ -128,6 +137,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
{renderUserMenuItemLink()}
+ {renderApiKeyMenuItemLink()}
{resources
.filter(settingsResources)
.map((r) => renderSettingsMenuItemLink(r))}
diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx
index 45f40b26d..cffbb3acf 100644
--- a/ui/src/layout/Menu.jsx
+++ b/ui/src/layout/Menu.jsx
@@ -103,7 +103,10 @@ const Menu = ({ dense = false }) => {
}
const subItems = (subMenu) => (resource) =>
- resource.hasList && resource.options && resource.options.subMenu === subMenu
+ resource.hasList &&
+ resource.options &&
+ resource.options.subMenu === subMenu &&
+ resource.name !== 'apikey'
return (