refactor(transcode): decouple transcode token claims from auth.Claims

Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt,
Channels, SampleRate, BitDepth) from auth.Claims, which is shared with
session and share tokens. Transcode tokens are signed parameter-passing
tokens, not authentication tokens, so coupling them to auth created
misleading dependencies.

The transcode package now owns its own JWT claim serialization via
Decision.toClaimsMap() and paramsFromToken(), using generic
auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth
encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight
tokens remain compatible.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-03-07 16:39:42 -05:00
parent 26c703e729
commit 54d7c3a284
5 changed files with 220 additions and 103 deletions

View File

@ -120,6 +120,19 @@ func createNewSecret(ctx context.Context, ds model.DataStore) string {
return secret
}
// EncodeToken creates a signed JWT from an arbitrary claims map.
// It sets the issuer claim automatically.
func EncodeToken(claims map[string]any) (string, error) {
claims[jwt.IssuerKey] = consts.JWTIssuer
_, token, err := TokenAuth.Encode(claims)
return token, err
}
// DecodeAndVerifyToken verifies a JWT string and returns the parsed token.
func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
return jwtauth.VerifyToken(TokenAuth, tokenStr)
}
func getEncKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,

View File

@ -21,14 +21,6 @@ type Claims struct {
ID string // "id" - artwork/mediafile ID
Format string // "f" - audio format
BitRate int // "b" - audio bitrate
// Transcoding claims
MediaID string // "mid" - media file ID
DirectPlay bool // "dp" - direct play flag
UpdatedAt int64 // "ua" - source file updated-at (Unix seconds)
Channels int // "ch" - target channels
SampleRate int // "sr" - target sample rate (Hz)
BitDepth int // "bd" - target bit depth
}
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
@ -62,24 +54,6 @@ func (c Claims) ToMap() map[string]any {
if c.BitRate != 0 {
m["b"] = c.BitRate
}
if c.MediaID != "" {
m["mid"] = c.MediaID
}
if c.DirectPlay {
m["dp"] = c.DirectPlay
}
if c.UpdatedAt != 0 {
m["ua"] = c.UpdatedAt
}
if c.Channels != 0 {
m["ch"] = c.Channels
}
if c.SampleRate != 0 {
m["sr"] = c.SampleRate
}
if c.BitDepth != 0 {
m["bd"] = c.BitDepth
}
return m
}
@ -118,37 +92,5 @@ func ClaimsFromToken(token jwt.Token) Claims {
c.BitRate = int(bf)
}
}
var mid string
if err := token.Get("mid", &mid); err == nil {
c.MediaID = mid
}
var dp bool
if err := token.Get("dp", &dp); err == nil {
c.DirectPlay = dp
}
if err := token.Get("ua", &c.UpdatedAt); err != nil {
var uaf float64
if err := token.Get("ua", &uaf); err == nil {
c.UpdatedAt = int64(uaf)
}
}
if err := token.Get("ch", &c.Channels); err != nil {
var chf float64
if err := token.Get("ch", &chf); err == nil {
c.Channels = int(chf)
}
}
if err := token.Get("sr", &c.SampleRate); err != nil {
var srf float64
if err := token.Get("sr", &srf); err == nil {
c.SampleRate = int(srf)
}
}
if err := token.Get("bd", &c.BitDepth); err != nil {
var bdf float64
if err := token.Get("bd", &bdf); err == nil {
c.BitDepth = int(bdf)
}
}
return c
}

View File

