package jellyfin import ( "net/http" "strings" "github.com/mithral/vellum/internal/apitoken" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" ) // require resolves the caller to an auth.User from a Jellyfin credential and // hands them to fn. It is the Jellyfin twin of auth.Authenticator.Require: // the same shape, but token-based. // // On any failure it has already written a 401. Note the deliberate difference // from the web layer: a Jellyfin token resolves to the same auth.User, and // every handler below still filters through VisibleLibraryIDs / // CanAccessLibrary, so a token can never see another user's libraries. func (h *Handlers) require(fn func(http.ResponseWriter, *http.Request, auth.User)) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, ok := h.currentUser(r) if !ok { httpx.WriteError(w, http.StatusUnauthorized, "authentication required") return } fn(w, r, u) }) } // currentUser resolves the request's Jellyfin credential through // apitoken.Resolve and loads the owning user. func (h *Handlers) currentUser(r *http.Request) (auth.User, bool) { secret := tokenFromRequest(r) if secret == "" { return auth.User{}, false } userID, err := apitoken.Resolve(r.Context(), h.db, secret) if err != nil { // ErrNotFound and any storage error both mean "not authenticated". return auth.User{}, false } u, err := auth.GetUserByID(r.Context(), h.db, userID) if err != nil { return auth.User{}, false } return u, true } // tokenFromRequest extracts the credential from the three places Jellyfin // clients put it, in order of reliability: // // - X-Emby-Token header (modern clients) // - api_key query parameter (media elements cannot set headers) // - Authorization: MediaBrowser Token="..." (older clients) // // X-MediaBrowser-Token is also accepted; some clients send it. func tokenFromRequest(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get("X-Emby-Token")); v != "" { return v } if v := strings.TrimSpace(r.Header.Get("X-MediaBrowser-Token")); v != "" { return v } if v := strings.TrimSpace(r.URL.Query().Get("api_key")); v != "" { return v } return mediaBrowserToken(r.Header.Get("Authorization")) } // mediaBrowserToken parses an Authorization header of the form // // MediaBrowser Token="abc", UserId="...", Client="...", Device="..." // // The token may be quoted or bare, and the parameter list may be comma- or // space-separated in the wild. func mediaBrowserToken(header string) string { if header == "" { return "" } // Normalise commas to spaces so both separators split the same way. fields := strings.Fields(strings.ReplaceAll(header, ",", " ")) for _, f := range fields { if v, ok := strings.CutPrefix(f, "Token="); ok { return strings.Trim(v, `"`) } } return "" }