Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 72 additions & 12 deletions internal/api/handlers/playback_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const (
transcodeStartFailedReasonV3 = "transcode_start_failed"
seekRestorationPlayerV3 = "player_position"
outputRouteChangedReasonV3 = "output_route_changed"
applePlatformIOSV3 = "ios"
applePlatformTVOSV3 = "tvos"
applePlatformMacOSV3 = "macos"
// Failed capability fetches are memoized briefly so an unreachable node
// costs one timeout per window instead of one per planning request.
v3NodeCapabilityErrorTTL = 15 * time.Second
Expand Down Expand Up @@ -89,6 +92,7 @@ type v3NodeCapabilityCache struct {

type preparedTransportV3 struct {
url string
headers map[string]string
nodeURL string
transportID string
hwAccel string
Expand Down Expand Up @@ -156,10 +160,17 @@ type playbackStartSideEffectsStateV3 struct {
// it. Both bits are resolved once from the attempt's (pinned) feature list and
// threaded down every branch rather than re-derived per URL builder.
type mediaAuthModeV3 struct {
// headerAuth is header_authenticated_media_v1: no client-visible URL
// carries a signed playback credential, and the client authenticates every
// media request with its own access token instead.
// headerAuth is header_authenticated_media_v1: no client-visible URL carries
// a signed playback credential. Ordinarily the client authenticates media
// requests with its own access token; sessionHeaderCapability is the bounded
// compatibility exception described below.
headerAuth bool
// sessionHeaderCapability keeps the signed, session-bound credential in a
// plan-supplied header. It is used only for clients whose media engine freezes
// request headers at load time, so a stale bearer cannot interrupt bytes while
// the API client independently refreshes. The URL remains credential-free and
// the response continues to honor header_authenticated_media_v1.
sessionHeaderCapability bool
// proxyEgress is authorized_media_origins_v1 negotiated on top of
// headerAuth: the client also honors credential-free absolute URLs on
// server-designated proxy origins, so media bytes need not all egress from
Expand All @@ -183,6 +194,52 @@ func headerAuthenticatedMediaV3(clientFeatures []string) mediaAuthModeV3 {
}
}

// mediaAuthModeForStartV3 keeps Apple build 31 media requests independent of
// the short-lived access token. The shared iOS/tvOS/macOS AetherEngine snapshots
// HTTP headers when an item loads and reuses them for range reads and internal
// reloads. An automatic episode transition can therefore retain the old bearer
// and begin receiving 401s while buffered playback continues. A session-bound
// header capability preserves the selected playback route and is accepted
// before any stale Authorization header.
func mediaAuthModeForStartV3(req playback.StartRequestV3) mediaAuthModeV3 {
mode := headerAuthenticatedMediaV3(req.ClientFeatures)
ctx := req.ClientPlaybackContext
platform := strings.ToLower(strings.TrimSpace(ctx.Device.Platform))
isApplePlatform := platform == applePlatformIOSV3 || platform == applePlatformTVOSV3 || platform == applePlatformMacOSV3
if mode.headerAuth &&
playback.HasFeatureV3(req.ClientFeatures, playback.FeatureDeviceQuirksV3) &&
isApplePlatform &&
strings.TrimSpace(ctx.AppBuild) == "31" {
mode.proxyEgress = false
mode.sessionHeaderCapability = true
}
return mode
}

func (h *PlaybackHandler) sessionCapabilityHeadersV3(mode mediaAuthModeV3, card playback.RecipeCard) map[string]string {
if !mode.sessionHeaderCapability {
return nil
}
token := h.signStreamClaims(card.ToClaims())
if token == "" {
return nil
}
return map[string]string{streamtoken.Header: token}
}

func applyPreparedTransportToPlanV3(plan *playback.PlanV3, transport preparedTransportV3) {
if plan == nil {
return
}
plan.Stream.URL = transport.url
if plan.Stream.Headers == nil {
plan.Stream.Headers = map[string]string{}
}
for name, value := range transport.headers {
plan.Stream.Headers[name] = value
}
}

// recipeCardStoreV3 is the shared control-plane store central hands a recipe
// card to another Silo process through (*noderecipe.Store). One instance owns
// one key space; the handler holds two of them, and what a stored card
Expand Down Expand Up @@ -1174,7 +1231,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
}
// A refused progressive remux is escalated before the decision is logged or
// a session is opened, so the logged route is the one that will actually run.
escalated, escalateErr := h.escalateRefusedProgressiveRemuxV3(r.Context(), headerAuthenticatedMediaV3(req.ClientFeatures),
escalated, escalateErr := h.escalateRefusedProgressiveRemuxV3(r.Context(), mediaAuthModeForStartV3(req),
func() playback.PlannerInputV3 {
return h.plannerInputV3(r.Context(), req, requestedFile, effectiveFile, audioIndex, nil)
}, result)
Expand Down Expand Up @@ -1341,7 +1398,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
if result.Plan == nil {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "The server produced no playback plan."}
}
mode := headerAuthenticatedMediaV3(req.ClientFeatures)
mode := mediaAuthModeForStartV3(req)
if checker, ok := h.sessionMgr.(transcodePermissionChecker); ok && (result.PlayMethod == playback.PlayTranscode || result.TranscodeAudio) {
if err := checker.CheckTranscodingAllowed(r.Context(), userID, result.PlayMethod == playback.PlayTranscode); err != nil {
reason := "transcoding_disabled"
Expand Down Expand Up @@ -1397,7 +1454,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
abort()
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
}
result.Plan.Stream.URL = transport.url
applyPreparedTransportToPlanV3(result.Plan, transport)
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe); err != nil {
transport.rollback()
abort()
Expand Down Expand Up @@ -1909,8 +1966,10 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(r *http.Request, session *p
streamURL = appendPlaybackQueryV3(streamURL, "seek", strconv.FormatFloat(seek, 'f', -1, 64))
}
}
capabilityHeaders := h.sessionCapabilityHeadersV3(mode, identityRecipeCard(&routeSession))
return preparedTransportV3{
url: streamURL,
url: streamURL,
headers: capabilityHeaders,
commit: func() {
if committed {
return
Expand Down Expand Up @@ -2658,17 +2717,18 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
}
}
}
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, "", ts.Opts())
card.OriginalStartedAt = session.StartedAt
url := fmt.Sprintf("/playback/transcode/%s/master.m3u8", session.ID)
if !mode.headerAuth {
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, "", ts.Opts())
card.OriginalStartedAt = session.StartedAt
url = appendStreamToken(url, h.signSessionToken(card, mode.headerAuth))
}
committed := false
previousNodeURL := session.TranscodeNodeURL
previousTransportID := remoteTransportID(session)
return preparedTransportV3{
url: url,
headers: h.sessionCapabilityHeadersV3(mode, card),
hwAccel: ts.Opts().HWAccel,
toneMapMode: ts.Opts().ToneMapMode,
commit: func() {
Expand Down Expand Up @@ -2861,7 +2921,7 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
previousNodeURL := session.TranscodeNodeURL
previousTransportID := remoteTransportID(session)
unlock := h.tm.LockSessionLifecycle(session.ID)
return preparedTransportV3{url: url, nodeURL: node.URL, transportID: transportID, hwAccel: confirmedHWAccel, toneMapMode: confirmedToneMapMode, commit: func() {
return preparedTransportV3{url: url, headers: h.sessionCapabilityHeadersV3(mode, card), nodeURL: node.URL, transportID: transportID, hwAccel: confirmedHWAccel, toneMapMode: confirmedToneMapMode, commit: func() {
if committed {
return
}
Expand Down Expand Up @@ -3763,7 +3823,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
// Media authentication is attempt-sticky (pinned in HandleReplanPlaybackV3),
// so this mode always equals the one the attempt started under: a reused
// transport cannot change the session's media security contract.
mode := headerAuthenticatedMediaV3(start.ClientFeatures)
mode := mediaAuthModeForStartV3(start)
if !seekReanchor {
// A freshly planned replan can land on the same refused progressive
// remux a start would have; escalate it identically. A seek reanchor
Expand Down Expand Up @@ -3856,7 +3916,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
artifactRecipe = frozenRecipe
}
}
result.Plan.Stream.URL = transport.url
applyPreparedTransportToPlanV3(result.Plan, transport)
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &artifactRecipe); err != nil {
transport.rollback()
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
Expand Down
103 changes: 99 additions & 4 deletions internal/api/handlers/playback_v3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -903,11 +903,14 @@ func TestHandleStartPlaybackV3ReturnsExecutableDirectPlan(t *testing.T) {

func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs(t *testing.T) {
for _, test := range []struct {
name string
optIn bool
wantStream bool
name string
optIn bool
appleBuild31 bool
wantStream bool
wantCapabilityHeader bool
}{
{name: "opted-in URLs carry no playback credential", optIn: true},
{name: "Apple build 31 uses a session-bound header credential", optIn: true, appleBuild31: true, wantCapabilityHeader: true},
{name: "legacy URL keeps restart token", wantStream: true},
} {
t.Run(test.name, func(t *testing.T) {
Expand All @@ -923,6 +926,12 @@ func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs
if test.optIn {
start.ClientFeatures = append(start.ClientFeatures, playback.FeatureHeaderAuthenticatedMediaV3)
}
if test.appleBuild31 {
start.ClientFeatures = append(start.ClientFeatures, playback.FeatureDeviceQuirksV3)
start.ClientPlaybackContext.FormFactor = "tv"
start.ClientPlaybackContext.AppBuild = "31"
start.ClientPlaybackContext.Device.Platform = "tvos"
}
subtitleIndex := 0
start.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", subtitleIndex)
start.SubtitleTrackIndex = &subtitleIndex
Expand All @@ -943,9 +952,19 @@ func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs
if got := streamURL.Query().Get(streamTokenParam); (got != "") != test.wantStream {
t.Fatalf("stream token present = %v for URL %q, want %v", got != "", response.PlaybackPlan.Stream.URL, test.wantStream)
}
if len(response.PlaybackPlan.Stream.Headers) != 0 {
capability := response.PlaybackPlan.Stream.Headers[streamtoken.Header]
if (capability != "") != test.wantCapabilityHeader {
t.Fatalf("session capability header present = %v, want %v", capability != "", test.wantCapabilityHeader)
}
if _, ok := response.PlaybackPlan.Stream.Headers["Authorization"]; ok {
t.Fatalf("plan persisted bearer material in headers: %#v", response.PlaybackPlan.Stream.Headers)
}
if capability != "" {
claims, verifyErr := streamtoken.Verify(capability, handler.JWTSecret)
if verifyErr != nil || claims.SessionID != response.SessionID || claims.MediaFileID != file.ID {
t.Fatalf("session capability claims = %#v, err = %v", claims, verifyErr)
}
}

artifact := response.PlaybackPlan.Subtitle.Artifact
if artifact == nil || len(response.PlaybackPlan.Subtitle.Inventory) != 1 {
Expand All @@ -965,6 +984,82 @@ func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs
}
}

func TestMediaAuthModeForStartV3AppliesOnlyToAffectedAppleClient(t *testing.T) {
base := playback.StartRequestV3{
ClientFeatures: []string{
playback.FeatureHeaderAuthenticatedMediaV3,
playback.FeatureDeviceQuirksV3,
},
ClientPlaybackContext: playback.ClientPlaybackContextV3{
FormFactor: "tv",
AppBuild: "31",
Device: playback.DeviceContextV3{
Platform: "tvos",
},
},
}

for _, test := range []struct {
name string
mutate func(*playback.StartRequestV3)
wantCapability bool
}{
{name: "tvOS build 31 uses session capability", wantCapability: true},
{
name: "iOS build 31 uses session capability",
mutate: func(req *playback.StartRequestV3) {
req.ClientPlaybackContext.Device.Platform = "ios"
req.ClientPlaybackContext.FormFactor = "mobile"
},
wantCapability: true,
},
{
name: "macOS build 31 uses session capability",
mutate: func(req *playback.StartRequestV3) {
req.ClientPlaybackContext.Device.Platform = "macos"
req.ClientPlaybackContext.FormFactor = "desktop"
},
wantCapability: true,
},
{
name: "later tvOS build keeps header authentication",
mutate: func(req *playback.StartRequestV3) {
req.ClientPlaybackContext.AppBuild = "32"
},
},
{
name: "client without device quirks keeps header authentication",
mutate: func(req *playback.StartRequestV3) {
req.ClientFeatures = []string{playback.FeatureHeaderAuthenticatedMediaV3}
},
},
{
name: "non Apple client keeps header authentication",
mutate: func(req *playback.StartRequestV3) {
req.ClientPlaybackContext.Device.Platform = "android"
},
},
} {
t.Run(test.name, func(t *testing.T) {
req := base
req.ClientFeatures = append([]string(nil), base.ClientFeatures...)
if test.mutate != nil {
test.mutate(&req)
}
got := mediaAuthModeForStartV3(req)
if !got.headerAuth {
t.Fatal("header-authenticated media contract was disabled")
}
if got.sessionHeaderCapability != test.wantCapability {
t.Fatalf("sessionHeaderCapability = %t, want %t", got.sessionHeaderCapability, test.wantCapability)
}
if got.sessionHeaderCapability && got.proxyEgress {
t.Fatal("session header capability must keep media on the API origin")
}
})
}
}

func TestHandleReplanPlaybackV3CannotDowngradeHeaderAuthenticatedAttempt(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
Expand Down
3 changes: 3 additions & 0 deletions internal/api/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ func (am *AuthMiddleware) RequireTransportAuth(secret string) func(http.Handler)
regularAuth := am.RequireAuth(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get(streamtoken.QueryParameter)
if token == "" {
token = r.Header.Get(streamtoken.Header)
}
sessionID := chi.URLParam(r, "session_id")
if secret != "" && token != "" {
claims, err := streamtoken.Verify(token, secret)
Expand Down
35 changes: 27 additions & 8 deletions internal/api/middleware/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,33 @@ func TestRequireTransportAuthUsesSessionBoundCapabilityWhenAccessTokenExpired(t
router.With(middleware.RequireTransportAuth(secret)).Get("/stream/{session_id}", handler)
router.With(middleware.RequireTransportAuth(secret)).Head("/stream/{session_id}", handler)

for _, method := range []string{http.MethodGet, http.MethodHead} {
req := httptest.NewRequest(method, "/stream/playback-1?"+streamtoken.QueryParameter+"="+url.QueryEscape(token), nil)
req.Header.Set("Authorization", "Bearer expired-access-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusNoContent {
t.Fatalf("%s status = %d, body = %s", method, rec.Code, rec.Body.String())
for _, carrier := range []struct {
name string
apply func(*http.Request)
}{
{
name: "query",
apply: func(req *http.Request) {
req.URL.RawQuery = streamtoken.QueryParameter + "=" + url.QueryEscape(token)
},
},
{
name: "header",
apply: func(req *http.Request) {
req.Header.Set(streamtoken.Header, token)
},
},
} {
for _, method := range []string{http.MethodGet, http.MethodHead} {
req := httptest.NewRequest(method, "/stream/playback-1", nil)
carrier.apply(req)
req.Header.Set("Authorization", "Bearer expired-access-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusNoContent {
t.Fatalf("%s %s status = %d, body = %s", carrier.name, method, rec.Code, rec.Body.String())
}
}
}
if validator.calls != 0 {
Expand Down
4 changes: 4 additions & 0 deletions internal/streamtoken/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const (
// the name here lets the API auth middleware and serve handlers agree without
// either package depending on the other.
QueryParameter = "st"
// Header carries the same session-bound capability when a client must keep
// credentials out of media URLs but cannot refresh a bearer captured by its
// media engine. It is checked before ordinary Authorization fallback.
Header = "X-Silo-Stream-Token"
// PlayMethodDownload identifies a token minted only after the API has
// authorized a file download. Proxy download routes reject playback tokens.
PlayMethodDownload = "download"
Expand Down
Loading