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>
263 lines
7.9 KiB
Dart
263 lines
7.9 KiB
Dart
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<String, Object?> claims([Map<String, Object?> 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<Map<String, Object?>> check(
|
|
String token, {
|
|
Set<String> algorithms = const {'RS256'},
|
|
Map<String?, JWTKey>? 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<IdTokenRejected>());
|
|
|
|
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': <String>[]}))), 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());
|
|
});
|
|
});
|
|
}
|