@ -358,55 +358,15 @@ func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile)
}
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
exp := time.Now().Add(tokenTTL)
claims := auth.Claims{
MediaID: decision.MediaID,
DirectPlay: decision.CanDirectPlay,
UpdatedAt: decision.SourceUpdatedAt.Truncate(time.Second).Unix(),
}
if decision.CanTranscode && decision.TargetFormat != "" {
claims.Format = decision.TargetFormat
claims.BitRate = decision.TargetBitrate
if decision.TargetChannels > 0 {
claims.Channels = decision.TargetChannels
}
if decision.TargetSampleRate > 0 {
claims.SampleRate = decision.TargetSampleRate
}
if decision.TargetBitDepth > 0 {
claims.BitDepth = decision.TargetBitDepth
}
}
return auth.CreateExpiringPublicToken(exp, claims)
return auth.EncodeToken(decision.toClaimsMap())
}
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
claims, err := auth.Validate(token)
func (s *deciderService) ParseTranscodeParams(tokenStr string) (*Params, error) {
token, err := auth.DecodeAndVerifyToken(tokenStr)
if err != nil {
return nil, err
}
// Required claims
if claims.MediaID == "" {
return nil, fmt.Errorf("%w: invalid transcode token: missing media ID", ErrTokenInvalid)
}
if claims.UpdatedAt == 0 {
return nil, fmt.Errorf("%w: invalid transcode token: missing source timestamp", ErrTokenInvalid)
}
params := &Params{
MediaID: claims.MediaID,
DirectPlay: claims.DirectPlay,
TargetFormat: claims.Format,
TargetBitrate: claims.BitRate,
TargetChannels: claims.Channels,
TargetSampleRate: claims.SampleRate,
TargetBitDepth: claims.BitDepth,
SourceUpdatedAt: time.Unix(claims.UpdatedAt, 0),
}
return params, nil
return paramsFromToken(token)
}
func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) {

View File

@ -6,6 +6,7 @@ import (
"fmt"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
@ -40,7 +41,7 @@ var _ = Describe("Decider", func() {
)
BeforeEach(func() {
ctx = context.Background()
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedTranscoding: &tests.MockTranscodingRepo{},
@ -1195,4 +1196,113 @@ var _ = Describe("Decider", func() {
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
})
})
Describe("Decision.toClaimsMap", func() {
It("includes required fields and omits zero transcode fields for direct play", func() {
d := &Decision{
MediaID: "song-1",
CanDirectPlay: true,
SourceUpdatedAt: time.Unix(1700000000, 0),
}
m := d.toClaimsMap()
Expect(m).To(HaveKeyWithValue("mid", "song-1"))
Expect(m).To(HaveKeyWithValue("dp", true))
Expect(m).To(HaveKeyWithValue("ua", int64(1700000000)))
Expect(m).NotTo(HaveKey("f"))
Expect(m).NotTo(HaveKey("b"))
Expect(m).NotTo(HaveKey("ch"))
Expect(m).NotTo(HaveKey("sr"))
Expect(m).NotTo(HaveKey("bd"))
})
It("includes transcode fields when CanTranscode is true", func() {
d := &Decision{
MediaID: "song-2",
CanTranscode: true,
TargetFormat: "opus",
TargetBitrate: 128,
TargetChannels: 2,
TargetSampleRate: 48000,
TargetBitDepth: 16,
SourceUpdatedAt: time.Unix(1700000000, 0),
}
m := d.toClaimsMap()
Expect(m).To(HaveKeyWithValue("mid", "song-2"))
Expect(m).NotTo(HaveKey("dp"))
Expect(m).To(HaveKeyWithValue("f", "opus"))
Expect(m).To(HaveKeyWithValue("b", 128))
Expect(m).To(HaveKeyWithValue("ch", 2))
Expect(m).To(HaveKeyWithValue("sr", 48000))
Expect(m).To(HaveKeyWithValue("bd", 16))
})
})
Describe("paramsFromToken", func() {
It("round-trips all fields through encode/decode", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
d := &Decision{
MediaID: "song-3",
CanTranscode: true,
TargetFormat: "mp3",
TargetBitrate: 320,
TargetChannels: 2,
TargetSampleRate: 44100,
TargetBitDepth: 16,
SourceUpdatedAt: time.Unix(1700000000, 0),
}
token, _, err := tokenAuth.Encode(d.toClaimsMap())
Expect(err).NotTo(HaveOccurred())
p, err := paramsFromToken(token)
Expect(err).NotTo(HaveOccurred())
Expect(p.MediaID).To(Equal("song-3"))
Expect(p.DirectPlay).To(BeFalse())
Expect(p.TargetFormat).To(Equal("mp3"))
Expect(p.TargetBitrate).To(Equal(320))
Expect(p.TargetChannels).To(Equal(2))
Expect(p.TargetSampleRate).To(Equal(44100))
Expect(p.TargetBitDepth).To(Equal(16))
Expect(p.SourceUpdatedAt).To(Equal(time.Unix(1700000000, 0)))
})
It("round-trips direct-play-only claims", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
d := &Decision{
MediaID: "song-4",
CanDirectPlay: true,
SourceUpdatedAt: time.Unix(1700000000, 0),
}
token, _, err := tokenAuth.Encode(d.toClaimsMap())
Expect(err).NotTo(HaveOccurred())
p, err := paramsFromToken(token)
Expect(err).NotTo(HaveOccurred())
Expect(p.MediaID).To(Equal("song-4"))
Expect(p.DirectPlay).To(BeTrue())
Expect(p.TargetFormat).To(BeEmpty())
Expect(p.TargetBitrate).To(BeZero())
Expect(p.TargetChannels).To(BeZero())
Expect(p.TargetSampleRate).To(BeZero())
Expect(p.TargetBitDepth).To(BeZero())
Expect(p.SourceUpdatedAt).To(Equal(time.Unix(1700000000, 0)))
})
It("returns error when media ID is missing", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)})
Expect(err).NotTo(HaveOccurred())
_, err = paramsFromToken(token)
Expect(err).To(MatchError(ContainSubstring("missing media ID")))
})
It("returns error when source timestamp is missing", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"})
Expect(err).NotTo(HaveOccurred())
_, err = paramsFromToken(token)
Expect(err).To(MatchError(ContainSubstring("missing source timestamp")))
})
})
})

