219 lines
6.8 KiB
Go
219 lines
6.8 KiB
Go
|
|
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"))
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|