mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 04:47:17 +02:00
feat(api): add API v1 scope rules and grant secrets
This commit is contained in:
parent
158721ea60
commit
21ce7c7115
5 changed files with 203 additions and 0 deletions
17
core/apiauth/apiauth_suite_test.go
Normal file
17
core/apiauth/apiauth_suite_test.go
Normal 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
77
core/apiauth/scopes.go
Normal 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"))
|
||||
}
|
||||
66
core/apiauth/scopes_test.go
Normal file
66
core/apiauth/scopes_test.go
Normal 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
20
core/apiauth/secret.go
Normal 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[:])
|
||||
}
|
||||
23
core/apiauth/secret_test.go
Normal file
23
core/apiauth/secret_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue