import 'dart:convert'; import 'dart:io'; import 'package:clock/clock.dart'; import 'package:halkyon_learn_server/src/auth/account.dart'; import 'package:halkyon_learn_server/src/auth/code_mail.dart'; import 'package:halkyon_learn_server/src/auth/email.dart'; import 'package:halkyon_learn_server/src/auth/scopes.dart'; import 'package:halkyon_learn_server/src/generated/protocol.dart'; import 'package:serverpod/serverpod.dart'; import 'package:serverpod_auth_idp_server/core.dart'; import 'package:test/test.dart'; import '../support/auth_fixtures.dart'; import '../test_tools/serverpod_test_tools.dart'; /// Keeps every code mailed, in place of a mail service. final class _Mailbox implements CodeMailer { final sent = <({String to, String code, String language})>[]; @override Future send( Session session, { required String to, required String code, required String language, }) async { sent.add((to: to, code: code, language: language)); } /// The last code mailed to [address]. String codeFor(String address) => sent.lastWhere((mail) => mail.to == address).code; } /// A mail service that takes no mail. final class _Refusing implements CodeMailer { @override Future send( Session session, { required String to, required String code, required String language, }) async => throw const SocketException('Connection refused'); } void main() { withServerpod( 'Email sign-in', // Mailing and accepting codes count attempts in transactions of their // own (Serverpod's rate limiter), which the test's database rollback // can't wrap. So these tests delete what they make instead. rollbackDatabase: RollbackDatabase.disabled, (sessionBuilder, endpoints) { late Session session; late _Mailbox mailbox; /// Anonymous, signed in on her phone. late UuidValue anna; late UuidValue annasPhone; /// Identified, signing in with ben@example.org. late UuidValue ben; late UuidValue bensLaptop; /// Identified, signing in with GitHub. late UuidValue carla; late UuidValue carlasPhone; setUp(() async { useServerAuth(); session = sessionBuilder.build(); await _deleteAll(session); mailbox = _Mailbox(); EmailCodes.of(session.serverpod).mailer = mailbox; anna = (await AuthServices.instance.authUsers.create(session)).id; await AnonymousAccount.db.insertRow( session, AnonymousAccount(authUserId: anna), ); annasPhone = await signInNewDevice(session, anna); ben = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; await EmailIdentity.db.insertRow( session, EmailIdentity( authUserId: ben, addressHash: addressHash(session.serverpod, 'ben@example.org'), createdAt: clock.now(), ), ); bensLaptop = await signInNewDevice( session, ben, scopes: {identified}, ); carla = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; await GitHubAccount.db.insertRow( session, GitHubAccount(userIdentifier: '1001', authUserId: carla), ); carlasPhone = await signInNewDevice( session, carla, scopes: {identified}, ); }); tearDown(() async { EmailCodes.of(session.serverpod).mailer = null; await _deleteAll(sessionBuilder.build()); }); TestSessionBuilder signedIn( UuidValue person, UuidValue sessionId, { bool isIdentified = false, }) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( person.uuid, {if (isIdentified) identified}, authId: sessionId.uuid, ), ); TestSessionBuilder asAnna() => signedIn(anna, annasPhone); TestSessionBuilder asBen() => signedIn(ben, bensLaptop, isIdentified: true); TestSessionBuilder asCarla() => signedIn(carla, carlasPhone, isIdentified: true); TestSessionBuilder notSignedIn() => sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ); /// Mails a code to [address] for [as] and types it. Future signInWith( TestSessionBuilder as, String address, ) async { final request = await endpoints.emailCode.sendCode( as, address: address, language: 'en', ); return await endpoints.emailCode.verifyCode( as, request: request, code: mailbox.codeFor(address.trim().toLowerCase()), ); } group('refuses', () { test('mailing and accepting codes when not signed in', () async { await expectLater( endpoints.emailCode.sendCode( notSignedIn(), address: 'anna@example.org', language: 'en', ), throwsA(isA()), ); await expectLater( endpoints.emailCode.verifyCode( notSignedIn(), request: const Uuid().v7obj(), code: 'K7QX-M2PD', ), throwsA(isA()), ); expect(mailbox.sent, isEmpty); }); test( 'everything on an instance that sends no mail, and says so', () async { EmailCodes.of(session.serverpod).mailer = null; await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ), throwsA(_refusal(EmailSignInRefusal.unavailable)), ); expect( (await endpoints.instance.info(notSignedIn())).emailSignIn, isFalse, ); }, ); test('a code it could not mail, keeping nothing of it', () async { EmailCodes.of(session.serverpod).mailer = _Refusing(); await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ), throwsA(_refusal(EmailSignInRefusal.mailFailed)), ); expect(await EmailCodeRequest.db.count(session), 0); expect(await SecretChallenge.db.count(session), 0); }); test('what is no address', () async { for (final typed in ['anna', 'anna@', '@example.org', 'a b@c.de']) { await expectLater( endpoints.emailCode.sendCode( asAnna(), address: typed, language: 'en', ), throwsA(_refusal(EmailSignInRefusal.notAnAddress)), reason: typed, ); } expect(mailbox.sent, isEmpty); }); test('a wrong code, and after three wrong ones even the right one; ' 'nothing changes', () async { final request = await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); for (var i = 0; i < 3; i++) { await expectLater( endpoints.emailCode.verifyCode( asAnna(), request: request, code: 'AAAA-AAAA', ), throwsA(_refusal(EmailSignInRefusal.wrongCode)), ); } await expectLater( endpoints.emailCode.verifyCode( asAnna(), request: request, code: mailbox.codeFor('anna@example.org'), ), throwsA(_refusal(EmailSignInRefusal.tooManyTries)), ); expect(await _addressesOf(session, anna), isEmpty); }); test('a code past its 15 minutes, a code used, and one replaced by ' 'a newer code', () async { final late = await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); final lateCode = mailbox.codeFor('anna@example.org'); await withClock( Clock.fixed(clock.now().add(const Duration(minutes: 16))), () => expectLater( endpoints.emailCode.verifyCode( asAnna(), request: late, code: lateCode, ), throwsA(_refusal(EmailSignInRefusal.codeExpired)), ), ); final replaced = await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); final replacedCode = mailbox.codeFor('anna@example.org'); final newer = await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); await expectLater( endpoints.emailCode.verifyCode( asAnna(), request: replaced, code: replacedCode, ), throwsA(_refusal(EmailSignInRefusal.codeExpired)), ); final newerCode = mailbox.codeFor('anna@example.org'); await endpoints.emailCode.verifyCode( asAnna(), request: newer, code: newerCode, ); await expectLater( endpoints.emailCode.verifyCode( asAnna(), request: newer, code: newerCode, ), throwsA(_refusal(EmailSignInRefusal.codeExpired)), ); }); test( 'a second address for a person who has one; nothing changes', () async { await expectLater( signInWith(asBen(), 'ben@elsewhere.org'), throwsA(_refusal(EmailSignInRefusal.otherAddressLinked)), ); expect(await _addressesOf(session, ben), [ addressHash(session.serverpod, 'ben@example.org'), ]); }, ); test('a fourth code for one address within an hour, and an ' 'eleventh from one network', () async { for (var i = 0; i < 3; i++) { await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); } await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'Anna@Example.org', language: 'en', ), throwsA(_refusal(EmailSignInRefusal.tooManyCodes)), ); for (var i = 0; i < 7; i++) { await endpoints.emailCode.sendCode( asAnna(), address: 'anna$i@example.org', language: 'en', ); } await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'anna.else@example.org', language: 'en', ), throwsA(_refusal(EmailSignInRefusal.tooManyCodes)), ); expect(mailbox.sent, hasLength(10)); }); test('a switch token used twice, or made up', () async { final token = (await signInWith( asAnna(), 'ben@example.org', )).switchToken!; await endpoints.emailSignIn.signIn(notSignedIn(), switchToken: token); for (final used in [token, 'made-up']) { await expectLater( endpoints.emailSignIn.signIn(notSignedIn(), switchToken: used), throwsA(_refusal(EmailSignInRefusal.codeExpired)), ); } }); test('saying how someone signs in when not signed in', () async { await expectLater( endpoints.account.signInWays(notSignedIn()), throwsA(isA()), ); }); }); test('mails a code in the language asked for, the same whether anybody ' 'has the address or not', () async { final known = await endpoints.emailCode.sendCode( asAnna(), address: 'ben@example.org', language: 'de', ); final unknown = await endpoints.emailCode.sendCode( asAnna(), address: ' Nobody@Example.org ', language: 'fr', ); expect(known, isNot(unknown)); expect( [for (final mail in mailbox.sent) (mail.to, mail.language)], [('ben@example.org', 'de'), ('nobody@example.org', 'en')], ); expect( mailbox.sent.map((mail) => mail.code), everyElement(matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$'))), ); }); test("a code for an address nobody has makes it the person's: they " 'become identified, on every session, and stop being anonymous; ' 'the instance keeps only a keyed hash of it', () async { final request = await endpoints.emailCode.sendCode( asAnna(), address: ' Anna@Example.org', language: 'en', ); final code = mailbox.codeFor('anna@example.org'); final accepted = await endpoints.emailCode.verifyCode( asAnna(), request: request, // As typed off the mail: lower case, a space for the dash. code: code.toLowerCase().replaceAll('-', ' '), ); expect(accepted.switchToken, isNull); final hash = addressHash(session.serverpod, 'anna@example.org'); expect(hash, matches(RegExp(r'^[0-9a-f]{64}$'))); expect(await _addressesOf(session, anna), [hash]); final person = await AuthServices.instance.authUsers.get( session, authUserId: anna, ); expect(person.scopeNames, contains(identified.name)); expect( (await ServerSideSession.db.findById( session, annasPhone, ))?.scopeNames, contains(identified.name), ); expect( await AnonymousAccount.db.count( session, where: (t) => t.authUserId.equals(anna), ), 0, ); expect(await EmailCodeRequest.db.count(session), 0); final kept = jsonEncode([ ...await EmailIdentity.db.find(session), ...await EmailCodeRequest.db.find(session), ...await RateLimitedRequestAttempt.db.find(session), ]); expect(kept.toLowerCase(), isNot(contains('anna'))); expect(kept, isNot(contains(code))); }); test('a code for the address the person signs in with changes ' 'nothing', () async { final accepted = await signInWith(asBen(), 'ben@example.org'); expect(accepted.switchToken, isNull); expect(await EmailIdentity.db.count(session), 1); }); test("a code for another person's address changes nothing here, and " 'gives a token that signs in as them, without a session', () async { final accepted = await signInWith(asAnna(), 'ben@example.org'); expect(accepted.switchToken, isNotNull); expect(await _addressesOf(session, anna), isEmpty); expect( await AnonymousAccount.db.count( session, where: (t) => t.authUserId.equals(anna), ), 1, ); final signedIn = await endpoints.emailSignIn.signIn( notSignedIn(), switchToken: accepted.switchToken!, ); expect(signedIn.authUserId, ben); expect(signedIn.scopeNames, contains(identified.name)); final sessions = await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(ben) & t.method.equals('email'), ); expect(sessions, hasLength(1)); expect(await EmailCodeRequest.db.count(session), 0); }); test('a person who signs in with GitHub adds an address', () async { Future<(bool, bool)> waysOf(TestSessionBuilder person) async { final ways = await endpoints.account.signInWays(person); return (ways.gitHub, ways.email); } expect(await waysOf(asCarla()), (true, false)); final accepted = await signInWith(asCarla(), 'carla@example.org'); expect(accepted.switchToken, isNull); expect(await waysOf(asCarla()), (true, true)); expect(await waysOf(asAnna()), (false, false)); }); test('tells the app that this instance offers it', () async { expect( (await endpoints.instance.info(notSignedIn())).emailSignIn, true, ); }); test('deleting a person deletes their address', () async { await deletePerson(session, ben); expect(await EmailIdentity.db.count(session), 0); }); test('forgets the codes past their time, hourly', () async { await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'en', ); final now = clock.now(); expect(await forgetExpiredEmailCodes(session, now: now), 0); expect( await forgetExpiredEmailCodes( session, now: now.add(const Duration(minutes: 16)), ), 1, ); expect(await EmailCodeRequest.db.count(session), 0); expect(await SecretChallenge.db.count(session), 0); }); }, ); } Matcher _refusal(EmailSignInRefusal reason) => isA().having( (exception) => exception.reason, 'reason', reason, ); /// The hashes of the addresses [person] signs in with. Future> _addressesOf(Session session, UuidValue person) async => [ for (final identity in await EmailIdentity.db.find( session, where: (t) => t.authUserId.equals(person), )) identity.addressHash, ]; /// Deletes every rate-limit record, code, address and person; their /// sessions and other identities go with them (`ON DELETE CASCADE`). Future _deleteAll(Session session) async { await RateLimitedRequestAttempt.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await EmailCodeRequest.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await SecretChallenge.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await EmailIdentity.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null)); }