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