halkyon/packages/halkyon_oidc_server/test/unit/provider_keys_test.dart
Tizian 「ティツィアーン」 54c0b3f7b8
feat(oidc): a fake provider for hosts' tests (testing.dart)
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>
2026-10-24 09:17:47 +02:00

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