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 = {}; /// What its token endpoint was asked, in order. final tokenRequests = []; /// 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 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 _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.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 claims; }