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
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
# 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](#what-a-key-is)
|
||||
- [The checks](#the-checks)
|
||||
- [Where keys are kept](#where-keys-are-kept)
|
||||
- [refresh()](#refresh)
|
||||
- [cached(), and the subject](#cached-and-the-subject)
|
||||
- [Key rotation](#key-rotation)
|
||||
- [The device clock](#the-device-clock)
|
||||
- [Verifying without the package](#verifying-without-the-package)
|
||||
- [garage_iap's licence, for contrast](#garage_iaps-licence-for-contrast)
|
||||
|
||||
|
||||
## What a key is
|
||||
|
||||
One compact RS256 JWT per entitlement. The header carries `alg` and `kid`, the
|
||||
payload looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"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`:
|
||||
|
||||
```dart
|
||||
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()](#cached-and-the-subject).
|
||||
|
||||
**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>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```dart
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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](#the-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:
|
||||
|
||||
0. Three dot-separated parts. Header `alg` is exactly `RS256` — reject anything
|
||||
else before looking further.
|
||||
1. 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`.
|
||||
2. `iss == "https://pay.imbenji.net"`. Hard-code it. Dont read it from the
|
||||
token and trust it.
|
||||
3. `aud == "<project>/<sku>"`, as a plain string, for the project and sku
|
||||
you're gating.
|
||||
4. `sub ==` the user you think you're talking to, from your own session — not
|
||||
from the token.
|
||||
5. `mode ==` `"live"` (or `"sandbox"` while the project is in sandbox). Missing
|
||||
is a fail.
|
||||
6. `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.
|
||||
Reference in New Issue
Block a user