Files
Garage-SDKs/docs/offline-licences.md
T
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

14 KiB

Offline licences

How garage_entitlements checks a licence key, where it keeps them between runs, and what it takes to do the same thing without the Flutter package. There's a short bit on garage_iap's older licence at the end, for contrast.

Everything here is read off the source in garage_entitlements/lib/src/ — jwks_verify.dart for the checks, key_cache.dart for storage, garage_entitlements.dart for refresh() and cached(). If this doc and the code ever disagree, the code wins.

What a key is

One compact RS256 JWT per entitlement. The header carries alg and kid, the payload looks like this:

{
  "sub": "user-123",
  "iss": "https://pay.imbenji.net",
  "aud": "field-notes/pro",
  "project": "field-notes",
  "sku": "pro",
  "kind": "one_off",
  "mode": "live",
  "expires_at": "2026-10-03T00:00:00Z",
  "iat": 1790000000,
  "exp": 1790003600
}
Claim What it is
sub the user id the key was minted for
iss who signed it
aud "<project>/<sku>" — one string, not an array
project, sku the same two things again, split out
kind "one_off" or "subscription"
mode "live" or "sandbox"
expires_at when the entitlement runs out, ISO 8601. Missing for something owned outright
iat, exp seconds since epoch. exp is the key's expiry

exp is the access decision. The server clamps it to the entitlement's own expiry before signing, so a key cant outlive the thing it unlocks. expires_at is for a UI to show ("renews on the 3rd") — dont gate on it.

The only claims that get checked are iss, aud, sub, mode and exp. project, sku and kind are read into the GarageKey after the checks pass, and expires_at becomes entitlementExpiresAt.

The checks

verifyKey() in jwks_verify.dart. The order is fixed, and the first failure stops it — any failure means not entitled. Every failure is an EntitlementsError with a code, so a caller can treat them all the same.

Before the six proper checks there are two cheap ones: the token has to split into three parts (bad_token) and the header alg has to be RS256 (bad_alg). No none, no HS256, no negotiating.

1. Signature. Look up the JWKS key whose kid matches the header's kid, rebuild the RSA public key from its n and e, and check a PKCS#1 v1.5 SHA-256 signature over header.payload. That's pointycastle, the same RSA stack the store signs with — there's no JWT library in the package, on purpose. Only kty: "RSA" entries are considered.

A kid that isnt in the JWKS is refused (kid_not_found), not guessed at. The package wont try the other keys to see if one happens to work. The one exception: if the header has no kid at all, the first RSA key in the doc is used.

A bad signature is bad_signature. A mangled signature that makes pointycastle throw is logged and treated the same.

2. iss. Must equal the issuer the app was built with — the issuer constructor argument, which defaults to kGarageLicenceIssuer:

const String kGarageLicenceIssuer = String.fromEnvironment(
  "GARAGE_LICENCE_ISSUER",
  defaultValue: "https://pay.imbenji.net",
);

So --dart-define=GARAGE_LICENCE_ISSUER=... overrides it at build time. The point is that it's a constant the app checks against. The token doesnt get to say who it is. Fails as iss_mismatch.

3. aud. Must be exactly "<projectSlug>/<sku>" — a single string. Right project wrong sku fails, right sku wrong project fails, an array fails. aud_mismatch.

4. sub. Must equal the signed-in user's id. Somebody elses key copied onto this device fails here. sub_mismatch. Where the expected sub comes from matters a lot offline, see cached().

5. mode. Must equal the mode the GarageEntitlements was constructed with ("live" by default). A sandbox key never satisfies a live check, and the other way round. A missing mode fails too. mode_mismatch.

6. exp. A key with no exp is refused (no_exp). Otherwise, if the device clock is past it, expired.

If all of that passes you get a GarageKey. has(sku) then just looks in the verified set — and key(sku) re-checks exp every time it's asked, dropping a key that went stale while the app was running.

Where keys are kept

