mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
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:
@@ -41,6 +41,16 @@ var embyCompatSessions = struct {
|
||||
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
|
||||
required := middleware.EmbyAuthRequired(secret)
|
||||
return func(c *gin.Context) {
|
||||
// 兼容小幻影视等客户端的「UserId 直连」凭据格式:
|
||||
// token 形如 X-Emby-Token=UserId="<uuid>",不是 JWT。解析出 uuid
|
||||
// 注入 CtxUserID,交由后续 activeEmbyUserRequired 查库验证用户
|
||||
// 存在且有效(未禁用/未过期),避免这类客户端每次 401 Invalid token。
|
||||
if uid := userIdDirectToken(c); uid != "" {
|
||||
c.Set(middleware.CtxUserID, uid)
|
||||
c.Set(middleware.EmbyCtxUserID, uid)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if embyRequestToken(c) == "" {
|
||||
if token := embyCompatSessionToken(c); token != "" {
|
||||
c.Request.Header.Set("X-Emby-Token", token)
|
||||
@@ -50,6 +60,64 @@ func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// userIdDirectToken 从 Emby token 来源中识别形如 UserId="<uuid>" 的直连凭据,
|
||||
// 返回其中的 uuid;不是该格式则返回空串。用于兼容小幻影视(RodelPlayer)等
|
||||
// 客户端把用户 ID 当作 token 提交的行为。
|
||||
// 识别三种来源:
|
||||
// 1. URL query:X-Emby-Token=UserId="<uuid>"
|
||||
// 2. Authorization / X-Emby-Authorization / X-MediaBrowser-Authorization 头:
|
||||
// Emby UserId="<uuid>", Client="...", ...(无 Token=)
|
||||
// 3. X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="<uuid>"
|
||||
func userIdDirectToken(c *gin.Context) string {
|
||||
if c == nil || c.Request == nil {
|
||||
return ""
|
||||
}
|
||||
const prefix = `UserId="`
|
||||
// 从一段文本中提取 UserId="<uuid>" 中的 uuid;不存在则返回空。
|
||||
extract := func(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.Index(raw, prefix)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := raw[idx+len(prefix):]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end <= 0 {
|
||||
return ""
|
||||
}
|
||||
uid := strings.TrimSpace(rest[:end])
|
||||
if len(uid) > 0 && len(uid) <= 64 {
|
||||
return uid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// 1) URL query 参数直传 UserId="..."。
|
||||
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token", "token", "api_key", "apiKey", "ApiKey"} {
|
||||
if uid := extract(c.Query(key)); uid != "" {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
// 2) 认证头中的 Emby/MediaBrowser UserId="..."(无 Token= 的直连凭据)。
|
||||
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
|
||||
if uid := extract(c.GetHeader(header)); uid != "" {
|
||||
// 仅当该头不是标准 Token= 形式时才当作直连凭据,避免误拦截。
|
||||
if !strings.Contains(c.GetHeader(header), "Token=") {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3) X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="..."。
|
||||
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if uid := extract(c.GetHeader(header)); uid != "" {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyRealtimeSessionActivity(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -166,3 +167,76 @@ func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
|
||||
t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyUserIdDirectTokenCompatibility covers clients (e.g. 小幻影视 / Hills)
|
||||
// that send `X-Emby-Token=UserId="<uuid>"` as the auth credential instead of a
|
||||
// JWT. The server must accept a valid, non-disabled user id in that format and
|
||||
// let the request through (user validity is enforced by activeEmbyUserRequired).
|
||||
func TestEmbyUserIdDirectTokenCompatibility(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
// :memory: SQLite 每个连接是独立库,必须锁单连接。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
user, _, err := auth.Register(context.Background(), "viewer", "secret-pass")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
// 1) UserId="<uuid>" direct credential must pass for a valid user.
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="`+user.ID+`"`), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("userId direct token (query) = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 1b) Emby UserId="<uuid>" in X-Emby-Authorization header (RodelPlayer / 小幻影视 style).
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
|
||||
req.Header.Set("X-Emby-Authorization", `Emby UserId="`+user.ID+`", Client="RodelPlayer", Device="WHILETRUE", DeviceId="dev-1", Version="2.2607.7.0"`)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("userId direct token (X-Emby-Authorization header) = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 2) A random / non-existent user id in the same format must be rejected.
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="does-not-exist"`), nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bogus userId direct token = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 2b) Non-existent user in X-Emby-Authorization header must be rejected too.
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
|
||||
req.Header.Set("X-Emby-Authorization", `Emby UserId="does-not-exist", Client="RodelPlayer"`)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bogus userId direct token (header) = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
|
||||
// client performs: POST /Users/AuthenticateByName to obtain an AccessToken,
|
||||
// then immediately reuses that token to fetch /Users/Me and /Items.
|
||||
//
|
||||
// This guards against regressions where login succeeds but the returned token
|
||||
// fails downstream auth (the "每次登录后立刻 401 Invalid token" report).
|
||||
func TestEmbyLoginThenAuthedRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
loginBody := `{"Username":"viewer","Pw":"secret-pass"}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var login struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
|
||||
t.Fatalf("decode login: %v", err)
|
||||
}
|
||||
if login.AccessToken == "" {
|
||||
t.Fatalf("empty AccessToken from login")
|
||||
}
|
||||
|
||||
// Reuse the returned token against authenticated endpoints.
|
||||
for _, path := range []string{"/emby/Users/Me", "/emby/Items", "/emby/Library/VirtualFolders"} {
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", login.AccessToken)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s with returned token = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyLoginWithAuthorizationHeaderToken covers clients that place the
|
||||
// bearer token in Authorization instead of X-Emby-Token.
|
||||
func TestEmbyLoginWithAuthorizationHeaderToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
// :memory: SQLite 每个连接是独立库,必须锁单连接否则表在不同连接上互相不可见。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var login struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
|
||||
t.Fatalf("decode login: %v", err)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
|
||||
req.Header.Set("Authorization", "MediaBrowser Token=\""+login.AccessToken+"\"")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Authorization-token request = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -113,11 +113,28 @@ func embyTokenFromAuthHeader(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
// 优先提取 Token="..." 引号内的纯 token。RodelPlayer 等客户端会把
|
||||
// UserId 和 Token 一起放进同一个 Emby/MediaBrowser 头里,例如
|
||||
// `Emby UserId="..", Client="..", Token="<jwt>"`。此时必须取 Token 引号内的
|
||||
// 纯 JWT,不能取整个头,否则 JWT 解析会因多余杂质失败。
|
||||
if strings.Contains(value, "Token=") {
|
||||
return embyTokenFromAuthHeaderTokenPart(value)
|
||||
}
|
||||
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
}
|
||||
}
|
||||
// 其它格式(例如只带 Client/Device 信息的 MediaBrowser 头)不是令牌,
|
||||
// 不能整串返回,否则会被当作 JWT 解析导致 "Invalid token"。
|
||||
if strings.HasPrefix(value, "MediaBrowser ") || strings.HasPrefix(value, "Emby ") {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// embyTokenFromAuthHeaderTokenPart 从 "Token=..." 形如的字段中取出引号内的纯 token。
|
||||
func embyTokenFromAuthHeaderTokenPart(value string) string {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
|
||||
if !strings.HasPrefix(part, "Token=") {
|
||||
@@ -126,10 +143,7 @@ func embyTokenFromAuthHeader(value string) string {
|
||||
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
|
||||
return strings.Trim(token, `"`)
|
||||
}
|
||||
if strings.Contains(value, "Token=") {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyAppendAPIKey(raw, token string) string {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user