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