KeyCache is the seam. The default is SecureKeyCache, on flutter_secure_storage — the same place garage_auth keeps its tokens.

One blob per project, under ge.keys.<projectSlug>:

{
  "keys": { "pro": "<compact jwt>", "extras": "<compact jwt>" },
  "jwks": { "keys": [ { "kty": "RSA", "kid": "...", "n": "...", "e": "..." } ] }
}

The JWKS and the whole key set are written together, in one write. There's deliberately no "keys but no JWKS" state, because that's a pile of tokens you cant check.

What's on disk is the raw tokens, not a verdict. Every load re-verifies them.

Things worth knowing about the default store:

  • A read that fails (or a blob that wont parse) is logged and treated as no cache.
  • A write or clear that fails is logged and not thrown. So a refresh() can succeed in memory and still not have persisted — the next cold start would see the old blob.

MemoryKeyCache is the in memory one, for tests or anywhere you dont want persistence:

final ent = GarageEntitlements(
  auth: auth,
  projectSlug: "field-notes",
  apiBaseUrl: "https://pay.imbenji.net/api",
  cache: MemoryKeyCache(),
);

clear() deletes the blob and empties the in memory set. Call it on sign-out. The sub check would reject the old user's keys anyway, but there's no reason to leave them on disk.

refresh()

GET <apiBaseUrl>/v1/licences?project=<projectSlug>[&ttl=<seconds>]

with the signed-in user's bearer. The response carries the keys and the JWKS together:

{
  "licences": [ { "sku": "pro", "licence": "<compact jwt>", ... } ],
  "jwks": { "keys": [ ... ] }
}

What it does with it, in order:

  1. No jwks object in the body is an error (no_jwks). Nothing changes.
  2. Work out the expected sub (see below).
  3. For each entry, take the sku from the token's own aud — not the envelope's sku, which is a convenience and ignored — and run the full six checks against the JWKS that came in the same response. A token with an aud it cant split into project/sku is bad_token.
  4. If any key fails, the whole refresh throws. The cache and the in memory set are left exactly as they were.
  5. Only once every key has verified: write the new blob (all keys + the new JWKS), then replace the in memory set and notify listeners.

Replace, not merge. A sku missing from the response is gone, from memory and from disk. That's how a cancelled subscription stops working the next time the app is online, rather than hanging on untill its key's exp. An empty licences list empties the set.

ttl asks for shorter keys. The server clamps it, so asking for longer than the project allows doesnt error, it just doesnt get you longer.

cached(), and the subject

cached() is the boot path: read the blob, verify each key, fill the set. It never fetches keys. But it does need the expected sub for check 4, and that is the one place it can need the network.

The subject comes from _subject():

  • The first time it's asked, it calls auth.profile(), which is the OIDC userinfo endpoint (plus the discovery document, if garage_auth hasnt fetched that yet this run). It takes sub out of the answer.
  • After that it's remembered on the GarageEntitlements instance, so later calls are free.

So what happens depends on whether that instance has resolved a subject yet:

  • It has — say refresh() ran earlier this session. cached() is fully offline.
  • It hasnt, and userinfo is reachable — one round trip, then offline.
  • It hasnt, and userinfo isnt reachable — a cold start with no network is the usual one. profile() throws, cached() logs "cant resolve the subject", and drops every key from the in memory set. has() is false for everything.
  • The user isnt signed in — profile() returns null, there's no sub (no_subject), same result: every key dropped.

In the last two cases only the in memory set is emptied. The blob on disk is left alone, so a later cached() that can resolve the subject picks the keys straight back up.

Note that it's the first cached() on a fresh instance that makes the call, so "verifies whats on disk, zero network" in the README holds once the subject is known, not on a cold offline launch. If your app has to unlock features on a plane from a cold start, that's the path to plan around.

Once the subject is resolved, each cached key is checked on its own. A key that fails — expired is the normal case, but also rotated away, wrong user, wrong mode — is logged and dropped, and the rest are kept. One dead key doesnt take the set with it. The sku it's checked against is the map key it was stored under, so a token filed under the wrong sku fails aud.

