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>
179 lines
5.3 KiB
Dart
179 lines
5.3 KiB
Dart
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;
|
|
}
|