mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-10 11:27:11 +02:00
feat(api): add the API v1 signing key and access-token JWTs
This commit is contained in:
parent
7dd8dba12b
commit
899cc428fd
4 changed files with 289 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
35
core/apiauth/db_test.go
Normal file
35
core/apiauth/db_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
132
core/apiauth/signer.go
Normal file
132
core/apiauth/signer.go
Normal file
|
|
@ -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
|
||||
}
|
||||
121
core/apiauth/signer_test.go
Normal file
121
core/apiauth/signer_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue