`takeKept` takes a `transaction`, so a host's step that fails (a name taken meanwhile) gives the identity back instead of losing it; `isKept` tells whether an identity waits under an attempt for its caller, so a host can check before asking more of the person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
832 lines
26 KiB
Dart
832 lines
26 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
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 '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("is taken in the host's transaction, which gives it back as it "
|
|
'fails', () 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(
|
|
session.db.transaction((transaction) async {
|
|
await idp.takeKept(
|
|
session,
|
|
attempt: attempt,
|
|
transaction: transaction,
|
|
);
|
|
throw StateError("the host's own step failed");
|
|
}),
|
|
throwsStateError,
|
|
);
|
|
final kept = await idp.takeKept(session, attempt: attempt);
|
|
expect(kept.subject, '42');
|
|
});
|
|
|
|
test('is told kept for who began it, without being taken', () async {
|
|
final anna = await newPerson();
|
|
final (:attempt, :code) = await allowed('42', caller: anna);
|
|
expect(
|
|
await idp.isKept(session, attempt: attempt, caller: anna),
|
|
isFalse,
|
|
);
|
|
await idp.keep(
|
|
session,
|
|
attempt: attempt,
|
|
identity: await idp.verify(
|
|
session,
|
|
attempt: attempt,
|
|
code: code,
|
|
caller: anna,
|
|
),
|
|
keepFor: const Duration(minutes: 10),
|
|
);
|
|
|
|
expect(
|
|
await idp.isKept(session, attempt: attempt, caller: anna),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
await idp.isKept(session, attempt: attempt, caller: anna),
|
|
isTrue,
|
|
);
|
|
expect(await idp.isKept(session, attempt: attempt), isFalse);
|
|
expect(
|
|
await idp.isKept(
|
|
session,
|
|
attempt: attempt,
|
|
caller: await newPerson(),
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
await withClock(
|
|
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
|
() => idp.isKept(session, attempt: attempt, caller: anna),
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
(await idp.takeKept(session, attempt: attempt, caller: anna)).subject,
|
|
'42',
|
|
);
|
|
expect(
|
|
await idp.isKept(session, attempt: attempt, caller: anna),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
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("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("sends the client's ID and secret form-encoded, as OAuth 2.0 "
|
|
'has them in Basic authentication (RFC 6749, 2.3.1)', () async {
|
|
useProvider(
|
|
FakeProvider.simpleLogin(
|
|
key: key,
|
|
clientId: 'halkyon:learn',
|
|
clientSecret: 'a secret/+&',
|
|
callbackUrl: Uri.parse('https://learn.example/simplelogin-callback'),
|
|
),
|
|
);
|
|
final start = await idp.begin(session, provider: 'simplelogin');
|
|
|
|
await idp.verify(
|
|
session,
|
|
attempt: start.attempt,
|
|
code: fake.authorize(start.authorizationUrl, subject: 'sl-1'),
|
|
);
|
|
|
|
expect(
|
|
fake.tokenRequests.single.headers['authorization'],
|
|
'Basic ${base64.encode(utf8.encode('halkyon%3Alearn:a+secret%2F%2B%26'))}',
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|