Skip to content

ADR-0018: Licensed content needs a short-lived content-access token

Status

Accepted

Date

2026-10-07

Context

CSL-255 sets the release baseline for protecting licensed C.S. Lewis text: a permanent app key must not be enough to retrieve it, guests must keep reading without an account, and protected responses must not be reusable through shared caches. Until now the only gate was the app key from ADR-0005, which ships inside the app, can be extracted, and works forever. Every licensed response was also Cache-Control: public.

Decision Drivers

  • Must keep guest reading open: no account, no persistent guest profile (COPPA position).
  • Must make the app key alone insufficient for licensed text.
  • Must let a token be refused once it expires, with no server state to clean up.
  • Should give rate limiting (CSL-258) a session to count against.
  • Should stay independent of Firebase, so a sign-in problem never blocks reading.

Considered Options

Option A: Require Firebase sign-in for licensed text

  • Pros: an identity to limit and ban.
  • Cons: ends guest reading, which the product and the COPPA position rely on.

Option B: App attestation (App Check / App Attest / Play Integrity)

  • Pros: proves a genuine app build.
  • Cons: excluded from this release by CSL-255; a priced hardening step on top of tokens.

Option C: A signed, short-lived token minted with the app key

  • Pros: no account; expires on its own; stateless; a random session id per token for rate limits; works for guests and signed-in readers alike.
  • Cons: anyone with the app key can mint one, so it raises the bar for bulk extraction but relies on rate limits (CSL-258) to stop a scripted client.

Decision

We will use Option C.

  • POST /api/content-access/token/ (app key only, no Firebase) returns {token, expires_at, expires_in}. Lifetime CONTENT_TOKEN_TTL_SECONDS, 15 minutes by default.
  • The token is a Django-signed value (HMAC-SHA256) under a dedicated salt, keyed by CONTENT_TOKEN_SECRET or SECRET_KEY, with fallback keys for rotation. It carries a version, a random session id and an expiry. Nothing identifies a reader.
  • The app sends it as X-Content-Token, separate from the Firebase Authorization header.
  • HasContentAccess checks it on every endpoint that returns licensed text or audio, after the app key. A route-inventory test fails until every API route is marked licensed or not.
  • Refusals are 401 with ContentTokenMissing, ContentTokenInvalid or ContentTokenExpired.
  • Licensed responses become Cache-Control: private with a bounded max-age (API_PRIVATE_CACHE_SECONDS), so no shared cache can serve them past the token check.
  • CONTENT_TOKEN_ENFORCED gates refusing a missing token, so the server can ship before the app sends tokens. A token that is sent is always checked.

Consequences

Positive

  • An extracted app key alone no longer returns licensed text once enforcement is on.
  • Guests keep reading; no account or device profile is created.
  • No table, no cleanup job; a token simply expires.

Negative

  • ADR-0005's "the app key gates the API" gains a second layer on licensed endpoints.
  • The app must mint, refresh and retry (CSL-261).
  • Licensed responses lose CDN caching.

Risks & Mitigations

  • Risk: a script mints tokens with an extracted app key. → Mitigation: rate limits on minting and on licensed reads per session (CSL-258); app attestation remains the priced next step.
  • Risk: enabling enforcement before the app sends tokens breaks reading. → Mitigation: the flag is off in both buildspecs until CSL-261 ships.
  • ADR-0005: API conventions (the app-key gate this layers on).
  • CSL-255 (baseline), CSL-256 (tokens), CSL-257 (enforcement), CSL-258 (rate limits), CSL-260 (cache policy), CSL-261 (mobile).