Skip to content

7. KMS Token Authorization — Implementation Plan

7. KMS Token Authorization — Implementation Plan

Section titled “7. KMS Token Authorization — Implementation Plan”

Status: implemented. This is the client-side plan for the backend change described in doc 6 (Frontend KMS Implementation Guidelines). Two opaque signed tokens are attached to every KMS key request:

Header Source Scope
x-content-access new content_access_token field on GET /v1/:type/:slug one title
x-entitlement new GET /v1/user/entitlements (bearer-auth, cached per user) one user

Both are treated as opaque: never decoded, never logged, never used for a client-side decision. The app only fetches, caches, and forwards.

Design decisions (each verified against the code)

Section titled “Design decisions (each verified against the code)”

D1. The entitlement token lives in a ValueNotifier in const.dart

Section titled “D1. The entitlement token lives in a ValueNotifier in const.dart”

EncryptionHelper.playerHeader() is synchronous and is called at 7 active sites the moment playback starts — it cannot await a network call. The token must therefore already be in memory. The app already has a convention for app-wide state (current_user, internet_available, …), so entitlement_token follows it. playerHeader() reads it synchronously.

D2. One listener on current_user is the whole invalidation story

Section titled “D2. One listener on current_user is the whole invalidation story”

The guidelines require that User A’s token is never reused after switching to User B. Every path that changes identity funnels through the global current_user notifier:

  • login (phone/email/social) — UserAuthDataSource sets it
  • logout — both removeUser() copies reset it
  • profile switch — ProfileManagementDataSource.switchProfile resets + re-sets it
  • app-start restore — SettingsDataSource.getCurrentUser sets it

EntitlementService listens once and reacts to the user id: same id → keep the token (a getUser() refresh must not thrash the cache); new id → drop, restore-from-disk-or-fetch; empty id → drop and delete the persisted copy. This avoids having to patch all six identity-changing call sites and the two duplicated removeUser() implementations.

D3. Persistence is per user id, in SharedPreferences

Section titled “D3. Persistence is per user id, in SharedPreferences”

entitlement_token_<userId>. A cold start offline can then still play: the token is restored from disk. On logout the key is removed, so a shared device never carries one account’s credential into another session.

D4. GET /v1/user/entitlements goes through getDynamicApiV1

Section titled “D4. GET /v1/user/entitlements goes through getDynamicApiV1”

Matches every other /user/* endpoint (getProfilesUrl = "/user/profiles"). The Authorization: Bearer header is attached automatically by the ApiClient interceptor, which reads current_user per request — no extra wiring. Response parsed as result.token, with a fallback to a bare token / string result so a slightly different envelope still works. Failure ⇒ token stays null ⇒ header omitted (anonymous behaviour), and the next ensureToken() retries.

JsonHelper.getJsonData returns "" for a missing key, so today’s backend (before the field ships) reads as empty. Both headers are omitted unless non-empty — an anonymous user must not send a blank x-entitlement.

D6. The content token rides on ContentEntity (and the download row)

Section titled “D6. The content token rides on ContentEntity (and the download row)”

content_access_token is parsed in ContentModel.fromJson and ContentPageModel.fromJson (top-level field injected into data, so either response shape works), mapped through toEntity(), and reaches:

  • online playback — buildPlayerMedia has the entity in scope
  • trailer — the parent title’s token (trailers get no field of their own; open backend question whether trailer assets are KMS-gated at all)
  • downloads — frozen onto CacheContentEntity (@HiveField(22)) at download start, because reenqueueMissing only ever sees the row, never the entity. Caveat from doc 6 stands: a short-lived token will be stale by the time the OS runs a re-enqueued task. Entitlement is not frozen — it is read live from the notifier at enqueue time.

D7. The cast/AirPlay proxy propagates the token down the rewrite chain

Section titled “D7. The cast/AirPlay proxy propagates the token down the rewrite chain”

The proxy knows only URLs. The token is registered per master URL when a session calls proxiedMasterUrl(..., contentAccessToken:) — both call sites (CastController._dispatchLoad, AirPlayController) have the ContentEntity in scope. It then follows the same trail the playlists describe:

proxiedMasterUrl(master, token) → _masterTokens[master]
_serveMaster: rewriteMaster → _variantTokens[child] (via proxyUrlFor)
_keyTokens[sessionKey] (via keyProxyUrlFor)
_serveMedia: rewriteMedia → _keyTokens[key] (via keyProxyUrlFor)
_serveKey: playerHeader(contentAccessToken: _keyTokens[key])

This is why the maps are keyed by URL rather than holding one “current” token: a cast queue proxies a whole series at once and several titles’ keys can be in flight concurrently (doc 6, drawback 2). All maps are cleared in stop() alongside _keyCache.

D8. KMS rejection → refresh + retry, on the one path that can see it

Section titled “D8. KMS rejection → refresh + retry, on the one path that can see it”

Of the three key-request clients only the cast proxy is Dart, so only it can react to “backend indicates the token is invalid”: on a 401/403 whose request carried x-entitlement, the proxy invalidates the cached entitlement token, re-fetches it via ensureToken(), rebuilds the headers, and retries once. On the native paths an expired token is indistinguishable from a network error (doc 6, drawback 4) — which is why token lifetime must outlast a title.

D9. Manifest/quality fetches get the same header map

Section titled “D9. Manifest/quality fetches get the same header map”

CastController._loadQualityOptions already sends the signed headers to the CDN; it now sends the token-bearing map too. On the direct (non-proxy) path the native player applies one header set to every request anyway, so the tokens already reach the CDN on segments (doc 6, drawback 1 — a known, accepted consequence until key requests are proxied app-side).

# File Change
1 core/constant/api_url_const.dart userEntitlementsUrl
2 core/constant/const.dart entitlement_token notifier
3 data/datasources/entitlement_data_source.dart new — fetch + parse
4 data/services/entitlement_service.dart new — cache, listener, persistence, retry
5 injection_container.dart register both
6 main.dart EntitlementService.init() at startup
7 data/services/app_bootstrap_impl.dart warm-up ensureToken() when signed in
8 core/helper/encryption_helper.dart playerHeader({contentAccessToken}) + 2 headers
9 data/models/content_model.dart parse + map content_access_token
10 data/models/content_page_model.dart top-level token injection
11 domain/entities/content.dart field + props
12 presentation/widgets/player/player_media_builder.dart pass token
13 presentation/pages/content_pages/single_content_screen.dart trailer token
14 domain/entities/cache_content_entity.dart @HiveField(22) + regen adapter
15 data/services/download_manager_impl.dart persist token on the row
16 data/services/hls/download_orchestrator.dart both keyHeaders call sites
17 domain/services/cast_lan_proxy_server.dart proxiedMasterUrl param
18 data/services/cast/cast_lan_proxy_server_impl.dart token maps + 403 retry
19 presentation/widgets/player/cast_controller.dart register token at dispatch
20 presentation/widgets/player/airplay/airplay_controller.dart proxy calls + header closure
21 presentation/pages/content_pages/video_player_screen.dart playbackUrlFor token
22 tests new: service, helper, proxy-propagation; update fakes
  • Offline downloads keep their plaintext key.bin and never re-contact KMS — a server-issued expiring download licence is a backend design decision.
  • Token lifetime must comfortably exceed a title; the client cannot refresh mid-playback on native paths.
  • Trailers: parent token forwarded; whether KMS gates trailer keys at all is question 4 in doc 6.