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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 20:33:13 +02:00
commit 54c0b3f7b8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 108 additions and 39 deletions

View file

@ -160,4 +160,10 @@ attempt the app holds. `oidcCallbackForwarding` is the page itself, for tests.
`config/passwords.yaml`. The providers in the tests are fakes that keep to the
specification, keys generated per run; the ID token's checks are tested one by one.
A host tests its own sign-in the same way, with `package:halkyon_oidc_server/testing.dart`:
`TestKey.generate` makes a provider's RSA key, and `FakeProvider` (or
`FakeProvider.simpleLogin`, SimpleLogin's endpoints without PKCE) answers the module
through its `client`, passed as `OidcIdpConfig`'s `httpClient`, and gives the code a
person's consent would (`authorize`), with the claims the test names.
Under the BSD 3-Clause licence, Serverpod's own.

View file

@ -0,0 +1,206 @@
import 'dart:async';
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:halkyon_oidc_server/src/oidc_provider.dart';
import 'package:halkyon_oidc_server/src/testing/test_key.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// An OpenID Connect provider answering the module's requests in a test,
/// through [client]: its discovery document, its keys, and its token
/// endpoint, which gives an ID token for a code it issued ([authorize]),
/// signed with [key], for its client alone.
final class FakeProvider {
FakeProvider({
required this.key,
this.issuer = 'https://id.example',
this.clientId = 'client-1',
this.clientSecret = 'secret-1',
this.pkce = true,
this.discovered = false,
Uri? callbackUrl,
}) : callbackUrl =
callbackUrl ?? Uri.parse('https://app.example/oidc-callback'),
_simpleLogin = false;
/// SimpleLogin as `OidcProvider.simpleLogin` knows it: its issuer, its
/// endpoints, no PKCE.
FakeProvider.simpleLogin({
required this.key,
required this.clientId,
required this.clientSecret,
required this.callbackUrl,
}) : issuer = 'https://app.simplelogin.io',
pkce = false,
discovered = false,
_simpleLogin = true;
final TestKey key;
final String issuer;
final String clientId;
final String clientSecret;
final bool pkce;
/// Whether the host reads its endpoints from its discovery document.
final bool discovered;
/// The callback the host registered, which the token endpoint holds the
/// exchange to.
final Uri callbackUrl;
final bool _simpleLogin;
String get _tokenPath => _simpleLogin ? '/oauth2/token' : '/token';
/// The codes it issued, each taken once: what its ID token says.
final _codes = <String, _Code>{};
/// What its token endpoint was asked, in order.
final tokenRequests = <http.Request>[];
/// How often its discovery document and its keys were fetched.
int discoveries = 0;
int keyFetches = 0;
/// What its token endpoint answers instead of a token, for tests of its
/// failures: a status (and body), or an error thrown.
({int status, String body})? tokenAnswer;
Exception? tokenError;
/// How long its token endpoint takes to answer.
Duration tokenDelay = Duration.zero;
/// What its discovery document says of its issuer, for a test of one
/// naming another.
String? discoveryIssuer;
/// The provider as a host names it: under the key `fake`, or SimpleLogin's
/// preset under its own.
OidcProvider get provider => _simpleLogin
? OidcProvider.simpleLogin(
clientId: clientId,
clientSecret: clientSecret,
callbackUrl: callbackUrl,
)
: OidcProvider(
key: 'fake',
issuer: issuer,
clientId: clientId,
clientSecret: clientSecret,
callbackUrl: callbackUrl,
pkce: pkce,
scopes: const ['openid', 'profile'],
endpoints: discovered
? null
: OidcEndpoints(
authorization: Uri.parse('$issuer/authorize'),
token: Uri.parse('$issuer$_tokenPath'),
jwks: Uri.parse('$issuer/jwks'),
),
);
/// The person [subject] allowing the sign-in [authorizationUrl] asks for: the
/// code the provider sends back, whose ID token carries the URL's nonce and
/// [claims]; [nonce] in its place, for a test of a code of another attempt.
String authorize(
Uri authorizationUrl, {
required String subject,
Map<String, Object?> claims = const {},
String? nonce,
}) {
final query = authorizationUrl.queryParameters;
final code = randomText(24);
_codes[code] = _Code(
subject: subject,
nonce: nonce ?? query['nonce']!,
challenge: query['code_challenge'],
claims: claims,
);
return code;
}
/// The HTTP client the module asks the provider through.
http.Client get client => MockClient(_answer);
Future<http.Response> _answer(http.Request request) async {
final path = request.url.path;
if (request.method == 'GET' &&
path == '/.well-known/openid-configuration') {
discoveries++;
return http.Response(
jsonEncode({
'issuer': discoveryIssuer ?? issuer,
'authorization_endpoint': '$issuer/authorize',
'token_endpoint': '$issuer/token',
'jwks_uri': '$issuer/jwks',
}),
200,
);
}
if (request.method == 'GET' && path == '/jwks') {
keyFetches++;
return http.Response(jwksOf([key]), 200);
}
if (request.method == 'POST' && path == _tokenPath) {
tokenRequests.add(request);
await Future<void>.delayed(tokenDelay);
if (tokenError case final error?) {
throw error;
}
if (tokenAnswer case (:final status, :final body)) {
return http.Response(body, status);
}
return _token(request);
}
return http.Response('', 404);
}
http.Response _token(http.Request request) {
final basic =
'Basic ${base64.encode(utf8.encode('$clientId:$clientSecret'))}';
final form = request.bodyFields;
final code = _codes.remove(form['code']);
if (request.headers['authorization'] != basic ||
form['grant_type'] != 'authorization_code' ||
form['redirect_uri'] != '$callbackUrl' ||
code == null ||
(code.challenge != null &&
pkceChallenge(form['code_verifier'] ?? '') != code.challenge)) {
return http.Response(jsonEncode({'error': 'invalid_grant'}), 400);
}
final now = clock.now();
return http.Response(
jsonEncode({
'access_token': randomText(),
'token_type': 'Bearer',
'expires_in': 3600,
'id_token': key.sign({
'iss': issuer,
'sub': code.subject,
'aud': clientId,
'exp': secondsOf(now.add(const Duration(hours: 1))),
'iat': secondsOf(now),
'nonce': code.nonce,
...code.claims,
}),
}),
200,
headers: {'content-type': 'application/json'},
);
}
}
final class _Code {
_Code({
required this.subject,
required this.nonce,
required this.challenge,
required this.claims,
});
final String subject;
final String nonce;
final String? challenge;
final Map<String, Object?> claims;
}

