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:
parent
620ad270db
commit
54c0b3f7b8
8 changed files with 108 additions and 39 deletions
|
|
@ -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.
|
||||
|
|
|
|||
206
packages/halkyon_oidc_server/lib/src/testing/fake_provider.dart
Normal file
206
packages/halkyon_oidc_server/lib/src/testing/fake_provider.dart
Normal 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;
|
||||
}
|
||||
104
packages/halkyon_oidc_server/lib/src/testing/test_key.dart
Normal file
104
packages/halkyon_oidc_server/lib/src/testing/test_key.dart
Normal 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),
|
||||
]);
|
||||
}
|
||||
8
packages/halkyon_oidc_server/lib/testing.dart
Normal file
8
packages/halkyon_oidc_server/lib/testing.dart
Normal 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';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue