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,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);
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
Reference in New Issue
Block a user