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) —
UserAuthDataSourcesets it - logout — both
removeUser()copies reset it - profile switch —
ProfileManagementDataSource.switchProfileresets + re-sets it - app-start restore —
SettingsDataSource.getCurrentUsersets 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.
D5. Empty string means absent
Section titled “D5. Empty string means absent”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 —
buildPlayerMediahas 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, becausereenqueueMissingonly 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).
Touched files
Section titled “Touched files”| # | 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 |
Known gaps deliberately left (from doc 6)
Section titled “Known gaps deliberately left (from doc 6)”- Offline downloads keep their plaintext
key.binand 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.