refactor(auth): replace untyped JWT claims with typed Claims struct
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions

Introduced a typed Claims struct in core/auth to replace the raw
map[string]any approach used for JWT claims throughout the codebase.
This provides compile-time safety and better readability when creating,
validating, and extracting JWT tokens. Also upgraded lestrrat-go/jwx
from v2 to v3 and go-chi/jwtauth to v5.4.0, adapting all callers to
the new API where token accessor methods now return tuples instead of
bare values. Updated all affected handlers, middleware, and tests.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-03-02 13:15:31 -05:00
parent 3d86d44fd9
commit 82f9f88c0f
16 changed files with 284 additions and 125 deletions

View file

@ -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")
}

View file

@ -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())

View file

@ -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 {

View file

@ -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))
})
})
})

94
core/auth/claims.go Normal file
View file

@ -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
}

99
core/auth/claims_test.go Normal file
View file

@ -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))
})
})
})

View file

@ -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 {

14
go.mod
View file

@ -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

28
go.sum
View file

@ -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=

View file

@ -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")

View file

@ -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 {

View file

@ -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)
}

View file

@ -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())
})

View file

@ -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

View file

@ -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
}

View file

@ -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 {