🏡 index : ironmagma/quintodrome.git

package lastfm

import (
	"bytes"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/navidrome/navidrome/core/agents"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("auth_router", func() {
	var (
		ds         *tests.MockDataStore
		userProps  *tests.MockedUserPropsRepo
		httpClient *tests.FakeHttpClient
		router     *Router
	)

	const (
		victimID   = "victim-user-id"
		attackerID = "attacker-user-id"
	)

	BeforeEach(func() {
		userProps = &tests.MockedUserPropsRepo{}
		ds = &tests.MockDataStore{
			MockedProperty:  &tests.MockedPropertyRepo{},
			MockedUserProps: userProps,
		}
		auth.Init(ds)

		httpClient = &tests.FakeHttpClient{}
		router = &Router{
			ds:          ds,
			apiKey:      "API_KEY",
			secret:      "SECRET",
			sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
		}
		router.client = newClient(router.apiKey, router.secret, httpClient)
		router.Handler = router.routes()
	})

	storedSessionKey := func(userID string) string {
		key, _ := userProps.Get(userID, sessionKeyProperty)
		return key
	}

	stubGetSessionOK := func(sessionKey string) {
		httpClient.Res = http.Response{
			Body:       io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
			StatusCode: 200,
		}
	}

	Describe("getLinkStatus", func() {
		It("includes a signed linkToken for the authenticated user", func() {
			req := httptest.NewRequest(http.MethodGet, "/link", nil)
			ctx := request.WithUser(req.Context(), model.User{ID: victimID})
			req = req.WithContext(ctx)
			rec := httptest.NewRecorder()

			router.getLinkStatus(rec, req)

			Expect(rec.Code).To(Equal(http.StatusOK))
			var body map[string]any
			Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
			Expect(body["apiKey"]).To(Equal("API_KEY"))
			Expect(body["status"]).To(Equal(false))
			token, ok := body["linkToken"].(string)
			Expect(ok).To(BeTrue())
			Expect(token).ToNot(BeEmpty())

			verified, err := verifyLinkToken(token)
			Expect(err).ToNot(HaveOccurred())
			Expect(verified).To(Equal(victimID))
		})
	})

	Describe("callback", func() {
		It("stores the session key under the user encoded in the signed token", func() {
			stubGetSessionOK("LEGIT_SESSION")
			linkToken, err := createLinkToken(victimID)
			Expect(err).ToNot(HaveOccurred())

			req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
			rec := httptest.NewRecorder()
			router.callback(rec, req)

			Expect(rec.Code).To(Equal(http.StatusOK))
			Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
		})

		It("rejects a raw (unsigned) uid value", func() {
			req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
			rec := httptest.NewRecorder()
			router.callback(rec, req)

			Expect(rec.Code).To(Equal(http.StatusBadRequest))
			Expect(storedSessionKey(victimID)).To(BeEmpty())
			Expect(httpClient.SavedRequest).To(BeNil())
		})

		It("rejects an expired link token", func() {
			expiredToken, err := auth.EncodeToken(map[string]any{
				"uid":   victimID,
				"scope": linkTokenScope,
				"exp":   time.Now().Add(-1 * time.Minute).UTC().Unix(),
			})
			Expect(err).ToNot(HaveOccurred())

			req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
			rec := httptest.NewRecorder()
			router.callback(rec, req)

			Expect(rec.Code).To(Equal(http.StatusBadRequest))
			Expect(storedSessionKey(victimID)).To(BeEmpty())
			Expect(httpClient.SavedRequest).To(BeNil())
		})

		It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
			sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
			Expect(err).ToNot(HaveOccurred())

			req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
			rec := httptest.NewRecorder()
			router.callback(rec, req)

			Expect(rec.Code).To(Equal(http.StatusBadRequest))
			Expect(storedSessionKey(attackerID)).To(BeEmpty())
			Expect(httpClient.SavedRequest).To(BeNil())
		})

		It("writes only under the user encoded in the token, regardless of query manipulation", func() {
			// An attacker holds a legitimate link token for their own account.
			// They attempt to call the callback hoping to overwrite the victim's
			// session key — but the handler must derive the user ID from the
			// signed token, not from any other input.
			stubGetSessionOK("ATTACKER_SESSION")
			attackerToken, err := createLinkToken(attackerID)
			Expect(err).ToNot(HaveOccurred())

			req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
			rec := httptest.NewRecorder()
			router.callback(rec, req)

			Expect(rec.Code).To(Equal(http.StatusOK))
			Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
			Expect(storedSessionKey(victimID)).To(BeEmpty())
		})

		It("returns 400 when uid is missing", func() {
			req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
			rec := httptest.NewRecorder()
			router.callback(rec, req)

			Expect(rec.Code).To(Equal(http.StatusBadRequest))
		})

		It("returns 400 when token is missing", func() {
			linkToken, err := createLinkToken(victimID)
			Expect(err).ToNot(HaveOccurred())

			req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
			rec := httptest.NewRecorder()
			router.callback(rec, req)

			Expect(rec.Code).To(Equal(http.StatusBadRequest))
		})
	})

	Describe("link token helpers", func() {
		It("round-trips a freshly issued token", func() {
			token, err := createLinkToken(victimID)
			Expect(err).ToNot(HaveOccurred())

			uid, err := verifyLinkToken(token)
			Expect(err).ToNot(HaveOccurred())
			Expect(uid).To(Equal(victimID))
		})

		It("rejects garbage", func() {
			_, err := verifyLinkToken("not-a-jwt")
			Expect(err).To(HaveOccurred())
		})

		It("rejects a token whose scope claim is wrong", func() {
			wrongScopeToken, err := auth.EncodeToken(map[string]any{
				"uid":   victimID,
				"scope": "some-other-scope",
				"exp":   time.Now().Add(linkTokenTTL).UTC().Unix(),
			})
			Expect(err).ToNot(HaveOccurred())

			_, err = verifyLinkToken(wrongScopeToken)
			Expect(err).To(MatchError("invalid link token scope"))
		})

		It("rejects a scoped token that has no expiration", func() {
			nonExpiringToken, err := auth.EncodeToken(map[string]any{
				"uid":   victimID,
				"scope": linkTokenScope,
			})
			Expect(err).ToNot(HaveOccurred())

			_, err = verifyLinkToken(nonExpiringToken)
			Expect(err).To(MatchError("link token missing expiration"))
		})
	})
})