simpleLogin.begin takes the way back (SimpleLoginReturn: loopback, scheme) rather than the platform, so macOS can come back through its scheme as iOS and Android do; the markers become learn-loopback, learn-scheme, todo-loopback and todo-scheme. Not breaking: the endpoint has never been released. D9 corrected by the developer after research: Android, iOS and macOS come back through the app's scheme, Linux and Windows through the loopback. Verified links protected nothing here: a code caught on its way is worthless without the attempt's ID, and a hostile app beginning an attempt of its own can ask for a way back that other platforms need open. The ADR notes the residual risk and the check of prompt=consent still to do. assetlinks.json stays served. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
1417 lines
46 KiB
Dart
1417 lines
46 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:halkyon_base_server/base.dart';
|
|
import 'package:halkyon_oidc_server/oidc.dart';
|
|
import 'package:halkyon_oidc_server/testing.dart';
|
|
import 'package:serverpod/serverpod.dart';
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import '../support/fixtures.dart';
|
|
import '../support/keys_device.dart';
|
|
import '../support/mailbox.dart';
|
|
import '../support/password_device.dart';
|
|
import '../support/password_support.dart';
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
// Signing in with SimpleLogin on the instance's register (ADR 0020, 3 to 5;
|
|
// steps 3 and 4 make accounts for newcomers): an attempt begun, signed out
|
|
// or in, SimpleLogin's code ending it once, a session for the account with
|
|
// that identity, the identity added to the caller's account, or someone new
|
|
// whose identity waits half an hour with a handle suggested from their name.
|
|
// Against a fake SimpleLogin signing real ID tokens (halkyon_oidc's
|
|
// testing.dart); every way an ID token is refused is the module's test.
|
|
|
|
void main() {
|
|
late TestKey key;
|
|
|
|
setUpAll(() => key = TestKey.generate('sl', seed: 11));
|
|
|
|
withServerpod(
|
|
'SimpleLogin on the register',
|
|
// Sessions issued, and the rate limiter's attempts in transactions of
|
|
// their own: these tests delete what they make instead.
|
|
rollbackDatabase: RollbackDatabase.disabled,
|
|
(sessionBuilder, endpoints) {
|
|
late Session session;
|
|
late Serverpod pod;
|
|
late FakeProvider simpleLogin;
|
|
|
|
/// Anonymous, signed in on her phone.
|
|
late UuidValue anna;
|
|
late UuidValue annasPhone;
|
|
|
|
/// Identified, signing in with SimpleLogin's ID `sl-ben`.
|
|
late UuidValue ben;
|
|
|
|
setUp(() async {
|
|
useBase(sessionBuilder, app: 'learn');
|
|
session = sessionBuilder.build();
|
|
pod = session.serverpod;
|
|
await _deleteAll(session);
|
|
simpleLogin = FakeProvider.simpleLogin(
|
|
key: key,
|
|
clientId: 'halkyon-learn',
|
|
clientSecret: 'a secret of the app',
|
|
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
|
|
);
|
|
useSimpleLogin(sessionBuilder, simpleLogin);
|
|
anna = await newAnonymousPerson(session);
|
|
annasPhone = await signInNewDevice(session, anna);
|
|
ben = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
await OidcIdentity.db.insertRow(
|
|
session,
|
|
OidcIdentity(
|
|
authUserId: ben,
|
|
provider: simpleLoginProvider,
|
|
subject: 'sl-ben',
|
|
),
|
|
);
|
|
});
|
|
tearDown(() async {
|
|
useSimpleLoginApp(pod, null);
|
|
await _deleteAll(sessionBuilder.build());
|
|
});
|
|
|
|
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
anna.uuid,
|
|
{},
|
|
authId: annasPhone.uuid,
|
|
),
|
|
);
|
|
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
);
|
|
|
|
Matcher refused(OidcRefusal reason) => throwsA(
|
|
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
|
|
);
|
|
|
|
/// A sign-in begun as [as] and allowed at SimpleLogin by [subject],
|
|
/// who shares [name]: the attempt and SimpleLogin's code.
|
|
Future<({UuidValue attempt, String code})> allowed(
|
|
TestSessionBuilder as, {
|
|
required String subject,
|
|
String? name,
|
|
}) async {
|
|
final start = await endpoints.simpleLogin.begin(
|
|
as,
|
|
back: SimpleLoginReturn.loopback,
|
|
);
|
|
return (
|
|
attempt: start.attempt,
|
|
code: simpleLogin.authorize(
|
|
start.authorizationUrl,
|
|
subject: subject,
|
|
claims: {'name': ?name},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<List<OidcIdentity>> identitiesOf(UuidValue person) =>
|
|
OidcIdentity.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(person),
|
|
);
|
|
|
|
group('is offered', () {
|
|
test('where the register has its SimpleLogin app', () async {
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('not without one, beginning nothing', () async {
|
|
useSimpleLoginApp(pod, null);
|
|
useSimpleLogin(sessionBuilder, null);
|
|
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
|
isFalse,
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.loopback,
|
|
),
|
|
refused(OidcRefusal.unknownProvider),
|
|
);
|
|
});
|
|
|
|
test('not where the operator offers passwords alone, nor ends what '
|
|
'was begun before', () async {
|
|
final (:attempt, :code) = await allowed(
|
|
notSignedIn(),
|
|
subject: 'sl-ben',
|
|
);
|
|
useOfferedWays(pod, {OfferedWay.password});
|
|
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
|
isFalse,
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.loopback,
|
|
),
|
|
refused(OidcRefusal.unknownProvider),
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
),
|
|
refused(OidcRefusal.unknownProvider),
|
|
);
|
|
});
|
|
|
|
test('alone where the operator offers it alone: no password sign-in, '
|
|
'its keys there or not', () async {
|
|
EmailCodes.of(pod).mailer = Mailbox();
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
addTearDown(() {
|
|
EmailCodes.of(pod).mailer = null;
|
|
usePasswordKeys(pod, null);
|
|
});
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
|
|
isTrue,
|
|
);
|
|
|
|
useOfferedWays(pod, {OfferedWay.simpleLogin});
|
|
|
|
final basics = await endpoints.instance.basics(notSignedIn());
|
|
expect(basics.passwordSignIn, isFalse);
|
|
expect(basics.simpleLogin, isTrue);
|
|
});
|
|
});
|
|
|
|
group('beginning', () {
|
|
test('sends the person to SimpleLogin, the state marked with the app '
|
|
'and its way back for the forwarding page', () async {
|
|
final loopback = await endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.loopback,
|
|
);
|
|
final scheme = await endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.scheme,
|
|
);
|
|
|
|
final url = loopback.authorizationUrl;
|
|
expect(
|
|
'${url.origin}${url.path}',
|
|
'https://app.simplelogin.io/oauth2/authorize',
|
|
);
|
|
expect(url.queryParameters['client_id'], 'halkyon-learn');
|
|
expect(
|
|
url.queryParameters['redirect_uri'],
|
|
'https://learn.test/simplelogin-callback',
|
|
);
|
|
expect(loopback.state, startsWith('learn-loopback.'));
|
|
expect(scheme.state, startsWith('learn-scheme.'));
|
|
expect(
|
|
(await OidcAttempt.db.findById(
|
|
session,
|
|
loopback.attempt,
|
|
))!.callerId,
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('signed in, notes who began it', () async {
|
|
final start = await endpoints.simpleLogin.begin(
|
|
asAnna(),
|
|
back: SimpleLoginReturn.scheme,
|
|
);
|
|
|
|
expect(
|
|
(await OidcAttempt.db.findById(session, start.attempt))!.callerId,
|
|
anna,
|
|
);
|
|
});
|
|
|
|
test('for an app the forwarding page has no way back to is its '
|
|
"host's mistake", () async {
|
|
// The tests' host, which the forwarding page knows nothing of.
|
|
useBase(sessionBuilder);
|
|
useSimpleLogin(sessionBuilder, simpleLogin);
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.loopback,
|
|
),
|
|
throwsA(isA<StateError>()),
|
|
);
|
|
});
|
|
|
|
test('at most 30 times an hour from one network', () async {
|
|
for (var i = 0; i < 30; i++) {
|
|
await endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.loopback,
|
|
);
|
|
}
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.loopback,
|
|
),
|
|
refused(OidcRefusal.tooManyAttempts),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('signed out', () {
|
|
test('signs in to the account with that identity, a session of '
|
|
"SimpleLogin's", () async {
|
|
final (:attempt, :code) = await allowed(
|
|
notSignedIn(),
|
|
subject: 'sl-ben',
|
|
name: 'Ben Berg',
|
|
);
|
|
|
|
final completed = await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
);
|
|
|
|
expect(completed.outcome, SimpleLoginOutcome.signedIn);
|
|
expect(completed.suggestedHandle, isNull);
|
|
expect(completed.session!.authUserId, ben);
|
|
final sessions = await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
);
|
|
expect(sessions.single.method, simpleLoginMethod);
|
|
expect(sessions.single.scopeNames, contains(identified.name));
|
|
});
|
|
|
|
test('someone new gets no account yet: their identity waits half an '
|
|
'hour, a handle suggested from their name', () async {
|
|
final people = await AuthUser.db.count(session);
|
|
final now = DateTime.now().toUtc();
|
|
final (:attempt, :code) = await allowed(
|
|
notSignedIn(),
|
|
subject: 'sl-carla',
|
|
name: 'Carla Müller',
|
|
);
|
|
|
|
final completed = await withClock(
|
|
Clock.fixed(now),
|
|
() => endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
),
|
|
);
|
|
|
|
expect(completed.outcome, SimpleLoginOutcome.newcomer);
|
|
expect(completed.session, isNull);
|
|
expect(completed.suggestedHandle, 'carla_mueller');
|
|
expect(await AuthUser.db.count(session), people);
|
|
expect(await OidcIdentity.db.count(session), 1);
|
|
final kept = (await OidcAttempt.db.findById(session, attempt))!;
|
|
expect(kept.subject, 'sl-carla');
|
|
expect(kept.expiresAt, now.add(const Duration(minutes: 30)));
|
|
});
|
|
|
|
test('the handle suggested is one nobody has, whatever its case, and '
|
|
'none without a name', () async {
|
|
await AccountRegister.of(pod).saveProfile(
|
|
session,
|
|
ben,
|
|
handle: 'Carla_Mueller',
|
|
displayName: 'Ben',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
|
|
final taken = await allowed(
|
|
notSignedIn(),
|
|
subject: 'sl-carla',
|
|
name: 'Carla Müller',
|
|
);
|
|
final nameless = await allowed(notSignedIn(), subject: 'sl-dora');
|
|
|
|
expect(
|
|
(await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: taken.attempt,
|
|
code: taken.code,
|
|
)).suggestedHandle,
|
|
'carla_mueller2',
|
|
);
|
|
final completed = await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: nameless.attempt,
|
|
code: nameless.code,
|
|
);
|
|
expect(completed.outcome, SimpleLoginOutcome.newcomer);
|
|
expect(completed.suggestedHandle, isNull);
|
|
});
|
|
|
|
test('ends an attempt once', () async {
|
|
final (:attempt, :code) = await allowed(
|
|
notSignedIn(),
|
|
subject: 'sl-ben',
|
|
);
|
|
await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
);
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
});
|
|
|
|
test('refuses an attempt begun signed in, signing nobody in', () async {
|
|
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-ben');
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
),
|
|
refused(OidcRefusal.wrongCaller),
|
|
);
|
|
expect(
|
|
await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('refuses a code SimpleLogin refuses, and an attempt whose ten '
|
|
'minutes are over', () async {
|
|
final first = await allowed(notSignedIn(), subject: 'sl-ben');
|
|
final second = await allowed(notSignedIn(), subject: 'sl-ben');
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: first.attempt,
|
|
code: second.code,
|
|
),
|
|
refused(OidcRefusal.rejected),
|
|
);
|
|
await expectLater(
|
|
withClock(
|
|
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
|
() => endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: second.attempt,
|
|
code: second.code,
|
|
),
|
|
),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('signed in', () {
|
|
test('adds SimpleLogin to the caller, who becomes identified and is '
|
|
'anonymous no longer', () async {
|
|
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
|
|
|
|
final completed = await endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: attempt,
|
|
code: code,
|
|
);
|
|
|
|
expect(completed.outcome, SimpleLoginOutcome.linked);
|
|
expect(completed.session, isNull);
|
|
expect((await identitiesOf(anna)).single.subject, 'sl-anna');
|
|
expect(
|
|
(await endpoints.account.signInWays(asAnna())).simpleLogin,
|
|
isTrue,
|
|
);
|
|
expect(
|
|
(await AuthUser.db.findById(session, anna))!.scopeNames,
|
|
contains(identified.name),
|
|
);
|
|
expect(
|
|
await AnonymousAccount.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(anna),
|
|
),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('the identity they have already changes nothing', () async {
|
|
final first = await allowed(asAnna(), subject: 'sl-anna');
|
|
await endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: first.attempt,
|
|
code: first.code,
|
|
);
|
|
final again = await allowed(asAnna(), subject: 'sl-anna');
|
|
|
|
expect(
|
|
(await endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: again.attempt,
|
|
code: again.code,
|
|
)).outcome,
|
|
SimpleLoginOutcome.linked,
|
|
);
|
|
expect(await identitiesOf(anna), hasLength(1));
|
|
});
|
|
|
|
test("refuses another person's identity, switching nobody and "
|
|
'changing nothing', () async {
|
|
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-ben');
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: attempt,
|
|
code: code,
|
|
),
|
|
refused(OidcRefusal.takenByAnotherPerson),
|
|
);
|
|
expect(await identitiesOf(anna), isEmpty);
|
|
expect(
|
|
await AnonymousAccount.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(anna),
|
|
),
|
|
1,
|
|
);
|
|
});
|
|
|
|
test('refuses a second SimpleLogin identity', () async {
|
|
final first = await allowed(asAnna(), subject: 'sl-anna');
|
|
await endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: first.attempt,
|
|
code: first.code,
|
|
);
|
|
final other = await allowed(asAnna(), subject: 'sl-anna-2');
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: other.attempt,
|
|
code: other.code,
|
|
),
|
|
refused(OidcRefusal.otherIdentityLinked),
|
|
);
|
|
expect((await identitiesOf(anna)).single.subject, 'sl-anna');
|
|
});
|
|
|
|
test("refuses an attempt begun signed out, or by someone else's "
|
|
'session', () async {
|
|
final signedOut = await allowed(notSignedIn(), subject: 'sl-anna');
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: signedOut.attempt,
|
|
code: signedOut.code,
|
|
),
|
|
refused(OidcRefusal.wrongCaller),
|
|
);
|
|
expect(await identitiesOf(anna), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('a sign-in stands for ten minutes (D20)', () {
|
|
test(
|
|
'signed out or in, under its attempt, for the account that '
|
|
'signed in alone, and says whether it has keys and its handle',
|
|
() async {
|
|
await giveKeys(session, ben, await KeysDevice.make());
|
|
await AccountRegister.of(pod).saveProfile(
|
|
session,
|
|
ben,
|
|
handle: 'Ben',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
final idp = AuthServices.instance.oidcIdp;
|
|
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
|
final annas = await allowed(asAnna(), subject: 'sl-anna');
|
|
|
|
final signedIn = await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: bens.attempt,
|
|
code: bens.code,
|
|
);
|
|
final linked = await endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: annas.attempt,
|
|
code: annas.code,
|
|
);
|
|
|
|
expect((signedIn.hasKeys, signedIn.handle), (true, 'Ben'));
|
|
expect((linked.hasKeys, linked.handle), (false, null));
|
|
for (final (attempt, person) in [
|
|
(bens.attempt, ben),
|
|
(annas.attempt, anna),
|
|
]) {
|
|
expect(
|
|
await idp.isKept(session, attempt: attempt, caller: person),
|
|
isTrue,
|
|
);
|
|
expect(await idp.isKept(session, attempt: attempt), isFalse);
|
|
expect(
|
|
await withClock(
|
|
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
|
() => idp.isKept(session, attempt: attempt, caller: person),
|
|
),
|
|
isFalse,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
group('signing up someone new (D18)', () {
|
|
late KeysDevice device;
|
|
|
|
setUp(() async => device = await KeysDevice.make());
|
|
|
|
/// Carla, new here, allowed at SimpleLogin: the attempt her identity
|
|
/// waits under.
|
|
Future<UuidValue> newcomer({String subject = 'sl-carla'}) async {
|
|
final (:attempt, :code) = await allowed(
|
|
notSignedIn(),
|
|
subject: subject,
|
|
name: 'Carla Müller',
|
|
);
|
|
await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
);
|
|
return attempt;
|
|
}
|
|
|
|
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
|
|
isA<ProfileRefusedException>().having(
|
|
(e) => e.reason,
|
|
'reason',
|
|
reason,
|
|
),
|
|
);
|
|
|
|
test('makes the account with its handle and keys, identified, signed '
|
|
'in with SimpleLogin, its identity taken once', () async {
|
|
final attempt = await newcomer();
|
|
|
|
final signedUp = await endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'Carla_M',
|
|
setup: device.setup(),
|
|
);
|
|
|
|
final carla = signedUp.authUserId;
|
|
expect(
|
|
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
|
{identified.name},
|
|
);
|
|
expect((await identitiesOf(carla)).single.subject, 'sl-carla');
|
|
final profile = (await PublicProfile.db.findFirstRow(
|
|
session,
|
|
where: (t) => t.authUserId.equals(carla),
|
|
))!;
|
|
expect((profile.handle, profile.handleKey), ('Carla_M', 'carla_m'));
|
|
expect(profile.displayName, isEmpty);
|
|
final keys = (await AccountPassword.db.findFirstRow(
|
|
session,
|
|
where: (t) => t.authUserId.equals(carla),
|
|
))!;
|
|
expect(keys.verifier, isNull);
|
|
expect(
|
|
Uint8List.sublistView(keys.passwordProofKey!),
|
|
device.passwordProofKey,
|
|
);
|
|
expect(
|
|
(await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(carla),
|
|
)).single.method,
|
|
simpleLoginMethod,
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'Carla_N',
|
|
setup: device.setup(),
|
|
),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
|
|
final again = await allowed(notSignedIn(), subject: 'sl-carla');
|
|
final signedIn = await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: again.attempt,
|
|
code: again.code,
|
|
);
|
|
expect(signedIn.session!.authUserId, carla);
|
|
expect((signedIn.hasKeys, signedIn.handle), (true, 'Carla_M'));
|
|
});
|
|
|
|
test('refuses a handle someone has, whatever its case, and one nobody '
|
|
'may have, the identity still waiting', () async {
|
|
await AccountRegister.of(pod).saveProfile(
|
|
session,
|
|
ben,
|
|
handle: 'Ben',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
final attempt = await newcomer();
|
|
|
|
for (final (handle, reason) in [
|
|
('BEN', ProfileRefusal.handleTaken),
|
|
('admin', ProfileRefusal.handleReserved),
|
|
('carla müller', ProfileRefusal.handleInvalid),
|
|
]) {
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: handle,
|
|
setup: device.setup(),
|
|
),
|
|
refusedAsProfile(reason),
|
|
reason: handle,
|
|
);
|
|
}
|
|
await endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
);
|
|
});
|
|
|
|
test('refuses keys out of range, the identity still waiting', () async {
|
|
final attempt = await newcomer();
|
|
final setup = device.setup();
|
|
|
|
for (final wrong in [
|
|
setup.copyWith(
|
|
costs: setup.costs.copyWith(opsLimit: 1),
|
|
),
|
|
setup.copyWith(salt: ByteData(15)),
|
|
setup.copyWith(passwordProofKey: ByteData(31)),
|
|
setup.copyWith(
|
|
passwordKeys: setup.passwordKeys.copyWith(nonce: ByteData(12)),
|
|
),
|
|
]) {
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: wrong,
|
|
),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(e) => e.reason,
|
|
'reason',
|
|
PasswordRefusal.invalidSetup,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
await endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: setup,
|
|
);
|
|
});
|
|
|
|
test('refuses an attempt nobody new waits under: unknown, past its '
|
|
"half hour, or an account's sign-in", () async {
|
|
final late = await newcomer();
|
|
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
|
await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: bens.attempt,
|
|
code: bens.code,
|
|
);
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: const Uuid().v4obj(),
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
await expectLater(
|
|
withClock(
|
|
Clock.fixed(DateTime.now().add(const Duration(minutes: 31))),
|
|
() => endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: late,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
),
|
|
),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: bens.attempt,
|
|
handle: 'ben2',
|
|
setup: device.setup(),
|
|
),
|
|
refused(OidcRefusal.wrongCaller),
|
|
);
|
|
expect(await identitiesOf(ben), hasLength(1));
|
|
});
|
|
|
|
test('refuses a signed-in caller, whose session it would leave for '
|
|
"another person's", () async {
|
|
final attempt = await newcomer();
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
asAnna(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
),
|
|
refused(OidcRefusal.wrongCaller),
|
|
);
|
|
await endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
);
|
|
});
|
|
|
|
test('makes one account of an identity waiting twice', () async {
|
|
final first = await newcomer();
|
|
final second = await newcomer();
|
|
final carla = (await endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: first,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
)).authUserId;
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: second,
|
|
handle: 'carla2',
|
|
setup: device.setup(),
|
|
),
|
|
refused(OidcRefusal.takenByAnotherPerson),
|
|
);
|
|
expect(await identitiesOf(carla), hasLength(1));
|
|
expect(
|
|
await PublicProfile.db.count(
|
|
session,
|
|
where: (t) => t.handleKey.equals('carla2'),
|
|
),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('signs up nobody where SimpleLogin is not offered', () async {
|
|
final attempt = await newcomer();
|
|
useSimpleLogin(sessionBuilder, null);
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
),
|
|
refused(OidcRefusal.unknownProvider),
|
|
);
|
|
});
|
|
|
|
group('checking a handle', () {
|
|
test('says whether it is free as saving a profile would, whatever '
|
|
'its case, taking nothing', () async {
|
|
await AccountRegister.of(pod).saveProfile(
|
|
session,
|
|
ben,
|
|
handle: 'Ben',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
final attempt = await newcomer();
|
|
|
|
for (final (handle, reason) in [
|
|
('carla', null),
|
|
('bEn', ProfileRefusal.handleTaken),
|
|
('support', ProfileRefusal.handleReserved),
|
|
('carla!', ProfileRefusal.handleInvalid),
|
|
]) {
|
|
expect(
|
|
await endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: handle,
|
|
),
|
|
reason,
|
|
reason: handle,
|
|
);
|
|
}
|
|
await endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
);
|
|
});
|
|
|
|
test('only for an attempt someone new waits under', () async {
|
|
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
|
await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: bens.attempt,
|
|
code: bens.code,
|
|
);
|
|
|
|
for (final attempt in [const Uuid().v4obj(), bens.attempt]) {
|
|
await expectLater(
|
|
endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('at most 30 times an hour for an attempt, and 60 from one '
|
|
'network', () async {
|
|
final first = await newcomer();
|
|
for (var i = 0; i < 30; i++) {
|
|
await endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: first,
|
|
handle: 'carla$i',
|
|
);
|
|
}
|
|
await expectLater(
|
|
endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: first,
|
|
handle: 'carla',
|
|
),
|
|
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
|
);
|
|
// A check refused counts for neither: the network has 30.
|
|
final second = await newcomer(subject: 'sl-dora');
|
|
for (var i = 0; i < 30; i++) {
|
|
await endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: second,
|
|
handle: 'dora$i',
|
|
);
|
|
}
|
|
final third = await newcomer(subject: 'sl-emil');
|
|
await expectLater(
|
|
endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: third,
|
|
handle: 'emil',
|
|
),
|
|
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
|
);
|
|
});
|
|
|
|
test('counts signing up with a handle, which says as much; the '
|
|
'identity left waiting when refused', () async {
|
|
final attempt = await newcomer();
|
|
for (var i = 0; i < 30; i++) {
|
|
await endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla$i',
|
|
);
|
|
}
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
),
|
|
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
|
);
|
|
expect(
|
|
await AuthServices.instance.oidcIdp.isKept(
|
|
session,
|
|
attempt: attempt,
|
|
),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test("doesn't count a handle that is none, which says nothing of "
|
|
'anyone', () async {
|
|
final attempt = await newcomer();
|
|
for (var i = 0; i < 30; i++) {
|
|
await endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla$i',
|
|
);
|
|
}
|
|
|
|
expect(
|
|
await endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla!',
|
|
),
|
|
ProfileRefusal.handleInvalid,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
group('mending a broken link (D7)', () {
|
|
/// With keys and the handle `Dora`, her SimpleLogin link broken:
|
|
/// SimpleLogin gives her `sl-dora-new` now, the account keeps
|
|
/// `sl-dora-old`. Signed in on her laptop.
|
|
late UuidValue dora;
|
|
late UuidValue dorasLaptop;
|
|
late KeysDevice dorasKeys;
|
|
|
|
setUp(() async {
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
dorasKeys = await KeysDevice.make(seed: 5);
|
|
dora = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
await giveKeys(session, dora, dorasKeys);
|
|
await AccountRegister.of(pod).saveProfile(
|
|
session,
|
|
dora,
|
|
handle: 'Dora',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
await OidcIdentity.db.insertRow(
|
|
session,
|
|
OidcIdentity(
|
|
authUserId: dora,
|
|
provider: simpleLoginProvider,
|
|
subject: 'sl-dora-old',
|
|
),
|
|
);
|
|
dorasLaptop = await signInNewDevice(
|
|
session,
|
|
dora,
|
|
method: simpleLoginMethod,
|
|
);
|
|
});
|
|
|
|
/// The attempt Dora's new identity waits under, someone new to the
|
|
/// register.
|
|
Future<UuidValue> waiting({String subject = 'sl-dora-new'}) async {
|
|
final (:attempt, :code) = await allowed(
|
|
notSignedIn(),
|
|
subject: subject,
|
|
);
|
|
await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
);
|
|
return attempt;
|
|
}
|
|
|
|
Future<KeyProofChallenge> beginMend(
|
|
UuidValue attempt, {
|
|
String handle = 'Dora',
|
|
}) => endpoints.simpleLogin.beginMend(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: handle,
|
|
);
|
|
|
|
Matcher refusedAsPassword(PasswordRefusal reason) => throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(e) => e.reason,
|
|
'reason',
|
|
reason,
|
|
),
|
|
);
|
|
|
|
test('takes the handle, whatever its case, and the recovery phrase: '
|
|
'the new identity in place of the old, every other session '
|
|
'ended, signed in for a change, a lost password too', () async {
|
|
final attempt = await waiting();
|
|
final challenge = await beginMend(attempt, handle: 'DORA');
|
|
expect(
|
|
challenge.recoveryKeys.toJson(),
|
|
dorasKeys.recoveryKeys.toJson(),
|
|
);
|
|
|
|
final mended = await endpoints.simpleLogin.mend(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
proof: await dorasKeys.provePhrase(
|
|
challenge,
|
|
digest: mendLinkDigest(attempt),
|
|
),
|
|
);
|
|
|
|
expect(mended.outcome, SimpleLoginOutcome.signedIn);
|
|
expect(mended.session!.authUserId, dora);
|
|
expect((mended.hasKeys, mended.handle), (true, 'Dora'));
|
|
expect((await identitiesOf(dora)).single.subject, 'sl-dora-new');
|
|
expect(
|
|
await ServerSideSession.db.findById(session, dorasLaptop),
|
|
isNull,
|
|
);
|
|
final sessions = await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(dora),
|
|
);
|
|
expect(sessions.single.method, simpleLoginMethod);
|
|
// The sign-in stands ten minutes: her password lost too, the
|
|
// phrase sets a new one.
|
|
final onPhone = sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
dora.uuid,
|
|
{identified},
|
|
authId: sessions.single.id!.uuid,
|
|
),
|
|
);
|
|
final next = await KeysDevice.make(seed: 6);
|
|
await endpoints.keys.resetPassword(
|
|
onPhone,
|
|
proof: await dorasKeys.ownerProof(
|
|
attempt,
|
|
phrase: await endpoints.keys.beginPhrase(onPhone),
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: newKeysPasswordDigest(next.password()),
|
|
),
|
|
password: next.password(),
|
|
);
|
|
// And SimpleLogin signs her in with the new identity.
|
|
final again = await allowed(notSignedIn(), subject: 'sl-dora-new');
|
|
expect(
|
|
(await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: again.attempt,
|
|
code: again.code,
|
|
)).session!.authUserId,
|
|
dora,
|
|
);
|
|
});
|
|
|
|
test('answers a handle nobody has, or one of an account it would not '
|
|
'mend, with a decoy of its own, refusing its proof '
|
|
'alike', () async {
|
|
// Ben: no keys. Carla: keys, but never a SimpleLogin link (nobody
|
|
// adds SimpleLogin signed out, D10).
|
|
await AccountRegister.of(pod).saveProfile(
|
|
session,
|
|
ben,
|
|
handle: 'Ben',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
final carla = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
await giveKeys(session, carla, await KeysDevice.make(seed: 8));
|
|
await AccountRegister.of(pod).saveProfile(
|
|
session,
|
|
carla,
|
|
handle: 'Carla',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
final attempt = await waiting();
|
|
|
|
for (final handle in ['nobody', 'Ben', 'Carla']) {
|
|
final decoy = await beginMend(attempt, handle: handle);
|
|
expect(
|
|
(await beginMend(attempt, handle: handle)).recoveryKeys.toJson(),
|
|
decoy.recoveryKeys.toJson(),
|
|
reason: '$handle: the same decoy every time',
|
|
);
|
|
expect(
|
|
decoy.recoveryKeys.cipherText.lengthInBytes,
|
|
dorasKeys.recoveryKeys.cipherText.lengthInBytes,
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.mend(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
proof: await dorasKeys.provePhrase(
|
|
decoy,
|
|
digest: mendLinkDigest(attempt),
|
|
),
|
|
),
|
|
refusedAsPassword(PasswordRefusal.wrongCredentials),
|
|
reason: handle,
|
|
);
|
|
}
|
|
expect((await identitiesOf(dora)).single.subject, 'sl-dora-old');
|
|
});
|
|
|
|
test('refuses a proof by another key, for another attempt or '
|
|
'another instance, the identity still waiting', () async {
|
|
final attempt = await waiting();
|
|
final other = await waiting(subject: 'sl-someone');
|
|
|
|
for (final (proof, reason) in [
|
|
(
|
|
await dorasKeys.provePhrase(
|
|
await beginMend(attempt),
|
|
digest: mendLinkDigest(attempt),
|
|
keys: dorasKeys.passwordProofKeys,
|
|
),
|
|
PasswordRefusal.wrongCredentials,
|
|
),
|
|
(
|
|
await dorasKeys.provePhrase(
|
|
await beginMend(attempt),
|
|
digest: mendLinkDigest(other),
|
|
),
|
|
PasswordRefusal.wrongCredentials,
|
|
),
|
|
(
|
|
await dorasKeys.provePhrase(
|
|
await beginMend(attempt),
|
|
digest: mendLinkDigest(attempt),
|
|
instanceUrl: 'https://evil.example/',
|
|
claimedUrl: testInstanceUrl,
|
|
),
|
|
PasswordRefusal.wrongCredentials,
|
|
),
|
|
(
|
|
await dorasKeys.provePhrase(
|
|
await beginMend(attempt),
|
|
digest: mendLinkDigest(attempt),
|
|
instanceUrl: 'https://evil.example/',
|
|
),
|
|
PasswordRefusal.otherInstance,
|
|
),
|
|
]) {
|
|
await expectLater(
|
|
endpoints.simpleLogin.mend(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
proof: proof,
|
|
),
|
|
refusedAsPassword(reason),
|
|
);
|
|
}
|
|
expect((await identitiesOf(dora)).single.subject, 'sl-dora-old');
|
|
await endpoints.simpleLogin.mend(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
proof: await dorasKeys.provePhrase(
|
|
await beginMend(attempt),
|
|
digest: mendLinkDigest(attempt),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('refuses an attempt nobody new waits under, and a signed-in '
|
|
'caller', () async {
|
|
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
|
await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: bens.attempt,
|
|
code: bens.code,
|
|
);
|
|
for (final attempt in [const Uuid().v4obj(), bens.attempt]) {
|
|
await expectLater(
|
|
beginMend(attempt),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
}
|
|
final attempt = await waiting();
|
|
final challenge = await beginMend(attempt);
|
|
await expectLater(
|
|
endpoints.simpleLogin.beginMend(
|
|
asAnna(),
|
|
attempt: attempt,
|
|
handle: 'Dora',
|
|
),
|
|
refused(OidcRefusal.wrongCaller),
|
|
);
|
|
final proof = await dorasKeys.provePhrase(
|
|
challenge,
|
|
digest: mendLinkDigest(attempt),
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.mend(
|
|
asAnna(),
|
|
attempt: attempt,
|
|
proof: proof,
|
|
),
|
|
refused(OidcRefusal.wrongCaller),
|
|
);
|
|
await expectLater(
|
|
withClock(
|
|
Clock.fixed(DateTime.now().add(const Duration(minutes: 31))),
|
|
() => endpoints.simpleLogin.mend(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
proof: proof,
|
|
),
|
|
),
|
|
refused(OidcRefusal.unknownAttempt),
|
|
);
|
|
// Checked before the proof, which still answers.
|
|
await endpoints.simpleLogin.mend(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
proof: proof,
|
|
);
|
|
});
|
|
|
|
test('begins at most 10 times an hour for a handle, whatever its '
|
|
'case, and 30 from a network', () async {
|
|
final attempt = await waiting();
|
|
for (var i = 0; i < 10; i++) {
|
|
await beginMend(attempt, handle: i.isEven ? 'dora' : 'DORA');
|
|
}
|
|
await expectLater(
|
|
beginMend(attempt),
|
|
refusedAsPassword(PasswordRefusal.tooManyAttempts),
|
|
);
|
|
// One refused counts for neither: the network has 10.
|
|
for (var i = 0; i < 20; i++) {
|
|
await beginMend(attempt, handle: 'someone$i');
|
|
}
|
|
await expectLater(
|
|
beginMend(attempt, handle: 'someone'),
|
|
refusedAsPassword(PasswordRefusal.tooManyAttempts),
|
|
);
|
|
});
|
|
|
|
test('takes the key for decoys, without which a server with a '
|
|
'SimpleLogin app stops as it starts', () async {
|
|
final attempt = await waiting();
|
|
checkSimpleLoginMending(pod);
|
|
usePasswordKeys(pod, null);
|
|
|
|
await expectLater(
|
|
beginMend(attempt),
|
|
refusedAsPassword(PasswordRefusal.unavailable),
|
|
);
|
|
expect(() => checkSimpleLoginMending(pod), throwsStateError);
|
|
useSimpleLoginApp(pod, null);
|
|
checkSimpleLoginMending(pod);
|
|
});
|
|
});
|
|
|
|
test('the identity goes with the account', () async {
|
|
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
|
|
await endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: attempt,
|
|
code: code,
|
|
);
|
|
|
|
await deletePerson(session, anna);
|
|
|
|
expect(await identitiesOf(anna), isEmpty);
|
|
expect(await identitiesOf(ben), hasLength(1));
|
|
});
|
|
|
|
test('no new GitHub account is linked where SimpleLogin is offered: '
|
|
'GitHub is deprecated', () async {
|
|
await expectLater(
|
|
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
|
|
throwsA(
|
|
isA<GitHubSignInRefusedException>().having(
|
|
(e) => e.reason,
|
|
'reason',
|
|
GitHubSignInRefusal.deprecated,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("forgets the attempts whose time ran out, a newcomer's too, and "
|
|
'says how many', () async {
|
|
await allowed(notSignedIn(), subject: 'sl-ben');
|
|
final (:attempt, :code) = await allowed(
|
|
notSignedIn(),
|
|
subject: 'sl-carla',
|
|
);
|
|
await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
code: code,
|
|
);
|
|
|
|
final now = DateTime.now();
|
|
expect(
|
|
await forgetUnfinishedSimpleLogins(
|
|
session,
|
|
now: now.add(const Duration(minutes: 11)),
|
|
),
|
|
1,
|
|
);
|
|
expect(
|
|
await forgetUnfinishedSimpleLogins(
|
|
session,
|
|
now: now.add(const Duration(minutes: 31)),
|
|
),
|
|
1,
|
|
);
|
|
expect(await OidcAttempt.db.count(session), 0);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteAll(Session session) async {
|
|
await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
|
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
await deleteAll(session);
|
|
}
|