Skip to content

10. Entitlement Token: Cache First, Refresh on Expiry

10. Entitlement Token: Cache First, Refresh on Expiry

Section titled “10. Entitlement Token: Cache First, Refresh on Expiry”

Branch feat/entitlement-cache-first (2026-09-30). This replaces the per-play fetch from doc 9. Doc 9 asked the server for a new entitlement token on every play. Now a play uses the cached token for as long as it is valid. The app refreshes the token shortly before it expires, and also when one of a few named events happens.

BEFORE (doc 9) AFTER
────────────── ─────
play → ensureFreshEntitlement() play → ensureEntitlement()
│ │
├─ fetch already running? join it ├─ cached & valid? use it, no network
├─ fetched < 15 s ago? use it ├─ valid but about to expire? use it, refresh behind it
└─ else fetch (every play) └─ missing or expired? wait for a fetch
(fails → send the old one)

Doc 9 cost one GET /v1/user/entitlements per play. On a slow connection the first frame could wait up to 6 s for it. With a warm cache, a play now makes no entitlement request at all.

The trade-off. A subscription that lapses or a rental that ends keeps working until the token’s exp. With today’s 5-minute tokens that is at most about 4 minutes. The flip side is that the cache only saves a request when plays are less than about 4 minutes apart: back-to-back episodes, retries, a trailer then the film, or cast dispatch plus proxy key fetches.

Open backend question: the lifetime is shorter than a title. The native player receives the headers once per load, so key requests later in a long title carry the token from load time. If the player asks KMS for a key again after 5 minutes (key rotation, a new rendition with its own key URI, or a re-fetch on seek), KMS may see an expired x-entitlement. Doc 7 already noted that token lifetime must outlast a title. This client change neither causes nor fixes that.

This was checked against production on 2026-09-30 by calling /v1/user/entitlements with a real session. The token is not a JWT. It is a PASETO v4.public:

v4.public.<base64url( JSON claims ‖ 64-byte Ed25519 signature )>
{"is_privileged": false, "subscriptions": [], "content_ids": [],
"iat": "2026-09-30T12:52:16+00:00", ← ISO-8601 strings, not epoch numbers
"exp": "2026-09-30T12:57:16+00:00"} ← lifetime: 5 minutes

content_access_token is also a v4.public PASETO, but it carries no iat/exp (only content_id and access_type). ContentAccessTokenService’s 10-minute maxAge stays its only expiry.

lib/core/helper/token_expiry.dart (formerly jwt_expiry.dart) reads both formats:

  • It reads v4.public PASETO and JWT. For JWT it reads the middle segment. For PASETO it reads the payload minus the trailing 64 signature bytes.
  • exp/iat may be ISO strings or epoch seconds.
  • A number too large to be epoch seconds (such as milliseconds) is rejected.
  • v4.local tokens are encrypted and unreadable, so they fall back to the 1-hour rule.

With a 5-minute token, a phone clock that is a couple of minutes off would break the cache. So when the token has iat, the expiry is measured on the device’s own clock:

effectiveExpiry = fetchedAt (device clock) + (exp − iat) ← when iat exists
effectiveExpiry = exp ← JWT-style, no iat
effectiveExpiry = fetchedAt + 1 h ← no exp at all

fetchedAt is taken when the request starts, so this errs slightly early.

domain/ data/
─────── ─────
entities/entitlement_token.dart models/entitlement_token_model.dart
value, fetchedAt, expiresAt, issuedAt PASETO/JWT → entity (reads exp and iat)
isExpired(now) · needsRefresh(now) stored as {"token", "fetched_at"} JSON
repositories/entitlement_repository.dart ◄──── repositories/entitlement_repository_impl.dart
cached · get(userId) · refresh(userId) THE cache-first policy
clear() memory → disk → network, one fetch at a time
│
usecases/get_entitlement_token.dart ├── datasources/entitlement_data_source.dart
usecases/refresh_entitlement_token.dart │ remote: GET /v1/user/entitlements
usecases/clear_entitlement_token.dart └── datasources/local/entitlement_token_store.dart
SharedPreferences, key entitlement_token_<userId>
services/playback_auth_resolver.dart ◄──────── services/playback_auth_resolver_impl.dart
ensureEntitlement · refreshEntitlement headers for the player, cast, AirPlay, downloads
headersFor · keyHeaders │
services/entitlement_service.dart
session glue: current_user + app lifecycle,
publishes to the `entitlement_token` ValueNotifier
(EncryptionHelper.playerHeader reads it)

EntitlementRepositoryImpl.get(userId):

