diff --git a/core/auth/auth.go b/core/auth/auth.go index f7ab3ac1b..a75111b35 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -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, diff --git a/core/auth/claims.go b/core/auth/claims.go index 30d5d57a3..c0e4dea7f 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -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 } diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index dadb16096..b5565e850 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -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) { diff --git a/core/transcode/transcode_test.go b/core/transcode/transcode_test.go index cb07a2d45..38b821b7d 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -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"))) + }) + }) }) diff --git a/core/transcode/types.go b/core/transcode/types.go index 922078e2e..f9c211de4 100644 --- a/core/transcode/types.go +++ b/core/transcode/types.go @@ -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 +}