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>
This commit is contained in:
parent
630226b51e
commit
f549fa59df
8 changed files with 15870 additions and 2 deletions
698
packages/halkyon_oidc_server/test/integration/oidc_idp_test.dart
Normal file
698
packages/halkyon_oidc_server/test/integration/oidc_idp_test.dart
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_oidc_server/oidc.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,
|
||||
// the person sent to the provider, the code it sends back ending the attempt
|
||||
// once, with the ID token it gives checked (test/unit/id_token_test.dart has
|
||||
// every way a token is refused), the identity linked to a person or a
|
||||
// session issued. Against a fake provider answering in the test.
|
||||
|
||||
void main() {
|
||||
late TestKey key;
|
||||
|
||||
setUpAll(() => key = TestKey.generate('k', seed: 7));
|
||||
|
||||
withServerpod('OpenID Connect', (sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late FakeProvider fake;
|
||||
late OidcIdp idp;
|
||||
|
||||
void useProvider(FakeProvider provider, {RateLimit? beginsPerNetwork}) {
|
||||
fake = provider;
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: [ServerSideSessionsConfigFromPasswords()],
|
||||
identityProviderBuilders: [
|
||||
OidcIdpConfig(
|
||||
providers: [provider.provider],
|
||||
httpClient: provider.client,
|
||||
beginsPerNetwork: beginsPerNetwork ?? OidcIdpConfig.defaultBegins,
|
||||
),
|
||||
],
|
||||
);
|
||||
idp = AuthServices.instance.oidcIdp;
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
session = sessionBuilder.build();
|
||||
useProvider(FakeProvider(key: key));
|
||||
});
|
||||
|
||||
Future<UuidValue> newPerson() async =>
|
||||
(await AuthServices.instance.authUsers.create(session)).id;
|
||||
|
||||
Matcher refused(OidcRefusal reason) => throwsA(
|
||||
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
|
||||
);
|
||||
|
||||
/// A sign-in begun and allowed by [subject] at the provider: the attempt
|
||||
/// and the code.
|
||||
Future<({UuidValue attempt, String code})> allowed(
|
||||
String subject, {
|
||||
UuidValue? caller,
|
||||
Map<String, Object?> claims = const {},
|
||||
}) async {
|
||||
final start = await idp.begin(session, provider: 'fake', caller: caller);
|
||||
return (
|
||||
attempt: start.attempt,
|
||||
code: fake.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: subject,
|
||||
claims: claims,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
group('beginning', () {
|
||||
test(
|
||||
'keeps an attempt for ten minutes and sends the person to the provider',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 10, 23, 18);
|
||||
final start = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => idp.begin(
|
||||
session,
|
||||
provider: 'fake',
|
||||
statePrefix: 'learn-desktop.',
|
||||
),
|
||||
);
|
||||
final attempt = (await OidcAttempt.db.findById(
|
||||
session,
|
||||
start.attempt,
|
||||
))!;
|
||||
final query = start.authorizationUrl.queryParameters;
|
||||
expect(start.authorizationUrl.path, '/authorize');
|
||||
expect(query['client_id'], 'client-1');
|
||||
expect(query['redirect_uri'], '${fake.callbackUrl}');
|
||||
expect(start.state, startsWith('learn-desktop.'));
|
||||
expect(query['state'], start.state);
|
||||
expect(attempt.state, start.state);
|
||||
expect(query['nonce'], attempt.nonce);
|
||||
expect(query['code_challenge'], pkceChallenge(attempt.codeVerifier!));
|
||||
expect(attempt.expiresAt, now.add(const Duration(minutes: 10)));
|
||||
expect(attempt.callerId, isNull);
|
||||
expect(
|
||||
'${start.authorizationUrl}',
|
||||
isNot(contains(start.attempt.uuid)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('without PKCE where the provider has none', () async {
|
||||
useProvider(FakeProvider(key: key, pkce: false));
|
||||
final start = await idp.begin(session, provider: 'fake');
|
||||
final attempt = (await OidcAttempt.db.findById(
|
||||
session,
|
||||
start.attempt,
|
||||
))!;
|
||||
expect(attempt.codeVerifier, isNull);
|
||||
expect(
|
||||
start.authorizationUrl.queryParameters,
|
||||
isNot(contains('code_challenge')),
|
||||
);
|
||||
});
|
||||
|
||||
test('a provider the host does not name is refused', () async {
|
||||
await expectLater(
|
||||
idp.begin(session, provider: 'other'),
|
||||
refused(OidcRefusal.unknownProvider),
|
||||
);
|
||||
});
|
||||
|
||||
test('too often from one network is refused', () async {
|
||||
useProvider(
|
||||
FakeProvider(key: key),
|
||||
beginsPerNetwork: const RateLimit(
|
||||
maxAttempts: 2,
|
||||
timeframe: Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
final network = randomText();
|
||||
await idp.begin(session, provider: 'fake', network: network);
|
||||
await idp.begin(session, provider: 'fake', network: network);
|
||||
await expectLater(
|
||||
idp.begin(session, provider: 'fake', network: network),
|
||||
refused(OidcRefusal.tooManyAttempts),
|
||||
);
|
||||
await idp.begin(session, provider: 'fake', network: randomText());
|
||||
});
|
||||
|
||||
test('forgets the attempts whose time ran out', () async {
|
||||
final old = await idp.begin(session, provider: 'fake');
|
||||
await withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => idp.begin(session, provider: 'fake'),
|
||||
);
|
||||
expect(await OidcAttempt.db.findById(session, old.attempt), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('verifying', () {
|
||||
test(
|
||||
"proves the provider's ID of the person and what its token says",
|
||||
() async {
|
||||
final (:attempt, :code) = await allowed(
|
||||
'42',
|
||||
claims: {'name': 'Ada'},
|
||||
);
|
||||
final verified = await idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
expect(verified.provider, 'fake');
|
||||
expect(verified.subject, '42');
|
||||
expect(verified.claims['name'], 'Ada');
|
||||
expect(verified.caller, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'asks the token endpoint as the client, with the verifier',
|
||||
() async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
final verifier = (await OidcAttempt.db.findById(
|
||||
session,
|
||||
attempt,
|
||||
))!.codeVerifier;
|
||||
await idp.verify(session, attempt: attempt, code: code);
|
||||
final form = fake.tokenRequests.single.bodyFields;
|
||||
expect(form['grant_type'], 'authorization_code');
|
||||
expect(form['code'], code);
|
||||
expect(form['redirect_uri'], '${fake.callbackUrl}');
|
||||
expect(form['code_verifier'], verifier);
|
||||
expect(form, isNot(contains('client_secret')));
|
||||
},
|
||||
);
|
||||
|
||||
test('ends the attempt: a second time, it is unknown', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await idp.verify(session, attempt: attempt, code: code);
|
||||
expect(await OidcAttempt.db.findById(session, attempt), isNull);
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test('an attempt nobody began is unknown', () async {
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: const Uuid().v4obj(), code: 'c'),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test('an attempt older than ten minutes is unknown', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await expectLater(
|
||||
withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => idp.verify(session, attempt: attempt, code: code),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'by someone other than who began it is refused, and ends it',
|
||||
() async {
|
||||
final anna = await newPerson();
|
||||
final ben = await newPerson();
|
||||
final (:attempt, :code) = await allowed('42', caller: anna);
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code, caller: ben),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code, caller: anna),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('signed out, of an attempt begun signed in, is refused', () async {
|
||||
final anna = await newPerson();
|
||||
final (:attempt, :code) = await allowed('42', caller: anna);
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
});
|
||||
|
||||
test('signed in, of an attempt begun signed out, is refused', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await expectLater(
|
||||
idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
caller: await newPerson(),
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
});
|
||||
|
||||
test('with the caller who began it, names them', () async {
|
||||
final anna = await newPerson();
|
||||
final (:attempt, :code) = await allowed('42', caller: anna);
|
||||
final verified = await idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
caller: anna,
|
||||
);
|
||||
expect(verified.caller, anna);
|
||||
});
|
||||
|
||||
test('a code caught on its way to another device ends nothing', () async {
|
||||
// The catcher's attempt is their own: with PKCE its verifier isn't
|
||||
// the one the code was asked with, and without (SimpleLogin) the
|
||||
// token carries the other attempt's nonce.
|
||||
for (final pkce in [true, false]) {
|
||||
useProvider(FakeProvider(key: key, pkce: pkce));
|
||||
final victims = await idp.begin(session, provider: 'fake');
|
||||
final catchers = await idp.begin(session, provider: 'fake');
|
||||
final caught = fake.authorize(
|
||||
victims.authorizationUrl,
|
||||
subject: '42',
|
||||
);
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: catchers.attempt, code: caught),
|
||||
refused(OidcRefusal.rejected),
|
||||
reason: 'PKCE: $pkce',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a code the provider refuses is rejected', () async {
|
||||
final (:attempt, code: _) = await allowed('42');
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: 'made-up'),
|
||||
refused(OidcRefusal.rejected),
|
||||
);
|
||||
});
|
||||
|
||||
test('a token answer without an ID token is rejected', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
fake.tokenAnswer = (status: 200, body: '{"access_token": "a"}');
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code),
|
||||
refused(OidcRefusal.rejected),
|
||||
);
|
||||
});
|
||||
|
||||
test('a provider failing or out of reach is unavailable', () async {
|
||||
for (final failure in [
|
||||
() => fake.tokenAnswer = (status: 503, body: ''),
|
||||
() => fake.tokenAnswer = (status: 200, body: 'not json'),
|
||||
() => fake.tokenError = const SocketException('down'),
|
||||
]) {
|
||||
fake
|
||||
..tokenAnswer = null
|
||||
..tokenError = null;
|
||||
failure();
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code),
|
||||
refused(OidcRefusal.providerUnavailable),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a provider answering too slowly is unavailable', () async {
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: [ServerSideSessionsConfigFromPasswords()],
|
||||
identityProviderBuilders: [
|
||||
OidcIdpConfig(
|
||||
providers: [fake.provider],
|
||||
httpClient: fake.client,
|
||||
timeout: const Duration(milliseconds: 50),
|
||||
),
|
||||
],
|
||||
);
|
||||
idp = AuthServices.instance.oidcIdp;
|
||||
fake.tokenDelay = const Duration(seconds: 2);
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code),
|
||||
refused(OidcRefusal.providerUnavailable),
|
||||
);
|
||||
});
|
||||
|
||||
test('reads the endpoints from the discovery document, once', () async {
|
||||
useProvider(FakeProvider(key: key, discovered: true));
|
||||
for (final subject in ['1', '2']) {
|
||||
final (:attempt, :code) = await allowed(subject);
|
||||
expect(
|
||||
(await idp.verify(session, attempt: attempt, code: code)).subject,
|
||||
subject,
|
||||
);
|
||||
}
|
||||
expect(fake.discoveries, 1);
|
||||
expect(fake.keyFetches, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'a discovery document of another issuer makes the provider unavailable',
|
||||
() async {
|
||||
useProvider(FakeProvider(key: key, discovered: true));
|
||||
fake.discoveryIssuer = 'https://evil.example';
|
||||
await expectLater(
|
||||
idp.begin(session, provider: 'fake'),
|
||||
refused(OidcRefusal.providerUnavailable),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('keeping a verified attempt', () {
|
||||
test('keeps the identity for the host to take once', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await idp.keep(
|
||||
session,
|
||||
attempt: attempt,
|
||||
identity: await idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
keepFor: const Duration(minutes: 10),
|
||||
);
|
||||
final kept = await idp.takeKept(session, attempt: attempt);
|
||||
expect((kept.provider, kept.subject), ('fake', '42'));
|
||||
await expectLater(
|
||||
idp.takeKept(session, attempt: attempt),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test('can be verified no more', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await idp.keep(
|
||||
session,
|
||||
attempt: attempt,
|
||||
identity: await idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
keepFor: const Duration(minutes: 10),
|
||||
);
|
||||
await expectLater(
|
||||
idp.verify(session, attempt: attempt, code: code),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps it no longer than said', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await idp.keep(
|
||||
session,
|
||||
attempt: attempt,
|
||||
identity: await idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
keepFor: const Duration(minutes: 10),
|
||||
);
|
||||
await expectLater(
|
||||
withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => idp.takeKept(session, attempt: attempt),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test('is taken by who began it alone', () async {
|
||||
final anna = await newPerson();
|
||||
final (:attempt, :code) = await allowed('42', caller: anna);
|
||||
await idp.keep(
|
||||
session,
|
||||
attempt: attempt,
|
||||
identity: await idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
caller: anna,
|
||||
),
|
||||
keepFor: const Duration(minutes: 10),
|
||||
);
|
||||
await expectLater(
|
||||
idp.takeKept(session, attempt: attempt),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
});
|
||||
|
||||
test('an attempt not verified yet is not taken', () async {
|
||||
final (:attempt, code: _) = await allowed('42');
|
||||
await expectLater(
|
||||
idp.takeKept(session, attempt: attempt),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('identities', () {
|
||||
test('linked, name their person', () async {
|
||||
final anna = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'fake', subject: '42'),
|
||||
anna,
|
||||
);
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'fake', subject: '43'),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'other', subject: '42'),
|
||||
isNull,
|
||||
);
|
||||
expect(await idp.hasIdentity(session, authUserId: anna), isTrue);
|
||||
});
|
||||
|
||||
test('linked again to the same person change nothing', () async {
|
||||
final anna = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
expect(await OidcIdentity.db.count(session), 1);
|
||||
});
|
||||
|
||||
test("another person's are refused", () async {
|
||||
final anna = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
await expectLater(
|
||||
idp.link(
|
||||
session,
|
||||
authUserId: await newPerson(),
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
),
|
||||
refused(OidcRefusal.takenByAnotherPerson),
|
||||
);
|
||||
});
|
||||
|
||||
test('a second at the same provider is refused', () async {
|
||||
final anna = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
await expectLater(
|
||||
idp.link(session, authUserId: anna, provider: 'fake', subject: '43'),
|
||||
refused(OidcRefusal.otherIdentityLinked),
|
||||
);
|
||||
});
|
||||
|
||||
test('go when unlinked', () async {
|
||||
final anna = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
await idp.unlink(session, authUserId: anna, provider: 'fake');
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'fake', subject: '42'),
|
||||
isNull,
|
||||
);
|
||||
expect(await idp.hasIdentity(session, authUserId: anna), isFalse);
|
||||
});
|
||||
|
||||
test('go with their person', () async {
|
||||
final anna = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
await AuthServices.instance.authUsers.delete(session, authUserId: anna);
|
||||
expect(await OidcIdentity.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('move to the person kept when two are merged', () async {
|
||||
final kept = await newPerson();
|
||||
final removed = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: removed,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
await session.db.transaction(
|
||||
(transaction) => idp.mergeAuthUsers(
|
||||
session,
|
||||
userToKeepId: kept,
|
||||
userToRemoveId: removed,
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'fake', subject: '42'),
|
||||
kept,
|
||||
);
|
||||
});
|
||||
|
||||
test('at a provider both merged people have stop the merge', () async {
|
||||
final kept = await newPerson();
|
||||
final removed = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: kept,
|
||||
provider: 'fake',
|
||||
subject: '41',
|
||||
);
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: removed,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
await expectLater(
|
||||
session.db.transaction(
|
||||
(transaction) => idp.mergeAuthUsers(
|
||||
session,
|
||||
userToKeepId: kept,
|
||||
userToRemoveId: removed,
|
||||
transaction: transaction,
|
||||
),
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'fake', subject: '42'),
|
||||
removed,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the stock sign-in', () {
|
||||
test('makes a new person for an ID nobody has, with a session', () async {
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
final signedIn = await idp.login(session, attempt: attempt, code: code);
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'fake', subject: '42'),
|
||||
signedIn.authUserId,
|
||||
);
|
||||
expect(signedIn.token, isNotEmpty);
|
||||
});
|
||||
|
||||
test('signs in the person who has the ID', () async {
|
||||
final anna = await newPerson();
|
||||
await idp.link(
|
||||
session,
|
||||
authUserId: anna,
|
||||
provider: 'fake',
|
||||
subject: '42',
|
||||
);
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
final signedIn = await idp.login(session, attempt: attempt, code: code);
|
||||
expect(signedIn.authUserId, anna);
|
||||
expect(await AuthUser.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('refuses an attempt begun signed in', () async {
|
||||
final anna = await newPerson();
|
||||
final (:attempt, :code) = await allowed('42', caller: anna);
|
||||
await expectLater(
|
||||
idp.login(session, attempt: attempt, code: code),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
});
|
||||
|
||||
test('links the ID to who began it signed in', () async {
|
||||
final anna = await newPerson();
|
||||
final (:attempt, :code) = await allowed('42', caller: anna);
|
||||
await idp.linkToCaller(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
caller: anna,
|
||||
);
|
||||
expect(
|
||||
await idp.accountOf(session, provider: 'fake', subject: '42'),
|
||||
anna,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('forgets attempts whose time ran out', () async {
|
||||
await idp.begin(session, provider: 'fake');
|
||||
final (:attempt, :code) = await allowed('42');
|
||||
await idp.keep(
|
||||
session,
|
||||
attempt: attempt,
|
||||
identity: await idp.verify(
|
||||
session,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
keepFor: const Duration(minutes: 30),
|
||||
);
|
||||
final later = DateTime.now().add(const Duration(minutes: 11));
|
||||
expect(
|
||||
await withClock(
|
||||
Clock.fixed(later),
|
||||
() => idp.forgetExpiredAttempts(session),
|
||||
),
|
||||
1,
|
||||
);
|
||||
expect(await OidcAttempt.db.count(session), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
// ignore_for_file: no_leading_underscores_for_local_identifiers
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:io' as _idi;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_test/serverpod_test.dart' as _ist;
|
||||
import 'package:halkyon_oidc_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_oidc_server/src/generated/endpoints.dart';
|
||||
export 'package:serverpod_test/serverpod_test_public_exports.dart';
|
||||
|
||||
/// Creates a new test group that takes a callback that can be used to write tests.
|
||||
/// The callback has two parameters: `sessionBuilder` and `endpoints`.
|
||||
/// `sessionBuilder` is used to build a `Session` object that represents the server state during an endpoint call and is used to set up scenarios.
|
||||
/// `endpoints` contains all your Serverpod endpoints and lets you call them:
|
||||
/// ```dart
|
||||
/// withServerpod('Given Example endpoint', (sessionBuilder, endpoints) {
|
||||
/// test('when calling `hello` then should return greeting', () async {
|
||||
/// final greeting = await endpoints.example.hello(sessionBuilder, 'Michael');
|
||||
/// expect(greeting, 'Hello Michael');
|
||||
/// });
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// **Configuration options**
|
||||
///
|
||||
/// [applyMigrations] Whether pending migrations should be applied when starting Serverpod. Defaults to `true`
|
||||
///
|
||||
/// [enableSessionLogging] Whether session logging should be enabled. Defaults to `false`
|
||||
///
|
||||
/// [ephemeralDatabase] Whether this group gets its own empty database, created when the group starts and dropped when it finishes. Defaults to `true`.
|
||||
/// Set this to `false` to use the database configured for [runMode], including a previously seeded database. [configOverride] can still replace that database. The configured database is not created or dropped, so groups that share it cannot run in parallel. [rollbackDatabase] still controls whether writes inside the group are rolled back.
|
||||
///
|
||||
/// [rollbackDatabase] Options for when to rollback the database during the test lifecycle.
|
||||
/// By default `withServerpod` does all database operations inside a transaction that is rolled back after each `test` case.
|
||||
/// Just like the following enum describes, the behavior of the automatic rollbacks can be configured:
|
||||
/// ```dart
|
||||
/// /// Options for when to rollback the database during the test lifecycle.
|
||||
/// enum RollbackDatabase {
|
||||
/// /// After each test. This is the default.
|
||||
/// afterEach,
|
||||
///
|
||||
/// /// After all tests.
|
||||
/// afterAll,
|
||||
///
|
||||
/// /// Disable rolling back the database.
|
||||
/// disabled,
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [runMode] The run mode that Serverpod should be running in. Defaults to `test`.
|
||||
///
|
||||
/// [serverpodLoggingMode] The logging mode used when creating Serverpod. Defaults to `ServerpodLoggingMode.normal`
|
||||
///
|
||||
/// [serverpodStartTimeout] The timeout to use when starting Serverpod, which connects to the database among other things. Defaults to `Duration(seconds: 120)`.
|
||||
///
|
||||
/// [testServerOutputMode] Options for controlling test server output during test execution. Defaults to `TestServerOutputMode.normal`.
|
||||
/// ```dart
|
||||
/// /// Options for controlling test server output during test execution.
|
||||
/// enum TestServerOutputMode {
|
||||
/// /// Default mode - only stderr is printed (stdout suppressed).
|
||||
/// /// This hides normal startup/shutdown logs while preserving error messages.
|
||||
/// normal,
|
||||
///
|
||||
/// /// All logging - both stdout and stderr are printed.
|
||||
/// /// Useful for debugging when you need to see all server output.
|
||||
/// verbose,
|
||||
///
|
||||
/// /// No logging - both stdout and stderr are suppressed.
|
||||
/// /// Completely silent mode, useful when you don't want any server output.
|
||||
/// silent,
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [configOverride] A function to override the server configuration. This function is called with
|
||||
/// the default server configuration after it is loaded from the config/ directory
|
||||
/// and before it is used to start the server. Use this to override particular
|
||||
/// settings in the server configuration.
|
||||
///
|
||||
/// [databaseInterceptor] Optional interceptor that replaces the default database for each session.
|
||||
/// See [Serverpod.databaseInterceptor] for more information.
|
||||
///
|
||||
/// [testGroupTagsOverride] By default Serverpod test tools tags the `withServerpod` test group with `"integration"`.
|
||||
/// This is to provide a simple way to only run unit or integration tests.
|
||||
/// This property allows this tag to be overridden to something else. Defaults to `['integration']`.
|
||||
///
|
||||
/// [experimentalFeatures] Optionally specify experimental features. See [Serverpod] for more information.
|
||||
///
|
||||
/// [serverDirectory] The server package directory `config/<runMode>.yaml`, `config/passwords.yaml`,
|
||||
/// and `migrations/<module>/...` are resolved against. Defaults to
|
||||
/// [Directory.current] at the time the test boots. Pass this when the test
|
||||
/// isolate's cwd is not the server package root (e.g. running tests from a
|
||||
/// workspace parent directory) so config and migrations are still loaded
|
||||
/// from the right place.
|
||||
@_ist.isTestGroup
|
||||
void withServerpod(
|
||||
String testGroupName,
|
||||
_ist.TestClosure<TestEndpoints> testClosure, {
|
||||
bool? applyMigrations,
|
||||
_is.ServerpodConfig Function(_is.ServerpodConfig)? configOverride,
|
||||
_is.DatabaseInterceptor? databaseInterceptor,
|
||||
bool? enableSessionLogging,
|
||||
bool? ephemeralDatabase,
|
||||
_is.ExperimentalFeatures? experimentalFeatures,
|
||||
_ist.RollbackDatabase? rollbackDatabase,
|
||||
String? runMode,
|
||||
_is.RuntimeParametersListBuilder? runtimeParametersBuilder,
|
||||
_idi.Directory? serverDirectory,
|
||||
_is.ServerpodLoggingMode? serverpodLoggingMode,
|
||||
Duration? serverpodStartTimeout,
|
||||
List<String>? testGroupTagsOverride,
|
||||
_ist.TestServerOutputMode? testServerOutputMode,
|
||||
}) {
|
||||
_ist.buildWithServerpod<_InternalTestEndpoints>(
|
||||
testGroupName,
|
||||
_ist.TestServerpod(
|
||||
testEndpoints: _InternalTestEndpoints(),
|
||||
endpoints: Endpoints(),
|
||||
serializationManager: Protocol(),
|
||||
runMode: runMode,
|
||||
applyMigrations: applyMigrations,
|
||||
ephemeralDatabase: ephemeralDatabase,
|
||||
isDatabaseEnabled: true,
|
||||
serverpodLoggingMode: serverpodLoggingMode,
|
||||
testServerOutputMode: testServerOutputMode,
|
||||
serverDirectory: serverDirectory,
|
||||
experimentalFeatures: experimentalFeatures,
|
||||
configOverride: configOverride,
|
||||
runtimeParametersBuilder: runtimeParametersBuilder,
|
||||
databaseInterceptor: databaseInterceptor,
|
||||
),
|
||||
maybeRollbackDatabase: rollbackDatabase,
|
||||
maybeEnableSessionLogging: enableSessionLogging,
|
||||
maybeTestGroupTagsOverride: testGroupTagsOverride,
|
||||
maybeServerpodStartTimeout: serverpodStartTimeout,
|
||||
maybeTestServerOutputMode: testServerOutputMode,
|
||||
)(testClosure);
|
||||
}
|
||||
|
||||
class TestEndpoints {}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
implements _ist.InternalTestEndpoints {
|
||||
@override
|
||||
void initialize(
|
||||
_is.SerializationManager serializationManager,
|
||||
_is.EndpointDispatch endpoints,
|
||||
) {}
|
||||
}
|
||||
179
packages/halkyon_oidc_server/test/support/fake_provider.dart
Normal file
179
packages/halkyon_oidc_server/test/support/fake_provider.dart
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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;
|
||||
}
|
||||
103
packages/halkyon_oidc_server/test/support/test_provider.dart
Normal file
103
packages/halkyon_oidc_server/test/support/test_provider.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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 for the tests: RSA keys made here (never committed), its keys 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 the 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),
|
||||
]);
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import 'package:halkyon_oidc_server/src/callback_forwarding.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// The forwarding page (ADR 0023, 7), with a host's table of two apps.
|
||||
|
||||
final _returns = {
|
||||
'app-desktop': Uri.parse('http://127.0.0.1:43823/oidc-callback'),
|
||||
'app-phone': Uri.parse('app.example.app://oidc-callback'),
|
||||
};
|
||||
|
||||
OidcCallbackAnswer _forward(
|
||||
OidcCallbackRequest request, {
|
||||
OidcRefusalPage refusalPage = defaultOidcRefusalPage,
|
||||
}) => oidcCallbackForwarding(
|
||||
request,
|
||||
returns: _returns,
|
||||
refusalPage: refusalPage,
|
||||
);
|
||||
|
||||
/// A GET of the page with [parameters] as the query, encoded as a browser
|
||||
/// sends them.
|
||||
OidcCallbackRequest _get(
|
||||
Map<String, String> parameters, {
|
||||
String? acceptLanguage,
|
||||
}) => (
|
||||
method: 'GET',
|
||||
query: Uri(queryParameters: parameters).query,
|
||||
acceptLanguage: acceptLanguage,
|
||||
);
|
||||
|
||||
/// A GET of the page with [query] exactly as written.
|
||||
OidcCallbackRequest _raw(String query) =>
|
||||
(method: 'GET', query: query, acceptLanguage: null);
|
||||
|
||||
void expectRefused(OidcCallbackAnswer answer, {int status = 400}) {
|
||||
expect(answer.status, status);
|
||||
expect(answer.location, isNull);
|
||||
expect(answer.html, contains('noindex'));
|
||||
expect(answer.html, isNot(contains('<script')));
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('a sign-in coming back from the provider', () {
|
||||
test('goes to exactly the address its marker has, with its code and '
|
||||
'state, whichever it is', () {
|
||||
for (final MapEntry(key: marker, value: own) in _returns.entries) {
|
||||
final state = '$marker.Az09_-attempt';
|
||||
final answer = _forward(
|
||||
_get({'code': 'qwertyuiop', 'state': state}),
|
||||
);
|
||||
expect(answer.status, 303, reason: marker);
|
||||
final location = answer.location!;
|
||||
expect(location.scheme, own.scheme, reason: marker);
|
||||
expect(location.host, own.host, reason: marker);
|
||||
expect(location.port, own.port, reason: marker);
|
||||
expect(location.path, own.path, reason: marker);
|
||||
expect(location.userInfo, isEmpty, reason: marker);
|
||||
expect(location.fragment, isEmpty, reason: marker);
|
||||
expect(location.queryParametersAll, {
|
||||
'code': ['qwertyuiop'],
|
||||
'state': [state],
|
||||
}, reason: marker);
|
||||
}
|
||||
});
|
||||
|
||||
test("takes the provider's refusal back as well, its error and state", () {
|
||||
final answer = _forward(
|
||||
_get({'error': 'deny', 'state': 'app-phone.abc'}),
|
||||
);
|
||||
expect(answer.status, 303);
|
||||
expect(
|
||||
answer.location,
|
||||
Uri.parse('app.example.app://oidc-callback').replace(
|
||||
queryParameters: {'error': 'deny', 'state': 'app-phone.abc'},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('takes what the provider writes as it writes it, and leaves out '
|
||||
'what the app needs not know', () {
|
||||
// SimpleLogin writes code, state and scope with Python's quote().
|
||||
final answer = _forward(
|
||||
_raw(
|
||||
'state=app-desktop.r4nd0m-_x&scope=openid%20email&code=abcdefghij',
|
||||
),
|
||||
);
|
||||
expect(answer.status, 303);
|
||||
expect(answer.location!.queryParametersAll, {
|
||||
'code': ['abcdefghij'],
|
||||
'state': ['app-desktop.r4nd0m-_x'],
|
||||
});
|
||||
});
|
||||
|
||||
test('is kept by nobody on the way', () {
|
||||
final answer = _forward(
|
||||
_get({'code': 'abc', 'state': 'app-desktop.abc'}),
|
||||
);
|
||||
expect(answer.headers, {
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
'x-content-type-options': 'nosniff',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('the page refuses, sending nobody anywhere,', () {
|
||||
test('a state naming no app it knows', () {
|
||||
for (final state in [
|
||||
'abc', // no marker
|
||||
'evil-desktop.abc', // a marker of no app
|
||||
'App-desktop.abc', // not as written
|
||||
'app-desktop', // no rest
|
||||
'app-desktop.', // an empty rest
|
||||
'.abc',
|
||||
'app-desktop..abc',
|
||||
]) {
|
||||
expectRefused(
|
||||
_forward(_get({'code': 'abc', 'state': state})),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a state holding anything but base64url, so nothing in it can '
|
||||
'reach the address', () {
|
||||
for (final state in [
|
||||
'app-desktop.x@evil.example',
|
||||
'app-desktop.x/../../evil',
|
||||
'//evil.example/app-desktop.abc',
|
||||
'app-desktop.abc\r\nSet-Cookie: a=b',
|
||||
'app-desktop.abc#fragment',
|
||||
'app-desktop.abc?code=other',
|
||||
'app-desktop.abc&code=other',
|
||||
'app-desktop.a b',
|
||||
'app-desktop.ab.c',
|
||||
'app-desktop.${'a' * 513}',
|
||||
]) {
|
||||
expectRefused(
|
||||
_forward(_get({'code': 'abc', 'state': state})),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing state', () {
|
||||
expectRefused(_forward(_get({'code': 'abc'})));
|
||||
expectRefused(_forward(_raw('')));
|
||||
});
|
||||
|
||||
test('neither a code nor an error, or both', () {
|
||||
expectRefused(_forward(_get({'state': 'app-desktop.abc'})));
|
||||
expectRefused(
|
||||
_forward(
|
||||
_get({
|
||||
'code': 'abc',
|
||||
'error': 'deny',
|
||||
'state': 'app-desktop.abc',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a code or an error that is no such thing', () {
|
||||
for (final code in [
|
||||
'',
|
||||
'a b',
|
||||
'//evil.example',
|
||||
'<script>',
|
||||
'abc\r\nLocation: x',
|
||||
'a' * 513,
|
||||
]) {
|
||||
expectRefused(
|
||||
_forward(
|
||||
_get({'code': code, 'state': 'app-desktop.abc'}),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final error in ['', 'access denied', '"deny"', 'a' * 65]) {
|
||||
expectRefused(
|
||||
_forward(
|
||||
_get({'error': error, 'state': 'app-desktop.abc'}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a code, an error or a state given twice', () {
|
||||
for (final query in [
|
||||
'code=a&code=b&state=app-desktop.abc',
|
||||
'error=deny&error=deny&state=app-desktop.abc',
|
||||
'code=a&state=app-desktop.abc&state=app-phone.abc',
|
||||
]) {
|
||||
expectRefused(_forward(_raw(query)));
|
||||
}
|
||||
});
|
||||
|
||||
test("a query it can't read, or one longer than any sign-in sends", () {
|
||||
expectRefused(
|
||||
_forward(_raw('code=%zz&state=app-desktop.abc')),
|
||||
);
|
||||
expectRefused(
|
||||
_forward(
|
||||
_raw('code=abc&state=app-desktop.abc&x=${'a' * 4096}'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('every method but GET', () {
|
||||
for (final method in ['POST', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']) {
|
||||
final answer = _forward((
|
||||
method: method,
|
||||
query: 'code=abc&state=app-desktop.abc',
|
||||
acceptLanguage: null,
|
||||
));
|
||||
expectRefused(answer, status: 405);
|
||||
expect(answer.headers['allow'], 'GET', reason: method);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeping its page from being kept, indexed or framed, and saying '
|
||||
'nothing of what it was given', () {
|
||||
final answer = _forward(
|
||||
_get({'code': 'zzsecretcodezz', 'state': 'zzsecretstatezz'}),
|
||||
);
|
||||
expectRefused(answer);
|
||||
expect(answer.html, isNot(contains('zzsecret')));
|
||||
expect(answer.headers, {
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy':
|
||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
||||
"frame-ancestors 'none'",
|
||||
});
|
||||
});
|
||||
|
||||
test("with the host's page, given the visitor's languages", () {
|
||||
final asked = <String?>[];
|
||||
final answer = _forward(
|
||||
_get({'code': 'abc'}, acceptLanguage: 'de-DE,de;q=0.9'),
|
||||
refusalPage: (acceptLanguage) {
|
||||
asked.add(acceptLanguage);
|
||||
return '<p>host</p>';
|
||||
},
|
||||
);
|
||||
expect(asked, ['de-DE,de;q=0.9']);
|
||||
expect(answer.html, '<p>host</p>');
|
||||
});
|
||||
});
|
||||
}
|
||||
264
packages/halkyon_oidc_server/test/unit/id_token_test.dart
Normal file
264
packages/halkyon_oidc_server/test/unit/id_token_test.dart
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
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: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
|
||||
// issuer, for this attempt (the nonce), not expired. Each test spoils one
|
||||
// thing of a token that otherwise passes.
|
||||
|
||||
const issuer = 'https://id.example';
|
||||
const clientId = 'client-1';
|
||||
const nonce = 'nonce-1';
|
||||
|
||||
void main() {
|
||||
late TestKey key;
|
||||
late TestKey other;
|
||||
final now = DateTime.utc(2026, 10, 23, 18);
|
||||
|
||||
setUpAll(() {
|
||||
key = TestKey.generate('a', seed: 1);
|
||||
other = TestKey.generate('b', seed: 2);
|
||||
});
|
||||
|
||||
Map<String, Object?> claims([Map<String, Object?> changes = const {}]) => {
|
||||
'iss': issuer,
|
||||
'sub': '42',
|
||||
'aud': clientId,
|
||||
'exp': secondsOf(now.add(const Duration(hours: 1))),
|
||||
'iat': secondsOf(now),
|
||||
'nonce': nonce,
|
||||
'name': 'Ada',
|
||||
...changes,
|
||||
}..removeWhere((_, value) => value == null);
|
||||
|
||||
Future<Map<String, Object?>> check(
|
||||
String token, {
|
||||
Set<String> algorithms = const {'RS256'},
|
||||
Map<String?, JWTKey>? keys,
|
||||
}) => withClock(
|
||||
Clock.fixed(now),
|
||||
() => checkIdToken(
|
||||
token,
|
||||
issuer: issuer,
|
||||
clientId: clientId,
|
||||
algorithms: algorithms,
|
||||
nonce: nonce,
|
||||
keyFor: (kid) async => (keys ?? {'a': RSAPublicKey.raw(key.public)})[kid],
|
||||
),
|
||||
);
|
||||
|
||||
Matcher rejected() => throwsA(isA<IdTokenRejected>());
|
||||
|
||||
group('a token that checks out', () {
|
||||
test(
|
||||
'is accepted without typ in its header, as SimpleLogin signs it',
|
||||
() async {
|
||||
final checked = await check(key.sign(claims()));
|
||||
expect(checked['sub'], '42');
|
||||
expect(checked['name'], 'Ada');
|
||||
},
|
||||
);
|
||||
|
||||
test('is accepted with typ JWT too', () async {
|
||||
final token = key.sign(
|
||||
claims(),
|
||||
header: {'alg': 'RS256', 'kid': 'a', 'typ': 'JWT'},
|
||||
);
|
||||
expect((await check(token))['sub'], '42');
|
||||
});
|
||||
|
||||
test(
|
||||
'is accepted with the client among several audiences, named as azp',
|
||||
() async {
|
||||
final token = key.sign(
|
||||
claims({
|
||||
'aud': [clientId, 'another'],
|
||||
'azp': clientId,
|
||||
}),
|
||||
);
|
||||
expect((await check(token))['sub'], '42');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'is accepted up to a minute after it expired, for the clocks',
|
||||
() async {
|
||||
final token = key.sign(
|
||||
claims({'exp': secondsOf(now.subtract(const Duration(seconds: 30)))}),
|
||||
);
|
||||
expect((await check(token))['sub'], '42');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'without a kid, is checked with the key when the provider has one',
|
||||
() async {
|
||||
final token = key.sign(claims(), header: {'alg': 'RS256'});
|
||||
final checked = await check(
|
||||
token,
|
||||
keys: {null: RSAPublicKey.raw(key.public)},
|
||||
);
|
||||
expect(checked['sub'], '42');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('the signature', () {
|
||||
test('by another key under the same kid is refused', () {
|
||||
final token = other.sign(
|
||||
claims(),
|
||||
header: {'alg': 'RS256', 'kid': 'a'},
|
||||
);
|
||||
expect(check(token), rejected());
|
||||
});
|
||||
|
||||
test('of a kid the provider has no key for is refused', () {
|
||||
expect(check(other.sign(claims())), rejected());
|
||||
});
|
||||
|
||||
test('over a payload changed afterwards is refused', () {
|
||||
final parts = key.sign(claims()).split('.');
|
||||
final changed = base64Url
|
||||
.encode(utf8.encode(jsonEncode(claims({'sub': '43'}))))
|
||||
.replaceAll('=', '');
|
||||
expect(check('${parts[0]}.$changed.${parts[2]}'), rejected());
|
||||
});
|
||||
|
||||
test('by an algorithm the provider does not use is refused', () {
|
||||
expect(check(key.sign(claims(), algorithm: 'RS512')), rejected());
|
||||
});
|
||||
|
||||
test('as an HMAC keyed with the public key is refused', () {
|
||||
// The classic confusion: the public key, which anyone has, as the
|
||||
// secret of an HMAC, which a check trusting the header would accept.
|
||||
final token = signToken(
|
||||
claims(),
|
||||
header: {'alg': 'HS256', 'kid': 'a'},
|
||||
algorithm: JWTAlgorithm.HS256,
|
||||
key: SecretKey(jsonEncode(key.jwk)),
|
||||
);
|
||||
expect(
|
||||
check(token, algorithms: {'RS256', 'HS256'}),
|
||||
rejected(),
|
||||
);
|
||||
});
|
||||
|
||||
test('that is none is refused', () {
|
||||
final parts = key.sign(claims()).split('.');
|
||||
final header = base64Url
|
||||
.encode(utf8.encode(jsonEncode({'alg': 'none', 'kid': 'a'})))
|
||||
.replaceAll('=', '');
|
||||
expect(check('$header.${parts[1]}.'), rejected());
|
||||
expect(check('$header.${parts[1]}'), rejected());
|
||||
});
|
||||
|
||||
test('without a kid is refused where the provider has several keys', () {
|
||||
final token = key.sign(claims(), header: {'alg': 'RS256'});
|
||||
expect(
|
||||
check(
|
||||
token,
|
||||
keys: {
|
||||
'a': RSAPublicKey.raw(key.public),
|
||||
'b': RSAPublicKey.raw(other.public),
|
||||
},
|
||||
),
|
||||
rejected(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the claims', () {
|
||||
test('from another issuer, or none, are refused', () {
|
||||
expect(
|
||||
check(key.sign(claims({'iss': 'https://evil.example'}))),
|
||||
rejected(),
|
||||
);
|
||||
expect(check(key.sign(claims({'iss': '$issuer/'}))), rejected());
|
||||
expect(check(key.sign(claims({'iss': null}))), rejected());
|
||||
});
|
||||
|
||||
test('for another client, or none, are refused', () {
|
||||
expect(check(key.sign(claims({'aud': 'client-2'}))), rejected());
|
||||
expect(check(key.sign(claims({'aud': null}))), rejected());
|
||||
expect(check(key.sign(claims({'aud': <String>[]}))), rejected());
|
||||
});
|
||||
|
||||
test('for several audiences without this client as azp are refused', () {
|
||||
expect(
|
||||
check(
|
||||
key.sign(
|
||||
claims({
|
||||
'aud': [clientId, 'another'],
|
||||
}),
|
||||
),
|
||||
),
|
||||
rejected(),
|
||||
);
|
||||
expect(
|
||||
check(
|
||||
key.sign(
|
||||
claims({
|
||||
'aud': [clientId, 'another'],
|
||||
'azp': 'another',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rejected(),
|
||||
);
|
||||
});
|
||||
|
||||
test('expired more than a minute ago, or never, are refused', () {
|
||||
expect(
|
||||
check(
|
||||
key.sign(
|
||||
claims({
|
||||
'exp': secondsOf(now.subtract(const Duration(minutes: 2))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
rejected(),
|
||||
);
|
||||
expect(check(key.sign(claims({'exp': null}))), rejected());
|
||||
expect(check(key.sign(claims({'exp': 'tomorrow'}))), rejected());
|
||||
});
|
||||
|
||||
test('issued, or valid from, more than a minute ahead are refused', () {
|
||||
final ahead = secondsOf(now.add(const Duration(minutes: 2)));
|
||||
expect(check(key.sign(claims({'iat': ahead}))), rejected());
|
||||
expect(check(key.sign(claims({'iat': null}))), rejected());
|
||||
expect(check(key.sign(claims({'nbf': ahead}))), rejected());
|
||||
});
|
||||
|
||||
test('for another attempt, or none, are refused (the nonce)', () {
|
||||
expect(check(key.sign(claims({'nonce': 'nonce-2'}))), rejected());
|
||||
expect(check(key.sign(claims({'nonce': null}))), rejected());
|
||||
});
|
||||
|
||||
test('naming nobody are refused', () {
|
||||
expect(check(key.sign(claims({'sub': null}))), rejected());
|
||||
expect(check(key.sign(claims({'sub': ''}))), rejected());
|
||||
expect(check(key.sign(claims({'sub': 42}))), rejected());
|
||||
});
|
||||
});
|
||||
|
||||
group('what is no token', () {
|
||||
test('is refused', () {
|
||||
final good = key.sign(claims()).split('.');
|
||||
expect(check(''), rejected());
|
||||
expect(check('${good[0]}.${good[1]}'), rejected());
|
||||
expect(check('x.${good[1]}.${good[2]}'), rejected());
|
||||
final notAnObject = base64Url
|
||||
.encode(utf8.encode('[1]'))
|
||||
.replaceAll('=', '');
|
||||
expect(check('$notAnObject.${good[1]}.${good[2]}'), rejected());
|
||||
expect(check('${good[0]}.$notAnObject.${good[2]}'), rejected());
|
||||
});
|
||||
});
|
||||
}
|
||||
189
packages/halkyon_oidc_server/test/unit/provider_keys_test.dart
Normal file
189
packages/halkyon_oidc_server/test/unit/provider_keys_test.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import 'dart:convert';
|
||||
|
||||
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: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
|
||||
// up `kid`s can't have it asked again and again. A fetch that fails keeps
|
||||
// the keys it had.
|
||||
|
||||
void main() {
|
||||
late TestKey a;
|
||||
late TestKey b;
|
||||
|
||||
setUpAll(() {
|
||||
a = TestKey.generate('a', seed: 1);
|
||||
b = TestKey.generate('b', seed: 2);
|
||||
});
|
||||
|
||||
final start = DateTime.utc(2026, 10, 23, 18);
|
||||
|
||||
late DateTime now;
|
||||
late List<String> documents;
|
||||
late int fetches;
|
||||
late ProviderKeys keys;
|
||||
|
||||
setUp(() {
|
||||
now = start;
|
||||
fetches = 0;
|
||||
documents = [];
|
||||
keys = ProviderKeys(() async {
|
||||
fetches++;
|
||||
if (documents.isEmpty) {
|
||||
throw ProviderUnavailable('down');
|
||||
}
|
||||
return documents.length == 1 ? documents.single : documents.removeAt(0);
|
||||
});
|
||||
});
|
||||
|
||||
Future<JWTKey?> keyFor(String? kid) =>
|
||||
withClock(Clock(() => now), () => keys.keyFor(kid));
|
||||
|
||||
test('are fetched once, and kept an hour', () async {
|
||||
documents = [
|
||||
jwksOf([a]),
|
||||
];
|
||||
expect(await keyFor('a'), isA<RSAPublicKey>());
|
||||
now = now.add(const Duration(minutes: 59));
|
||||
expect(await keyFor('a'), isA<RSAPublicKey>());
|
||||
expect(fetches, 1);
|
||||
now = now.add(const Duration(minutes: 2));
|
||||
expect(await keyFor('a'), isA<RSAPublicKey>());
|
||||
expect(fetches, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
"are fetched again for a kid they don't know, which a new key may be",
|
||||
() async {
|
||||
documents = [
|
||||
jwksOf([a]),
|
||||
jwksOf([a, b]),
|
||||
];
|
||||
expect(await keyFor('a'), isNotNull);
|
||||
now = now.add(const Duration(minutes: 2));
|
||||
expect(await keyFor('b'), isA<RSAPublicKey>());
|
||||
expect(fetches, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test('are fetched again for an unknown kid at most once a minute', () async {
|
||||
documents = [
|
||||
jwksOf([a]),
|
||||
];
|
||||
expect(await keyFor('a'), isNotNull);
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
expect(await keyFor('x'), isNull);
|
||||
expect(fetches, 2);
|
||||
now = now.add(const Duration(seconds: 30));
|
||||
expect(await keyFor('y'), isNull);
|
||||
expect(await keyFor('z'), isNull);
|
||||
expect(fetches, 2);
|
||||
now = now.add(const Duration(seconds: 31));
|
||||
expect(await keyFor('x'), isNull);
|
||||
expect(fetches, 3);
|
||||
});
|
||||
|
||||
test('fetched for a token without a kid give the one key there is', () async {
|
||||
documents = [
|
||||
jwksOf([a]),
|
||||
];
|
||||
expect(await keyFor(null), isA<RSAPublicKey>());
|
||||
});
|
||||
|
||||
test(
|
||||
'fetched for a token without a kid give none where there are several',
|
||||
() async {
|
||||
documents = [
|
||||
jwksOf([a, b]),
|
||||
];
|
||||
expect(await keyFor(null), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('leave out what no ID token is checked with', () async {
|
||||
documents = [
|
||||
jsonEncode({
|
||||
'keys': [
|
||||
{...a.jwk, 'use': 'enc'},
|
||||
{'kty': 'oct', 'kid': 'secret', 'k': 'c2VjcmV0'},
|
||||
{...b.jwk, 'd': 'AQAB'},
|
||||
{'kty': 'RSA', 'kid': 'broken'},
|
||||
'not a key',
|
||||
{...b.jwk, 'kid': 'c', 'use': 'sig'},
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(await keyFor('a'), isNull);
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
expect(await keyFor('secret'), isNull);
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
expect(await keyFor('b'), isNull);
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
expect(await keyFor('broken'), isNull);
|
||||
expect(await keyFor('c'), isA<RSAPublicKey>());
|
||||
});
|
||||
|
||||
test('that never came make the provider unavailable', () {
|
||||
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
|
||||
});
|
||||
|
||||
test('that are no JWKS make the provider unavailable', () {
|
||||
for (final document in ['', '{}', '{"keys": 1}', '[]', '{"keys": []}']) {
|
||||
documents = [document];
|
||||
keys = ProviderKeys(() async => documents.single);
|
||||
expect(
|
||||
() => keyFor('a'),
|
||||
throwsA(isA<ProviderUnavailable>()),
|
||||
reason: document,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'stay as they were where fetching them again fails, tried once a minute',
|
||||
() async {
|
||||
documents = [
|
||||
jwksOf([a]),
|
||||
];
|
||||
expect(await keyFor('a'), isNotNull);
|
||||
documents = [];
|
||||
now = now.add(const Duration(hours: 2));
|
||||
expect(await keyFor('a'), isA<RSAPublicKey>());
|
||||
expect(await keyFor('a'), isA<RSAPublicKey>());
|
||||
expect(fetches, 2);
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
expect(await keyFor('a'), isA<RSAPublicKey>());
|
||||
expect(fetches, 3);
|
||||
},
|
||||
);
|
||||
|
||||
test('that never came are tried again a minute later', () async {
|
||||
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
|
||||
await pumpEventQueue();
|
||||
expect(() => keyFor('a'), throwsA(isA<ProviderUnavailable>()));
|
||||
await pumpEventQueue();
|
||||
expect(fetches, 1);
|
||||
documents = [
|
||||
jwksOf([a]),
|
||||
];
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
expect(await keyFor('a'), isA<RSAPublicKey>());
|
||||
expect(fetches, 2);
|
||||
});
|
||||
|
||||
test('are fetched once for lookups at the same time', () async {
|
||||
documents = [
|
||||
jwksOf([a]),
|
||||
];
|
||||
final found = await Future.wait([keyFor('a'), keyFor('a'), keyFor('a')]);
|
||||
expect(found, everyElement(isNotNull));
|
||||
expect(fetches, 1);
|
||||
});
|
||||
}
|
||||
202
packages/halkyon_oidc_server/test/unit/provider_test.dart
Normal file
202
packages/halkyon_oidc_server/test/unit/provider_test.dart
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_oidc_server/src/oidc_provider.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// A provider as the host names it (ADR 0023, 2): where its endpoints are,
|
||||
// read from its discovery document unless given, and the address a person is
|
||||
// sent to, which carries everything the provider needs and nothing else.
|
||||
|
||||
void main() {
|
||||
final callback = Uri.parse('https://app.example/oidc-callback');
|
||||
|
||||
OidcProvider provider({bool pkce = true}) => OidcProvider(
|
||||
key: 'test',
|
||||
issuer: 'https://id.example',
|
||||
clientId: 'client-1',
|
||||
clientSecret: 'secret-1',
|
||||
callbackUrl: callback,
|
||||
scopes: const ['openid', 'profile'],
|
||||
pkce: pkce,
|
||||
);
|
||||
|
||||
final endpoints = OidcEndpoints(
|
||||
authorization: Uri.parse('https://id.example/authorize?tenant=t'),
|
||||
token: Uri.parse('https://id.example/token'),
|
||||
jwks: Uri.parse('https://id.example/jwks'),
|
||||
);
|
||||
|
||||
group('the authorization address', () {
|
||||
test('asks for a code for the client, to the callback, with the scopes, state and nonce', () {
|
||||
final url = authorizationUrl(
|
||||
provider(pkce: false),
|
||||
endpoints,
|
||||
state: 'learn-desktop.s',
|
||||
nonce: 'n',
|
||||
);
|
||||
expect(url.host, 'id.example');
|
||||
expect(url.path, '/authorize');
|
||||
expect(url.queryParameters, {
|
||||
'tenant': 't',
|
||||
'response_type': 'code',
|
||||
'client_id': 'client-1',
|
||||
'redirect_uri': '$callback',
|
||||
'scope': 'openid profile',
|
||||
'state': 'learn-desktop.s',
|
||||
'nonce': 'n',
|
||||
});
|
||||
});
|
||||
|
||||
test("with PKCE, carries the verifier's S256 challenge", () {
|
||||
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
final url = authorizationUrl(
|
||||
provider(),
|
||||
endpoints,
|
||||
state: 's',
|
||||
nonce: 'n',
|
||||
codeVerifier: verifier,
|
||||
);
|
||||
// RFC 7636, appendix B.
|
||||
expect(
|
||||
url.queryParameters['code_challenge'],
|
||||
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
);
|
||||
expect(url.queryParameters['code_challenge_method'], 'S256');
|
||||
expect(
|
||||
pkceChallenge(verifier),
|
||||
base64Url
|
||||
.encode(sha256.convert(ascii.encode(verifier)).bytes)
|
||||
.replaceAll('=', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('never carries the secret', () {
|
||||
final url = authorizationUrl(
|
||||
provider(),
|
||||
endpoints,
|
||||
state: 's',
|
||||
nonce: 'n',
|
||||
codeVerifier: newRandomValue(),
|
||||
);
|
||||
expect('$url', isNot(contains('secret-1')));
|
||||
});
|
||||
});
|
||||
|
||||
group('random values', () {
|
||||
test('are 32 bytes of base64url, each new', () {
|
||||
final values = {for (var i = 0; i < 100; i++) newRandomValue()};
|
||||
expect(values, hasLength(100));
|
||||
for (final value in values) {
|
||||
expect(value, matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('the discovery document', () {
|
||||
String document([Map<String, Object?> changes = const {}]) => jsonEncode({
|
||||
'issuer': 'https://id.example',
|
||||
'authorization_endpoint': 'https://id.example/authorize',
|
||||
'token_endpoint': 'https://id.example/token',
|
||||
'jwks_uri': 'https://id.example/jwks',
|
||||
...changes,
|
||||
});
|
||||
|
||||
test("gives the provider's endpoints", () {
|
||||
final read = OidcEndpoints.fromDiscovery(
|
||||
document(),
|
||||
issuer: 'https://id.example',
|
||||
);
|
||||
expect(read.authorization, Uri.parse('https://id.example/authorize'));
|
||||
expect(read.token, Uri.parse('https://id.example/token'));
|
||||
expect(read.jwks, Uri.parse('https://id.example/jwks'));
|
||||
});
|
||||
|
||||
test('naming another issuer is refused', () {
|
||||
expect(
|
||||
() => OidcEndpoints.fromDiscovery(
|
||||
document({'issuer': 'https://evil.example'}),
|
||||
issuer: 'https://id.example',
|
||||
),
|
||||
throwsA(isA<ProviderUnavailable>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('without an endpoint, or with one that is no address, is refused', () {
|
||||
for (final name in [
|
||||
'authorization_endpoint',
|
||||
'token_endpoint',
|
||||
'jwks_uri',
|
||||
]) {
|
||||
expect(
|
||||
() => OidcEndpoints.fromDiscovery(
|
||||
document({name: null}),
|
||||
issuer: 'https://id.example',
|
||||
),
|
||||
throwsA(isA<ProviderUnavailable>()),
|
||||
reason: name,
|
||||
);
|
||||
expect(
|
||||
() => OidcEndpoints.fromDiscovery(
|
||||
document({name: 42}),
|
||||
issuer: 'https://id.example',
|
||||
),
|
||||
throwsA(isA<ProviderUnavailable>()),
|
||||
reason: name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('that is no JSON object is refused', () {
|
||||
for (final body in ['', '[]', 'nonsense']) {
|
||||
expect(
|
||||
() => OidcEndpoints.fromDiscovery(body, issuer: 'https://id.example'),
|
||||
throwsA(isA<ProviderUnavailable>()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("is where OpenID Connect's discovery puts it", () {
|
||||
expect(
|
||||
discoveryUrl('https://id.example'),
|
||||
Uri.parse('https://id.example/.well-known/openid-configuration'),
|
||||
);
|
||||
expect(
|
||||
discoveryUrl('https://id.example/realms/r/'),
|
||||
Uri.parse(
|
||||
'https://id.example/realms/r/.well-known/openid-configuration',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group("SimpleLogin's preset", () {
|
||||
test(
|
||||
"names SimpleLogin's issuer and endpoints, without PKCE, RS256 alone",
|
||||
() {
|
||||
final simpleLogin = OidcProvider.simpleLogin(
|
||||
clientId: 'c',
|
||||
clientSecret: 's',
|
||||
callbackUrl: callback,
|
||||
);
|
||||
expect(simpleLogin.key, 'simplelogin');
|
||||
expect(simpleLogin.issuer, 'https://app.simplelogin.io');
|
||||
expect(simpleLogin.pkce, isFalse);
|
||||
expect(simpleLogin.algorithms, {'RS256'});
|
||||
expect(simpleLogin.scopes, ['openid']);
|
||||
expect(
|
||||
simpleLogin.endpoints?.authorization,
|
||||
Uri.parse('https://app.simplelogin.io/oauth2/authorize'),
|
||||
);
|
||||
expect(
|
||||
simpleLogin.endpoints?.token,
|
||||
Uri.parse('https://app.simplelogin.io/oauth2/token'),
|
||||
);
|
||||
expect(
|
||||
simpleLogin.endpoints?.jwks,
|
||||
Uri.parse('https://app.simplelogin.io/jwks'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue