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 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 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()); now = now.add(const Duration(minutes: 59)); expect(await keyFor('a'), isA()); expect(fetches, 1); now = now.add(const Duration(minutes: 2)); expect(await keyFor('a'), isA()); 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()); 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()); }); 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()); }); test('that never came make the provider unavailable', () { expect(() => keyFor('a'), throwsA(isA())); }); 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()), 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()); expect(await keyFor('a'), isA()); expect(fetches, 2); now = now.add(const Duration(minutes: 1)); expect(await keyFor('a'), isA()); expect(fetches, 3); }, ); test('that never came are tried again a minute later', () async { expect(() => keyFor('a'), throwsA(isA())); await pumpEventQueue(); expect(() => keyFor('a'), throwsA(isA())); await pumpEventQueue(); expect(fetches, 1); documents = [ jwksOf([a]), ]; now = now.add(const Duration(minutes: 1)); expect(await keyFor('a'), isA()); 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); }); }