feat(api): add API v1 scope rules and grant secrets

This commit is contained in:
Deluan 2026-09-26 01:04:31 -04:00
commit 21ce7c7115
5 changed files with 203 additions and 0 deletions

View file

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

77
core/apiauth/scopes.go Normal file
View file

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

View file

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

20
core/apiauth/secret.go Normal file
View file

@ -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[:])
}

View file

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