halkyon/packages/halkyon_base_server/test/integration/simplelogin/simplelogin_test.dart
Tizian 「ティツィアーン」 7ea9f16904
feat(base): SimpleLogin's sign-in comes back by the app's scheme or the loopback (ADR 0020, D9 corrected)
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>
2026-10-24 09:17:49 +02:00

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);
}