View file

@ -0,0 +1,104 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:pointycastle/export.dart' as pc;
// A provider's keys for tests: RSA keys made in the test (never committed),
// published as a JWKS, and ID tokens signed as a provider signs them, with
// headers written by hand, since dart_jsonwebtoken always adds a `typ`
// SimpleLogin never sends.
/// An RSA key pair of a test provider's, under [kid].
final class TestKey {
TestKey._(this.kid, this._private, this.public);
/// A new 2048-bit key, from [seed] so runs stay alike.
factory TestKey.generate(String kid, {required int seed}) {
final random = pc.FortunaRandom()
..seed(
pc.KeyParameter(
Uint8List.fromList(List.generate(32, (i) => (seed * 31 + i) & 0xff)),
),
);
final generator = pc.RSAKeyGenerator()
..init(
pc.ParametersWithRandom(
pc.RSAKeyGeneratorParameters(BigInt.from(65537), 2048, 64),
random,
),
);
final pair = generator.generateKeyPair();
return TestKey._(kid, pair.privateKey, pair.publicKey);
}
final String kid;
final pc.RSAPrivateKey _private;
final pc.RSAPublicKey public;
/// The public key as a JWKS entry, as a provider publishes it.
Map<String, Object?> get jwk => {
'kty': 'RSA',
'kid': kid,
'use': 'sig',
'alg': 'RS256',
'n': _base64Url(_bytesOf(public.modulus!)),
'e': _base64Url(_bytesOf(public.publicExponent!)),
};
/// [claims] signed with [algorithm] (RS256 unless said), the header
/// [header] or `alg` and this key's `kid` alone.
String sign(
Map<String, Object?> claims, {
String algorithm = 'RS256',
Map<String, Object?>? header,
}) => signToken(
claims,
header: header ?? {'alg': algorithm, 'kid': kid},
algorithm: JWTAlgorithm.fromName(algorithm),
key: RSAPrivateKey.raw(_private),
);
}
/// A JWS of [claims] under [header], signed by [algorithm] with [key].
String signToken(
Map<String, Object?> claims, {
required Map<String, Object?> header,
required JWTAlgorithm algorithm,
required JWTKey key,
}) {
final body =
'${_base64Url(utf8.encode(jsonEncode(header)))}.'
'${_base64Url(utf8.encode(jsonEncode(claims)))}';
final signature = algorithm.sign(key, Uint8List.fromList(utf8.encode(body)));
return '$body.${_base64Url(signature)}';
}
/// [keys] as the provider's JWKS document.
String jwksOf(Iterable<TestKey> keys) => jsonEncode({
'keys': [for (final key in keys) key.jwk],
});
/// Seconds since the epoch of [time], as tokens count.
int secondsOf(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
String _base64Url(List<int> bytes) =>
base64Url.encode(bytes).replaceAll('=', '');
Uint8List _bytesOf(BigInt number) {
final hex = number.toRadixString(16);
final even = hex.length.isOdd ? '0$hex' : hex;
return Uint8List.fromList([
for (var i = 0; i < even.length; i += 2)
int.parse(even.substring(i, i + 2), radix: 16),
]);
}
/// Random letters, for values tests need to differ.
String randomText([int length = 16]) {
final random = Random();
return String.fromCharCodes([
for (var i = 0; i < length; i++) 97 + random.nextInt(26),
]);
}

View file

@ -0,0 +1,8 @@
/// A provider for tests, the module's and a host's own: RSA keys made in the
/// test, and a fake provider answering the module's requests as an OpenID
/// Connect provider would, SimpleLogin's way where asked. Never for a
/// server's own code.
library;
export 'src/testing/fake_provider.dart';
export 'src/testing/test_key.dart';

View file

@ -24,11 +24,12 @@ dependencies:
# The providers' discovery documents, keys and token endpoints.
http: ^1.6.0
meta: ^1.16.0
# Makes the RSA keys the fake provider signs its tokens with (`testing.dart`,
# for hosts' tests); dart_jsonwebtoken signs and checks with it too.
pointycastle: ^4.0.0
serverpod: 4.0.4
serverpod_auth_idp_server: 4.0.4
dev_dependencies:
# Makes the RSA keys the tests sign their providers' tokens with.
pointycastle: ^4.0.0
serverpod_test: 4.0.4
test: ^1.25.5

View file

@ -3,12 +3,11 @@ import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:halkyon_oidc_server/testing.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fake_provider.dart';
import '../support/test_provider.dart';
import 'test_tools/serverpod_test_tools.dart';
// Signing in with an OpenID Connect provider (ADR 0023): an attempt begun,
@ -671,6 +670,35 @@ void main() {
});
});
test("SimpleLogin's preset signs in as SimpleLogin has it: its "
'endpoints, no PKCE, no discovery', () async {
useProvider(
FakeProvider.simpleLogin(
key: key,
clientId: 'halkyon',
clientSecret: 'secret-2',
callbackUrl: Uri.parse('https://learn.example/simplelogin-callback'),
),
);
final start = await idp.begin(session, provider: 'simplelogin');
final url = start.authorizationUrl;
expect(
'${url.origin}${url.path}',
'https://app.simplelogin.io/oauth2/authorize',
);
expect(url.queryParameters, isNot(contains('code_challenge')));
final identity = await idp.verify(
session,
attempt: start.attempt,
code: fake.authorize(url, subject: 'sl-1'),
);
expect((identity.provider, identity.subject), ('simplelogin', 'sl-1'));
expect(fake.tokenRequests.single.url.path, '/oauth2/token');
expect(fake.discoveries, 0);
});
test('forgets attempts whose time ran out', () async {
await idp.begin(session, provider: 'fake');
final (:attempt, :code) = await allowed('42');

View file

@ -3,10 +3,9 @@ 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';
import '../support/test_provider.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

View file

@ -4,10 +4,9 @@ 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';
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