get(userId)
│
in memory for this user? ──no──► read disk (entitlement_token_<userId>)
│ yes │
▼ ▼
┌───────────────── the cached token, if any ─────────────────┐
│ │
valid, not near exp valid, inside the missing, or expired
│ refresh-ahead window (now + 60 s ≥ exp)
▼ │ │
return it return it, and start wait for refresh(userId)
(no network) refresh() in the │
background (at most one) ok ──► return the new token
fail ─► return the expired token
(or Left when nothing is cached)

When a token counts as “about to expire”

Section titled “When a token counts as “about to expire””
issuedAt (iat) exp
│◄──────────────────── lifetime ────────────────────►│
│ │◄ refresh-ahead ►│◄ 60 s ►│
│ used as it is │ used, and │ counts │
│ │ refreshed │ as │
│ │ behind it │ expired│
  • Refresh-ahead is 10% of the lifetime, clamped to between 30 s and 5 min. For the real 5-minute token it is 30 s. The token is then used from cache until 3:30, refreshed in the background from 3:30, and fetched blocking after 4:00 (the 60 s leeway).
  • Without an iat claim, the lifetime is measured from when the token was fetched.
  • A token with no exp is treated as expiring 1 h after it was fetched. Without that rule, an opaque token could be cached forever.

The cache does not wait for expiry in these cases:

Event Where What happens
Purchase verified (IAP) in_app_purchase_bloc.dart, verify success refreshEntitlement()
Subscription changed on the same account EntitlementService._onUserChanged Compares subscription.id + expires_at and calls refresh()
Playback error on the phone video_player_screen.dart _recoverWithFreshKmsTokens refreshEntitlement(), then one reload (doc 8)
KMS 403 through the cast proxy injection_container.dart → onEntitlementRejected refreshEntitlement(), then one retry
Login, logout, account switch EntitlementService._onUserChanged Clear memory and disk, then a cache-first load for the new user

A forced refresh keeps the old token if the fetch fails.

Removed: the “renew after 30 min in the background” rule. On resume the service runs the normal cache-first check, which fetches only when the token is missing, close to expiry or expired.

Caveat: User equality ignores subscription. User.props does not include subscription. If getUser() returns a user whose only change is the plan, ValueNotifier treats it as equal and neither stores nor announces it. The subscription-change row above only fires when something else about the user changed too. For in-app purchases the explicit refresh in the bloc covers this. Other payment paths (web checkout) get the new token at the next expiry or the next cold start.

Play tap ─► CastAwareNavigation.play ─► ensureEntitlement() (warm-up, cheap when cached)
│
┌───────────┼───────────────────────┬──────────────────────┐
▼ ▼ ▼ ▼
phone player Chromecast AirPlay download
prepare hook _dispatchLoad awaits prepare hook / start() and
headersFor() ensureEntitlement() headersFor() reenqueueMissing()
+ proxy key miss keyHeaders()
ensureEntitlement()

Every path goes through the same cache-first check. Trailers and shorts ads no longer need a separate “don’t fetch” flag, so the freshEntitlement: parameter is gone.

  • Tokens are stored per user under entitlement_token_<userId>, which is the same key the old service used. Writing one user’s token removes every other user’s.
  • The new format is JSON: {"token": "...", "fetched_at": <ms>}. A value in the old format (just the token string) is still read. Its fetch time counts as unknown, so it counts as expired and is refetched once. If that refetch fails, the old value is still sent.
File Covers
test/core/helper/token_expiry_test.dart PASETO v4.public with the live claim shape, ISO and offset timestamps, footer, JWT, milliseconds rejected, opaque and v4.local tokens
test/domain/entities/entitlement_token_test.dart Leeway, refresh-ahead clamps, the no-exp fallback, skew with the device clock ahead and behind
test/data/repositories/entitlement_repository_impl_test.dart No network when the token is valid, restore from disk, old storage format, one background refresh inside the window, blocking on expiry, one shared fetch, stale token on failure, Left when nothing is cached, dropping a fetch from a changed session, clear
test/data/services/entitlement_service_test.dart Publishing to the notifier, account switch and sign-out, subscription change, resume makes no fetch
test/data/services/playback_auth_resolver_test.dart Plays reuse the cache, blocking after expiry, background refresh near expiry, forced refresh, download headers

Each of these was proved by mutation. Each change below turns a test red:

  • always fetching
  • dropping the refresh-ahead trigger
  • returning Left in place of the stale token
  • removing the session guard
  • removing the subscription-change refresh
  • giving opaque tokens no expiry
  • removing the refresh-ahead margin
  • not awaiting the cast dispatch
  • dropping the PASETO branch
  • dropping the ISO parse or the UTC conversion
  • dropping the milliseconds guard
  • keeping the signature bytes in the payload
  • dropping the skew anchoring

The purchase-success refresh has no test. That branch of InAppPurchaseBloc calls context.router and getIt<UserAuthBloc>(), and the existing bloc tests stop short of it.