diff --git a/core/auth/claims.go b/core/auth/claims.go index c0e4dea7f..c7e6f02fe 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -21,6 +21,7 @@ type Claims struct { ID string // "id" - artwork/mediafile ID Format string // "f" - audio format BitRate int // "b" - audio bitrate + ShareID string // "sid" - share ID for share stream tokens } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any { if c.BitRate != 0 { m["b"] = c.BitRate } + if c.ShareID != "" { + m["sid"] = c.ShareID + } return m } @@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims { c.BitRate = int(bf) } } + var sid string + if err := token.Get("sid", &sid); err == nil { + c.ShareID = sid + } return c } diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index cf6b07263..8820fd295 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -28,6 +28,7 @@ var _ = Describe("Claims", func() { Expect(m).NotTo(HaveKey("id")) Expect(m).NotTo(HaveKey("f")) Expect(m).NotTo(HaveKey("b")) + Expect(m).NotTo(HaveKey("sid")) }) It("includes expiration and issued-at when set", func() { @@ -52,6 +53,12 @@ var _ = Describe("Claims", func() { Expect(m).To(HaveKeyWithValue("f", "mp3")) Expect(m).To(HaveKeyWithValue("b", 192)) }) + + It("includes share ID claim when set", func() { + c := auth.Claims{ShareID: "abc1234567"} + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("sid", "abc1234567")) + }) }) Describe("ClaimsFromToken", func() { @@ -84,6 +91,7 @@ var _ = Describe("Claims", func() { ID: "al-456", Format: "opus", BitRate: 128, + ShareID: "abc1234567", } token, _, err := tokenAuth.Encode(original.ToMap()) Expect(err).NotTo(HaveOccurred()) @@ -91,6 +99,7 @@ var _ = Describe("Claims", func() { c := auth.ClaimsFromToken(token) Expect(c.Issuer).To(Equal("ND")) Expect(c.ID).To(Equal("al-456")) + Expect(c.ShareID).To(Equal("abc1234567")) Expect(c.Format).To(Equal("opus")) Expect(c.BitRate).To(Equal(128)) }) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 24ecff1d6..18bfcc01c 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -102,6 +102,7 @@ func encodeMediafileShare(s model.Share, id string) string { ID: id, Format: s.Format, BitRate: s.MaxBitRate, + ShareID: s.ID, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index daa09c375..7d5a836b3 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -4,11 +4,13 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -23,6 +25,18 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + if info.shareID != "" { + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + } + mf, err := pub.ds.MediaFile(ctx).Get(info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { @@ -63,17 +77,14 @@ type shareTrackInfo struct { id string format string bitrate int + shareID string } func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - token, err := auth.TokenAuth.Decode(tokenString) + c, err := auth.Validate(tokenString) if err != nil { return shareTrackInfo{}, err } - if token == nil { - return shareTrackInfo{}, errors.New("unauthorized") - } - c := auth.ClaimsFromToken(token) if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } @@ -81,5 +92,6 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { id: c.ID, format: c.Format, bitrate: c.BitRate, + shareID: c.ShareID, }, nil } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go new file mode 100644 index 000000000..222d5ef1a --- /dev/null +++ b/server/public/handle_streams_test.go @@ -0,0 +1,196 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockStreamer struct { + req stream.Request + called bool +} + +func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) { + m.called = true + m.req = r + return nil, errors.New("mock: not implemented") +} + +var _ = Describe("decodeStreamInfo", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("decodes a valid token with all fields", func() { + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(192)) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("accepts a token without exp (non-expiring share)", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects a token without an id claim", func() { + claims := auth.Claims{ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an invalid token string", func() { + _, err := decodeStreamInfo("not-a-valid-token") + Expect(err).To(HaveOccurred()) + }) + + It("handles tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123", Format: "opus"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("opus")) + Expect(info.shareID).To(BeEmpty()) + }) +}) + +var _ = Describe("encodeMediafileShare", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("includes the share ID in the token", func() { + exp := P(time.Now().Add(time.Hour)) + s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} + token := encodeMediafileShare(s, "mf-999") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareABC")) + Expect(info.id).To(Equal("mf-999")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(320)) + }) + + It("creates a non-expiring token when share has no expiry", func() { + s := model.Share{ID: "shareXYZ", ExpiresAt: nil} + token := encodeMediafileShare(s, "mf-111") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareXYZ")) + Expect(info.id).To(Equal("mf-111")) + }) +}) + +var _ = Describe("handleStream", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var streamer *mockStreamer + var pub *Router + + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + streamer = &mockStreamer{} + pub = &Router{ds: ds, streamer: streamer} + }) + + makeRequest := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil) + w := httptest.NewRecorder() + pub.handleStream(w, r) + return w + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareRepo.ID = "share123" + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + ds.MockedMediaFile = mfRepo + + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + Expect(streamer.req.Format).To(Equal("mp3")) + Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("returns 400 for an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 when share has been deleted", func() { + shareRepo.ID = "other-share" + claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 410 when share has been set to expired", func() { + shareRepo.ID = "share123" + expired := time.Now().Add(-time.Hour) + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 500 when share lookup fails", func() { + shareRepo.Error = errors.New("db error") + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("skips share check for tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + // Should get past share check, then fail on media file lookup (no mock data) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 400 for an invalid token", func() { + w := makeRequest("not-a-valid-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 22eb9446c..9fbf0057e 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -44,3 +44,16 @@ func (m *MockShareRepo) Exists(id string) (bool, error) { } return id == m.ID, nil } + +func (m *MockShareRepo) Get(id string) (*model.Share, error) { + if m.Error != nil { + return nil, m.Error + } + if id != m.ID { + return nil, model.ErrNotFound + } + if s, ok := m.Entity.(*model.Share); ok { + return s, nil + } + return &model.Share{ID: id}, nil +}