The module's test provider moves into `package:halkyon_oidc_server/testing.dart`, so a host tests its own sign-in against the module's real checks rather than a copy: `TestKey` makes and publishes an RSA key and signs ID tokens with headers written by hand (no `typ`, as SimpleLogin's), and `FakeProvider` answers discovery, the keys and the token endpoint through an HTTP client the host hands the module. `FakeProvider.simpleLogin` has SimpleLogin's issuer and endpoints, without PKCE, as the preset expects; a test now signs in through the preset end to end. pointycastle, already in the lock through dart_jsonwebtoken, becomes a dependency for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
188 lines
5.4 KiB
Dart
188 lines
5.4 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
|
import 'package:halkyon_oidc_server/src/oidc_provider.dart';
|
|
import 'package:halkyon_oidc_server/src/provider_keys.dart';
|
|
import 'package:halkyon_oidc_server/testing.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
// A provider's keys, as the server keeps them (ADR 0023, 5): an hour, then
|
|
// fetched again; a `kid` it doesn't know fetched again at once, as the
|
|
// provider may have a new key, but at most once a minute, so tokens with made
|
|
// up `kid`s can't have it asked again and again. A fetch that fails keeps
|
|
// the keys it had.
|
|
|
|
void main() {
|
|
late TestKey a;
|
|
late TestKey b;
|
|
|
|
setUpAll(() {
|
|
a = TestKey.generate('a', seed: 1);
|
|
b = TestKey.generate('b', seed: 2);
|
|
});
|
|
|
|
final start = DateTime.utc(2026, 10, 23, 18);
|
|
|
|
late DateTime now;
|
|
late List<String> documents;
|
|
late int fetches;
|
|
late ProviderKeys keys;
|
|
|
|
setUp(() {
|
|
now = start;
|
|
fetches = 0;
|
|
documents = [];
|
|
keys = ProviderKeys(() async {
|
|
fetches++;
|
|
if (documents.isEmpty) {
|
|
throw ProviderUnavailable('down');
|
|
}
|
|
return documents.length == 1 ? documents.single : documents.removeAt(0);
|
|
});
|
|
});
|
|
|
|
Future<JWTKey?> keyFor(String? kid) =>
|
|
withClock(Clock(() => now), () => keys.keyFor(kid));
|
|
|
|
test('are fetched once, and kept an hour', () async {
|
|
documents = [
|
|
jwksOf([a]),
|
|
];
|
|
expect(await keyFor('a'), isA<RSAPublicKey>());
|
|
now = now.add(const Duration(minutes: 59));
|
|
expect(await keyFor('a'), isA<RSAPublicKey>());
|
|
expect(fetches, 1);
|
|
now = now.add(const Duration(minutes: 2));
|
|
expect(await keyFor('a'), isA<RSAPublicKey>());
|
|
expect(fetches, 2);
|
|
});
|
|
|
|
test(
|
|
"are fetched again for a kid they don't know, which a new key may be",
|
|
() async {
|
|
documents = [
|
|
jwksOf([a]),
|
|
jwksOf([a, b]),
|
|
];
|
|
expect(await keyFor('a'), isNotNull);
|
|
now = now.add(const Duration(minutes: 2));
|
|
expect(await keyFor('b'), isA<RSAPublicKey>());
|
|
expect(fetches, 2);
|
|
},
|
|
);
|
|
|
|
test('are fetched again for an unknown kid at most once a minute', () async {
|
|
documents = [
|
|
jwksOf([a]),
|
|
];
|
|
expect(await keyFor('a'), isNotNull);
|
|
now = now.add(const Duration(minutes: 1));
|
|
expect(await keyFor('x'), isNull);
|
|
expect(fetches, 2);
|
|
now = now.add(const Duration(seconds: 30));
|
|
expect(await keyFor('y'), isNull);
|
|
expect(await keyFor('z'), isNull);
|
|
expect(fetches, 2);
|
|
now = now.add(const Duration(seconds: 31));
|
|
expect(await keyFor('x'), isNull);
|
|
expect(fetches, 3);
|
|
});
|
|
|
|
test('fetched for a token without a kid give the one key there is', () async {
|
|
documents = [
|
|
jwksOf([a]),
|
|
];
|
|
expect(await keyFor(null), isA<RSAPublicKey>());
|
|
});
|
|
|
|
test(
|
|
'fetched for a token without a kid give none where there are several',
|
|
() async {
|
|
documents = [
|
|
jwksOf([a, b]),
|
|
];
|
|
expect(await keyFor(null), isNull);
|
|
},
|
|
);
|
|
|
|
test('leave out what no ID token is checked with', () async {
|
|
documents = [
|
|
jsonEncode({
|
|
'keys': [
|
|
{...a.jwk, 'use': 'enc'},
|
|
{'kty': 'oct', 'kid': 'secret', 'k': 'c2VjcmV0'},
|
|
{...b.jwk, 'd': 'AQAB'},
|
|
{'kty': 'RSA', 'kid': 'broken'},
|
|
'not a key',
|
|
{...b.jwk, 'kid': 'c', 'use': 'sig'},
|
|
],
|
|
}),
|
|
];
|
|
expect(await keyFor('a'), isNull);
|
|
now = now.add(const Duration(minutes: 1));
|
|
expect(await keyFor('secret'), isNull);
|
|
now = now.add(const Duration(minutes: 1));
|
|
expect(await keyFor('b'), isNull);
|
|
now = now.add(const Duration(minutes: 1));
|
|
expect(await keyFor('broken'), isNull);
|
|
expect(await keyFor('c'), isA<RSAPublicKey>());
|
|
});
|
|
|
|
test('that never came make the provider unavailable', () {
|
|
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
|
|
});
|
|
|
|
test('that are no JWKS make the provider unavailable', () {
|
|
for (final document in ['', '{}', '{"keys": 1}', '[]', '{"keys": []}']) {
|
|
documents = [document];
|
|
keys = ProviderKeys(() async => documents.single);
|
|
expect(
|
|
() => keyFor('a'),
|
|
throwsA(isA<ProviderUnavailable>()),
|
|
reason: document,
|
|
);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'stay as they were where fetching them again fails, tried once a minute',
|
|
() async {
|
|
documents = [
|
|
jwksOf([a]),
|
|
];
|
|
expect(await keyFor('a'), isNotNull);
|
|
documents = [];
|
|
now = now.add(const Duration(hours: 2));
|
|
expect(await keyFor('a'), isA<RSAPublicKey>());
|
|
expect(await keyFor('a'), isA<RSAPublicKey>());
|
|
expect(fetches, 2);
|
|
now = now.add(const Duration(minutes: 1));
|
|
expect(await keyFor('a'), isA<RSAPublicKey>());
|
|
expect(fetches, 3);
|
|
},
|
|
);
|
|
|
|
test('that never came are tried again a minute later', () async {
|
|
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
|
|
await pumpEventQueue();
|
|
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
|
|
await pumpEventQueue();
|
|
expect(fetches, 1);
|
|
documents = [
|
|
jwksOf([a]),
|
|
];
|
|
now = now.add(const Duration(minutes: 1));
|
|
expect(await keyFor('a'), isA<RSAPublicKey>());
|
|
expect(fetches, 2);
|
|
});
|
|
|
|
test('are fetched once for lookups at the same time', () async {
|
|
documents = [
|
|
jwksOf([a]),
|
|
];
|
|
final found = await Future.wait([keyFor('a'), keyFor('a'), keyFor('a')]);
|
|
expect(found, everyElement(isNotNull));
|
|
expect(fetches, 1);
|
|
});
|
|
}
|