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:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,7 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
# caught errors get printed on purpose — a swallowed exception is worse
# than a noisy console.
avoid_print: false
@@ -0,0 +1,21 @@
// Offline licence keys for Garage apps — layered on garage_auth.
//
// One key per entitlement, verified against the store's published JWKS. The
// key is the gate; the ledger is the truth behind it.
//
// final ent = GarageEntitlements(
// auth: auth,
// projectSlug: "field-notes",
// apiBaseUrl: "https://pay.imbenji.net/api",
// );
// await ent.cached(); // boot, no network
// await ent.refresh(); // when theres a connection
// if (ent.has("pro-annual")) unlock();
library;
export "src/garage_entitlements.dart"
show GarageEntitlements, kGaragePortalBaseUrl, kGarageLicenceIssuer;
export "src/models.dart"
show GarageKey, GarageEntitlement, GarageProduct, EntitlementsError;
export "src/key_cache.dart"
show KeyCache, SecureKeyCache, MemoryKeyCache, CachedKeys;
@@ -0,0 +1,391 @@
import "dart:async";
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:garage_auth/garage_auth.dart";
import "package:http/http.dart" as http;
import "jwks_verify.dart";
import "key_cache.dart";
import "models.dart";
/// Where the payment portal lives. Overridable per call, but this is the one
/// buyers actually land on.
const String kGaragePortalBaseUrl = String.fromEnvironment(
"GARAGE_PORTAL_BASE_URL",
defaultValue: "https://pay.imbenji.net",
);
/// What `iss` has to say. A constant, checked against — not something the
/// token gets to tell us.
const String kGarageLicenceIssuer = String.fromEnvironment(
"GARAGE_LICENCE_ISSUER",
defaultValue: "https://pay.imbenji.net",
);
/// Offline licence keys for one project.
///
/// One key per entitlement. Money bought one thing, so a key unlocks that one
/// thing — a project-wide licence would be a wallet, and handing a wallet to a
/// lock that only cares about one feature tells it everything the user owns.
///
/// final ent = GarageEntitlements(
/// auth: auth,
/// projectSlug: "field-notes",
/// apiBaseUrl: "https://pay.imbenji.net/api",
/// );
/// await ent.cached(); // fast, no network
/// await ent.refresh(); // when you have a connection
/// if (ent.has("pro-annual")) { ... }
///
/// A ChangeNotifier, so a ListenableBuilder redraws the gates when the set
/// moves.
class GarageEntitlements extends ChangeNotifier {
GarageEntitlements({
required this.auth,
required this.projectSlug,
required this.apiBaseUrl,
KeyCache? cache,
this.mode = "live",
this.issuer = kGarageLicenceIssuer,
}) : _cache = cache ?? SecureKeyCache();
final GarageAuth auth;
final String projectSlug;
final String apiBaseUrl;
/// Which side of the ledger you expect. A sandbox key must never satisfy a
/// live check, so this is checked rather than believed — set it to "sandbox"
/// while the project is, and back when it goes live.
final String mode;
/// The `iss` a key has to carry.
final String issuer;
final KeyCache _cache;
/// sku -> verified key. The gate reads this.
final Map<String, GarageKey> _keys = {};
/// Every verified key we currently hold, sku -> key.
Map<String, GarageKey> get keys => Map.unmodifiable(_keys);
Uri _u(String path, [Map<String, String>? q]) {
final base = apiBaseUrl.endsWith("/")
? apiBaseUrl.substring(0, apiBaseUrl.length - 1)
: apiBaseUrl;
return Uri.parse("$base$path").replace(queryParameters: q);
}
// -------------------------------------------------------------------------
// the gate
// -------------------------------------------------------------------------
/// The verified key for [sku], or null. This is the gate — it is synchronous
/// and it never touches the network. Call [cached] once at boot and [refresh]
/// when you have a connection.
GarageKey? key(String sku) {
final k = _keys[sku];
if (k == null) return null;
// held keys are checked at load, but a long running app can sit past one.
if (k.isExpired) {
_keys.remove(sku);
return null;
}
return k;
}
/// Do they own [sku] right now.
bool has(String sku) => key(sku) != null;
// -------------------------------------------------------------------------
// fetching
// -------------------------------------------------------------------------
/// Pull the whole key set for this project and REPLACE what we hold.
///
/// Replace, not merge. A cancelled subscription simply stops coming back in
/// the response — merging would leave its key sat there working untill its
/// own exp, which is exactly the bug this shape exists to avoid.
///
/// Every key is verified BEFORE anything is written. A response we cant fully
/// check leaves the previous set alone rather than half-applying it.
///
/// [ttl] asks for a shorter key than the ceiling would give. Asking for more
/// is not an error, it is just quietly clamped.
Future<Map<String, GarageKey>> refresh({Duration? ttl}) async {
_requireSignedIn();
final query = <String, String>{"project": projectSlug};
if (ttl != null) query["ttl"] = "${ttl.inSeconds}";
final resp = await auth.client.get(_u("/v1/licences", query));
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not fetch keys.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final jwks = body["jwks"];
if (jwks is! Map) {
throw EntitlementsError(
"No jwks in the response — the keys cant be checked.",
code: "no_jwks",
);
}
final jwksDoc = Map<String, dynamic>.from(jwks);
final sub = await _subject();
// verify the lot first. the envelope's sku is a convenience; the verified
// token is the only thing we key off.
final verified = <String, GarageKey>{};
final tokens = <String, String>{};
for (final raw in (body["licences"] as List? ?? const [])) {
if (raw is! Map) continue;
final token = raw["licence"] as String?;
if (token == null || token.isEmpty) continue;
final aud = peekAudience(token);
if (aud == null) {
throw EntitlementsError(
"A key came back with no usable audience.",
code: "bad_token",
);
}
final key = verifyKey(
token,
jwksDoc,
expectedIssuer: issuer,
expectedProject: projectSlug,
expectedSku: aud.sku,
expectedSub: sub,
expectedMode: mode,
);
verified[key.sku] = key;
tokens[key.sku] = token;
}
// only now does anything change.
await _cache.write(projectSlug, CachedKeys(keys: tokens, jwks: jwksDoc));
_keys
..clear()
..addAll(verified);
notifyListeners();
return keys;
}
/// Load and verify the cached set. Zero network — this is the boot path, and
/// the one that keeps working on a plane.
///
/// A key that no longer verifies (expired, rotated away, signed for somebody
/// else) is dropped and logged rather than throwing, so one dead key doesnt
/// take the whole set with it.
Future<Map<String, GarageKey>> cached() async {
final blob = await _cache.read(projectSlug);
if (blob == null) {
_keys.clear();
notifyListeners();
return keys;
}
final String sub;
try {
sub = await _subject();
} catch (error, stack) {
// no profile to match against — offline and never signed in properly.
print("[garage_entitlements] cant resolve the subject: $error");
print(stack);
_keys.clear();
notifyListeners();
return keys;
}
final loaded = <String, GarageKey>{};
blob.keys.forEach((sku, token) {
try {
loaded[sku] = verifyKey(
token,
blob.jwks,
expectedIssuer: issuer,
expectedProject: projectSlug,
expectedSku: sku,
expectedSub: sub,
expectedMode: mode,
);
} catch (error) {
// expected often enough (an expired key is the normal case) that a
// stack would be noise — but never silent.
print("[garage_entitlements] dropping cached key '$sku': $error");
}
});
_keys
..clear()
..addAll(loaded);
notifyListeners();
return keys;
}
/// The ledger, straight from the server. The truth, and a fallback for when
/// key signing is switched off on a deployment.
///
/// Gates should ask [key] / [has] instead — this is a network call, it says
/// nothing offline, and it hands back the whole account's grants for the
/// project rather than the one thing you were asking about. Good for a
/// purchase screen ("you allready own this"), wrong for a feature check.
Future<List<GarageEntitlement>> entitlements() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/entitlements"));
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not load entitlements.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (body["entitlements"] as List? ?? const [])
.whereType<Map>()
.map((m) => GarageEntitlement.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
/// One product by sku, off the public portal lookup. Null when there isnt
/// one. No bearer needed, though we send one if we have it so `owned` comes
/// back meaning something.
Future<GarageProduct?> productBySku(String sku) async {
final uri = _u("/v1/portal/by-sku/$projectSlug/$sku");
final resp = auth.isSignedIn
? await auth.client.get(uri)
: await http.get(uri);
if (resp.statusCode == 404) return null;
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not load that product.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final product = body["product"];
if (product is! Map) return null;
return GarageProduct.fromJson(Map<String, dynamic>.from(product));
}
// -------------------------------------------------------------------------
// buying
// -------------------------------------------------------------------------
/// The portal url for [productId], with a one-shot handoff code on it so the
/// buyer doesnt sign in twice.
///
/// Returns a Uri — LAUNCHING it is yours. This package has no url_launcher
/// dependency and isnt going to grow one; you allready have a way to open a
/// url and it isnt this package's business what it is.
///
/// [returnUrl] has to be registered on the project or checkout refuses it.
Future<Uri> portalUrl(
String productId, {
String? returnUrl,
String? portalBaseUrl,
}) async {
_requireSignedIn();
final base = portalBaseUrl ?? kGaragePortalBaseUrl;
final trimmed = base.endsWith("/")
? base.substring(0, base.length - 1)
: base;
final query = <String, String>{};
if (returnUrl != null && returnUrl.isNotEmpty) {
query["return_url"] = returnUrl;
}
final code = await _mintHandoffCode();
if (code != null) query["handoff"] = code;
return Uri.parse(
"$trimmed/pay/$productId",
).replace(queryParameters: query.isEmpty ? null : query);
}
/// One-shot code from auth, or null when we couldnt get one — the portal
/// still works then, the buyer just signs in when they land.
Future<String?> _mintHandoffCode() async {
try {
final resp = await http.post(
Uri.parse("${auth.issuer}/auth/handoff/issue"),
headers: {"Authorization": "Bearer ${auth.accessToken}"},
);
if (resp.statusCode != 200) {
print(
"[garage_entitlements] handoff issue refused "
"(${resp.statusCode}): ${resp.body}",
);
return null;
}
final json = jsonDecode(resp.body) as Map<String, dynamic>;
return json["handoff_code"] as String?;
} catch (error, stack) {
print("[garage_entitlements] couldnt mint a handoff code: $error");
print(stack);
return null;
}
}
// -------------------------------------------------------------------------
/// Throw the cached set away. Sign-out should call this — the keys are for
/// whoever was signed in, and the sub check would reject them anyway, but
/// leaving them on disk is untidy.
Future<void> clear() async {
await _cache.clear(projectSlug);
_keys.clear();
notifyListeners();
}
// resolve the user id a key has to be for. profile() round trips userinfo,
// which is fine — its behind the authed client and only wanted at load time.
String? _cachedSub;
Future<String> _subject() async {
if (_cachedSub != null) return _cachedSub!;
final me = await auth.profile();
final sub = me?["sub"] as String?;
if (sub == null || sub.isEmpty) {
throw EntitlementsError(
"No subject in the profile — cant match a key to a user.",
code: "no_subject",
);
}
_cachedSub = sub;
return sub;
}
void _requireSignedIn() {
if (!auth.isSignedIn) {
throw EntitlementsError(
"Not signed in — call auth.signIn() first.",
code: "not_signed_in",
);
}
}
EntitlementsError _errorFrom(http.Response resp, {required String fallback}) {
try {
final b = jsonDecode(resp.body) as Map<String, dynamic>;
final code = b["error"] as String?;
final message = b["message"] as String? ?? fallback;
return EntitlementsError(message, code: code);
} catch (error) {
// a non-json body (a gateway page, usually) — still worth saying.
print(
"[garage_entitlements] ${resp.statusCode} with an unreadable body: "
"$error",
);
return EntitlementsError("$fallback (${resp.statusCode})");
}
}
}
@@ -0,0 +1,211 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
import "models.dart";
// Offline key verification. The store signs a key RS256 and publishes the
// matching public half as a JWKS; we only ever hold that half, so we can check
// a key but never mint one.
//
// No jwt library here on purpose. A JWKS RSA verify is "split the compact
// token, rebuild the public key from n/e, check PKCS1v15 SHA-256 over
// header.payload", and pointycastle — the same stack the backend signs with —
// does exactly that.
/// Verify [token] against [jwks] and the caller's expectations.
///
/// The order is fixed and it matters: signature, then `iss`, then `aud`, then
/// `sub`, then `mode`, then `exp`. Anything failing means not entitled — every
/// failure is an [EntitlementsError] so a caller can treat them alike.
///
/// `aud` is `"<project>/<sku>"`, which is what lets a lock check a key knowing
/// only its own sku and the public key.
GarageKey verifyKey(
String token,
Map<String, dynamic> jwks, {
required String expectedIssuer,
required String expectedProject,
required String expectedSku,
required String expectedSub,
required String expectedMode,
}) {
final parts = token.split(".");
if (parts.length != 3) {
throw EntitlementsError("Malformed key token.", code: "bad_token");
}
final header = _decodeJsonSegment(parts[0]);
final payload = _decodeJsonSegment(parts[1]);
final alg = header["alg"] as String?;
if (alg != "RS256") {
throw EntitlementsError("Unexpected key alg: $alg", code: "bad_alg");
}
// 1. signature.
final kid = header["kid"] as String?;
final key = _findKey(jwks, kid);
if (key == null) {
// rotation: the kid isnt in the jwks we hold. a fresh refresh pulls the new
// jwks down with the keys, so this sorts itself out next time we're online.
throw EntitlementsError("No JWKS key for kid '$kid'.",
code: "kid_not_found");
}
final signingInput = utf8.encode("${parts[0]}.${parts[1]}");
final signature = _b64UrlBytes(parts[2]);
if (!_verifyRs256(key, Uint8List.fromList(signingInput), signature)) {
throw EntitlementsError("Key signature failed.", code: "bad_signature");
}
// 2. iss.
final iss = payload["iss"] as String?;
if (iss == null || iss != expectedIssuer) {
throw EntitlementsError("Key issuer mismatch.", code: "iss_mismatch");
}
// 3. aud — one string, "<project>/<sku>".
final aud = payload["aud"];
final wanted = "$expectedProject/$expectedSku";
if (aud is! String || aud != wanted) {
throw EntitlementsError("Key audience mismatch.", code: "aud_mismatch");
}
// 4. sub.
final sub = payload["sub"] as String?;
if (sub == null || sub != expectedSub) {
throw EntitlementsError("Key subject mismatch.", code: "sub_mismatch");
}
// 5. mode. a sandbox key must never satisfy a live check.
final mode = payload["mode"] as String?;
if (mode == null || mode != expectedMode) {
throw EntitlementsError("Key mode mismatch.", code: "mode_mismatch");
}
final iat = _epoch(payload["iat"]);
final exp = _epoch(payload["exp"]);
if (exp == null) {
throw EntitlementsError("Key has no exp.", code: "no_exp");
}
final rawEntExpiry = payload["expires_at"] as String?;
final verified = GarageKey(
subject: sub,
issuer: iss,
project: payload["project"] as String? ?? expectedProject,
sku: payload["sku"] as String? ?? expectedSku,
kind: payload["kind"] as String? ?? "one_off",
mode: mode,
issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
expiresAt: exp,
entitlementExpiresAt: (rawEntExpiry == null || rawEntExpiry.isEmpty)
? null
: DateTime.tryParse(rawEntExpiry)?.toUtc(),
);
// 6. exp. trusts the device clock when offline, which is an accepted
// limitation — the alternative is refusing to work on a plane.
if (verified.isExpired) {
throw EntitlementsError("Key expired.", code: "expired");
}
return verified;
}
/// The `aud` of a compact token without verifying anything — used to work out
/// which sku a cached key belongs to before we know what to check it against.
/// Never trust what comes out of here; it is a routing hint, nothing more.
({String project, String sku})? peekAudience(String token) {
try {
final parts = token.split(".");
if (parts.length != 3) return null;
final payload = _decodeJsonSegment(parts[1]);
final aud = payload["aud"];
if (aud is! String) return null;
final slash = aud.indexOf("/");
if (slash <= 0 || slash == aud.length - 1) return null;
return (project: aud.substring(0, slash), sku: aud.substring(slash + 1));
} catch (error, stack) {
print("[garage_entitlements] couldnt peek at a token's aud: $error");
print(stack);
return null;
}
}
// ---- key lookup ----
// pull the RSA public key for a kid out of the jwks. a header with no kid and
// exactly one key in the doc is fine — take that one.
RSAPublicKey? _findKey(Map<String, dynamic> jwks, String? kid) {
final keys = jwks["keys"];
if (keys is! List || keys.isEmpty) return null;
Map<String, dynamic>? match;
for (final k in keys) {
if (k is! Map) continue;
final m = Map<String, dynamic>.from(k);
if (m["kty"] != "RSA") continue;
if (kid == null || m["kid"] == kid) {
match = m;
break;
}
}
if (match == null) return null;
final nB = match["n"] as String?;
final eB = match["e"] as String?;
if (nB == null || eB == null) return null;
return RSAPublicKey(
_bytesToBigInt(_b64UrlBytes(nB)),
_bytesToBigInt(_b64UrlBytes(eB)),
);
}
bool _verifyRs256(RSAPublicKey key, Uint8List input, Uint8List sig) {
final verifier = Signer("SHA-256/RSA") as RSASigner;
verifier.init(false, PublicKeyParameter<RSAPublicKey>(key));
try {
return verifier.verifySignature(input, RSASignature(sig));
} catch (error, stack) {
// a mangled signature throws rather than coming back false.
print("[garage_entitlements] key verify threw: $error");
print(stack);
return false;
}
}
// ---- small codec helpers ----
Map<String, dynamic> _decodeJsonSegment(String seg) {
final bytes = _b64UrlBytes(seg);
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
Uint8List _b64UrlBytes(String s) {
// jwt segments are base64url with the padding stripped — put it back.
var out = s.replaceAll("-", "+").replaceAll("_", "/");
final pad = out.length % 4;
if (pad > 0) out = out.padRight(out.length + (4 - pad), "=");
return base64.decode(out);
}
BigInt _bytesToBigInt(List<int> bytes) {
var r = BigInt.zero;
for (final b in bytes) {
r = (r << 8) | BigInt.from(b & 0xff);
}
return r;
}
DateTime? _epoch(Object? v) {
if (v == null) return null;
final n = v is int ? v : int.tryParse("$v");
if (n == null) return null;
return DateTime.fromMillisecondsSinceEpoch(n * 1000, isUtc: true);
}
@@ -0,0 +1,96 @@
import "dart:convert";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// Where the keys live between runs. ONE blob per project holding the JWKS and
// the whole key set, written together — there is deliberately no "keys but no
// jwks" state, because that is a set of tokens you cannot check.
//
// Same secure storage garage_auth keeps the tokens in. A key is low value (it
// is public-key verifiable and short lived), but keeping it beside the tokens
// means one place to look for sdk state.
/// The seam, so tests and odd platforms can swap the backend out.
abstract class KeyCache {
Future<CachedKeys?> read(String projectSlug);
Future<void> write(String projectSlug, CachedKeys value);
Future<void> clear(String projectSlug);
}
/// The whole cached set for one project. [keys] is sku -> compact JWT.
class CachedKeys {
CachedKeys({required this.keys, required this.jwks});
final Map<String, String> keys;
/// the jwks doc as fetched ( {"keys":[...]} )
final Map<String, dynamic> jwks;
Map<String, dynamic> toJson() => {"keys": keys, "jwks": jwks};
factory CachedKeys.fromJson(Map<String, dynamic> j) => CachedKeys(
keys: Map<String, String>.from(j["keys"] as Map),
jwks: Map<String, dynamic>.from(j["jwks"] as Map),
);
}
class SecureKeyCache implements KeyCache {
SecureKeyCache({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _k(String slug) => "ge.keys.$slug";
@override
Future<CachedKeys?> read(String projectSlug) async {
try {
final raw = await _storage.read(key: _k(projectSlug));
if (raw == null || raw.isEmpty) return null;
return CachedKeys.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (error, stack) {
// a cache we cant read is the same as no cache — say so and carry on.
print("[garage_entitlements] key cache read failed: $error");
print(stack);
return null;
}
}
@override
Future<void> write(String projectSlug, CachedKeys value) async {
try {
await _storage.write(
key: _k(projectSlug),
value: jsonEncode(value.toJson()),
);
} catch (error, stack) {
print("[garage_entitlements] key cache write failed: $error");
print(stack);
}
}
@override
Future<void> clear(String projectSlug) async {
try {
await _storage.delete(key: _k(projectSlug));
} catch (error, stack) {
print("[garage_entitlements] key cache clear failed: $error");
print(stack);
}
}
}
/// In memory. For tests, or anywhere you explicitly dont want persistence.
class MemoryKeyCache implements KeyCache {
final Map<String, CachedKeys> _m = {};
@override
Future<CachedKeys?> read(String projectSlug) async => _m[projectSlug];
@override
Future<void> write(String projectSlug, CachedKeys value) async =>
_m[projectSlug] = value;
@override
Future<void> clear(String projectSlug) async => _m.remove(projectSlug);
}
+174
View File
@@ -0,0 +1,174 @@
// The shapes this package deals in. A key, an entitlement row, and just enough
// of a product to put a price on a buy button.
/// A verified licence key. One entitlement, one key — the key unlocks the thing
/// that was bought and says nothing about the rest of the account.
///
/// Two clocks live on here and they are not the same thing:
///
/// * [expiresAt] is the KEY's expiry. It is the access decision. The server
/// allready clamped it against everything below, so checking it alone is
/// correct.
/// * [entitlementExpiresAt] is when the SUBSCRIPTION runs out, if it ever
/// does. Informational — the thing a UI says out loud ("renews on the 3rd").
class GarageKey {
GarageKey({
required this.subject,
required this.issuer,
required this.project,
required this.sku,
required this.kind,
required this.mode,
required this.issuedAt,
required this.expiresAt,
this.entitlementExpiresAt,
});
/// the user id (`sub`)
final String subject;
final String issuer;
final String project;
final String sku;
/// "one_off" or "subscription"
final String kind;
/// "live" or "sandbox". A sandbox key never satisfies a live check.
final String mode;
final DateTime issuedAt;
/// The key's own expiry (`exp`). THIS is the gate.
final DateTime expiresAt;
/// The entitlement's expiry (`expires_at`), when it has one. Null for
/// something owned outright. Never the gate — for showing a date.
final DateTime? entitlementExpiresAt;
bool get isSubscription => kind == "subscription";
bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt);
/// How long this key has left. Negative once it has gone.
Duration get timeLeft => expiresAt.difference(DateTime.now().toUtc());
}
/// A row from the entitlement ledger. The truth, when you are online.
class GarageEntitlement {
GarageEntitlement({
required this.productId,
required this.kind,
required this.status,
required this.active,
this.projectId,
this.source,
this.expiresAt,
this.graceUntil,
});
final String productId;
final String? projectId;
final String kind;
/// the raw provider state — 'active' | 'trialing' | 'past_due' | 'expired' |
/// 'refunded' | 'revoked'. Read [active] instead, unless you are showing
/// somebody why.
final String status;
final String? source;
/// null = owned outright.
final DateTime? expiresAt;
/// set while a renewal is bouncing; access holds untill it passes.
final DateTime? graceUntil;
/// the server's own verdict — status, expiry and grace allready folded in.
final bool active;
factory GarageEntitlement.fromJson(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
final grace = j["grace_until"] as String?;
return GarageEntitlement(
productId: j["product_id"] as String? ?? "",
projectId: j["project_id"] as String?,
kind: j["kind"] as String? ?? "app",
status: j["status"] as String? ?? "expired",
source: j["source"] as String?,
expiresAt: (exp == null || exp.isEmpty) ? null : DateTime.tryParse(exp),
graceUntil:
(grace == null || grace.isEmpty) ? null : DateTime.tryParse(grace),
active: j["active"] == true,
);
}
}
/// Enough of a product to build a purchase screen. Comes off the public
/// by-sku lookup, so there is no bearer needed and nothing secret in it.
class GarageProduct {
GarageProduct({
required this.id,
required this.sku,
required this.name,
required this.kind,
required this.priceMinor,
required this.currency,
this.description,
this.interval,
this.trialDays,
this.active = true,
});
/// a uuid. Treat it as opaque, dont parse it
final String id;
final String sku;
final String name;
final String? description;
/// 'app' or 'subscription' — the catalogue's own wording, which is not quite
/// the key's `kind`. A key says "one_off"; the catalogue still says "app".
final String kind;
final int priceMinor;
final String currency;
/// 'month' | 'year' | null
final String? interval;
final int? trialDays;
final bool active;
bool get isSubscription => kind == "subscription";
bool get isFree => priceMinor <= 0;
factory GarageProduct.fromJson(Map<String, dynamic> j) => GarageProduct(
id: j["id"] as String? ?? "",
sku: j["sku"] as String? ?? "",
name: j["name"] as String? ?? "",
description: j["description"] as String?,
kind: j["kind"] as String? ?? "app",
priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0,
currency: j["currency"] as String? ?? "gbp",
interval: j["interval"] as String?,
trialDays: (j["trial_days"] as num?)?.toInt(),
active: j["active"] == true || j["active"] == 1,
);
/// a rough display price — no FX, no locale. Your UI does the pretty bit.
String get displayPrice {
if (isFree) return "Free";
return "${currency.toUpperCase()} ${(priceMinor / 100.0).toStringAsFixed(2)}";
}
}
/// Everything this package throws. Network errors from the authed client come
/// up as themselves.
class EntitlementsError implements Exception {
EntitlementsError(this.message, {this.code});
final String message;
final String? code;
@override
String toString() => code == null ? message : "$message ($code)";
}
+36
View File
@@ -0,0 +1,36 @@
name: garage_entitlements
description: "Offline licence keys for Garage apps — fetch a key per entitlement, verify it against the published JWKS, and gate features with no network."
version: 0.1.0
publish_to: 'none'
environment:
sdk: ^3.5.0
flutter: ">=3.5.0"
dependencies:
flutter:
sdk: flutter
garage_auth:
path: ../garage_auth
http: ^1.5.0
# verify reuses the backend's rsa/asn.1 stack, so a key is checked on the
# client the exact way it was signed on the store.
pointycastle: ^4.0.0
# keys + jwks live next to the tokens garage_auth allready keeps there.
flutter_secure_storage: ">=9.2.2 <12.0.0"
# deliberately NOT here: flutter_stripe and url_launcher. this package has to
# build clean on desktop, and neither of them is any of its business —
# portalUrl hands you a Uri and you launch it however you allready do.
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
@@ -0,0 +1,187 @@
import "package:flutter_test/flutter_test.dart";
import "package:garage_entitlements/src/jwks_verify.dart";
import "package:garage_entitlements/src/models.dart";
import "package:pointycastle/export.dart";
import "keys.dart";
void main() {
late RSAPublicKey pub;
late RSAPrivateKey priv;
late Map<String, dynamic> jwks;
setUpAll(() {
final pair = genKey(1);
pub = pair.publicKey as RSAPublicKey;
priv = pair.privateKey as RSAPrivateKey;
jwks = jwksOf(pub);
});
GarageKey verify(String token, {
Map<String, dynamic>? doc,
String iss = "https://pay.imbenji.net",
String project = "field-notes",
String sku = "pro",
String sub = "user-123",
String mode = "live",
}) =>
verifyKey(
token,
doc ?? jwks,
expectedIssuer: iss,
expectedProject: project,
expectedSku: sku,
expectedSub: sub,
expectedMode: mode,
);
String? codeOf(Object? e) => e is EntitlementsError ? e.code : null;
test("verifies a signed key and reads every claim", () {
final ends = "2027-03-01T00:00:00.000Z";
final key = verify(
keyToken(
priv,
kind: "subscription",
entitlementExpiresAt: ends,
),
);
expect(key.subject, "user-123");
expect(key.issuer, "https://pay.imbenji.net");
expect(key.project, "field-notes");
expect(key.sku, "pro");
expect(key.kind, "subscription");
expect(key.mode, "live");
expect(key.isSubscription, isTrue);
expect(key.isExpired, isFalse);
// the two clocks are separate things and both survive the round trip
expect(key.entitlementExpiresAt, DateTime.parse(ends).toUtc());
expect(key.expiresAt.isBefore(DateTime.parse(ends)), isTrue);
});
test("a one-off owned outright carries no entitlement expiry", () {
final key = verify(keyToken(priv));
expect(key.kind, "one_off");
expect(key.entitlementExpiresAt, isNull);
});
test("a tampered signature is refused", () {
final good = keyToken(priv);
final tampered = "${good.substring(0, good.length - 4)}AAAA";
expect(
() => verify(tampered),
throwsA(predicate((e) => codeOf(e) == "bad_signature")),
);
});
test("a key signed by somebody elses key is refused", () {
final other = genKey(9);
final token = keyToken(other.privateKey as RSAPrivateKey);
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "bad_signature")),
);
});
test("iss mismatch", () {
final token = keyToken(priv, iss: "https://not-us.example");
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "iss_mismatch")),
);
});
test("aud mismatch — right project, wrong sku", () {
// the exact thing aud exists to stop: a key for the cheap tier being
// handed to the lock on the expensive one.
final token = keyToken(priv, sku: "basic");
expect(
() => verify(token, sku: "pro"),
throwsA(predicate((e) => codeOf(e) == "aud_mismatch")),
);
});
test("aud mismatch — right sku, wrong project", () {
final token = keyToken(priv, project: "someone-else", sku: "pro");
expect(
() => verify(token, project: "field-notes"),
throwsA(predicate((e) => codeOf(e) == "aud_mismatch")),
);
});
test("aud that isnt project/sku at all", () {
final token = keyToken(priv, audOverride: "field-notes");
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "aud_mismatch")),
);
});
test("sub mismatch — somebody elses key on this device", () {
final token = keyToken(priv, sub: "user-999");
expect(
() => verify(token, sub: "user-123"),
throwsA(predicate((e) => codeOf(e) == "sub_mismatch")),
);
});
test("mode mismatch — a sandbox key never satisfies a live check", () {
final token = keyToken(priv, mode: "sandbox");
expect(
() => verify(token, mode: "live"),
throwsA(predicate((e) => codeOf(e) == "mode_mismatch")),
);
// and the other way, so nobody can force live data into a sandbox build
final live = keyToken(priv, mode: "live");
expect(
() => verify(live, mode: "sandbox"),
throwsA(predicate((e) => codeOf(e) == "mode_mismatch")),
);
});
test("an expired key is refused", () {
final token = keyToken(priv, life: const Duration(hours: -1));
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "expired")),
);
});
test("an unknown kid is refused rather than guessed at", () {
final token = keyToken(priv, kid: "rotated-away");
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "kid_not_found")),
);
});
test("rotation: the jwks carrying both keys still verifies the old one", () {
final next = genKey(4);
final rotated = {
"keys": [
...(jwksOf(next.publicKey as RSAPublicKey, kid: "k2")["keys"] as List),
...(jwks["keys"] as List),
],
};
final old = keyToken(priv, kid: "k1");
expect(verify(old, doc: rotated).sku, "pro");
});
test("a malformed token is refused, not thrown past", () {
expect(
() => verify("not.a.jwt.at.all"),
throwsA(isA<EntitlementsError>()),
);
expect(() => verify("rubbish"), throwsA(isA<EntitlementsError>()));
});
test("peekAudience splits project and sku without verifying", () {
final aud = peekAudience(keyToken(priv, project: "p", sku: "s"));
expect(aud?.project, "p");
expect(aud?.sku, "s");
expect(peekAudience("rubbish"), isNull);
});
}
+96
View File
@@ -0,0 +1,96 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
// A keypair + a signer, shared by the tests. Lifted from garage_iap's verify
// test — generating a real 2048 bit RSA key beats a fixture, since the whole
// point is that we verify the same way the backend signs.
String b64uBig(BigInt n) {
final bytes = <int>[];
var v = n;
while (v > BigInt.zero) {
bytes.insert(0, (v & BigInt.from(0xff)).toInt());
v = v >> 8;
}
return base64Url.encode(Uint8List.fromList(bytes)).replaceAll("=", "");
}
String b64uStr(String s) => base64Url.encode(utf8.encode(s)).replaceAll("=", "");
String b64uBytes(List<int> b) => base64Url.encode(b).replaceAll("=", "");
AsymmetricKeyPair<PublicKey, PrivateKey> genKey(int seed) {
final rng = SecureRandom("Fortuna")
..seed(
KeyParameter(Uint8List.fromList(List.generate(32, (i) => (i + seed) & 0xff))),
);
final gen = RSAKeyGenerator()
..init(
ParametersWithRandom(
RSAKeyGeneratorParameters(BigInt.parse("65537"), 2048, 64),
rng,
),
);
return gen.generateKeyPair();
}
// build a compact RS256 JWT the same way the backend does — header.payload
// signed PKCS1v15 SHA-256.
String signJwt(
RSAPrivateKey priv,
Map<String, dynamic> header,
Map<String, dynamic> payload,
) {
final h = b64uStr(jsonEncode(header));
final p = b64uStr(jsonEncode(payload));
final input = utf8.encode("$h.$p");
final signer = Signer("SHA-256/RSA") as RSASigner;
signer.init(true, PrivateKeyParameter<RSAPrivateKey>(priv));
final sig = signer.generateSignature(Uint8List.fromList(input));
return "$h.$p.${b64uBytes(sig.bytes)}";
}
Map<String, dynamic> jwksOf(RSAPublicKey pub, {String kid = "k1"}) => {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": kid,
"n": b64uBig(pub.modulus!),
"e": b64uBig(pub.exponent!),
},
],
};
/// A key the way the store mints them. Everything is overridable so a test can
/// break exactly one claim.
String keyToken(
RSAPrivateKey priv, {
String kid = "k1",
String sub = "user-123",
String iss = "https://pay.imbenji.net",
String project = "field-notes",
String sku = "pro",
String kind = "one_off",
String mode = "live",
String? entitlementExpiresAt,
Duration life = const Duration(hours: 1),
String? audOverride,
}) {
final now = DateTime.now().toUtc();
return signJwt(priv, {"alg": "RS256", "kid": kid, "typ": "JWT"}, {
"sub": sub,
"iss": iss,
"aud": audOverride ?? "$project/$sku",
"project": project,
"sku": sku,
"kind": kind,
"mode": mode,
if (entitlementExpiresAt != null) "expires_at": entitlementExpiresAt,
"iat": now.millisecondsSinceEpoch ~/ 1000,
"exp": now.add(life).millisecondsSinceEpoch ~/ 1000,
});
}
+282
View File
@@ -0,0 +1,282 @@
import "dart:convert";
import "package:flutter_test/flutter_test.dart";
import "package:garage_auth/garage_auth.dart";
import "package:garage_entitlements/garage_entitlements.dart";
import "package:http/http.dart" as http;
import "package:http/testing.dart";
import "package:pointycastle/export.dart";
import "keys.dart";
const _issuer = "https://hub.test/auth-api";
const _api = "https://pay.test/api";
const _project = "field-notes";
void main() {
late RSAPublicKey pub;
late RSAPrivateKey priv;
late Map<String, dynamic> jwks;
// what the next /v1/licences call answers with, sku -> token.
late Map<String, String> served;
// set to a body to return instead, for the failure cases.
String? servedRaw;
int licenceCalls = 0;
setUpAll(() {
final pair = genKey(2);
pub = pair.publicKey as RSAPublicKey;
priv = pair.privateKey as RSAPrivateKey;
jwks = jwksOf(pub);
});
setUp(() {
served = {};
servedRaw = null;
licenceCalls = 0;
});
Future<GarageAuth> signedInAuth() async {
final store = MemoryTokenStore();
await store.write("ga.access.test-client", "oauth_fake");
final mock = MockClient((req) async {
final path = req.url.path;
if (path.endsWith("/.well-known/openid-configuration")) {
return http.Response(
jsonEncode({
"authorization_endpoint": "$_issuer/oauth/authorize",
"token_endpoint": "$_issuer/oauth/token",
"userinfo_endpoint": "$_issuer/oauth/userinfo",
}),
200,
headers: {"content-type": "application/json"},
);
}
if (path.endsWith("/oauth/userinfo")) {
return http.Response(
jsonEncode({"sub": "user-123"}),
200,
headers: {"content-type": "application/json"},
);
}
if (path.endsWith("/v1/licences")) {
licenceCalls++;
if (servedRaw != null) return http.Response(servedRaw!, 200);
return http.Response(
jsonEncode({
"licences": [
for (final e in served.entries)
{"sku": e.key, "licence": e.value, "expires_in": 3600},
],
"jwks": jwks,
}),
200,
headers: {"content-type": "application/json"},
);
}
return http.Response(jsonEncode({"error": "nope"}), 404);
});
final auth = GarageAuth(
issuer: _issuer,
clientId: "test-client",
redirectUri: "test://cb",
httpClient: mock,
tokenStore: store,
);
await auth.restore();
return auth;
}
Future<GarageEntitlements> subject({KeyCache? cache}) async => GarageEntitlements(
auth: await signedInAuth(),
projectSlug: _project,
apiBaseUrl: _api,
cache: cache ?? MemoryKeyCache(),
);
String tokenFor(String sku, {Duration life = const Duration(hours: 1)}) =>
keyToken(priv, project: _project, sku: sku, life: life);
test("refresh verifies and holds every key it got", () async {
final ent = await subject();
served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")};
await ent.refresh();
expect(ent.has("pro"), isTrue);
expect(ent.has("extras"), isTrue);
expect(ent.has("never-bought"), isFalse);
expect(ent.key("pro")!.sku, "pro");
});
// THE important one. a cancelled subscription stops coming back in the
// response; if a refresh merged, its key would sit there working untill its
// own exp — which could be a day.
test("refresh REPLACES the set, it does not merge", () async {
final cache = MemoryKeyCache();
final ent = await subject(cache: cache);
served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")};
await ent.refresh();
expect(ent.has("extras"), isTrue);
// they cancelled "extras". it simply isnt in the response any more.
served = {"pro": tokenFor("pro")};
await ent.refresh();
expect(ent.has("pro"), isTrue);
expect(ent.has("extras"), isFalse, reason: "a dropped key must be evicted");
// and it is gone from disk too, not just from memory — otherwise the next
// cold boot would bring it back.
final blob = await cache.read(_project);
expect(blob!.keys.keys, ["pro"]);
});
test("everything gone means everything gone", () async {
final ent = await subject();
served = {"pro": tokenFor("pro")};
await ent.refresh();
expect(ent.has("pro"), isTrue);
served = {};
await ent.refresh();
expect(ent.keys, isEmpty);
});
test("a response with one bad key changes nothing", () async {
final cache = MemoryKeyCache();
final ent = await subject(cache: cache);
served = {"pro": tokenFor("pro")};
await ent.refresh();
// second call carries a key signed by somebody else entirely
final rogue = genKey(11).privateKey as RSAPrivateKey;
served = {
"pro": tokenFor("pro"),
"extras": keyToken(rogue, project: _project, sku: "extras"),
};
await expectLater(ent.refresh(), throwsA(isA<EntitlementsError>()));
// the good set from before is untouched — no half applied refresh.
expect(ent.has("pro"), isTrue);
final blob = await cache.read(_project);
expect(blob!.keys.keys, ["pro"]);
});
test("no jwks in the response is refused", () async {
final ent = await subject();
servedRaw = jsonEncode({"licences": []});
await expectLater(
ent.refresh(),
throwsA(predicate((e) => e is EntitlementsError && e.code == "no_jwks")),
);
});
test("cached() reads the blob back with no network at all", () async {
final cache = MemoryKeyCache();
final first = await subject(cache: cache);
served = {"pro": tokenFor("pro")};
await first.refresh();
final callsAfterRefresh = licenceCalls;
final second = await subject(cache: cache);
await second.cached();
expect(second.has("pro"), isTrue);
// the second instance has its own mock, so this only proves the first one
// wasnt asked again — which is the bit that matters.
expect(licenceCalls, callsAfterRefresh);
});
test("cached() drops an expired key and keeps the rest", () async {
final cache = MemoryKeyCache();
// write a blob by hand: one live key, one that went stale on disk.
await cache.write(
_project,
CachedKeys(
keys: {
"pro": tokenFor("pro"),
"trial": tokenFor("trial", life: const Duration(hours: -1)),
},
jwks: jwks,
),
);
final ent = await subject(cache: cache);
await ent.cached();
expect(ent.has("pro"), isTrue);
expect(ent.has("trial"), isFalse);
});
test("cached() with nothing stored is simply empty", () async {
final ent = await subject();
await ent.cached();
expect(ent.keys, isEmpty);
expect(ent.has("pro"), isFalse);
});
test("clear() empties memory and disk", () async {
final cache = MemoryKeyCache();
final ent = await subject(cache: cache);
served = {"pro": tokenFor("pro")};
await ent.refresh();
await ent.clear();
expect(ent.has("pro"), isFalse);
expect(await cache.read(_project), isNull);
});
test("it notifies, so a ListenableBuilder redraws the gates", () async {
final ent = await subject();
var fired = 0;
ent.addListener(() => fired++);
served = {"pro": tokenFor("pro")};
await ent.refresh();
expect(fired, 1);
served = {};
await ent.refresh();
expect(fired, 2);
});
test("not signed in is an error, not an empty set", () async {
final auth = GarageAuth(
issuer: _issuer,
clientId: "test-client",
redirectUri: "test://cb",
httpClient: MockClient((_) async => http.Response("{}", 200)),
tokenStore: MemoryTokenStore(),
);
await auth.restore();
final ent = GarageEntitlements(
auth: auth,
projectSlug: _project,
apiBaseUrl: _api,
cache: MemoryKeyCache(),
);
await expectLater(
ent.refresh(),
throwsA(
predicate((e) => e is EntitlementsError && e.code == "not_signed_in"),
),
);
});
}