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.
+142
View File
@@ -0,0 +1,142 @@
import "package:flutter/material.dart";
import "package:garage_auth/garage_auth.dart";
import "package:garage_iap/garage_iap.dart";
// A tiny store screen: sign in, list products, buy one, show what you own. The
// real wiring (deep-link callback for completeSignIn etc.) is the host app's
// job — see garage_auth's README. This just shows the iap surface.
late final GarageAuth auth;
late final GarageIap iap;
void main() {
auth = GarageAuth(
issuer: "https://hub.imbenji.net/auth-api",
clientId: "example-app",
redirectUri: "exampleapp://auth/callback",
);
iap = GarageIap(
auth: auth,
appSlug: "example-app",
apiBaseUrl: "https://store.imbenji.net/api",
);
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "garage_iap example",
theme: ThemeData(useMaterial3: true),
home: const StorePage(),
);
}
}
class StorePage extends StatefulWidget {
const StorePage({super.key});
@override
State<StorePage> createState() => _StorePageState();
}
class _StorePageState extends State<StorePage> {
List<GarageProduct> _products = [];
String _status = "";
bool _busy = false;
@override
void initState() {
super.initState();
_boot();
}
Future<void> _boot() async {
await auth.restore();
// populate the offline gate cheaply before any network — cachedLicence reads
// straight from secure storage and verifies locally.
try {
await iap.cachedLicence();
} catch (e) {
// expired-offline etc. — fine, just means not entitled until we re-fetch.
print("cached licence not usable: $e");
}
if (auth.isSignedIn) {
await _load();
}
if (mounted) setState(() {});
}
Future<void> _load() async {
setState(() => _busy = true);
try {
_products = await iap.products();
await iap.entitlements();
await iap.licence(); // refresh + cache the offline licence
_status = "loaded";
} catch (e) {
_status = "load failed: $e";
}
if (mounted) setState(() => _busy = false);
}
Future<void> _buy(GarageProduct p) async {
setState(() => _busy = true);
try {
final outcome = await iap.purchase(p, mode: PurchaseMode.sheet);
_status = "purchase: ${outcome.name}";
} on IapError catch (e) {
_status = "purchase error: ${e.message}";
} catch (e) {
_status = "purchase failed: $e";
}
if (mounted) setState(() => _busy = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Store")),
body: !auth.isSignedIn
? Center(
child: ElevatedButton(
onPressed: () => auth.signIn(),
child: const Text("Sign in with Garage"),
),
)
: Column(
children: [
if (_busy) const LinearProgressIndicator(),
Padding(
padding: const EdgeInsets.all(12),
child: Text(_status),
),
Expanded(
child: ListView(
children: [
for (final p in _products)
ListTile(
title: Text(p.name),
subtitle: Text(p.displayPrice),
trailing: iap.has(p.sku)
? const Chip(label: Text("Owned"))
: FilledButton(
onPressed: () => _buy(p),
child: const Text("Buy"),
),
),
],
),
),
],
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
name: garage_iap_example
description: "Minimal example wiring garage_auth + garage_iap together."
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.5.0
flutter: ">=3.5.0"
dependencies:
flutter:
sdk: flutter
garage_auth:
path: ../../garage_auth
garage_iap:
path: ../
dev_dependencies:
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
+25
View File
@@ -0,0 +1,25 @@
// In-app purchasing for Garage apps — layered on garage_auth.
//
// Hand it a signed-in GarageAuth and the store API base url; it lists the app's
// products, runs both purchase modes (embedded Stripe sheet + browser handoff),
// and keeps a short-lived signed licence you can verify offline.
//
// final iap = GarageIap(auth: auth, appSlug: "my-app",
// apiBaseUrl: "https://store.imbenji.net/api");
// final products = await iap.products();
// await iap.purchase(products.first, mode: PurchaseMode.sheet);
// final pro = iap.has("pro");
library;
export "src/garage_iap.dart" show GarageIap;
export "src/models.dart"
show
GarageProduct,
GarageEntitlement,
GarageLicence,
LicenceProduct,
PurchaseMode,
PurchaseOutcome,
IapError;
export "src/licence_cache.dart"
show LicenceCache, SecureLicenceCache, MemoryLicenceCache, CachedLicence;
+499
View File
@@ -0,0 +1,499 @@
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 "package:url_launcher/url_launcher.dart";
import "jwks_verify.dart";
import "licence_cache.dart";
import "models.dart";
import "sheet/sheet.dart";
// In-app purchasing for one app. Takes a signed-in GarageAuth and the store API
// base url, lists products, runs both purchase flows, and keeps an offline
// licence. It never touches sign-in — that's garage_auth's job; we just borrow
// its authed client so the bearer + refresh are handled for us.
//
// final iap = GarageIap(
// auth: auth,
// appSlug: "my-app",
// apiBaseUrl: "https://store.imbenji.net/api",
// );
// final products = await iap.products();
// await iap.purchase(products.first, mode: PurchaseMode.sheet);
// final pro = iap.has("pro");
//
// it's a ChangeNotifier so UIs can rebuild when entitlements move.
/// Where the payment portal lives. Overridable per call, but this is the one
/// buyers actually get sent to.
const String kGaragePortalBaseUrl = String.fromEnvironment(
"GARAGE_PORTAL_BASE_URL",
defaultValue: "https://pay.imbenji.net",
);
class GarageIap extends ChangeNotifier {
GarageIap({
required this.auth,
required this.appSlug,
required this.apiBaseUrl,
this.merchantName = "Garage",
LicenceCache? licenceCache,
Duration pollTimeout = const Duration(minutes: 3),
}) : _cache = licenceCache ?? SecureLicenceCache(),
_pollTimeout = pollTimeout;
final GarageAuth auth;
final String appSlug;
final String apiBaseUrl;
final String merchantName;
final LicenceCache _cache;
final Duration _pollTimeout;
// last known entitlements from an online fetch. has() reads this first.
List<GarageEntitlement> _entitlements = [];
List<GarageEntitlement> get cachedEntitlements =>
List.unmodifiable(_entitlements);
// product id -> sku, learned from products(). entitlements are keyed by id, so
// we need this to answer has(sku) off an online entitlement.
final Map<String, String> _skuById = {};
// the last verified offline licence, if we've loaded one this session.
GarageLicence? _licence;
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);
}
// -------------------------------------------------------------------------
// catalogue
// -------------------------------------------------------------------------
// this app's buyable products (paywall + IAP items). active only for buyers.
Future<List<GarageProduct>> products() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/apps/$appSlug/products"));
if (resp.statusCode != 200) {
throw IapError("Could not load products (${resp.statusCode}).",
code: "products_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final list = (body["products"] as List? ?? []);
final out = list
.whereType<Map>()
.map((m) => GarageProduct.fromJson(Map<String, dynamic>.from(m)))
.toList();
for (final p in out) {
_skuById[p.id] = p.sku;
}
return out;
}
// -------------------------------------------------------------------------
// entitlements
// -------------------------------------------------------------------------
// the caller's grants across every app. caches the result so has() can answer
// synchronously afterwards. throws on network failure (offline -> use the
// licence path instead).
Future<List<GarageEntitlement>> entitlements() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/entitlements"));
if (resp.statusCode != 200) {
throw IapError("Could not load entitlements (${resp.statusCode}).",
code: "entitlements_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final list = (body["entitlements"] as List? ?? []);
_entitlements = list
.whereType<Map>()
.map((m) => GarageEntitlement.fromJson(Map<String, dynamic>.from(m)))
.toList();
notifyListeners();
return cachedEntitlements;
}
// quick own/not-own for this app's paywall product. lighter than entitlements().
Future<bool> owned() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/apps/$appSlug/entitlement"));
if (resp.statusCode != 200) {
throw IapError("Could not check entitlement (${resp.statusCode}).",
code: "entitlement_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return body["entitled"] == true;
}
// -------------------------------------------------------------------------
// purchase
// -------------------------------------------------------------------------
// buy [product]. sheet tries the embedded flutter_stripe sheet and falls back
// to the browser handoff (subscriptions, or platforms with no native sdk).
// handoff always uses the browser. either way we poll entitlements after the
// user-facing step — the webhook is the real source of truth, the redirect /
// sheet-close is just a nudge.
// -------------------------------------------------------------------------
// the payment portal
// -------------------------------------------------------------------------
/// Send the buyer to the Garage Payment Portal for [productId].
///
/// The portal is a different origin, so it cant see this app's token. We mint
/// a one-shot handoff code off the signed-in session and hang it on the url —
/// the portal trades it for the session and the buyer never sees a login.
///
/// [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 has to sign 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_iap] 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_iap] couldnt mint a handoff code: $error");
print(stack);
return null;
}
}
Future<PurchaseOutcome> purchase(
GarageProduct product, {
PurchaseMode mode = PurchaseMode.sheet,
String? returnUrl,
}) async {
_requireSignedIn();
if (mode == PurchaseMode.handoff) {
return _handoff(product, returnUrl);
}
return _sheet(product, returnUrl);
}
// browser handoff: POST /checkout, open the hosted url, poll until granted.
Future<PurchaseOutcome> _handoff(
GarageProduct product, String? returnUrl) async {
final resp = await auth.client.post(
_u("/v1/checkout"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"product_id": product.id,
if (returnUrl != null) "return_url": returnUrl,
}),
);
if (resp.statusCode != 200) {
throw _checkoutError(resp);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final url = body["checkout_url"] as String?;
if (url == null || url.isEmpty) {
throw IapError("Checkout returned no url.", code: "no_checkout_url");
}
final opened = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!opened) {
throw IapError("Could not open the checkout page.",
code: "launch_failed");
}
return _pollForGrant(product);
}
// embedded sheet: stripe-config -> payment-intent -> present. on a subscription
// (or no native sheet) the server / platform tells us to hand off instead.
Future<PurchaseOutcome> _sheet(
GarageProduct product, String? returnUrl) async {
// grab the publishable key up front (also confirms config is reachable).
final cfg = await auth.client.get(_u("/v1/stripe-config"));
if (cfg.statusCode != 200) {
throw IapError("Could not load stripe config (${cfg.statusCode}).",
code: "stripe_config_failed");
}
final pubKey = (jsonDecode(cfg.body)
as Map<String, dynamic>)["publishable_key"] as String?;
final resp = await auth.client.post(
_u("/v1/payment-intent"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"product_id": product.id,
if (returnUrl != null) "return_url": returnUrl,
}),
);
if (resp.statusCode != 200) {
throw _checkoutError(resp);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
// subscription -> server handed back a hosted url. open it like a handoff.
if (body["fallback"] == "handoff") {
final url = body["checkout_url"] as String?;
if (url == null || url.isEmpty) {
throw IapError("Handoff fallback with no url.",
code: "no_checkout_url");
}
final opened =
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
if (!opened) {
throw IapError("Could not open the checkout page.",
code: "launch_failed");
}
return _pollForGrant(product);
}
final clientSecret = body["client_secret"] as String?;
final publishable = (body["publishable_key"] as String?) ?? pubKey;
if (clientSecret == null || publishable == null) {
throw IapError("Payment intent missing client_secret.",
code: "no_client_secret");
}
final result = await presentSheet(SheetParams(
clientSecret: clientSecret,
publishableKey: publishable,
// third-party PI lives on the connected account.
stripeAccount: body["stripe_account"] as String?,
customer: body["customer"] as String?,
ephemeralKey: body["ephemeral_key"] as String?,
merchantName: merchantName,
));
switch (result) {
case SheetResult.unsupported:
// no native sheet here -> degrade to the browser handoff.
return _handoff(product, returnUrl);
case SheetResult.canceled:
return PurchaseOutcome.canceled;
case SheetResult.completed:
return _pollForGrant(product);
}
}
// poll /entitlements until the product shows up active, with a backoff. the
// webhook can lag the redirect by a few seconds, hence the wait. returns
// pending (not an error) if the grant doesnt land before the timeout — it may
// still arrive, the caller can re-check later.
Future<PurchaseOutcome> _pollForGrant(GarageProduct product) async {
final deadline = DateTime.now().add(_pollTimeout);
var wait = const Duration(seconds: 2);
while (DateTime.now().isBefore(deadline)) {
try {
final ents = await entitlements();
final granted = ents.any((e) => e.productId == product.id && e.active);
if (granted) return PurchaseOutcome.granted;
} catch (e) {
// a transient error mid-poll shouldnt kill the whole wait.
print("garage_iap poll error: $e");
}
await Future.delayed(wait);
// gentle backoff, capped so we still check reasonably often.
final next = wait.inMilliseconds * 2;
wait = Duration(milliseconds: next > 8000 ? 8000 : next);
}
return PurchaseOutcome.pending;
}
// -------------------------------------------------------------------------
// offline licence
// -------------------------------------------------------------------------
// Returns a verified licence for this app. Online: fetches the licence JWT AND
// the JWKS in the same trip, caches both, verifies, returns it. Offline (the
// fetch throws): falls back to the cached licence and verifies it locally.
//
// No cached licence + offline => null (treated as not entitled until the first
// online fetch). Cached but past exp while offline => throws expired, also not
// entitled until we can re-fetch.
Future<GarageLicence?> licence({bool forceRefresh = false}) async {
_requireSignedIn();
if (!forceRefresh) {
// try a fresh online fetch first; fall through to cache on any failure.
try {
return await _fetchAndCacheLicence();
} catch (e) {
print("garage_iap licence online fetch failed, trying cache: $e");
}
} else {
return _fetchAndCacheLicence();
}
return _loadCachedLicence();
}
// the cached, offline-verified licence — no network at all. handy for a fast
// boot-time gate before you've been back online.
Future<GarageLicence?> cachedLicence() => _loadCachedLicence();
Future<GarageLicence> _fetchAndCacheLicence() async {
// licence + jwks in the same online trip, so offline always has the key.
final licResp = await auth.client.get(_u("/v1/licence", {"app": appSlug}));
if (licResp.statusCode != 200) {
throw IapError("Licence fetch failed (${licResp.statusCode}).",
code: "licence_failed");
}
final licBody = jsonDecode(licResp.body) as Map<String, dynamic>;
final token = licBody["licence"] as String?;
if (token == null || token.isEmpty) {
throw IapError("No licence in response.", code: "no_licence");
}
// jwks is public, no auth needed — but the authed client works fine for it.
final jwksResp = await auth.client.get(_u("/v1/licence/jwks.json"));
if (jwksResp.statusCode != 200) {
throw IapError("JWKS fetch failed (${jwksResp.statusCode}).",
code: "jwks_failed");
}
final jwks = jsonDecode(jwksResp.body) as Map<String, dynamic>;
final sub = await _subject();
final verified =
verifyLicence(token, jwks, expectedApp: appSlug, expectedSub: sub);
// only cache once it verifies — never persist a bad licence.
await _cache.write(appSlug, CachedLicence(token: token, jwks: jwks));
_licence = verified;
notifyListeners();
return verified;
}
Future<GarageLicence?> _loadCachedLicence() async {
final cached = await _cache.read(appSlug);
if (cached == null) return null;
final sub = await _subject();
// throws on expiry / bad sig — let it propagate so the caller can tell
// "no licence" (null) from "expired offline" (throw).
final verified = verifyLicence(cached.token, cached.jwks,
expectedApp: appSlug, expectedSub: sub);
_licence = verified;
return verified;
}
// resolve the user id we expect in the licence. profile() round-trips userinfo
// — fine, it's cached behind the authed client and only needed at fetch 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 IapError("No subject in profile — cant match the licence.",
code: "no_subject");
}
_cachedSub = sub;
return sub;
}
// -------------------------------------------------------------------------
// has() — the one-liner the apps actually call
// -------------------------------------------------------------------------
// true if [sku] is owned. checks the last online entitlements first, then the
// in-memory verified licence. it's synchronous on purpose — call entitlements()
// or licence() to refresh, then has() to read. for a cold offline start, call
// cachedLicence() once to populate, then has().
bool has(String sku) {
// online: an active entitlement whose product resolves to this sku. needs
// products() to have run so we know the id->sku map.
for (final e in _entitlements) {
if (!e.active) continue;
if (_skuById[e.productId] == sku) return true;
}
// offline: the verified, unexpired licence vouches for the sku directly.
final lic = _licence;
if (lic != null && !lic.isExpired && lic.grants(sku)) {
return true;
}
return false;
}
// -------------------------------------------------------------------------
Future<void> clearLicence() => _cache.clear(appSlug);
void _requireSignedIn() {
if (!auth.isSignedIn) {
throw IapError("Not signed in — call auth.signIn() first.",
code: "not_signed_in");
}
}
IapError _checkoutError(http.Response resp) {
try {
final b = jsonDecode(resp.body) as Map<String, dynamic>;
final code = b["error"] as String?;
final msg = b["message"] as String? ?? "Checkout failed.";
// surface the meaningful ones the backend can return.
if (resp.statusCode == 451) {
return IapError(msg, code: code ?? "region_blocked");
}
if (resp.statusCode == 409) {
return IapError(msg, code: code ?? "connect_not_ready");
}
return IapError(msg, code: code);
} catch (_) {
return IapError("Checkout failed (${resp.statusCode}).",
code: "checkout_failed");
}
}
}
+166
View File
@@ -0,0 +1,166 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
import "models.dart";
// Offline licence verification. The store signs licences RS256 with its own key
// and publishes the matching public key as a JWKS — we only ever hold the public
// half, so we can verify a licence but never forge one.
//
// We deliberately do NOT pull in a heavy jwt lib here: a JWKS RSA verify is just
// "split the compact token, rebuild the public key from n/e, check the PKCS1v15
// SHA-256 signature over header.payload". pointycastle (the same stack the
// backend signs with) gives us exactly that.
// verifies `token` against `jwks`, then checks the licence is for `expectedApp`
// and `expectedSub` and isnt past exp. throws IapError on any failure so the
// caller can treat "not entitled" uniformly.
GarageLicence verifyLicence(
String token,
Map<String, dynamic> jwks, {
required String expectedApp,
required String expectedSub,
}) {
final parts = token.split(".");
if (parts.length != 3) {
throw IapError("Malformed licence token.", code: "bad_token");
}
final header = _decodeJsonSegment(parts[0]);
final payload = _decodeJsonSegment(parts[1]);
final alg = header["alg"] as String?;
if (alg != "RS256") {
throw IapError("Unexpected licence alg: $alg", code: "bad_alg");
}
final kid = header["kid"] as String?;
final key = _findKey(jwks, kid);
if (key == null) {
// rotation: the licence's kid isnt in the cached jwks. a fresh online fetch
// refreshes the jwks, so this resolves itself next time we're online.
throw IapError("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 IapError("Licence signature failed.", code: "bad_signature");
}
// claims
final sub = payload["sub"] as String?;
final app = payload["app"] as String?;
if (sub == null || sub != expectedSub) {
throw IapError("Licence subject mismatch.", code: "sub_mismatch");
}
if (app == null || app != expectedApp) {
throw IapError("Licence app mismatch.", code: "app_mismatch");
}
final iat = _epoch(payload["iat"]);
final exp = _epoch(payload["exp"]);
if (exp == null) {
throw IapError("Licence has no exp.", code: "no_exp");
}
final products = <LicenceProduct>[];
final rawProducts = payload["products"];
if (rawProducts is List) {
for (final p in rawProducts) {
if (p is Map) {
products.add(LicenceProduct.fromClaim(Map<String, dynamic>.from(p)));
}
}
}
final licence = GarageLicence(
subject: sub,
app: app,
products: products,
issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
expiresAt: exp,
);
// exp check trusts the device clock when offline — an accepted limitation.
if (licence.isExpired) {
throw IapError("Licence expired.", code: "expired");
}
return licence;
}
// ---- key lookup ----
// pull the RSA public key out of the jwks for a given kid. if the licence header
// carried no kid and there's exactly one key, use that.
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;
final n = _bytesToBigInt(_b64UrlBytes(nB));
final e = _bytesToBigInt(_b64UrlBytes(eB));
return RSAPublicKey(n, e);
}
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 (e) {
// a malformed signature can throw rather than just returning false.
print("garage_iap licence verify threw: $e");
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);
}
+91
View File
@@ -0,0 +1,91 @@
import "dart:convert";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// Where the offline licence lives. We cache BOTH the licence JWT and the JWKS
// public key in the same trip so offline verification has everything it needs —
// there's deliberately no "licence but no key" state. Keyed by app slug so two
// apps in the same process dont collide.
//
// Reuses secure storage (same backend garage_auth keeps tokens in) — a licence
// is low-value (public-key verifiable, short lived) but keeping it next to the
// tokens means one consistent place for sdk state.
// the small seam, so tests / odd platforms can swap the backend out.
abstract class LicenceCache {
Future<CachedLicence?> read(String appSlug);
Future<void> write(String appSlug, CachedLicence value);
Future<void> clear(String appSlug);
}
class CachedLicence {
CachedLicence({required this.token, required this.jwks});
// the raw compact licence JWT
final String token;
// the jwks doc as fetched ( {"keys":[...]} )
final Map<String, dynamic> jwks;
Map<String, dynamic> toJson() => {"token": token, "jwks": jwks};
factory CachedLicence.fromJson(Map<String, dynamic> j) => CachedLicence(
token: j["token"] as String,
jwks: Map<String, dynamic>.from(j["jwks"] as Map),
);
}
class SecureLicenceCache implements LicenceCache {
SecureLicenceCache({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _key(String slug) => "gi.licence.$slug";
@override
Future<CachedLicence?> read(String appSlug) async {
try {
final raw = await _storage.read(key: _key(appSlug));
if (raw == null || raw.isEmpty) return null;
return CachedLicence.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (e) {
print("garage_iap licence cache read failed: $e");
return null;
}
}
@override
Future<void> write(String appSlug, CachedLicence value) async {
try {
await _storage.write(
key: _key(appSlug), value: jsonEncode(value.toJson()));
} catch (e) {
print("garage_iap licence cache write failed: $e");
}
}
@override
Future<void> clear(String appSlug) async {
try {
await _storage.delete(key: _key(appSlug));
} catch (e) {
print("garage_iap licence cache clear failed: $e");
}
}
}
// in-memory, handy for tests or where you explicitly dont want persistence.
class MemoryLicenceCache implements LicenceCache {
final Map<String, CachedLicence> _m = {};
@override
Future<CachedLicence?> read(String appSlug) async => _m[appSlug];
@override
Future<void> write(String appSlug, CachedLicence value) async =>
_m[appSlug] = value;
@override
Future<void> clear(String appSlug) async => _m.remove(appSlug);
}
+162
View File
@@ -0,0 +1,162 @@
// The buyer-facing data shapes. These mirror the store API json (see
// `productJson` / `entitlementJson` in the backend) but only carry what a
// client actually wants — no created_at/updated_at churn, no raw provider blobs.
// how a purchase is taken. sheet = our embedded flutter_stripe sheet; handoff =
// the system browser to hosted checkout. sheet quietly degrades to handoff for
// subscriptions and on platforms with no native stripe sdk.
enum PurchaseMode { sheet, handoff }
// where a purchase ended up. granted means the entitlement landed (the webhook
// fired and we saw it on poll). pending means we finished the user-facing bit
// but the grant hadnt shown up before we gave up waiting — it may still arrive.
enum PurchaseOutcome { granted, pending, canceled }
class GarageProduct {
GarageProduct({
required this.id,
required this.sku,
required this.name,
required this.kind,
required this.priceMinor,
required this.currency,
this.description,
this.providerPriceId,
this.trialDays,
this.active = true,
});
final String id;
final String sku;
final String name;
final String? description;
// 'app' | 'subscription'. consumables are shelved server side.
final String kind;
final String? providerPriceId;
// price in the currency's minor unit (pence, cents...).
final int priceMinor;
final String currency;
final int? trialDays;
final bool active;
bool get isSubscription => kind == "subscription";
bool get isFree => priceMinor <= 0;
bool get hasTrial => (trialDays ?? 0) > 0;
factory GarageProduct.fromJson(Map<String, dynamic> j) {
return 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",
providerPriceId: j["provider_price_id"] as String?,
priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0,
currency: (j["currency"] as String? ?? "usd"),
trialDays: (j["trial_days"] as num?)?.toInt(),
active: j["active"] == true || j["active"] == 1,
);
}
// a rough display price. no FX, no locale — the storefront does the pretty
// formatting, this is just a sane default for quick UIs.
String get displayPrice {
if (isFree) return "Free";
final major = priceMinor / 100.0;
return "${currency.toUpperCase()} ${major.toStringAsFixed(2)}";
}
}
class GarageEntitlement {
GarageEntitlement({
required this.productId,
required this.kind,
required this.status,
required this.active,
this.appId,
this.source,
this.expiresAt,
});
final String? appId;
final String productId;
final String kind;
// 'active' | 'trialing' | 'expired' | 'refunded' | 'revoked'
final String status;
final String? source;
// null = perpetual.
final DateTime? expiresAt;
// the server's own verdict (status + expiry) — we trust it rather than
// recomputing the rule on the client.
final bool active;
factory GarageEntitlement.fromJson(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
return GarageEntitlement(
appId: j["app_id"] as String?,
productId: j["product_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),
active: j["active"] == true,
);
}
}
// a verified, decoded licence. the products list is the sku/kind set the licence
// vouches for; `has` reads off this when offline.
class GarageLicence {
GarageLicence({
required this.subject,
required this.app,
required this.products,
required this.issuedAt,
required this.expiresAt,
});
final String subject; // the user id (`sub`)
final String app; // the app slug
final List<LicenceProduct> products;
final DateTime issuedAt;
final DateTime expiresAt;
bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt);
bool grants(String sku) => products.any((p) => p.sku == sku);
}
class LicenceProduct {
LicenceProduct({required this.sku, required this.kind, this.expiresAt});
final String sku;
final String kind;
final DateTime? expiresAt;
factory LicenceProduct.fromClaim(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
return LicenceProduct(
sku: j["sku"] as String? ?? "",
kind: j["kind"] as String? ?? "app",
expiresAt: exp == null || exp.isEmpty ? null : DateTime.tryParse(exp),
);
}
}
// thrown for the iap-specific failures. network errors from the authed client
// bubble up as-is.
class IapError implements Exception {
IapError(this.message, {this.code});
final String message;
final String? code;
@override
String toString() => code == null ? message : "$message ($code)";
}
+35
View File
@@ -0,0 +1,35 @@
// The embedded purchase sheet seam. flutter_stripe only has a native sheet on
// iOS / Android (and web has its own thing), so the actual impl is conditionally
// imported — native pulls in flutter_stripe, everything else gets a stub that
// reports "unsupported" and lets GarageIap fall back to the browser handoff.
export "sheet_stub.dart" if (dart.library.io) "sheet_io.dart";
// the result of trying to present the sheet.
// completed — the user paid (sheet closed on success). poll entitlements.
// canceled — the user dismissed it.
// unsupported— no native sheet on this platform; caller should handoff.
enum SheetResult { completed, canceled, unsupported }
// what the sheet needs to init + present. mirrors the payment-intent response.
class SheetParams {
SheetParams({
required this.clientSecret,
required this.publishableKey,
this.stripeAccount,
this.customer,
this.ephemeralKey,
this.merchantName = "Garage",
});
final String clientSecret;
final String publishableKey;
// set for third-party (direct charge) sales — confirm on the connected acct.
final String? stripeAccount;
final String? customer;
final String? ephemeralKey;
final String merchantName;
}
+49
View File
@@ -0,0 +1,49 @@
import "dart:io";
import "package:flutter_stripe/flutter_stripe.dart";
import "sheet.dart";
// Native embedded sheet via flutter_stripe. Only iOS + Android actually carry
// the PaymentSheet — desktop dart:io platforms (linux/macos/windows) report
// unsupported so GarageIap drops to the browser handoff there.
Future<SheetResult> presentSheet(SheetParams params) async {
if (!(Platform.isIOS || Platform.isAndroid)) {
return SheetResult.unsupported;
}
// publishable key drives which stripe account the SDK talks to. for a
// third-party direct charge the PI lives on the connected account, so we set
// stripeAccountId too.
Stripe.publishableKey = params.publishableKey;
if (params.stripeAccount != null && params.stripeAccount!.isNotEmpty) {
Stripe.stripeAccountId = params.stripeAccount;
} else {
Stripe.stripeAccountId = null;
}
await Stripe.instance.applySettings();
try {
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: params.clientSecret,
merchantDisplayName: params.merchantName,
customerId: params.customer,
customerEphemeralKeySecret: params.ephemeralKey,
),
);
await Stripe.instance.presentPaymentSheet();
// present completes without throwing -> payment succeeded (or is processing
// and will be finalised by the webhook). either way we go poll entitlements.
return SheetResult.completed;
} on StripeException catch (e) {
// the user backing out is the common, non-error path.
if (e.error.code == FailureCode.Canceled) {
return SheetResult.canceled;
}
print("garage_iap payment sheet error: ${e.error.localizedMessage}");
rethrow;
}
}
+7
View File
@@ -0,0 +1,7 @@
import "sheet.dart";
// Web / linux / anywhere without a native flutter_stripe sheet. Always reports
// unsupported so GarageIap transparently falls back to the browser handoff.
Future<SheetResult> presentSheet(SheetParams params) async {
return SheetResult.unsupported;
}
+40
View File
@@ -0,0 +1,40 @@
name: garage_iap
description: "In-app purchasing for Garage apps — products, both purchase modes (handoff + embedded Stripe sheet), and an offline-verifiable signed licence. Layers on garage_auth."
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
crypto: ^3.0.6
# offline licence verify reuses the backend's asn.1 / rsa stack so a licence
# verifies on the client the exact way it was signed on the store.
pointycastle: ^4.0.0
# licence + jwks cache. tokens already live in secure storage via garage_auth.
flutter_secure_storage: ">=9.2.2 <12.0.0"
# the system-browser handoff for hosted checkout + the subscription fallback.
url_launcher: ^6.3.1
# the embedded white-label purchase sheet. falls back to handoff where there's
# no native sdk (linux, web).
flutter_stripe: ^11.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
+152
View File
@@ -0,0 +1,152 @@
import "dart:convert";
import "dart:typed_data";
import "package:flutter_test/flutter_test.dart";
import "package:garage_iap/src/jwks_verify.dart";
import "package:garage_iap/src/models.dart";
import "package:pointycastle/export.dart";
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 with 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)}";
}
void main() {
test("verifies a signed licence and reads products", () {
final pair = _genKey(1);
final pub = pair.publicKey as RSAPublicKey;
final priv = pair.privateKey as RSAPrivateKey;
final jwks = {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "k1",
"n": _b64uBig(pub.modulus!),
"e": _b64uBig(pub.exponent!)
}
]
};
final now = DateTime.now().toUtc();
final token = _signJwt(priv, {
"alg": "RS256",
"kid": "k1"
}, {
"sub": "user-123",
"app": "my-app",
"products": [
{"sku": "pro", "kind": "app"}
],
"exp": now.add(const Duration(hours: 24)).millisecondsSinceEpoch ~/ 1000,
});
final lic = verifyLicence(token, jwks,
expectedApp: "my-app", expectedSub: "user-123");
expect(lic.grants("pro"), isTrue);
expect(lic.grants("nope"), isFalse);
expect(lic.isExpired, isFalse);
});
test("rejects wrong app, wrong sub, and a tampered signature", () {
final pair = _genKey(7);
final pub = pair.publicKey as RSAPublicKey;
final priv = pair.privateKey as RSAPrivateKey;
final jwks = {
"keys": [
{
"kty": "RSA",
"kid": "k1",
"alg": "RS256",
"n": _b64uBig(pub.modulus!),
"e": _b64uBig(pub.exponent!)
}
]
};
final now = DateTime.now().toUtc();
final exp =
now.add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000;
final good = _signJwt(priv, {"alg": "RS256", "kid": "k1"},
{"sub": "u", "app": "my-app", "products": [], "exp": exp});
expect(
() => verifyLicence(good, jwks, expectedApp: "other", expectedSub: "u"),
throwsA(isA<IapError>()));
expect(
() => verifyLicence(good, jwks,
expectedApp: "my-app", expectedSub: "someone-else"),
throwsA(isA<IapError>()));
final tampered = "${good.substring(0, good.length - 4)}AAAA";
expect(
() => verifyLicence(tampered, jwks,
expectedApp: "my-app", expectedSub: "u"),
throwsA(isA<IapError>()));
});
test("rejects an expired licence", () {
final pair = _genKey(3);
final pub = pair.publicKey as RSAPublicKey;
final priv = pair.privateKey as RSAPrivateKey;
final jwks = {
"keys": [
{
"kty": "RSA",
"kid": "k1",
"alg": "RS256",
"n": _b64uBig(pub.modulus!),
"e": _b64uBig(pub.exponent!)
}
]
};
final past = DateTime.now()
.toUtc()
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch ~/
1000;
final token = _signJwt(priv, {"alg": "RS256", "kid": "k1"},
{"sub": "u", "app": "my-app", "products": [], "exp": past});
expect(
() =>
verifyLicence(token, jwks, expectedApp: "my-app", expectedSub: "u"),
throwsA(predicate((e) => e is IapError && e.code == "expired")));
});
}