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,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