diff --git a/consts/consts.go b/consts/consts.go index 9bdac9125..c97c72cab 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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 diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go new file mode 100644 index 000000000..0dc791634 --- /dev/null +++ b/core/apiauth/db_test.go @@ -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 +} diff --git a/core/apiauth/signer.go b/core/apiauth/signer.go new file mode 100644 index 000000000..fd1fc97c8 --- /dev/null +++ b/core/apiauth/signer.go @@ -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 +} diff --git a/core/apiauth/signer_test.go b/core/apiauth/signer_test.go new file mode 100644 index 000000000..599f8de05 --- /dev/null +++ b/core/apiauth/signer_test.go @@ -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()) + }) +})