fix(emby): extract pure JWT from combined UserId+Token auth header (RodelPlayer 401)

fix(emby): extract pure JWT from combined UserId+Token auth header (RodelPlayer 401)
This commit is contained in:
truewhile
2026-08-17 23:31:15 +08:00
parent 42ef19b7e5
commit 8071285ff2
6 changed files with 412 additions and 13 deletions
+16 -9
View File
@@ -42,14 +42,14 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
return []byte(secret), nil
})
if err != nil || !parsed.Valid || claims.UserID == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"Code": 40101,
"Message": "Invalid token",
})
c.Abort()
return
}
if err != nil || !parsed.Valid || claims.UserID == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"Code": 40101,
"Message": "Invalid token",
})
c.Abort()
return
}
c.Set(EmbyCtxUserID, claims.UserID)
c.Set(CtxUserID, claims.UserID)
@@ -83,12 +83,19 @@ func extractEmbyToken(c *gin.Context) string {
}
func tokenFromAuthHeader(value string) string {
// 先尝试提取 Token="..." 引号内的纯 token(RodelPlayer 等客户端会把
// UserId 和 Token 一起放进同一个 Emby/MediaBrowser 头里,例如
// `Emby UserId="..", Client="..", Token="<jwt>"`。此时必须取 Token 引号内的
// 纯 JWT,不能取整个头,否则 JWT 解析会因多余杂质失败。
if strings.Contains(value, "Token=") {
return tokenFromMediaBrowserAuth(value)
}
for _, prefix := range []string{"Bearer ", "Emby "} {
if strings.HasPrefix(value, prefix) {
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
}
}
if strings.HasPrefix(value, "MediaBrowser ") || strings.Contains(value, "Token=") {
if strings.HasPrefix(value, "MediaBrowser ") {
return tokenFromMediaBrowserAuth(value)
}
return value
+89
View File
@@ -1,6 +1,7 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -29,6 +30,7 @@ func TestEmbyAuthRequiredAcceptsEmbyClientTokenFormats(t *testing.T) {
{name: "query api key", query: "?api_key=" + token},
{name: "query x emby token", query: "?X-Emby-Token=" + token},
{name: "query x mediabrowser token", query: "?X-MediaBrowser-Token=" + token},
{name: "query token", query: "?token=" + token},
}
for _, tt := range tests {
@@ -68,3 +70,90 @@ func signedTestToken(t *testing.T, secret string) string {
}
return token
}
// TestEmbyAuthRequiredRejectsMediaBrowserHeaderWithoutToken guards against a
// subtle parsing bug: some clients (e.g. Hills) send an Authorization header
// like `MediaBrowser Client="Hills", DeviceId="..."` that contains no Token=.
// The parser must NOT treat the whole header value as the token — otherwise it
// is fed to JWT parsing and fails with 40101 "Invalid token" on every request
// after a successful login.
func TestEmbyAuthRequiredRejectsMediaBrowserHeaderWithoutToken(t *testing.T) {
gin.SetMode(gin.TestMode)
const secret = "test-secret"
token := signedTestToken(t, secret)
// 1) A MediaBrowser header with a Token= must still work.
router := gin.New()
router.Use(func(c *gin.Context) {
c.Header("X-Emby-Token", token)
c.Next()
})
router.GET("/with/header/token", EmbyAuthRequired(secret), func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
req.Header.Set("Authorization", `MediaBrowser Client="Hills", Token="`+token+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with Token=, got %d: %s", w.Code, w.Body.String())
}
// 2) A MediaBrowser header WITHOUT Token= must be treated as "no token",
// i.e. the request must fail with 40101 (Unauthorized), NOT "Invalid token".
req = httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
req.Header.Set("Authorization", `MediaBrowser Client="Hills", DeviceId="device-42"`)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for no-token MediaBrowser header, got %d", w.Code)
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if msg, _ := body["Message"].(string); msg != "Unauthorized" {
t.Fatalf("expected Message=Unauthorized for no-token header, got %q", msg)
}
}
// TestEmbyAuthRequiredExtractsTokenFromEmbyHeaderWithUserId covers the real
// RodelPlayer / 小幻影视 request shape: all credentials live in a single
// X-Emby-Authorization header that carries BOTH UserId and Token:
//
// Emby UserId="<uuid>", Client="RodelPlayer", Device="WHILETRUE",
// DeviceId="...", Version="2.2607.7.0", Token="<jwt>"
//
// The parser must extract the pure JWT from Token="...", NOT the whole header
// value — otherwise JWT parsing fails with 40101 "Invalid token".
func TestEmbyAuthRequiredExtractsTokenFromEmbyHeaderWithUserId(t *testing.T) {
gin.SetMode(gin.TestMode)
const secret = "test-secret"
token := signedTestToken(t, secret)
// 1) Full RodelPlayer-style header with both UserId and Token must pass.
router := gin.New()
router.GET("/with/header/token", EmbyAuthRequired(secret), func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
req.Header.Set("X-Emby-Authorization",
`Emby UserId="23d5bec4-9ffc-4178-be9b-f5496f8b1b54", Client="RodelPlayer", Device="WHILETRUE", DeviceId="7aacf5c9-815a-4e9c-8b26-6a842da9fbd4", Version="2.2607.7.0", Token="`+token+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with Emby UserId+Token header, got %d: %s", w.Code, w.Body.String())
}
// 2) The same header WITHOUT Token= (UserId only) must NOT be treated as a
// token — it should fail with 40101 "Unauthorized" (no token), not
// "Invalid token" from misparsed JWT.
req = httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
req.Header.Set("X-Emby-Authorization",
`Emby UserId="23d5bec4-9ffc-4178-be9b-f5496f8b1b54", Client="RodelPlayer", Device="WHILETRUE", DeviceId="7aacf5c9-815a-4e9c-8b26-6a842da9fbd4"`)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for Emby UserId-only header, got %d", w.Code)
}
}