halkyon/packages/halkyon_oidc_server/test/support/fake_provider.dart

179 lines
5.3 KiB
Dart
Raw Normal View History

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>
2026-10-23 20:28:37 +02:00
import 'dart:async';
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'test_provider.dart';
/// An OpenID Connect provider answering the module's requests in the test:
/// 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,
});
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;
final callbackUrl = Uri.parse('https://app.example/oidc-callback');
/// 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 the host names it.
OidcProvider get provider => 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/token'),
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 == '/token') {
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;
}