diff --git a/adapters/deezer/client_auth.go b/adapters/deezer/client_auth.go index d0924b768..eb664c00b 100644 --- a/adapters/deezer/client_auth.go +++ b/adapters/deezer/client_auth.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/log" ) @@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) { } // Calculate TTL with a 1-minute buffer for clock skew and network delays - expiresAt := token.Expiration() - if expiresAt.IsZero() { + expiresAt, ok := token.Expiration() + if !ok || expiresAt.IsZero() { return "", errors.New("deezer: JWT token has no expiration time") } diff --git a/adapters/deezer/client_auth_test.go b/adapters/deezer/client_auth_test.go index 59add7097..005a84e1a 100644 --- a/adapters/deezer/client_auth_test.go +++ b/adapters/deezer/client_auth_test.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -179,7 +179,8 @@ var _ = Describe("JWT Authentication", func() { Expect(err).To(BeNil()) // Verify token has no expiration - Expect(testToken.Expiration().IsZero()).To(BeTrue()) + _, hasExp := testToken.Expiration() + Expect(hasExp).To(BeFalse()) testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) Expect(err).To(BeNil()) diff --git a/core/auth/auth.go b/core/auth/auth.go index e03820a17..f7ab3ac1b 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -4,12 +4,11 @@ import ( "cmp" "context" "crypto/sha256" - "maps" "sync" "time" "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -46,38 +45,30 @@ func Init(ds model.DataStore) { }) } -func createBaseClaims() map[string]any { - tokenClaims := map[string]any{} - tokenClaims[jwt.IssuerKey] = consts.JWTIssuer - return tokenClaims -} - -func CreatePublicToken(claims map[string]any) (string, error) { - tokenClaims := createBaseClaims() - maps.Copy(tokenClaims, claims) - _, token, err := TokenAuth.Encode(tokenClaims) - +func CreatePublicToken(claims Claims) (string, error) { + claims.Issuer = consts.JWTIssuer + _, token, err := TokenAuth.Encode(claims.ToMap()) return token, err } -func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) { - tokenClaims := createBaseClaims() +func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { + claims.Issuer = consts.JWTIssuer if !exp.IsZero() { - tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix() + claims.ExpiresAt = exp } - maps.Copy(tokenClaims, claims) - _, token, err := TokenAuth.Encode(tokenClaims) - + _, token, err := TokenAuth.Encode(claims.ToMap()) return token, err } func CreateToken(u *model.User) (string, error) { - claims := createBaseClaims() - claims[jwt.SubjectKey] = u.UserName - claims[jwt.IssuedAtKey] = time.Now().UTC().Unix() - claims["uid"] = u.ID - claims["adm"] = u.IsAdmin - token, _, err := TokenAuth.Encode(claims) + claims := Claims{ + Issuer: consts.JWTIssuer, + Subject: u.UserName, + IssuedAt: time.Now(), + UserID: u.ID, + IsAdmin: u.IsAdmin, + } + token, _, err := TokenAuth.Encode(claims.ToMap()) if err != nil { return "", err } @@ -86,23 +77,18 @@ func CreateToken(u *model.User) (string, error) { } func TouchToken(token jwt.Token) (string, error) { - claims, err := token.AsMap(context.Background()) - if err != nil { - return "", err - } - - claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix() - _, newToken, err := TokenAuth.Encode(claims) - + claims := ClaimsFromToken(token). + WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) + _, newToken, err := TokenAuth.Encode(claims.ToMap()) return newToken, err } -func Validate(tokenStr string) (map[string]any, error) { +func Validate(tokenStr string) (Claims, error) { token, err := jwtauth.VerifyToken(TokenAuth, tokenStr) if err != nil { - return nil, err + return Claims{}, err } - return token.AsMap(context.Background()) + return ClaimsFromToken(token), nil } func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 38f6820f5..761dd205c 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -54,7 +54,7 @@ var _ = Describe("Auth", func() { decodedClaims, err := auth.Validate(tokenStr) Expect(err).NotTo(HaveOccurred()) - Expect(decodedClaims["iss"]).To(Equal("issuer")) + Expect(decodedClaims.Issuer).To(Equal("issuer")) }) It("returns ErrExpired if the `exp` field is in the past", func() { @@ -82,11 +82,11 @@ var _ = Describe("Auth", func() { claims, err := auth.Validate(tokenStr) Expect(err).NotTo(HaveOccurred()) - Expect(claims["iss"]).To(Equal(consts.JWTIssuer)) - Expect(claims["sub"]).To(Equal("johndoe")) - Expect(claims["uid"]).To(Equal("123")) - Expect(claims["adm"]).To(Equal(true)) - Expect(claims["exp"]).To(BeTemporally(">", time.Now())) + Expect(claims.Issuer).To(Equal(consts.JWTIssuer)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.UserID).To(Equal("123")) + Expect(claims.IsAdmin).To(Equal(true)) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) }) }) @@ -104,8 +104,7 @@ var _ = Describe("Auth", func() { decodedClaims, err := auth.Validate(touched) Expect(err).NotTo(HaveOccurred()) - exp := decodedClaims["exp"].(time.Time) - Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay)) + Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay)) }) }) }) diff --git a/core/auth/claims.go b/core/auth/claims.go new file mode 100644 index 000000000..ca496ae9a --- /dev/null +++ b/core/auth/claims.go @@ -0,0 +1,94 @@ +package auth + +import ( + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// Claims represents the typed JWT claims used throughout Navidrome, +// replacing the untyped map[string]any approach. +type Claims struct { + // Standard JWT claims + Issuer string + Subject string // username for session tokens + IssuedAt time.Time + ExpiresAt time.Time + + // Custom claims + UserID string // "uid" + IsAdmin bool // "adm" + ID string // "id" - artwork/mediafile ID + Format string // "f" - audio format + BitRate int // "b" - audio bitrate +} + +// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). +// Only non-zero fields are included. +func (c Claims) ToMap() map[string]any { + m := make(map[string]any) + if c.Issuer != "" { + m[jwt.IssuerKey] = c.Issuer + } + if c.Subject != "" { + m[jwt.SubjectKey] = c.Subject + } + if !c.IssuedAt.IsZero() { + m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix() + } + if !c.ExpiresAt.IsZero() { + m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix() + } + if c.UserID != "" { + m["uid"] = c.UserID + } + if c.IsAdmin { + m["adm"] = c.IsAdmin + } + if c.ID != "" { + m["id"] = c.ID + } + if c.Format != "" { + m["f"] = c.Format + } + if c.BitRate != 0 { + m["b"] = c.BitRate + } + return m +} + +func (c Claims) WithExpiresAt(t time.Time) Claims { + c.ExpiresAt = t + return c +} + +// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get(). +func ClaimsFromToken(token jwt.Token) Claims { + var c Claims + c.Issuer, _ = token.Issuer() + c.Subject, _ = token.Subject() + c.IssuedAt, _ = token.IssuedAt() + c.ExpiresAt, _ = token.Expiration() + + var uid string + if err := token.Get("uid", &uid); err == nil { + c.UserID = uid + } + var adm bool + if err := token.Get("adm", &adm); err == nil { + c.IsAdmin = adm + } + var id string + if err := token.Get("id", &id); err == nil { + c.ID = id + } + var f string + if err := token.Get("f", &f); err == nil { + c.Format = f + } + var b int + if err := token.Get("b", &b); err == nil { + c.BitRate = b + } + return c +} diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go new file mode 100644 index 000000000..cf6b07263 --- /dev/null +++ b/core/auth/claims_test.go @@ -0,0 +1,99 @@ +package auth_test + +import ( + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Claims", func() { + Describe("ToMap", func() { + It("includes only non-zero fields", func() { + c := auth.Claims{ + Issuer: "ND", + Subject: "johndoe", + UserID: "123", + IsAdmin: true, + } + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("iss", "ND")) + Expect(m).To(HaveKeyWithValue("sub", "johndoe")) + Expect(m).To(HaveKeyWithValue("uid", "123")) + Expect(m).To(HaveKeyWithValue("adm", true)) + Expect(m).NotTo(HaveKey("exp")) + Expect(m).NotTo(HaveKey("iat")) + Expect(m).NotTo(HaveKey("id")) + Expect(m).NotTo(HaveKey("f")) + Expect(m).NotTo(HaveKey("b")) + }) + + It("includes expiration and issued-at when set", func() { + now := time.Now() + c := auth.Claims{ + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + } + m := c.ToMap() + Expect(m).To(HaveKey("iat")) + Expect(m).To(HaveKey("exp")) + }) + + It("includes custom claims for public tokens", func() { + c := auth.Claims{ + ID: "al-123", + Format: "mp3", + BitRate: 192, + } + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("id", "al-123")) + Expect(m).To(HaveKeyWithValue("f", "mp3")) + Expect(m).To(HaveKeyWithValue("b", 192)) + }) + }) + + Describe("ClaimsFromToken", func() { + It("round-trips session claims through encode/decode", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + now := time.Now().Truncate(time.Second) + original := auth.Claims{ + Issuer: "ND", + Subject: "johndoe", + UserID: "123", + IsAdmin: true, + } + m := original.ToMap() + m["iat"] = now.UTC().Unix() + token, _, err := tokenAuth.Encode(m) + Expect(err).NotTo(HaveOccurred()) + + c := auth.ClaimsFromToken(token) + Expect(c.Issuer).To(Equal("ND")) + Expect(c.Subject).To(Equal("johndoe")) + Expect(c.UserID).To(Equal("123")) + Expect(c.IsAdmin).To(BeTrue()) + Expect(c.IssuedAt.UTC()).To(Equal(now.UTC())) + }) + + It("round-trips public token claims through encode/decode", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + original := auth.Claims{ + Issuer: "ND", + ID: "al-456", + Format: "opus", + BitRate: 128, + } + token, _, err := tokenAuth.Encode(original.ToMap()) + Expect(err).NotTo(HaveOccurred()) + + c := auth.ClaimsFromToken(token) + Expect(c.Issuer).To(Equal("ND")) + Expect(c.ID).To(Equal("al-456")) + Expect(c.Format).To(Equal("opus")) + Expect(c.BitRate).To(Equal(128)) + }) + }) + +}) diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index ff6f4221e..c1b8e01c4 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -18,7 +18,7 @@ import ( // ImageURL generates a public URL for artwork images. // It creates a signed token for the artwork ID and builds a complete public URL. func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { - token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()}) + token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) uri := path.Join(consts.URLPathPublicImages, token) params := url.Values{} if size > 0 { diff --git a/go.mod b/go.mod index c52bb211d..1f6fa30a6 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 - github.com/go-chi/jwtauth/v5 v5.3.3 + github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc @@ -43,7 +43,7 @@ require ( github.com/kardianos/service v1.2.4 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/lestrrat-go/jwx/v3 v3.0.13 github.com/maruel/natural v1.3.0 github.com/matoous/go-nanoid/v2 v2.1.0 github.com/mattn/go-sqlite3 v1.14.34 @@ -98,7 +98,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect + github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -109,10 +109,11 @@ require ( github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -134,6 +135,7 @@ require ( github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index bbfd51e11..26e4d3925 100644 --- a/go.sum +++ b/go.sum @@ -83,8 +83,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= -github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= -github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= +github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= -github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -163,16 +163,18 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= -github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= +github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= @@ -296,6 +298,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 5e3c54a80..b97e2684d 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -229,11 +229,10 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID { token, err := auth.TokenAuth.Decode(tokenPart) Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token") - claims, err := token.AsMap(context.Background()) - Expect(err).ToNot(HaveOccurred(), "Failed to get claims from token") + c := auth.ClaimsFromToken(token) - id, ok := claims["id"].(string) - Expect(ok).To(BeTrue(), "Token should contain 'id' claim") + id := c.ID + Expect(id).ToNot(BeEmpty(), "Token should contain 'id' claim") artID, err := model.ParseArtworkID(id) Expect(err).ToNot(HaveOccurred(), "Failed to parse artwork ID from token") diff --git a/server/auth.go b/server/auth.go index 86e63722b..a7edaab0a 100644 --- a/server/auth.go +++ b/server/auth.go @@ -185,12 +185,16 @@ func tokenFromHeader(r *http.Request) string { } func UsernameFromToken(r *http.Request) string { - token, claims, err := jwtauth.FromContext(r.Context()) - if err != nil || claims["sub"] == nil || token == nil { + token, _, err := jwtauth.FromContext(r.Context()) + if err != nil || token == nil { return "" } - log.Trace(r, "Found username in JWT token", "username", token.Subject()) - return token.Subject() + sub, _ := token.Subject() + if sub == "" { + return "" + } + log.Trace(r, "Found username in JWT token", "username", sub) + return sub } func UsernameFromExtAuthHeader(r *http.Request) string { diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 5b1194cc9..f4985dea5 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -7,7 +7,6 @@ import ( "net/http" "time" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" @@ -76,22 +75,14 @@ func decodeArtworkID(tokenString string) (model.ArtworkID, error) { if token == nil { return model.ArtworkID{}, errors.New("unauthorized") } - err = jwt.Validate(token, jwt.WithRequiredClaim("id")) - if err != nil { - return model.ArtworkID{}, err + c := auth.ClaimsFromToken(token) + if c.ID == "" { + return model.ArtworkID{}, errors.New("required claim \"id\" not found") } - claims, err := token.AsMap(context.Background()) - if err != nil { - return model.ArtworkID{}, err - } - id, ok := claims["id"].(string) - if !ok { - return model.ArtworkID{}, errors.New("invalid id type") - } - artID, err := model.ParseArtworkID(id) + artID, err := model.ParseArtworkID(c.ID) if err == nil { return artID, nil } // Try to default to mediafile artworkId (if used with a mediafileShare token) - return model.ParseArtworkID("mf-" + id) + return model.ParseArtworkID("mf-" + c.ID) } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 0995f4f61..6895241f6 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -3,7 +3,6 @@ package public import ( "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -15,18 +14,11 @@ var _ = Describe("decodeArtworkID", func() { It("fails to decode an invalid token", func() { _, err := decodeArtworkID("xx-123") - Expect(err).To(MatchError("invalid JWT")) - }) - - It("defaults to kind mediafile for empty artwork ID", func() { - token, _ := auth.CreatePublicToken(map[string]any{"id": ""}) - id, err := decodeArtworkID(token) - Expect(err).ToNot(HaveOccurred()) - Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) + Expect(err).To(HaveOccurred()) }) It("fails to decode a token without an id", func() { - token, _ := auth.CreatePublicToken(map[string]any{}) + token, _ := auth.CreatePublicToken(auth.Claims{}) _, err := decodeArtworkID(token) Expect(err).To(HaveOccurred()) }) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 36764dece..15e63d4db 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -97,12 +97,10 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { } func encodeMediafileShare(s model.Share, id string) string { - claims := map[string]any{"id": id} - if s.Format != "" { - claims["f"] = s.Format - } - if s.MaxBitRate != 0 { - claims["b"] = s.MaxBitRate + claims := auth.Claims{ + ID: id, + Format: s.Format, + BitRate: s.MaxBitRate, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index cf120f0b5..d6819974b 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -1,13 +1,11 @@ package public import ( - "context" "errors" "io" "net/http" "strconv" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/req" @@ -85,21 +83,13 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { if token == nil { return shareTrackInfo{}, errors.New("unauthorized") } - err = jwt.Validate(token, jwt.WithRequiredClaim("id")) - if err != nil { - return shareTrackInfo{}, err + c := auth.ClaimsFromToken(token) + if c.ID == "" { + return shareTrackInfo{}, errors.New("required claim \"id\" not found") } - claims, err := token.AsMap(context.Background()) - if err != nil { - return shareTrackInfo{}, err - } - id, ok := claims["id"].(string) - if !ok { - return shareTrackInfo{}, errors.New("invalid id type") - } - resp := shareTrackInfo{} - resp.id = id - resp.format, _ = claims["f"].(string) - resp.bitrate, _ = claims["b"].(int) - return resp, nil + return shareTrackInfo{ + id: c.ID, + format: c.Format, + bitrate: c.BitRate, + }, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index d984bac42..7698a3c7b 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -159,7 +159,7 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error switch { case jwt != "": claims, err := auth.Validate(jwt) - valid = err == nil && claims["sub"] == user.UserName + valid = err == nil && claims.Subject == user.UserName case pass != "": if strings.HasPrefix(pass, "enc:") { if dec, err := hex.DecodeString(pass[4:]); err == nil {