mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
26c703e729
commit
54d7c3a284
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user