feat(auth): split session and public-link JWT secrets, rotating sessions on id migration

This commit is contained in:
Deluan 2026-07-20 12:35:41 -04:00
parent 3fff973ab6
commit 037a5fd446
12 changed files with 129 additions and 29 deletions

View File

@ -28,6 +28,7 @@ const (
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
JWTSecretKey = "JWTSecret"
JWTPublicSecretKey = "JWTPublicSecret"
JWTIssuer = "ND"
DefaultSessionTimeout = 48 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year

View File

@ -19,35 +19,41 @@ import (
)
var (
once sync.Once
once sync.Once
// TokenAuth signs UI/API session tokens. Rotated by the id migration so stale sessions die.
TokenAuth *jwtauth.JWTAuth
// PublicTokenAuth signs public-link tokens (artwork, share streams), on a separate secret that survives the rotation.
PublicTokenAuth *jwtauth.JWTAuth
)
// Init creates a JWTAuth object from the secret stored in the DB.
// If the secret is not found, it will create a new one and store it in the DB.
// Init creates the JWTAuth objects from the secrets stored in the DB.
// Missing or undecryptable secrets are regenerated and stored.
func Init(ds model.DataStore) {
once.Do(func() {
ctx := context.TODO()
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
if err != nil || secret == "" {
log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions")
secret = createNewSecret(ctx, ds)
} else {
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err)
secret = createNewSecret(ctx, ds)
}
}
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil)
PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil)
})
}
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
secret, err := ds.Property(ctx).Get(key)
if err != nil || secret == "" {
log.Info(ctx, "Creating new JWT secret", "key", key)
return createNewSecret(ctx, ds, key)
}
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err)
return createNewSecret(ctx, ds, key)
}
return secret
}
func CreatePublicToken(claims Claims) (string, error) {
claims.Issuer = consts.JWTIssuer
_, token, err := TokenAuth.Encode(claims.ToMap())
_, token, err := PublicTokenAuth.Encode(claims.ToMap())
return token, err
}
@ -56,7 +62,7 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
if !exp.IsZero() {
claims.ExpiresAt = exp
}
_, token, err := TokenAuth.Encode(claims.ToMap())
_, token, err := PublicTokenAuth.Encode(claims.ToMap())
return token, err
}
@ -91,6 +97,15 @@ func Validate(tokenStr string) (Claims, error) {
return ClaimsFromToken(token), nil
}
// ValidatePublic verifies a public-link token against the public secret.
func ValidatePublic(tokenStr string) (Claims, error) {
token, err := jwtauth.VerifyToken(PublicTokenAuth, tokenStr)
if err != nil {
return Claims{}, err
}
return ClaimsFromToken(token), nil
}
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
u, err := ds.User(ctx).FindFirstAdmin()
if err != nil {
@ -107,14 +122,14 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
return request.WithUser(ctx, *u)
}
func createNewSecret(ctx context.Context, ds model.DataStore) string {
func createNewSecret(ctx context.Context, ds model.DataStore, key string) string {
secret := id.NewRandom()
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
if err != nil {
log.Error(ctx, "Could not encrypt JWT secret", err)
return secret
}
if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil {
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
log.Error(ctx, "Could not save JWT secret in DB", err)
}
return secret

View File

@ -89,6 +89,51 @@ var _ = Describe("Auth", func() {
})
})
Describe("Session/Public secret split", func() {
claims := func() map[string]any {
return map[string]any{"iss": "issuer", "exp": time.Now().Add(1 * time.Minute).Unix()}
}
It("verifies a session token via Validate but not ValidatePublic", func() {
_, tokenStr, err := auth.TokenAuth.Encode(claims())
Expect(err).NotTo(HaveOccurred())
_, err = auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
_, err = auth.ValidatePublic(tokenStr)
Expect(err).To(HaveOccurred())
})
It("verifies a public token via ValidatePublic but not Validate", func() {
_, tokenStr, err := auth.PublicTokenAuth.Encode(claims())
Expect(err).NotTo(HaveOccurred())
_, err = auth.ValidatePublic(tokenStr)
Expect(err).NotTo(HaveOccurred())
_, err = auth.Validate(tokenStr)
Expect(err).To(HaveOccurred())
})
It("decodes public tokens minted by CreatePublicToken via PublicTokenAuth", func() {
tokenStr, err := auth.CreatePublicToken(auth.Claims{ID: "art-1"})
Expect(err).NotTo(HaveOccurred())
claims, err := auth.ValidatePublic(tokenStr)
Expect(err).NotTo(HaveOccurred())
Expect(claims.ID).To(Equal("art-1"))
_, err = auth.Validate(tokenStr)
Expect(err).To(HaveOccurred())
})
It("decodes expiring public tokens minted by CreateExpiringPublicToken via PublicTokenAuth", func() {
exp := time.Now().Add(1 * time.Hour)
tokenStr, err := auth.CreateExpiringPublicToken(exp, auth.Claims{ID: "art-2"})
Expect(err).NotTo(HaveOccurred())
claims, err := auth.ValidatePublic(tokenStr)
Expect(err).NotTo(HaveOccurred())
Expect(claims.ID).To(Equal("art-2"))
_, err = auth.Validate(tokenStr)
Expect(err).To(HaveOccurred())
})
})
Describe("TouchToken", func() {
It("updates the expiration time", func() {
yesterday := time.Now().Add(-oneDay)

View File

@ -175,7 +175,7 @@ var _ = Describe("Public URL Utilities", func() {
BeforeEach(func() {
conf.Server.ShareURL = "https://share.example.com"
// Initialize JWT auth for token generation
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
})
It("generates a URL with the artwork token", func() {

View File

@ -8,6 +8,7 @@ import (
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/pressly/goose/v3"
)
@ -72,10 +73,22 @@ func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
return err
}
}
if err := rotateSessionSecret(ctx, tx); err != nil {
return err
}
_, err := tx.ExecContext(ctx, "DROP TABLE _id_map")
return err
}
// rotateSessionSecret renames the session JWT secret to the public key, so public-link tokens
// keep verifying while auth.Init mints a fresh session secret, killing every stale session.
func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
`UPDATE property SET id = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM property WHERE id = ?)`,
consts.JWTPublicSecretKey, consts.JWTSecretKey, consts.JWTPublicSecretKey)
return err
}
// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins.
func buildIDMap(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,

View File

@ -26,6 +26,8 @@ var _ = Describe("upUniformCanonicalIds", func() {
uuidOld = "f47ac10b-58cc-4372-a567-0e02b2c3d479" // uuid -> re-encoded
uuidNew = "7rke2SAWaicSeSYzkhww6R"
shareID = "aB3xY9kQz1" // exempt family
sessionSecret = "encrypted-session-secret-sentinel"
)
BeforeEach(func() {
@ -89,6 +91,7 @@ var _ = Describe("upUniformCanonicalIds", func() {
// malformed JSON in both a plugin list and a playlist rule: must pass through byte-for-byte
seed(`INSERT INTO plugin VALUES ('broken', 'not-json')`)
seed(`INSERT INTO playlist VALUES (?, ?, '{broken')`, hashID, hashID)
seed(`INSERT INTO property VALUES (?, ?)`, consts.JWTSecretKey, sessionSecret)
})
JustBeforeEach(func() {
@ -204,4 +207,27 @@ var _ = Describe("upUniformCanonicalIds", func() {
Expect(rescanCount()).To(Equal(1))
})
})
propCount := func(key string) int {
var count int
ExpectWithOffset(1, db.QueryRow(`SELECT count(*) FROM property WHERE id = ?`, key).Scan(&count)).To(Succeed())
return count
}
It("rotates the session secret to the public key", func() {
Expect(propCount(consts.JWTSecretKey)).To(Equal(0))
Expect(get(`SELECT value FROM property WHERE id = '` + consts.JWTPublicSecretKey + `'`)).To(Equal(sessionSecret))
})
Context("with no stored session secret", func() {
BeforeEach(func() {
_, err := db.Exec(`DELETE FROM property WHERE id = ?`, consts.JWTSecretKey)
Expect(err).ToNot(HaveOccurred())
})
It("is a no-op", func() {
Expect(propCount(consts.JWTSecretKey)).To(Equal(0))
Expect(propCount(consts.JWTPublicSecretKey)).To(Equal(0))
})
})
})

View File

@ -225,7 +225,7 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID {
}
// Decode JWT token
token, err := auth.TokenAuth.Decode(tokenPart)
token, err := auth.PublicTokenAuth.Decode(tokenPart)
Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token")
c := auth.ClaimsFromToken(token)

View File

@ -68,7 +68,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
}
func decodeArtworkID(tokenString string) (model.ArtworkID, error) {
token, err := auth.TokenAuth.Decode(tokenString)
token, err := auth.PublicTokenAuth.Decode(tokenString)
if err != nil {
return model.ArtworkID{}, err
}

View File

@ -9,7 +9,7 @@ import (
var _ = Describe("decodeArtworkID", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
})
It("fails to decode an invalid token", func() {

View File

@ -111,8 +111,8 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share {
// admin flag) and grants access to nothing beyond the share it belongs to; the
// stream handler still verifies the share exists, is unexpired, and that the
// track is actually a member of it. An attacker who can forge these tokens
// necessarily already holds the signing secret, which also signs real user
// sessions, so that scenario is out of scope for the share boundary specifically.
// necessarily already holds the public-link signing secret, a full-server
// compromise that is out of scope for the share boundary specifically.
func encodeMediafileShare(s model.Share, id string) string {
claims := auth.Claims{
ID: id,

View File

@ -107,7 +107,7 @@ func shareContainsTrack(share *model.Share, mediaFileID string) bool {
// public-share capability, not an auth credential; see encodeMediafileShare for
// why a JWT is used here.
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
c, err := auth.Validate(tokenString)
c, err := auth.ValidatePublic(tokenString)
if err != nil {
return shareTrackInfo{}, err
}

View File

@ -29,7 +29,7 @@ func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream
var _ = Describe("decodeStreamInfo", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
})
It("decodes a valid token with all fields", func() {
@ -81,7 +81,7 @@ var _ = Describe("decodeStreamInfo", func() {
var _ = Describe("encodeMediafileShare", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
})
It("includes the share ID in the token", func() {
@ -113,7 +113,7 @@ var _ = Describe("handleStream", func() {
var pub *Router
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
ds = &tests.MockDataStore{}
shareRepo = &tests.MockShareRepo{}
ds.MockedShare = shareRepo