The remembered subject lives as long as the instance and clear() doesnt reset it. If a different user can sign in without the app restarting, give them a new GarageEntitlements rather than reusing the old one.

Key rotation

Rotation needs nothing from the app.

The JWKS always arrives in the same response as the keys, and is cached with them, so the keys on disk are always paired with the JWKS they were checked against. While both old and new signing keys are in the published JWKS, keys signed by either verify (there's a test for exactly that). When a key is finally rotated out, any cached key still signed by it fails kid_not_found on the next cached() — and the next refresh() brings down fresh keys and the new JWKS together, so it sorts itself out the next time the app is online.

The device clock

Offline, exp is compared against the device clock, and the device clock can be wound back. That's an accepted limitation. The alternative is refusing to work without a network, which is the thing offline keys exist to avoid.

What bounds it is the key lifetime. The shorter the TTL, the more often the app has to come online and fetch fresh keys anyway.

Verifying without the package

If you're checking a key somewhere else — a backend, a CLI, another language — it's the same job.

Getting keys and the JWKS. The package gets both from one call:

GET https://pay.imbenji.net/api/v1/licences?project=<projectSlug>
Authorization: Bearer <the user's access token>

ttl=<seconds> is optional. The body is {"licences": [{"licence": "<jwt>", ...}], "jwks": {...}}. garage_entitlements doesnt call a separate JWKS endpoint — it only ever uses the jwks that comes back in that body — so that's the one to use.

If you're holding a key you got some other way, you still need the JWKS that goes with it. Keep them together, like the package does.

Checking one. In this order, and treat any failure as not entitled:

  1. Three dot-separated parts. Header alg is exactly RS256 — reject anything else before looking further.
  2. Find the JWKS entry with kty: "RSA" and a kid equal to the header's kid. Not found = reject. Dont fall back to trying every key. Verify RSASSA-PKCS1-v1_5 with SHA-256 over the ASCII bytes of <header>.<payload> (the base64url strings as they are in the token), using the public key from base64url n and e.
  3. iss == "https://pay.imbenji.net". Hard-code it. Dont read it from the token and trust it.
  4. aud == "<project>/<sku>", as a plain string, for the project and sku you're gating.
  5. sub == the user you think you're talking to, from your own session — not from the token.
  6. mode == "live" (or "sandbox" while the project is in sandbox). Missing is a fail.
  7. exp present, and now is before it.

Most JWT libraries will do 1, 2, 3 and 6 for you if you give them the JWKS, pin the algorithm to RS256 and tell them the issuer and audience. sub and mode you check yourself.

On a server you have a proper clock and a network, so the offline caveats dont apply — and if all you want is "does this user own it right now", the /v1/entitlements ledger is the truth anyway. The key is for when you cant ask.

garage_iap's licence, for contrast

garage_iap predates all of this and works differently. One licence JWT for the whole app, not one per entitlement:

  • Fetched from GET <apiBaseUrl>/v1/licence?app=<appSlug>, with the JWKS fetched separately from GET <apiBaseUrl>/v1/licence/jwks.json in the same trip.
  • Its payload has an app claim and a products claim — a list of {sku, kind, expires_at} for everything the user owns in that app.
  • Cached as {token, jwks} under gi.licence.<appSlug> via the LicenceCache seam (SecureLicenceCache, or MemoryLicenceCache for tests). It's only written after it verifies.

verifyLicence() checks less: RS256, the signature by kid (same lookup rules as above), then sub, then app, then exp. There's no iss, no aud, and no mode check. has(sku) then asks whether the sku is anywhere in products — the per product expires_at isnt looked at, only the licence's own exp.

That's the wallet problem the per-entitlement keys were built to avoid: a lock that only cares about one feature gets handed the list of everything the user owns. It also resolves the subject through profile() the same way, so the same cold-offline caveat applies — except there a failure to resolve it throws out of cachedLicence() rather than quietly emptying the set.