feat(api): add the API v1 signing key and access-token JWTs

This commit is contained in:
Deluan 2026-09-26 01:11:07 -04:00
commit 899cc428fd
4 changed files with 289 additions and 0 deletions

View file

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