feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023)

halkyon_oidc_server and its generated client halkyon_oidc_client: any
provider following OpenID Connect's code flow, beside Serverpod's own
providers, with a preset for SimpleLogin (no PKCE, no `typ`). Nothing
of Halkyon's is in it; BSD-3-Clause, `publish_to: none` for now.

- Attempts in `halkyon_oidc_attempt` (ADR 0020's D13): the ID
  PostgreSQL's gen_random_uuid(), a state and a nonce of 32 random
  bytes, PKCE's verifier where used, the caller if signed in; ten
  minutes, taken once. A host may begin the state with a marker.
- The code exchanged by the module itself, so a provider out of reach
  (a timeout, a 5xx) is told apart from a refused code; the ID token
  checked with dart_jsonwebtoken's algorithms (ADR 0020's D12): the
  provider's algorithms alone, the key its `kid` names, of the right
  type; issuer, audience and `azp`, expiry, time issued, `nbf`, a
  minute's leeway; the attempt's nonce; a `sub`.
- Keys kept an hour, fetched again for an unknown `kid` at most once
  a minute; discovery checked against the issuer; every request with
  a timeout.
- Identities in `halkyon_oidc_identity`: the provider's key and the
  `sub`, one per person and provider, deleted with the person.
- The stock endpoint (`OidcIdpBaseEndpoint`), and the parts for a host
  with rules of its own: begin, verify, keep and takeKept, link,
  accountOf, unlink; merging people; a limit per network.
- The forwarding page, moved here from halkyon_base in the next commit.

The package's main library is the code generator's, as halkyon_base's;
a host imports `package:halkyon_oidc_server/oidc.dart`. ADR 0023's
point 5 no longer names OAuth2PkceUtil, which the module doesn't use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 20:28:37 +02:00
commit f549fa59df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,189 @@
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:test/test.dart';
import '../support/test_provider.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);
});
}