import 'dart:convert'; import 'package:clock/clock.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:halkyon_oidc_server/src/id_token.dart'; import 'package:halkyon_oidc_server/testing.dart'; import 'package:test/test.dart'; // An ID token proves who someone is only when everything in it checks out // (ADR 0023, 5): signed with one of the provider's keys, the one its `kid` // names, by an algorithm the provider uses; for this client, from this // issuer, for this attempt (the nonce), not expired. Each test spoils one // thing of a token that otherwise passes. const issuer = 'https://id.example'; const clientId = 'client-1'; const nonce = 'nonce-1'; void main() { late TestKey key; late TestKey other; final now = DateTime.utc(2026, 10, 23, 18); setUpAll(() { key = TestKey.generate('a', seed: 1); other = TestKey.generate('b', seed: 2); }); Map claims([Map changes = const {}]) => { 'iss': issuer, 'sub': '42', 'aud': clientId, 'exp': secondsOf(now.add(const Duration(hours: 1))), 'iat': secondsOf(now), 'nonce': nonce, 'name': 'Ada', ...changes, }..removeWhere((_, value) => value == null); Future> check( String token, { Set algorithms = const {'RS256'}, Map? keys, }) => withClock( Clock.fixed(now), () => checkIdToken( token, issuer: issuer, clientId: clientId, algorithms: algorithms, nonce: nonce, keyFor: (kid) async => (keys ?? {'a': RSAPublicKey.raw(key.public)})[kid], ), ); Matcher rejected() => throwsA(isA()); group('a token that checks out', () { test( 'is accepted without typ in its header, as SimpleLogin signs it', () async { final checked = await check(key.sign(claims())); expect(checked['sub'], '42'); expect(checked['name'], 'Ada'); }, ); test('is accepted with typ JWT too', () async { final token = key.sign( claims(), header: {'alg': 'RS256', 'kid': 'a', 'typ': 'JWT'}, ); expect((await check(token))['sub'], '42'); }); test( 'is accepted with the client among several audiences, named as azp', () async { final token = key.sign( claims({ 'aud': [clientId, 'another'], 'azp': clientId, }), ); expect((await check(token))['sub'], '42'); }, ); test( 'is accepted up to a minute after it expired, for the clocks', () async { final token = key.sign( claims({'exp': secondsOf(now.subtract(const Duration(seconds: 30)))}), ); expect((await check(token))['sub'], '42'); }, ); test( 'without a kid, is checked with the key when the provider has one', () async { final token = key.sign(claims(), header: {'alg': 'RS256'}); final checked = await check( token, keys: {null: RSAPublicKey.raw(key.public)}, ); expect(checked['sub'], '42'); }, ); }); group('the signature', () { test('by another key under the same kid is refused', () { final token = other.sign( claims(), header: {'alg': 'RS256', 'kid': 'a'}, ); expect(check(token), rejected()); }); test('of a kid the provider has no key for is refused', () { expect(check(other.sign(claims())), rejected()); }); test('over a payload changed afterwards is refused', () { final parts = key.sign(claims()).split('.'); final changed = base64Url .encode(utf8.encode(jsonEncode(claims({'sub': '43'})))) .replaceAll('=', ''); expect(check('${parts[0]}.$changed.${parts[2]}'), rejected()); }); test('by an algorithm the provider does not use is refused', () { expect(check(key.sign(claims(), algorithm: 'RS512')), rejected()); }); test('as an HMAC keyed with the public key is refused', () { // The classic confusion: the public key, which anyone has, as the // secret of an HMAC, which a check trusting the header would accept. final token = signToken( claims(), header: {'alg': 'HS256', 'kid': 'a'}, algorithm: JWTAlgorithm.HS256, key: SecretKey(jsonEncode(key.jwk)), ); expect( check(token, algorithms: {'RS256', 'HS256'}), rejected(), ); }); test('that is none is refused', () { final parts = key.sign(claims()).split('.'); final header = base64Url .encode(utf8.encode(jsonEncode({'alg': 'none', 'kid': 'a'}))) .replaceAll('=', ''); expect(check('$header.${parts[1]}.'), rejected()); expect(check('$header.${parts[1]}'), rejected()); }); test('without a kid is refused where the provider has several keys', () { final token = key.sign(claims(), header: {'alg': 'RS256'}); expect( check( token, keys: { 'a': RSAPublicKey.raw(key.public), 'b': RSAPublicKey.raw(other.public), }, ), rejected(), ); }); }); group('the claims', () { test('from another issuer, or none, are refused', () { expect( check(key.sign(claims({'iss': 'https://evil.example'}))), rejected(), ); expect(check(key.sign(claims({'iss': '$issuer/'}))), rejected()); expect(check(key.sign(claims({'iss': null}))), rejected()); }); test('for another client, or none, are refused', () { expect(check(key.sign(claims({'aud': 'client-2'}))), rejected()); expect(check(key.sign(claims({'aud': null}))), rejected()); expect(check(key.sign(claims({'aud': []}))), rejected()); }); test('for several audiences without this client as azp are refused', () { expect( check( key.sign( claims({ 'aud': [clientId, 'another'], }), ), ), rejected(), ); expect( check( key.sign( claims({ 'aud': [clientId, 'another'], 'azp': 'another', }), ), ), rejected(), ); }); test('expired more than a minute ago, or never, are refused', () { expect( check( key.sign( claims({ 'exp': secondsOf(now.subtract(const Duration(minutes: 2))), }), ), ), rejected(), ); expect(check(key.sign(claims({'exp': null}))), rejected()); expect(check(key.sign(claims({'exp': 'tomorrow'}))), rejected()); }); test('issued, or valid from, more than a minute ahead are refused', () { final ahead = secondsOf(now.add(const Duration(minutes: 2))); expect(check(key.sign(claims({'iat': ahead}))), rejected()); expect(check(key.sign(claims({'iat': null}))), rejected()); expect(check(key.sign(claims({'nbf': ahead}))), rejected()); }); test('for another attempt, or none, are refused (the nonce)', () { expect(check(key.sign(claims({'nonce': 'nonce-2'}))), rejected()); expect(check(key.sign(claims({'nonce': null}))), rejected()); }); test('naming nobody are refused', () { expect(check(key.sign(claims({'sub': null}))), rejected()); expect(check(key.sign(claims({'sub': ''}))), rejected()); expect(check(key.sign(claims({'sub': 42}))), rejected()); }); }); group('what is no token', () { test('is refused', () { final good = key.sign(claims()).split('.'); expect(check(''), rejected()); expect(check('${good[0]}.${good[1]}'), rejected()); expect(check('x.${good[1]}.${good[2]}'), rejected()); final notAnObject = base64Url .encode(utf8.encode('[1]')) .replaceAll('=', ''); expect(check('$notAnObject.${good[1]}.${good[2]}'), rejected()); expect(check('${good[0]}.$notAnObject.${good[2]}'), rejected()); }); }); }