View File

@ -3,8 +3,10 @@ package transcode
import (
"context"
"errors"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/model"
)
@ -111,6 +113,35 @@ type Decision struct {
TranscodeStream *StreamDetails
}
// toClaimsMap converts a Decision into a JWT claims map for token encoding.
// Only non-zero transcode fields are included.
func (d *Decision) toClaimsMap() map[string]any {
m := map[string]any{
"mid": d.MediaID,
"ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(),
jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(),
}
if d.CanDirectPlay {
m["dp"] = true
}
if d.CanTranscode && d.TargetFormat != "" {
m["f"] = d.TargetFormat
if d.TargetBitrate != 0 {
m["b"] = d.TargetBitrate
}
if d.TargetChannels != 0 {
m["ch"] = d.TargetChannels
}
if d.TargetSampleRate != 0 {
m["sr"] = d.TargetSampleRate
}
if d.TargetBitDepth != 0 {
m["bd"] = d.TargetBitDepth
}
}
return m
}
// StreamDetails describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type StreamDetails struct {
@ -138,3 +169,64 @@ type Params struct {
TargetBitDepth int
SourceUpdatedAt time.Time
}
// paramsFromToken extracts and validates Params from a parsed JWT token.
// Returns an error if required claims (media ID, source timestamp) are missing.
func paramsFromToken(token jwt.Token) (*Params, error) {
var p Params
var mid string
if err := token.Get("mid", &mid); err == nil {
p.MediaID = mid
}
if p.MediaID == "" {
return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid)
}
var dp bool
if err := token.Get("dp", &dp); err == nil {
p.DirectPlay = dp
}
var ua int64
if err := token.Get("ua", &ua); err == nil {
p.SourceUpdatedAt = time.Unix(ua, 0)
} else {
var uaf float64
if err := token.Get("ua", &uaf); err == nil {
p.SourceUpdatedAt = time.Unix(int64(uaf), 0)
}
}
if p.SourceUpdatedAt.IsZero() {
return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid)
}
var f string
if err := token.Get("f", &f); err == nil {
p.TargetFormat = f
}
if err := token.Get("b", &p.TargetBitrate); err != nil {
var bf float64
if err := token.Get("b", &bf); err == nil {
p.TargetBitrate = int(bf)
}
}
if err := token.Get("ch", &p.TargetChannels); err != nil {
var chf float64
if err := token.Get("ch", &chf); err == nil {
p.TargetChannels = int(chf)
}
}
if err := token.Get("sr", &p.TargetSampleRate); err != nil {
var srf float64
if err := token.Get("sr", &srf); err == nil {
p.TargetSampleRate = int(srf)
}
}
if err := token.Get("bd", &p.TargetBitDepth); err != nil {
var bdf float64
if err := token.Get("bd", &bdf); err == nil {
p.TargetBitDepth = int(bdf)
}
}
return &p, nil
}