Files
Garage-SDKs/garage_entitlements/test/jwks_verify_test.dart
T
ImBenjiandClaude Opus 5.5 b269201919 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
2026-09-23 18:49:21 +01:00

188 lines
5.5 KiB
Dart

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);
});
}