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