import 'dart:convert'; import 'dart:typed_data'; import 'package:clock/clock.dart'; import 'package:halkyon_base_server/base.dart'; import 'package:halkyon_base_server/src/register/deletion_notices.dart'; import 'package:halkyon_base_server/src/register/instance_api.dart'; import 'package:halkyon_base_server/src/register/register_protocol.dart'; import 'package:halkyon_base_server/src/register/remote_register.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/password_device.dart'; import '../test_tools/serverpod_test_tools.dart'; const _secret = 'a shared secret only the instance knows'; final _learn = Uri.parse('http://learn.test:8082'); /// The instance's register, in memory: what a member asks it, and what it /// answers. final class _Register implements AccountRegister { final accounts = {}; final addresses = {}; final gitHub = {}; final profiles = {}; final codes = {}; /// The calls asked, by name, in their order. final asked = []; final networks = []; final deleted = []; /// What [knownAccounts] was asked about, call by call. final askedAbout = >[]; bool offered = true; /// Whether it offers password sign-in. bool passwordOffered = true; /// The accounts with a password, and those with an authenticator too. final passwords = {}; final authenticated = {}; /// The people a password was set for with a GitHub sign-in of the /// member's made just now. final freshGitHubSignIns = {}; /// When the session dismissing a warning was made, by person. final dismissedMadeAt = {}; /// Those [setPassword] was told are anonymous on the member. final anonymousHere_ = {}; /// What a password sign-in proves; null refuses it. PasswordProof? proof; /// Thrown by every call while set: the register refusing, or down. Exception? failing; /// The base URLs sign-ins were bound to, as members named them. final instanceUrls = []; /// The accounts warned that a session was ended for its wrong proofs, and /// those whose warning was dismissed. final warned = {}; final dismissed = {}; /// Thrown by [noteSessionEndedForProofs] alone while set (as [failing] /// throws from everything): the register down as a member tells it. Exception? notingFails; Future _answer(String call, T Function() answer) async { asked.add(call); if (failing case final error?) { throw error; } return answer(); } /// [person] as an account with the address [address]. void add(UuidValue person, {String? address}) { accounts.add(person); if (address != null) { addresses[address] = person; } } @override Future profileOf(Session session, UuidValue person) => _answer('profileOf', () => profiles[person]); @override Future> profilesOf( Session session, Set people, ) => _answer( 'profilesOf', () => { for (final person in people) person: ?profiles[person], }, ); @override Future profileByHandle(Session session, String handle) => _answer( 'profileByHandle', () => profiles.values .where((profile) => profile.handle.toLowerCase() == handle) .firstOrNull, ); @override Future personWithHandle(Session session, String handle) => _answer( 'personWithHandle', () => profiles.entries .where((entry) => entry.value.handle.toLowerCase() == handle) .firstOrNull ?.key, ); @override Future saveProfile( Session session, UuidValue person, { required String handle, required String displayName, required String bio, required List links, }) => _answer( 'saveProfile', () => profiles[person] = Profile( handle: handle, address: '$handle@learn.test', displayName: displayName, bio: bio, links: links, pageUrl: 'https://learn.test/@$handle', handleFixed: false, ), ); @override Future checkHandle( Session session, UuidValue person, String handle, ) => _answer('checkHandle', () => null); @override Future checkLinks(Session session, UuidValue person) => _answer('checkLinks', () => profiles[person]!); @override Future setAvatar(Session session, UuidValue person, ByteData png) => _answer('setAvatar', () => profiles[person]!); @override Future removeAvatar(Session session, UuidValue person) => _answer('removeAvatar', () => profiles[person]!); @override Future removeProfile(Session session, UuidValue person) => _answer('removeProfile', () => profiles.remove(person)); @override Future fixHandle(Session session, UuidValue person) => _answer('fixHandle', () {}); @override Future emailSignInOffered(Session session) => _answer('emailSignInOffered', () => offered); @override Future sendSignInCode( Session session, { required String address, required String language, required EmailCodePurpose purpose, required String network, }) => _answer('sendSignInCode', () { networks.add(network); final request = const Uuid().v7obj(); codes[request] = (address: address, code: 'K7QX-M2PD'); return request; }); @override Future linkGitHub( Session session, { required UuidValue person, required String gitHubUserId, }) => _answer('linkGitHub', () { accounts.add(person); gitHub[gitHubUserId] = person; }); @override Future accountWithGitHub(Session session, String gitHubUserId) => _answer('accountWithGitHub', () => gitHub[gitHubUserId]); @override Future signInWaysOf(Session session, UuidValue person) => _answer( 'signInWaysOf', () => SignInWays( gitHub: gitHub.containsValue(person), email: addresses.containsValue(person), password: passwords.contains(person), authenticator: authenticated.contains(person), passwordOnly: passwords.contains(person) && authenticated.contains(person), ), ); @override Future noteSessionEndedForProofs( Session session, UuidValue person, ) async { // Not through [_answer]: a session's tenth wrong proof comes from the // register refusing it, while [failing] refuses everything. asked.add('noteSessionEndedForProofs'); if (notingFails case final error?) { throw error; } warned.add(person); } @override Future dismissSessionEndedWarning( Session session, UuidValue person, { required DateTime? sessionMadeAt, }) => _answer('dismissSessionEndedWarning', () { dismissed.add(person); dismissedMadeAt[person] = sessionMadeAt; }); @override Future passwordSignInOffered(Session session) => _answer('passwordSignInOffered', () => passwordOffered); @override Future setPassword( Session session, { required UuidValue person, required PasswordSetup setup, UuidValue? request, String? code, bool freshGitHubSignIn = false, bool anonymousHere = false, UuidValue? keepSession, }) => _answer('setPassword', () { add(person); passwords.add(person); if (freshGitHubSignIn) { freshGitHubSignIns.add(person); } if (anonymousHere) { anonymousHere_.add(person); } }); @override Future beginPasswordSignIn( Session session, { required String address, required String network, }) => _answer('beginPasswordSignIn', () { networks.add(network); return PasswordChallenge( challenge: const Uuid().v7obj(), salt: ByteData(16), costs: newPasswordCosts, serverValue: ByteData(384), ); }); PasswordProof _proven() => proof ?? (throw PasswordRefusedException( reason: PasswordRefusal.wrongCredentials, )); @override Future completePasswordSignIn( Session session, { required UuidValue challenge, required ByteData clientValue, required ByteData clientProof, String? totpCode, String? recoveryCode, UuidValue? request, String? code, required String instanceUrl, String? address, String? language, }) { instanceUrls.add(instanceUrl); return _answer('completePasswordSignIn', _proven); } @override Future signUpWithCode( Session session, { required UuidValue request, required String code, required PasswordSetup setup, }) => _answer('signUpWithCode', () { final account = const Uuid().v7obj(); add(account); passwords.add(account); return account; }); @override Future enrolAuthenticator( Session session, { required UuidValue person, required String name, required String label, }) => _answer( 'enrolAuthenticator', () => AuthenticatorEnrolment( authenticator: const Uuid().v7obj(), uri: 'otpauth://totp/learn.test:$label', ), ); @override Future> confirmAuthenticator( Session session, { required UuidValue person, required UuidValue authenticator, required String code, OwnerProof? proof, }) => _answer('confirmAuthenticator', () { authenticated.add(person); return const ['K7QX-M2PD']; }); @override Future> authenticatorsOf( Session session, UuidValue person, ) => _answer('authenticatorsOf', () => const []); @override Future removeAuthenticator( Session session, { required UuidValue person, required UuidValue authenticator, OwnerProof? proof, }) => _answer('removeAuthenticator', () {}); @override Future> newRecoveryCodes( Session session, { required UuidValue person, required OwnerProof proof, }) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']); @override Future resetAuthenticator( Session session, { required UuidValue challenge, required ByteData clientValue, required ByteData clientProof, required String address, required String language, UuidValue? request, String? code, UuidValue? keyChallenge, ByteData? signature, required String instanceUrl, }) => _answer( 'resetAuthenticator', () => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)), ); @override Future beginKeyProof( Session session, { required String address, required String network, }) => _answer('beginKeyProof', () { networks.add(network); return KeyProofChallenge( challenge: const Uuid().v7obj(), nonce: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)), ); }); /// The moment a reset ended the account's sessions at the register. DateTime endedBefore = DateTime.utc(2026, 10, 20, 12); @override Future resetPasswordWithPhrase( Session session, { required UuidValue challenge, required ByteData signature, required NewPassword password, String? totpCode, String? recoveryCode, required String instanceUrl, String? address, String? language, }) => _answer( 'resetPasswordWithPhrase', () => (account: _proven().account, endedBefore: endedBefore), ); @override Future startAccountReset( Session session, { required String address, required String language, required UuidValue request, required String code, }) => _answer( 'startAccountReset', () => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)), ); @override Future completeAccountReset( Session session, { required String address, required UuidValue request, required String code, required PasswordSetup setup, }) => _answer( 'completeAccountReset', () => (account: _proven().account, endedBefore: endedBefore), ); /// The sessions kept by the password changes asked, by person. final changed = {}; @override Future beginPasswordChange( Session session, { required UuidValue person, }) => _answer( 'beginPasswordChange', () => PasswordChallenge( challenge: const Uuid().v7obj(), salt: ByteData(16), costs: newPasswordCosts, serverValue: ByteData(384), ), ); @override Future changePassword( Session session, { required UuidValue person, required OwnerProof proof, required NewPassword password, UuidValue? keepSession, }) => _answer('changePassword', () { changed[person] = keepSession; changedWith.add(proof); }); @override Future callOffResets( Session session, { required UuidValue person, required OwnerProof proof, }) => _answer('callOffResets', () {}); @override Future setRecoveryKeys( Session session, { required UuidValue person, required OwnerProof proof, required WrappedKeys recoveryKeys, required ByteData recoveryProofKey, }) => _answer('setRecoveryKeys', () {}); @override Future deleteAccount( Session session, UuidValue person, { DeletionProof? proof, }) => _answer('deleteAccount', () { deleted.add(person); deletedWith.add(proof); accounts.remove(person); }); /// The proofs deletions came with, as the member bound them. final deletedWith = []; final changedWith = []; @override Future> knownAccounts( Session session, Set people, ) => _answer('knownAccounts', () { askedAbout.add(people); return people.intersection(accounts); }); } void main() { withServerpod( 'A member of an instance', // Sessions issued and revoked, and deletions in transactions of their // own: these tests delete what they make instead. rollbackDatabase: RollbackDatabase.disabled, (sessionBuilder, endpoints) { late Session session; late Serverpod pod; late _Register register; /// Anonymous here, signed in on her phone. late UuidValue anna; late UuidValue annasPhone; /// An account of the register, identified here, signed in on his /// laptop. late UuidValue ben; late UuidValue bensLaptop; /// Whose data the host deleted. late List hostDeleted; setUp(() async { hostDeleted = []; useBase( sessionBuilder, accounts: MemberOfRegister(_learn), deletePersonData: [ (session, person, transaction) async { hostDeleted.add(person); return null; }, ], ); session = sessionBuilder.build(); pod = session.serverpod; await _deleteAll(session); useInstanceSecret(pod, _secret); register = _Register(); AccountRegister.use(pod, register); anna = await newAnonymousPerson(session); annasPhone = await signInNewDevice(session, anna); ben = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; register.add(ben, address: 'ben@example.org'); bensLaptop = await signInNewDevice(session, ben, scopes: {identified}); }); tearDown(() async { AccountRegister.use(pod, null); useInstanceSecret(pod, null); await _deleteAll(sessionBuilder.build()); }); TestSessionBuilder asAnna() => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( anna.uuid, {}, authId: annasPhone.uuid, ), ); TestSessionBuilder asBen() => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( ben.uuid, {identified}, authId: bensLaptop.uuid, ), ); TestSessionBuilder notSignedIn() => sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ); Future> scopesOf(UuidValue person) async => (await AuthUser.db.findById(session, person))!.scopeNames; group('signs in by asking the register', () { test('which mails the code, its device network passed on', () async { await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'de', purpose: EmailCodePurpose.signUp, ); expect(register.asked, ['sendSignInCode']); expect(register.codes.values.single.address, 'anna@example.org'); expect(register.networks, hasLength(1)); }); test('and refuses as the register refuses', () async { register.failing = EmailSignInRefusedException( reason: EmailSignInRefusal.tooManyCodes, ); await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'de', purpose: EmailCodePurpose.signUp, ), throwsA( isA().having( (error) => error.reason, 'reason', EmailSignInRefusal.tooManyCodes, ), ), ); }); test('an account signing in again is the same person here', () async { await issueSession(session, ben, method: 'test'); await issueSession(session, ben, method: 'test'); expect( await AuthUser.db.count(session, where: (t) => t.id.equals(ben)), 1, ); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 3, ); }); test('an account the register proved is identified here even where ' 'linking its way in was cut off', () async { final success = await issueSession(session, anna, method: 'test'); expect(success.authUserId, anna); expect(await scopesOf(anna), contains(identified.name)); expect( (await ServerSideSession.db.findById( session, annasPhone, ))!.scopeNames, contains(identified.name), ); }); test('email sign-in is offered as the register offers it', () async { expect( (await endpoints.instance.basics(notSignedIn())).emailSignIn, isTrue, ); register.offered = false; expect( (await endpoints.instance.basics(notSignedIn())).emailSignIn, isFalse, ); }); test('the basics still answer while the register is out of reach, ' 'without email sign-in', () async { register.failing = RegisterUnavailableException('test'); final basics = await endpoints.instance.basics(notSignedIn()); expect(basics.emailSignIn, isFalse); expect(basics.app, testApp); }); test('the ways in are those the register knows', () async { register.gitHub['1001'] = ben; final ways = await endpoints.account.signInWays(asBen()); expect(ways.toJson(), SignInWays(gitHub: true, email: true).toJson()); }); }); group('passwords, by asking the register (ADR 0017)', () { test('sign the account the register proved in here, made a person ' 'here the first time, the device network passed on', () async { final carla = const Uuid().v7obj(); register.proof = PasswordProof( account: carla, serverProof: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), needsAuthenticator: false, ); final challenge = await endpoints.passwordSignIn.begin( notSignedIn(), address: 'carla@example.org', ); final signedIn = await endpoints.passwordSignIn.complete( notSignedIn(), challenge: challenge.challenge, clientValue: ByteData(384), clientProof: ByteData(32), totpCode: '123456', instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ); expect(register.asked, [ 'beginPasswordSignIn', 'completePasswordSignIn', ]); expect(register.networks, hasLength(1)); // Bound to the base URL the device addressed: this member's. expect(register.instanceUrls, [testInstanceUrl]); expect(signedIn.authUserId, carla); expect(await scopesOf(carla), {identified.name}); expect( (await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(carla), )).single.method, passwordMethod, ); }); test('carrying out a reset of the authenticators, end the sessions ' 'here made before the register ended them, the new one staying ' '(the fourth reading)', () async { final endedBefore = DateTime.now().toUtc(); register.proof = PasswordProof( account: anna, serverProof: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), needsAuthenticator: true, sessionsEndedBefore: endedBefore, ); final challenge = await endpoints.passwordSignIn.begin( notSignedIn(), address: 'anna@example.org', ); final signedIn = await endpoints.passwordSignIn.complete( notSignedIn(), challenge: challenge.challenge, clientValue: ByteData(384), clientProof: ByteData(32), instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ); expect(signedIn.authUserId, anna); expect( await ServerSideSession.db.findById(session, annasPhone), isNull, ); final left = await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(anna), ); expect(left, hasLength(1)); expect(left.single.createdAt.isBefore(endedBefore), isFalse); }); test("count a proof whose answer the register couldn't give, as it " 'may have checked it (the fourth reading)', () async { register.failing = RegisterUnavailableException('test'); await expectLater( endpoints.authenticator.newRecoveryCodes( asAnna(), proof: OwnerProof( challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), instanceUrl: testInstanceUrl, totpCode: '123456', ), ), throwsA(isA()), ); expect( (await SessionCodeFailures.db.findFirstRow( session, where: (t) => t.sessionId.equals(annasPhone), ))?.failures, 1, ); }); test('sign up at the register, the new account signed in here, ' 'identified', () async { final device = await PasswordDevice.make(); final signedUp = await endpoints.passwordSignIn.signUp( notSignedIn(), request: const Uuid().v7obj(), code: 'K7QX-M2PD', setup: device.setup(), ); expect(register.asked, ['signUpWithCode']); expect(register.passwords, {signedUp.authUserId}); expect(await scopesOf(signedUp.authUserId), {identified.name}); expect( (await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(signedUp.authUserId), )).single.method, passwordMethod, ); }); test('refuse as the register refuses, signing nobody in', () async { final people = await AuthUser.db.count(session); await expectLater( endpoints.passwordSignIn.complete( notSignedIn(), challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ), throwsA( isA().having( (error) => error.reason, 'reason', PasswordRefusal.wrongCredentials, ), ), ); expect(await AuthUser.db.count(session), people); }); test('set one at the register, the person becoming identified ' 'here', () async { final device = await PasswordDevice.make(); await endpoints.password.set( asAnna(), setup: device.setup(), request: const Uuid().v7obj(), code: 'K7QX-M2PD', ); expect(register.passwords, {anna}); expect(register.freshGitHubSignIns, isEmpty); // Anna was anonymous here, which the register can't know (the // full reading's M1). expect(register.anonymousHere_, {anna}); expect(await scopesOf(anna), contains(identified.name)); expect( await AnonymousAccount.db.count( session, where: (t) => t.authUserId.equals(anna), ), 0, ); }); test( 'tell the register whether the session asking was made by a ' 'GitHub sign-in of the last ten minutes (the fourth reading)', () async { final device = await PasswordDevice.make(); Future setFrom(UuidValue device_) => endpoints.password.set( sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( anna.uuid, {}, authId: device_.uuid, ), ), setup: device.setup(), request: const Uuid().v7obj(), code: 'K7QX-M2PD', ); final old = await withClock( Clock.fixed(DateTime.now().subtract(const Duration(minutes: 11))), () => signInNewDevice(session, anna, method: gitHubMethod), ); await setFrom(old); expect(register.freshGitHubSignIns, isEmpty); await setFrom(annasPhone); expect(register.freshGitHubSignIns, isEmpty); await setFrom( await signInNewDevice(session, anna, method: gitHubMethod), ); expect(register.freshGitHubSignIns, {anna}); }, ); test('are offered as the register offers them, and not while it is ' 'out of reach', () async { expect( (await endpoints.instance.basics(notSignedIn())).passwordSignIn, isTrue, ); register.passwordOffered = false; expect( (await endpoints.instance.basics(notSignedIn())).passwordSignIn, isFalse, ); register ..passwordOffered = true ..failing = RegisterUnavailableException('test'); expect( (await endpoints.instance.basics(notSignedIn())).passwordSignIn, isFalse, ); }); test('keep GitHub from signing into an account that has one, as ' 'the register knows it', () async { register.gitHub['1001'] = ben; expect(await gitHubAccountToSignIn(session, '1001'), ben); register ..passwords.add(ben) ..authenticated.add(ben); await expectLater( gitHubAccountToSignIn(session, '1001'), throwsA( isA().having( (error) => error.reason, 'reason', GitHubSignInRefusal.usePassword, ), ), ); }); }); group('profiles', () { test('are saved and found at the register', () async { final saved = await endpoints.profile.save( asBen(), 'Ben', 'Ben', '', const [], ); expect(register.profiles[ben]!.toJson(), saved.toJson()); expect( (await endpoints.profiles.byHandle(asAnna(), 'ben')).toJson(), saved.toJson(), ); expect((await profileOf(session, ben))!.toJson(), saved.toJson()); expect(await PublicProfile.db.count(session), 0); }); test("have their page at the register's address, where this " "server's sends its visitors", () async { await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []); final page = await profilePage( session, '@ben', host: 'todo.test', ); expect(page.status, 308); expect(page.location, 'https://learn.test/@Ben'); }); }); group('deleting the account', () { test('of an identified person deletes it at the register, which ' 'tells every member, and here at once', () async { await endpoints.account.delete(asBen()); expect(register.deleted, [ben]); expect(await AuthUser.db.findById(session, ben), isNull); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 0, ); expect(hostDeleted, [ben]); expect(await AuthUser.db.findById(session, anna), isNotNull); }); test("passes the password's proof on to the register, bound to " 'the base URL the device addressed here', () async { await endpoints.account.delete( asBen(), proof: DeletionProof( challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), totpCode: '123456', instanceUrl: 'HTTP://LOCALHOST:0', ), ); expect(register.deleted, [ben]); expect(register.deletedWith.single?.instanceUrl, testInstanceUrl); expect(register.deletedWith.single?.totpCode, '123456'); }); test('of an anonymous person deletes it here alone, once the ' 'register says it has no such account', () async { await endpoints.account.delete(asAnna()); expect(register.asked, ['knownAccounts']); expect(await AuthUser.db.findById(session, anna), isNull); expect(hostDeleted, [anna]); }); test( 'of a person the register knows deletes it at the register, ' "whatever this session's scopes say (the full reading's L7)", () async { // Linked at the register, her becoming identified here failed. register.accounts.add(anna); await endpoints.account.delete(asAnna()); expect(register.deleted, [anna]); }, ); test('is refused while the register is out of reach, deleting ' 'nothing here', () async { register.failing = RegisterUnavailableException('test'); await expectLater( endpoints.account.delete(asBen()), throwsA(isA()), ); expect(await AuthUser.db.findById(session, ben), isNotNull); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 1, ); expect(hostDeleted, isEmpty); }); }); group('told by the register that an account is gone', () { Future tell( Object? account, { String? authorization = 'Bearer $_secret', }) => answerRegister( session, jsonEncode({'account': account}), authorization: authorization, ); test('deletes that person here with their sessions, and nobody ' 'else', () async { final answer = await tell(ben.uuid); expect(answer.status, 200); expect(await AuthUser.db.findById(session, ben), isNull); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 0, ); expect(hostDeleted, [ben]); expect(await AuthUser.db.findById(session, anna), isNotNull); expect(register.asked, isEmpty); }); test('confirms an account it never had', () async { expect((await tell(const Uuid().v7())).status, 200); expect(hostDeleted, isEmpty); }); test('refuses without the instance secret, or with a wrong one, ' 'deleting nobody', () async { for (final authorization in [ null, 'Bearer wrong', 'Basic $_secret', ]) { expect( (await tell(ben.uuid, authorization: authorization)).status, 401, ); } useInstanceSecret(pod, null); expect((await tell(ben.uuid)).status, 401); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(hostDeleted, isEmpty); }); test('refuses what it cannot read', () async { for (final account in [null, 7, 'not an id']) { expect((await tell(account)).status, 400, reason: '$account'); } expect( (await answerRegister( session, 'not JSON', authorization: 'Bearer $_secret', )).status, 400, ); expect(await AuthUser.db.findById(session, ben), isNotNull); }); test('is answered by no register', () async { useBase(sessionBuilder); expect((await tell(ben.uuid)).status, 404); expect(await AuthUser.db.findById(session, ben), isNotNull); }); }); group('told by the register that sessions ended (ADR 0017)', () { Future tell( Map body, { String? authorization = 'Bearer $_secret', }) => answerSessionsEnded( session, jsonEncode(body), authorization: authorization, ); Future createdAt(UuidValue id) async => (await ServerSideSession.db.findById(session, id))!.createdAt; test('ends those of the account made before the moment, but the one ' 'kept and those made since, and nobody else', () async { final bensPhone = await signInNewDevice(session, ben); final endedBefore = (await createdAt( bensPhone, )).add(const Duration(microseconds: 1)); await Future.delayed(const Duration(milliseconds: 5)); final bensTablet = await signInNewDevice(session, ben); final answer = await tell({ 'account': ben.uuid, 'endedBefore': endedBefore.toIso8601String(), 'keepSession': bensLaptop.uuid, }); expect(answer.status, 200); final left = { for (final row in await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(ben), )) row.id!, }; expect(left, {bensLaptop, bensTablet}); expect( await ServerSideSession.db.findById(session, annasPhone), isNotNull, ); expect(register.asked, isEmpty); }); test( "a moment sent with the register's clock is taken by this " "server's own: a clock ahead ends the sessions made here in its " "gap, those made since stay (the reading of the fixes' C1)", () async { // This server runs 60 s ahead of the register. By its clock, a // session was made 20 s ago, the register ended the account's // sessions 10 s ago, and another was made 5 s ago. final now = DateTime.now().toUtc(); Future madeAgo(Duration ago) async { final id = await signInNewDevice(session, ben); final row = (await ServerSideSession.db.findById(session, id))!; await ServerSideSession.db.updateRow( session, row.copyWith(createdAt: now.subtract(ago)), ); return id; } final inTheGap = await madeAgo(const Duration(seconds: 20)); final since = await madeAgo(const Duration(seconds: 5)); const ahead = Duration(seconds: 60); final endedBefore = now .subtract(const Duration(seconds: 10)) .subtract(ahead); final answer = await tell({ 'account': ben.uuid, 'endedBefore': endedBefore.toIso8601String(), 'sentAt': now.subtract(ahead).toIso8601String(), }); expect(answer.status, 200); expect( await ServerSideSession.db.findById(session, inTheGap), isNull, ); expect( await ServerSideSession.db.findById(session, since), isNotNull, ); }, ); test('a moment sent before it was taken (a clock set back) ends ' 'every session made here until now, rather than none', () async { final now = DateTime.now().toUtc(); final bensPhone = await signInNewDevice(session, ben); final answer = await tell({ 'account': ben.uuid, 'endedBefore': now .subtract(const Duration(hours: 1)) .toIso8601String(), 'sentAt': now.subtract(const Duration(hours: 2)).toIso8601String(), }); expect(answer.status, 200); expect( await ServerSideSession.db.findById(session, bensPhone), isNull, ); expect( (await tell({ 'account': ben.uuid, 'endedBefore': now.toIso8601String(), 'sentAt': 'then', })).status, 400, ); }); test('refuses without the secret and what it cannot read, and is ' 'answered by no register, ending nothing', () async { final everything = { 'account': ben.uuid, 'endedBefore': DateTime.now().toUtc().toIso8601String(), }; expect((await tell(everything, authorization: null)).status, 401); expect( (await tell(everything, authorization: 'Bearer wrong')).status, 401, ); expect( (await tell({'account': ben.uuid, 'endedBefore': 'soon'})).status, 400, ); expect((await tell({'account': ben.uuid})).status, 400); useBase(sessionBuilder); expect((await tell(everything)).status, 404); expect( await ServerSideSession.db.findById(session, bensLaptop), isNotNull, ); }); }); group('changing a password, by asking the register (ADR 0017)', () { test('keeps the session asking: its ID goes to the register, which ' 'tells every service', () async { final challenge = await endpoints.password.beginChange(asBen()); await endpoints.password.change( asBen(), proof: OwnerProof( challenge: challenge.challenge, clientValue: ByteData(384), clientProof: ByteData(32), instanceUrl: 'HTTP://LOCALHOST:0', totpCode: '123456', ), password: NewPassword( salt: ByteData(16), costs: newPasswordCosts, verifier: ByteData(384), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), ), ); expect(register.asked, ['beginPasswordChange', 'changePassword']); expect(register.changed, {ben: bensLaptop}); // Bound to the base URL the device addressed here. expect(register.changedWith.single.instanceUrl, testInstanceUrl); expect(register.changedWith.single.totpCode, '123456'); }); test("counts its own session's wrong proofs, which the register " 'refuses: the tenth in a row ends the session here', () async { register.failing = PasswordRefusedException( reason: PasswordRefusal.wrongCredentials, ); Future> newCodes() => endpoints.authenticator.newRecoveryCodes( asBen(), proof: OwnerProof( challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), instanceUrl: testInstanceUrl, totpCode: '000000', ), ); for (var i = 0; i < 9; i++) { await expectLater( newCodes(), throwsA( isA().having( (refusal) => refusal.reason, 'reason', PasswordRefusal.wrongCredentials, ), ), ); } expect(register.warned, isEmpty); await expectLater( newCodes(), throwsA( isA().having( (refusal) => refusal.reason, 'reason', PasswordRefusal.sessionEnded, ), ), ); expect( await ServerSideSession.db.findById(session, bensLaptop), isNull, ); // The owner's warning is the register's to keep. expect(register.warned, {ben}); }); test('ends the session all the same when the register, down, ' "can't be told to warn its owner", () async { register ..failing = PasswordRefusedException( reason: PasswordRefusal.wrongCredentials, ) ..notingFails = RegisterUnavailableException('test'); for (var i = 0; i < 10; i++) { await expectLater( endpoints.authenticator.newRecoveryCodes( asBen(), proof: OwnerProof( challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), instanceUrl: testInstanceUrl, totpCode: '000000', ), ), throwsA( isA().having( (refusal) => refusal.reason, 'reason', i < 9 ? PasswordRefusal.wrongCredentials : PasswordRefusal.sessionEnded, ), ), ); } expect( await ServerSideSession.db.findById(session, bensLaptop), isNull, ); expect(register.asked.last, 'noteSessionEndedForProofs'); expect(register.warned, isEmpty); }); test("passes its person's calling off of resets to the register, " 'never one signed out', () async { await endpoints.password.callOffResets( asBen(), proof: OwnerProof( challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), totpCode: '123456', instanceUrl: testInstanceUrl, ), ); expect(register.asked, contains('callOffResets')); register.asked.clear(); await expectLater( endpoints.password.callOffResets( sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ), proof: OwnerProof( challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), instanceUrl: testInstanceUrl, ), ), throwsA(isA()), ); expect(register.asked, isNot(contains('callOffResets'))); }); test("passes its person's dismissing of the warning to the register, " "never another's", () async { await endpoints.password.dismissSessionEndedWarning(asBen()); expect(register.dismissed, {ben}); // With when Ben's session here was made (the full reading's L1). expect(register.dismissedMadeAt[ben], isNotNull); await expectLater( endpoints.password.dismissSessionEndedWarning( sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ), ), throwsA(isA()), ); expect(register.dismissed, {ben}); }); test('a reset ends the sessions here made before the register ended ' 'them, the new one made after', () async { register ..proof = PasswordProof( account: ben, serverProof: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), needsAuthenticator: false, ) ..endedBefore = DateTime.now().toUtc(); final signedIn = await endpoints.accountRecovery.resetPassword( notSignedIn(), challenge: const Uuid().v7obj(), signature: ByteData(64), password: NewPassword( salt: ByteData(16), costs: newPasswordCosts, verifier: ByteData(384), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), ), totpCode: '123456', instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ); expect(signedIn.authUserId, ben); final left = await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(ben), ); expect(left.single.id, isNot(bensLaptop)); expect( left.single.createdAt.isBefore(register.endedBefore), isFalse, ); }); PasswordProof bensProof() => PasswordProof( account: ben, serverProof: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), needsAuthenticator: false, ); Future resetBensPassword() => endpoints.accountRecovery.resetPassword( notSignedIn(), challenge: const Uuid().v7obj(), signature: ByteData(64), password: NewPassword( salt: ByteData(16), costs: newPasswordCosts, verifier: ByteData(384), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), ), totpCode: '123456', instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ); test("a clock behind the register's neither waits nor refuses: the " "sessions here end, and the new one stays when the register's " 'notice, sent with its clock, arrives', () async { final endedBefore = DateTime.now().toUtc().add( const Duration(minutes: 1), ); register ..proof = bensProof() ..endedBefore = endedBefore; final watch = Stopwatch()..start(); final signedIn = await resetBensPassword(); expect(watch.elapsed, lessThan(const Duration(seconds: 5))); final left = await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(ben), ); expect(left.single.id, isNot(bensLaptop)); expect(signedIn.authUserId, ben); final answer = await answerSessionsEnded( session, jsonEncode({ 'account': ben.uuid, 'endedBefore': endedBefore.toIso8601String(), 'sentAt': endedBefore .add(const Duration(seconds: 1)) .toIso8601String(), }), authorization: 'Bearer $_secret', ); expect(answer.status, 200); expect( await ServerSideSession.db.findById(session, left.single.id!), isNotNull, ); }); test("a clock ahead of the register's ends the sessions made here in " "its gap too (the reading of the fixes' C1)", () async { final now = DateTime.now().toUtc(); register ..proof = bensProof() ..endedBefore = now.subtract(const Duration(minutes: 1)); // Whoever knew the old password signed in here 20 s before the // reset: by this server's clock, 40 s after the register's moment. final inTheGap = await signInNewDevice( session, ben, scopes: {identified}, ); final row = (await ServerSideSession.db.findById(session, inTheGap))!; await ServerSideSession.db.updateRow( session, row.copyWith(createdAt: now.subtract(const Duration(seconds: 20))), ); final signedIn = await resetBensPassword(); expect(signedIn.authUserId, ben); expect( await ServerSideSession.db.findById(session, inTheGap), isNull, ); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 1, ); }); }); group('pruning', () { late UuidValue carla; setUp(() async { carla = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; }); test('deletes the identified people the register no longer knows, ' 'asking only about those', () async { final forgotten = await forgetPeopleTheRegisterForgot( session, batch: 1, ); expect(forgotten, 1); expect(await AuthUser.db.findById(session, carla), isNull); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await AuthUser.db.findById(session, anna), isNotNull); expect(register.askedAbout.expand((people) => people), {ben, carla}); }); test('deletes nobody while the register knows none of those asked ' 'about, as a register misnamed or emptied would say', () async { register.accounts.clear(); expect(await forgetPeopleTheRegisterForgot(session), 0); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await AuthUser.db.findById(session, carla), isNotNull); }); test('deletes nobody while the register is out of reach', () async { register.failing = RegisterUnavailableException('test'); await expectLater( forgetPeopleTheRegisterForgot(session), throwsA(isA()), ); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await AuthUser.db.findById(session, carla), isNotNull); }); test('does nothing on the register', () async { useBase(sessionBuilder); expect(await forgetPeopleTheRegisterForgot(session), 0); expect(register.asked, isEmpty); expect(await AuthUser.db.findById(session, carla), isNotNull); }); }); test('does not start without the secret where it would need it, and ' 'needs none alone', () { expect(() => checkInstanceSecret(pod), returnsNormally); useInstanceSecret(pod, null); expect(() => checkInstanceSecret(pod), throwsStateError); useBase(sessionBuilder, accounts: HoldsRegister(members: [_learn])); expect(() => checkInstanceSecret(pod), throwsStateError); useBase(sessionBuilder); expect(() => checkInstanceSecret(pod), returnsNormally); }); test('does not start with a secret shorter than 32 characters, alone ' 'or not: it gates every account of the instance', () { for (final accounts in [ const HoldsRegister(), HoldsRegister(members: [_learn]), MemberOfRegister(_learn), ]) { useBase(sessionBuilder, accounts: accounts); useInstanceSecret(pod, 'x' * 31); expect(() => checkInstanceSecret(pod), throwsStateError); useInstanceSecret(pod, 'x' * 32); expect(() => checkInstanceSecret(pod), returnsNormally); } }); test('asks the register at its address with the secret, and asks ' 'nothing without one', () { AccountRegister.use(pod, null); expect( AccountRegister.of(pod), isA() .having((remote) => remote.register, 'register', _learn) .having((remote) => remote.secret, 'secret', _secret), ); useInstanceSecret(pod, null); expect(() => AccountRegister.of(pod), throwsStateError); expect( MemberNotifier.of(pod), isA().having( (notifier) => notifier.secret, 'secret', isNull, ), ); }); }, ); } Future _deleteAll(Session session) async { await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null)); }