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:halkyon_base_server/src/register/sessions_ended.dart' show sessionNoticesDelivered; import 'package:halkyon_oidc_server/oidc.dart'; import 'package:halkyon_oidc_server/testing.dart'; import 'package:http/http.dart' as http; import 'package:http/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/password_device.dart'; import '../test_tools/serverpod_test_tools.dart'; const _secret = 'a shared secret only the instance knows'; final _todo = Uri.parse('http://todo.test:8082'); final _train = Uri.parse('http://train.test:8082'); /// Keeps every code mailed, in place of a mail service. final class _Mailbox implements CodeMailer { final sent = <({String to, String code})>[]; @override Future send( Session session, { required String to, required String code, required EmailCodePurpose purpose, required String language, }) async => sent.add((to: to, code: code)); @override Future sendResetNotice( Session session, { required String to, required AccountResetKind kind, required DateTime takesEffectAt, required String language, }) async {} @override Future sendCodesWaitNotice( Session session, { required String to, required DateTime until, required String language, }) async {} String codeFor(String address) => sent.lastWhere((mail) => mail.to == address).code; } /// The members as the register tells them, each answering as [answers] /// says (confirming by default). final class _Members implements MemberNotifier { final told = <(Uri, UuidValue)>[]; /// The sessions ended they were told of: member, account, moment, kept. final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[]; bool Function(Uri member) answers = (_) => true; @override Future accountGone(Uri member, UuidValue account) async { told.add((member, account)); return answers(member); } @override Future sessionsEnded( Uri member, UuidValue account, { required DateTime endedBefore, UuidValue? keepSession, }) async { sessionsTold.add((member, account, endedBefore, keepSession)); return answers(member); } } void main() { withServerpod( 'The register answering its members', // Mailing codes counts attempts in transactions of their own // (Serverpod's rate limiter), which the test's rollback can't wrap. rollbackDatabase: RollbackDatabase.disabled, (sessionBuilder, endpoints) { late Session session; late Serverpod pod; late _Mailbox mailbox; late _Members members; /// The register as a member's server asks it, over HTTP into this /// server's answers. late RemoteRegister remote; /// Identified, signing in with ben@example.org and GitHub 1001. late UuidValue ben; setUp(() async { useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo])); session = sessionBuilder.build(); pod = session.serverpod; await _deleteAll(session); useInstanceSecret(pod, _secret); mailbox = _Mailbox(); EmailCodes.of(pod).mailer = mailbox; members = _Members(); MemberNotifier.use(pod, members); remote = RemoteRegister( Uri.parse('http://learn.test:8082'), secret: _secret, client: () => MockClient((request) async { final answer = await answerMember( session, request.url.pathSegments.last, request.body, authorization: request.headers['authorization'], ); return http.Response.bytes( utf8.encode(jsonEncode(answer.body)), answer.status, ); }), ); ben = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; await EmailIdentity.db.insertRow( session, EmailIdentity( authUserId: ben, addressHash: addressHash(pod, 'ben@example.org'), createdAt: clock.now(), ), ); await GitHubAccount.db.insertRow( session, GitHubAccount(userIdentifier: '1001', authUserId: ben), ); }); tearDown(() async { EmailCodes.of(pod).mailer = null; MemberNotifier.use(pod, null); useInstanceSecret(pod, null); await _deleteAll(sessionBuilder.build()); }); Future ask( String operation, Map arguments, { String? authorization = 'Bearer $_secret', }) => answerMember( session, operation, jsonEncode(arguments), authorization: authorization, ); Matcher refused(Object reason) => throwsA( isA().having( (error) => refusalJson(error)?['reason'], 'reason', (reason as Enum).name, ), ); group('refuses', () { test('a call without the instance secret, with a wrong one or in ' 'another scheme, doing nothing', () async { for (final authorization in [ null, '', 'Bearer', 'Bearer ', 'Bearer not the secret', 'Bearer $_secret ', 'Basic $_secret', _secret, ]) { final answer = await ask(RegisterOperation.deleteAccount, { 'person': ben.uuid, }, authorization: authorization); expect(answer.status, 401, reason: authorization); } expect(await AuthUser.db.findById(session, ben), isNotNull); }); test( 'every call on a register without a secret, whatever is sent', () async { useInstanceSecret(pod, null); for (final authorization in [null, 'Bearer ', 'Bearer null']) { final answer = await ask(RegisterOperation.deleteAccount, { 'person': ben.uuid, }, authorization: authorization); expect(answer.status, 401, reason: authorization); } expect(await AuthUser.db.findById(session, ben), isNotNull); }, ); test('register calls on a member, which holds no register', () async { useBase( sessionBuilder, accounts: MemberOfRegister(Uri.parse('http://learn.test:8082')), ); final answer = await ask(RegisterOperation.knownAccounts, { 'people': [ben.uuid], }); expect(answer.status, 404); }); test('what it cannot read, and operations it does not know', () async { for (final body in ['', 'not JSON', '[]', '"ben"', 'null']) { final answer = await answerMember( session, RegisterOperation.knownAccounts, body, authorization: 'Bearer $_secret', ); expect(answer.status, 400, reason: body); } for (final arguments in >[ {}, {'people': 'not a list'}, { 'people': [1, 2], }, { 'people': ['not an id'], }, ]) { final answer = await ask( RegisterOperation.knownAccounts, arguments, ); expect(answer.status, 400, reason: '$arguments'); } expect( (await ask(RegisterOperation.deleteAccount, { 'person': 'not an id', })).status, 400, ); expect((await ask('drop-everything', {})).status, 404); expect(await AuthUser.db.findById(session, ben), isNotNull); }); }); group('profiles', () { test('are saved, read and fixed for a member as for this server, ' 'the same handle never twice', () async { final anna = const Uuid().v7obj(); final saved = await remote.saveProfile( session, anna, handle: 'Anna', displayName: ' Anna ', bio: 'Learning Greek.', links: ['https://anna.example.org'], ); expect(saved.handle, 'Anna'); expect(saved.displayName, 'Anna'); expect( (await remote.profileOf(session, anna))!.toJson(), saved.toJson(), ); expect((await profileOf(session, anna))!.toJson(), saved.toJson()); expect( (await remote.profileByHandle(session, 'anna'))!.toJson(), saved.toJson(), ); expect(await remote.personWithHandle(session, 'ANNA'), anna); expect((await remote.profilesOf(session, {anna, ben})).keys, [anna]); expect( await remote.checkHandle( session, anna, 'Anna', network: '203.0.113.7', ), isNull, ); expect( await remote.checkHandle( session, ben, 'anna', network: '203.0.113.7', ), ProfileRefusal.handleTaken, ); await expectLater( remote.saveProfile( session, ben, handle: 'anna', displayName: '', bio: '', links: const [], ), refused(ProfileRefusal.handleTaken), ); await remote.fixHandle(session, anna); expect((await remote.profileOf(session, anna))!.handleFixed, isTrue); await expectLater( remote.removeProfile(session, anna), refused(ProfileRefusal.published), ); }); test("not public yet are nobody's to a member's apps, while its code " 'still finds whose a handle is (D5)', () async { final anna = const Uuid().v7obj(); await remote.saveProfile( session, anna, handle: 'Anna', displayName: '', bio: '', links: const [], network: '198.51.100.7', ); expect(await remote.profileByHandle(session, 'anna'), isNull); expect(await remote.personWithHandle(session, 'anna'), anna); expect((await remote.profileOf(session, anna))!.handle, 'Anna'); }); test("count a member's answers about handles on the register, by " "the person and the visitor's network", () async { final anna = const Uuid().v7obj(); await remote.saveProfile( session, anna, handle: 'Anna', displayName: '', bio: '', links: const [], network: '198.51.100.7', ); await remote.checkHandle( session, anna, 'Anna_G', network: '198.51.100.7', ); final counted = await RateLimitedRequestAttempt.db.find( session, where: (t) => t.domain.equals('profile'), ); expect( counted.map((note) => (note.source, note.key)).toSet(), { ('handle_checks', anna.uuid), ('handle_checks_per_network', '198.51.100.7'), }, ); expect(counted, hasLength(4)); }); test('missing are said to be so', () async { final nobody = const Uuid().v7obj(); expect(await remote.profileOf(session, nobody), isNull); expect(await remote.profileByHandle(session, 'nobody'), isNull); expect(await remote.personWithHandle(session, 'nobody'), isNull); expect(await remote.profilesOf(session, {nobody}), isEmpty); await expectLater( remote.removeAvatar(session, nobody), throwsA(isA()), ); await expectLater( remote.checkLinks(session, nobody), throwsA(isA()), ); }); }); group('email sign-in', () { test('is offered as the register offers it', () async { expect(await remote.emailSignInOffered(session), isTrue); EmailCodes.of(pod).mailer = null; expect(await remote.emailSignInOffered(session), isFalse); }); test("counts the codes a member asks for by its device's network, " "not the member's", () async { for (var i = 0; i < 10; i++) { await remote.sendSignInCode( session, address: 'person$i@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.signUp, ); } await expectLater( remote.sendSignInCode( session, address: 'person10@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.signUp, ), refused(EmailSignInRefusal.tooManyCodes), ); await remote.sendSignInCode( session, address: 'person10@example.org', language: 'en', network: '198.51.100.4', purpose: EmailCodePurpose.signUp, ); expect(mailbox.sent, hasLength(11)); }); test("a code proves the address for a member's password, and a " 'wrong one refuses it, keeping nothing', () async { usePasswordKeys(pod, testPasswordKeys); addTearDown(() => usePasswordKeys(pod, null)); final device = await PasswordDevice.make(); final annaOnTodo = const Uuid().v7obj(); final request = await remote.sendSignInCode( session, address: 'Anna@example.org', language: 'de', network: '203.0.113.7', purpose: EmailCodePurpose.signUp, ); await expectLater( remote.setPassword( session, person: annaOnTodo, anonymousHere: true, setup: device.setup(), request: request, code: 'AAAA-AAAA', ), refused(EmailSignInRefusal.wrongCode), ); expect(await AuthUser.db.findById(session, annaOnTodo), isNull); // Without the member saying Anna is anonymous there, refused // before her code is used (the full reading's M1). await expectLater( remote.setPassword( session, person: annaOnTodo, setup: device.setup(), request: request, code: mailbox.codeFor('anna@example.org'), ), refused(PasswordRefusal.wrongCredentials), ); await remote.setPassword( session, person: annaOnTodo, anonymousHere: true, setup: device.setup(), request: request, code: mailbox.codeFor('anna@example.org'), ); expect( (await AuthUser.db.findById(session, annaOnTodo))!.scopeNames, contains(identified.name), ); expect(await remote.knownAccounts(session, {annaOnTodo, ben}), { annaOnTodo, ben, }); }); }); group('GitHub', () { test("links a member's person, who becomes an account under the " 'same ID, found by it afterwards', () async { final annaOnTodo = const Uuid().v7obj(); await remote.linkGitHub( session, person: annaOnTodo, gitHubUserId: '2002', ); expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo); expect(await remote.accountWithGitHub(session, '1001'), ben); expect(await remote.accountWithGitHub(session, '3003'), isNull); expect( (await AuthUser.db.findById(session, annaOnTodo))!.scopeNames, contains(identified.name), ); }); test( "refuses another person's GitHub account, keeping nothing", () async { final annaOnTodo = const Uuid().v7obj(); await expectLater( remote.linkGitHub( session, person: annaOnTodo, gitHubUserId: '1001', ), refused(GitHubSignInRefusal.takenByAnotherPerson), ); expect(await AuthUser.db.findById(session, annaOnTodo), isNull); }, ); }); group('SimpleLogin (ADR 0020)', () { late TestKey key; late FakeProvider simpleLogin; setUpAll(() => key = TestKey.generate('sl', seed: 12)); setUp(() { 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); }); tearDown(() => useSimpleLoginApp(pod, null)); test('is offered as the register offers it', () async { expect(await remote.simpleLoginOffered(session), isTrue); useSimpleLogin(sessionBuilder, null); expect(await remote.simpleLoginOffered(session), isFalse); }); test("begins for a member's app, signed in or out, and signs in " 'the account with that identity', () async { await OidcIdentity.db.insertRow( session, OidcIdentity( authUserId: ben, provider: simpleLoginProvider, subject: 'sl-ben', ), ); final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); final proof = await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: 'sl-ben', ), ); expect(start.state, startsWith('todo-loopback.')); expect(proof.outcome, SimpleLoginOutcome.signedIn); expect(proof.account, ben); }); test("adds SimpleLogin to a member's person, who becomes an account " 'under the same ID', () async { final annaOnTodo = const Uuid().v7obj(); final start = await remote.beginSimpleLogin( session, marker: 'todo-scheme', caller: annaOnTodo, network: '203.0.113.0/24', ); final proof = await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: 'sl-anna', ), caller: annaOnTodo, ); expect(proof.outcome, SimpleLoginOutcome.linked); expect(proof.account, annaOnTodo); expect( (await AuthUser.db.findById(session, annaOnTodo))!.scopeNames, contains(identified.name), ); expect( (await remote.signInWaysOf(session, annaOnTodo)).simpleLogin, isTrue, ); }); test('passes on the handle suggested to someone new', () async { final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); final proof = await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: 'sl-carla', claims: {'name': 'Carla Müller'}, ), ); expect(proof.outcome, SimpleLoginOutcome.newcomer); expect(proof.account, isNull); expect(proof.suggestedHandle, 'carla_mueller'); }); test("refuses as the register refuses, halkyon_oidc's refusals " 'crossing whole', () async { final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); final code = simpleLogin.authorize( start.authorizationUrl, subject: 'sl-carla', ); await expectLater( remote.completeSimpleLogin( session, attempt: start.attempt, code: code, caller: const Uuid().v7obj(), ), throwsA( isA().having( (error) => error.reason, 'reason', OidcRefusal.wrongCaller, ), ), ); await expectLater( remote.completeSimpleLogin( session, attempt: const Uuid().v7obj(), code: code, ), refused(OidcRefusal.unknownAttempt), ); }); test("signs up someone new for a member's app, the handle checked " 'first, whose keys then unlock with the password', () async { final device = await KeysDevice.make(); final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: 'sl-carla', ), ); expect( await remote.checkSimpleLoginHandle( session, attempt: start.attempt, handle: 'carla', network: '203.0.113.0/24', ), isNull, ); final carla = await remote.signUpWithSimpleLogin( session, attempt: start.attempt, handle: 'carla', setup: device.setup(), network: '203.0.113.0/24', ); expect( (await AuthUser.db.findById(session, carla))!.scopeNames, {identified.name}, ); final ways = await remote.signInWaysOf(session, carla); expect( (ways.simpleLogin, ways.keys, ways.password), ( true, true, false, ), ); final keys = await remote.unlockKeys( session, carla, await device.prove( await remote.beginPasswordKeyProof(session, carla), ), ); expect( Uint8List.sublistView(keys.signingPublicKey), device.signingPublicKey, ); }); test('refuses a sign-up and keys as the register refuses, a ' "handle's refusals crossing whole", () async { final device = await KeysDevice.make(); final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: 'sl-carla', ), ); expect( await remote.checkSimpleLoginHandle( session, attempt: start.attempt, handle: 'admin', network: '203.0.113.0/24', ), ProfileRefusal.handleReserved, ); await expectLater( remote.signUpWithSimpleLogin( session, attempt: start.attempt, handle: 'admin', setup: device.setup(), network: '203.0.113.0/24', ), refused(ProfileRefusal.handleReserved), ); await expectLater( remote.signUpWithSimpleLogin( session, attempt: const Uuid().v7obj(), handle: 'carla', setup: device.setup(), network: '203.0.113.0/24', ), refused(OidcRefusal.unknownAttempt), ); await expectLater( remote.beginPasswordKeyProof(session, ben), refused(PasswordRefusal.noPassword), ); final carla = await remote.signUpWithSimpleLogin( session, attempt: start.attempt, handle: 'carla', setup: device.setup(), network: '203.0.113.0/24', ); await expectLater( remote.unlockKeys( session, carla, await (await KeysDevice.make(seed: 5)).prove( await remote.beginPasswordKeyProof(session, carla), ), ), refused(PasswordRefusal.wrongCredentials), ); }); /// A SimpleLogin sign-in of [person]'s through a member, as /// [subject], just now: its attempt, which stands for it ten /// minutes (D20). Future signedInAgain( UuidValue person, String subject, ) async { final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', caller: person, network: '203.0.113.0/24', ); await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: subject, ), caller: person, ); return start.attempt; } test( "gives a member's account its first keys and handle, once " 'SimpleLogin was added to it, its refusals crossing whole', () async { final device = await KeysDevice.make(); final attempt = await signedInAgain(ben, 'sl-ben'); await expectLater( remote.setFirstKeys( session, person: ben, attempt: attempt, setup: device.setup(), network: '203.0.113.0/24', ), refused(ProfileRefusal.handleInvalid), ); await expectLater( remote.setFirstKeys( session, person: ben, attempt: const Uuid().v7obj(), setup: device.setup(), network: '203.0.113.0/24', handle: 'ben', ), refused(OidcRefusal.unknownAttempt), ); await remote.setFirstKeys( session, person: ben, attempt: attempt, setup: device.setup(), network: '203.0.113.0/24', handle: 'ben', ); expect((await remote.signInWaysOf(session, ben)).keys, isTrue); expect((await remote.profileOf(session, ben))!.handle, 'ben'); await expectLater( remote.setFirstKeys( session, person: ben, attempt: await signedInAgain(ben, 'sl-ben'), setup: device.setup(), network: '203.0.113.0/24', ), refused(PasswordRefusal.passwordExists), ); }, ); test("changes a member's account's keys, each change with the " "password's proof (the phrase's for a lost one) and a sign-in, " 'and deletes it alike', () async { final device = await KeysDevice.make(); final next = await KeysDevice.make(seed: 7); final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: 'sl-carla', ), ); final carla = await remote.signUpWithSimpleLogin( session, attempt: start.attempt, handle: 'carla', setup: device.setup(), network: '203.0.113.0/24', ); final keepSession = const Uuid().v7obj(); await remote.changeKeysPassword( session, person: carla, proof: await device.ownerProof( await signedInAgain(carla, 'sl-carla'), challenge: await remote.beginPasswordKeyProof(session, carla), purpose: KeyProofPurpose.newPassword, digest: newKeysPasswordDigest(next.password()), ), password: next.password(), keepSession: keepSession, ); // Every member is told to end the account's other sessions. await sessionNoticesDelivered(); expect(members.sessionsTold.single.$2, carla); expect(members.sessionsTold.single.$4, keepSession); await remote.unlockKeys( session, carla, await next.prove( await remote.beginPasswordKeyProof(session, carla), ), ); await remote.resetKeysPassword( session, person: carla, proof: await device.ownerProof( await signedInAgain(carla, 'sl-carla'), phrase: await remote.beginPhraseKeyProof(session, carla), purpose: KeyProofPurpose.passwordReset, digest: newKeysPasswordDigest(device.password()), ), password: device.password(), ); await remote.setKeysRecovery( session, person: carla, proof: await device.ownerProof( await signedInAgain(carla, 'sl-carla'), challenge: await remote.beginPasswordKeyProof(session, carla), purpose: KeyProofPurpose.newPhrase, digest: newPhraseDigest( next.recoveryKeys, ByteData.sublistView(next.recoveryProofKey), ), ), recoveryKeys: next.recoveryKeys, recoveryProofKey: ByteData.sublistView(next.recoveryProofKey), ); expect( (await remote.beginPhraseKeyProof( session, carla, )).recoveryKeys.toJson(), next.recoveryKeys.toJson(), ); final deletion = await signedInAgain(carla, 'sl-carla'); await expectLater( remote.deleteAccount(session, carla), refused(PasswordRefusal.wrongCredentials), ); await remote.deleteAccount( session, carla, keysProof: await device.ownerProof( deletion, challenge: await remote.beginPasswordKeyProof(session, carla), purpose: KeyProofPurpose.deleteAccount, ), ); expect(await AuthUser.db.findById(session, carla), isNull); }); test( "mends a member's account's broken link with its handle and " 'recovery phrase, a decoy and its refusal crossing whole', () async { usePasswordKeys(pod, testPasswordKeys); final device = await KeysDevice.make(); final first = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); await remote.completeSimpleLogin( session, attempt: first.attempt, code: simpleLogin.authorize( first.authorizationUrl, subject: 'sl-dora-old', ), ); final dora = await remote.signUpWithSimpleLogin( session, attempt: first.attempt, handle: 'Dora', setup: device.setup(), network: '203.0.113.0/24', ); final start = await remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ); await remote.completeSimpleLogin( session, attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: 'sl-dora-new', ), ); final decoy = await remote.beginSimpleLoginMend( session, attempt: start.attempt, handle: 'nobody', network: '203.0.113.0/24', ); await expectLater( remote.mendSimpleLogin( session, attempt: start.attempt, proof: await device.provePhrase( decoy, digest: mendLinkDigest(start.attempt), ), ), refused(PasswordRefusal.wrongCredentials), ); final mended = await remote.mendSimpleLogin( session, attempt: start.attempt, proof: await device.provePhrase( await remote.beginSimpleLoginMend( session, attempt: start.attempt, handle: 'dora', network: '203.0.113.0/24', ), digest: mendLinkDigest(start.attempt), ), ); expect(mended.outcome, SimpleLoginOutcome.signedIn); expect(mended.account, dora); expect(mended.endedBefore, isNotNull); expect( (await OidcIdentity.db.findFirstRow( session, where: (t) => t.authUserId.equals(dora), ))!.subject, 'sl-dora-new', ); // Every member is told to end the account's sessions. await sessionNoticesDelivered(); expect(members.sessionsTold.single.$2, dora); }, ); test('refuses where the register has no SimpleLogin app', () async { useSimpleLogin(sessionBuilder, null); await expectLater( remote.beginSimpleLogin( session, marker: 'todo-loopback', network: '203.0.113.0/24', ), refused(OidcRefusal.unknownProvider), ); }); }); group('passwords (ADR 0017)', () { late DateTime now; Future atNow(Future Function() body) => withClock(Clock(() => now), body); setUp(() { usePasswordKeys(pod, testPasswordKeys); now = DateTime.utc(2026, 10, 20, 12); }); test('are offered as the register offers them', () async { expect(await remote.passwordSignInOffered(session), isTrue); usePasswordKeys(pod, null); expect(await remote.passwordSignInOffered(session), isFalse); await expectLater( remote.beginPasswordSignIn( session, address: 'ben@example.org', network: '203.0.113.7', ), refused(PasswordRefusal.unavailable), ); }); test("cross for a member's device, every operation, with what the " 'register refuses', () async { final device = await PasswordDevice.make(); await atNow(() async { final annaOnTodo = const Uuid().v7obj(); final request = await remote.sendSignInCode( session, address: 'anna@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.signUp, ); await remote.setPassword( session, person: annaOnTodo, anonymousHere: true, setup: device.setup(), request: request, code: mailbox.codeFor('anna@example.org'), ); expect( (await AuthUser.db.findById(session, annaOnTodo))!.scopeNames, contains(identified.name), ); await expectLater( remote.setPassword( session, person: ben, setup: device.setup().copyWith(salt: ByteData(3)), ), refused(PasswordRefusal.invalidSetup), ); final enrolment = await remote.enrolAuthenticator( session, person: annaOnTodo, name: 'Phone', label: 'anna@example.org', ); final codes = await remote.confirmAuthenticator( session, person: annaOnTodo, authenticator: enrolment.authenticator, code: codeOf(enrolment), proof: device.ownerProof( await remote.beginPasswordChange(session, person: annaOnTodo), purpose: PasswordProofPurpose.firstAuthenticator, digest: authenticatorDigest(enrolment.authenticator), ), ); expect(codes, hasLength(recoveryCodeCount)); expect( (await remote.authenticatorsOf(session, annaOnTodo)).single.id, enrolment.authenticator, ); final spare = await remote.enrolAuthenticator( session, person: annaOnTodo, name: 'Spare', label: '', ); await expectLater( remote.confirmAuthenticator( session, person: annaOnTodo, authenticator: spare.authenticator, code: codeOf(spare), ), refused(PasswordRefusal.wrongCredentials), ); now = now.add(totpPeriod); Future ownerProof( PasswordProofPurpose purpose, { List digest = const [], }) async => device.ownerProof( await remote.beginPasswordChange(session, person: annaOnTodo), purpose: purpose, digest: digest, totpCode: codeOf(enrolment), ); expect( await remote.confirmAuthenticator( session, person: annaOnTodo, authenticator: spare.authenticator, code: codeOf(spare), proof: await ownerProof( PasswordProofPurpose.addAuthenticator, digest: authenticatorDigest(spare.authenticator), ), ), isEmpty, ); now = now.add(totpPeriod); await remote.removeAuthenticator( session, person: annaOnTodo, authenticator: spare.authenticator, proof: await ownerProof( PasswordProofPurpose.removeAuthenticator, digest: authenticatorDigest(spare.authenticator), ), ); final ways = await remote.signInWaysOf(session, annaOnTodo); expect(ways.password && ways.authenticator, isTrue); expect(ways.sessionEndedForProofsAt, isNull); // A member's session ended for its wrong proofs: the owner's // warning, kept here, read and dismissed through the member. await remote.noteSessionEndedForProofs(session, annaOnTodo); final warned = now; expect( (await remote.signInWaysOf( session, annaOnTodo, )).sessionEndedForProofsAt, warned, ); now = now.add(totpPeriod); final challenge = await remote.beginPasswordSignIn( session, address: 'anna@example.org', network: '203.0.113.7', ); final answer = device.answer(challenge); final proof = await remote.completePasswordSignIn( session, challenge: challenge.challenge, clientValue: answer.clientValue, clientProof: answer.clientProof, totpCode: codeOf(enrolment, at: now), instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ); expect(proof.account, annaOnTodo); expect(answer.verifiesServer(proof.serverProof), isTrue); expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson()); expect(proof.sessionEndedForProofsAt, warned); // An older session of the member's dismisses nothing (the full // reading's L1); one made since does. await expectLater( remote.dismissSessionEndedWarning( session, annaOnTodo, sessionMadeAt: warned, ), refused(PasswordRefusal.signedInBefore), ); await remote.dismissSessionEndedWarning( session, annaOnTodo, sessionMadeAt: warned.add(const Duration(seconds: 1)), ); expect( (await remote.signInWaysOf( session, annaOnTodo, )).sessionEndedForProofsAt, isNull, ); final again = await remote.beginPasswordSignIn( session, address: 'anna@example.org', network: '203.0.113.7', ); final againAnswer = device.answer(again); await expectLater( remote.completePasswordSignIn( session, challenge: again.challenge, clientValue: againAnswer.clientValue, clientProof: againAnswer.clientProof, totpCode: codeOf(enrolment, at: now), instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ), refused(PasswordRefusal.wrongCredentials), ); now = now.add(totpPeriod); expect( await remote.newRecoveryCodes( session, person: annaOnTodo, proof: await ownerProof(PasswordProofPurpose.newRecoveryCodes), ), hasLength(recoveryCodeCount), ); final keyChallenge = await remote.beginKeyProof( session, address: 'anna@example.org', network: '203.0.113.7', ); expect( keyChallenge.recoveryKeys.toJson(), device.recoveryKeys.toJson(), ); now = now.add(totpPeriod); final newPassword = device.newPassword(); expect( await remote.resetPasswordWithPhrase( session, challenge: keyChallenge.challenge, signature: await device.sign( keyChallenge, password: newPassword, ), password: newPassword, totpCode: codeOf(enrolment, at: now), instanceUrl: testInstanceUrl, address: 'anna@example.org', language: 'en', ), (account: annaOnTodo, endedBefore: now), ); // Every member is told to end the account's sessions too. await sessionNoticesDelivered(); expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null)); final forReset = await remote.beginPasswordSignIn( session, address: 'anna@example.org', network: '203.0.113.7', ); final resetAnswer = device.answer( forReset, purpose: PasswordProofPurpose.authenticatorReset, ); final resetRequest = await remote.sendSignInCode( session, address: 'anna@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.authenticatorReset, ); final pending = await remote.resetAuthenticator( session, challenge: forReset.challenge, clientValue: resetAnswer.clientValue, clientProof: resetAnswer.clientProof, address: 'anna@example.org', language: 'en', request: resetRequest, code: mailbox.codeFor('anna@example.org'), instanceUrl: testInstanceUrl, ); expect(pending.takesEffectAt, now.add(resetDelay)); final startRequest = await remote.sendSignInCode( session, address: 'anna@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.accountReset, ); await remote.startAccountReset( session, address: 'anna@example.org', language: 'en', request: startRequest, code: mailbox.codeFor('anna@example.org'), ); now = now.add(resetDelay); final fresh = await PasswordDevice.make(seed: 99); final completeRequest = await remote.sendSignInCode( session, address: 'anna@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.accountReset, ); expect( await remote.completeAccountReset( session, address: 'anna@example.org', request: completeRequest, code: mailbox.codeFor('anna@example.org'), setup: fresh.setup(), ), (account: annaOnTodo, endedBefore: now), ); }); }); }); group('changes while signed in (ADR 0017)', () { late DateTime now; Future atNow(Future Function() body) => withClock(Clock(() => now), body); setUp(() { usePasswordKeys(pod, testPasswordKeys); now = DateTime.utc(2026, 10, 20, 12); }); test( "cross for a member's device: the password, keeping the " "member's session, the phrase's wrapping, and a reset's date", () async { final device = await PasswordDevice.make(); await atNow(() async { final annaOnTodo = const Uuid().v7obj(); final annasPhone = const Uuid().v7obj(); final request = await remote.sendSignInCode( session, address: 'anna@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.signUp, ); await remote.setPassword( session, person: annaOnTodo, anonymousHere: true, setup: device.setup(), request: request, code: mailbox.codeFor('anna@example.org'), ); final enrolment = await remote.enrolAuthenticator( session, person: annaOnTodo, name: 'Phone', label: 'anna@example.org', ); await remote.confirmAuthenticator( session, person: annaOnTodo, authenticator: enrolment.authenticator, code: codeOf(enrolment), proof: device.ownerProof( await remote.beginPasswordChange( session, person: annaOnTodo, ), purpose: PasswordProofPurpose.firstAuthenticator, digest: authenticatorDigest(enrolment.authenticator), ), ); now = now.add(totpPeriod); final challenge = await remote.beginPasswordChange( session, person: annaOnTodo, ); // The old password proves it: the device takes the new one's // x as it makes it. final old = device.privateValue; final newPassword = device.newPassword(); await remote.changePassword( session, person: annaOnTodo, proof: device.ownerProof( challenge, purpose: PasswordProofPurpose.newPassword, digest: newPasswordDigest(newPassword), privateValue: old, totpCode: codeOf(enrolment), ), password: newPassword, keepSession: annasPhone, ); await sessionNoticesDelivered(); expect(members.sessionsTold.single, ( _todo, annaOnTodo, now, annasPhone, )); now = now.add(totpPeriod); final fresh = WrappedKeys( nonce: ByteData(24), cipherText: ByteData(wrappedKeysLength), ); Future newPhrase() async { final challenge = await remote.beginPasswordChange( session, person: annaOnTodo, ); final proofKey = ByteData.sublistView(device.recoveryProofKey); await remote.setRecoveryKeys( session, person: annaOnTodo, proof: device.ownerProof( challenge, purpose: PasswordProofPurpose.newPhrase, digest: newPhraseDigest(fresh, proofKey), totpCode: codeOf(enrolment), ), recoveryKeys: fresh, recoveryProofKey: proofKey, ); } await newPhrase(); // The same step's code again. await expectLater( newPhrase(), refused(PasswordRefusal.wrongCredentials), ); final start = await remote.sendSignInCode( session, address: 'anna@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.accountReset, ); final pending = await remote.startAccountReset( session, address: 'anna@example.org', language: 'en', request: start, code: mailbox.codeFor('anna@example.org'), ); final early = await remote.sendSignInCode( session, address: 'anna@example.org', language: 'en', network: '203.0.113.7', purpose: EmailCodePurpose.accountReset, ); await expectLater( remote.completeAccountReset( session, address: 'anna@example.org', request: early, code: mailbox.codeFor('anna@example.org'), setup: device.setup(), ), throwsA( isA().having( (error) => error.takesEffectAt, 'takesEffectAt', pending.takesEffectAt, ), ), ); // The member's device is shown the reset, and its owner calls // it off with their proof (the full reading's H1). expect( (await remote.signInWaysOf(session, annaOnTodo)).accountResetAt, pending.takesEffectAt, ); now = now.add(totpPeriod); await remote.callOffResets( session, person: annaOnTodo, proof: device.ownerProof( await remote.beginPasswordChange( session, person: annaOnTodo, ), purpose: PasswordProofPurpose.callOffResets, totpCode: codeOf(enrolment), ), ); expect( (await remote.signInWaysOf(session, annaOnTodo)).accountResetAt, isNull, ); }); }, ); }); group('ways in without a working password (ADR 0017)', () { late DateTime now; Future atNow(Future Function() body) => withClock(Clock(() => now), body); setUp(() { usePasswordKeys(pod, testPasswordKeys); now = DateTime.utc(2026, 10, 20, 12); }); test("cross for a member's device: signing up with a code, and a " 'setup never finished signing in with the password and a code, ' 'with what the register refuses', () async { final device = await PasswordDevice.make(); await atNow(() async { Future code([ EmailCodePurpose purpose = EmailCodePurpose.signUp, ]) => remote.sendSignInCode( session, address: 'carla@example.org', language: 'en', network: '203.0.113.7', purpose: purpose, ); final carla = await remote.signUpWithCode( session, request: await code(), code: mailbox.codeFor('carla@example.org'), setup: device.setup(), ); expect( (await AuthUser.db.findById(session, carla))!.scopeNames, contains(identified.name), ); await expectLater( remote.signUpWithCode( session, request: await code(), code: mailbox.codeFor('carla@example.org'), setup: device.setup(), ), refused(PasswordRefusal.passwordExists), ); final challenge = await remote.beginPasswordSignIn( session, address: 'carla@example.org', network: '203.0.113.7', ); final answer = device.answer(challenge); final proof = await remote.completePasswordSignIn( session, challenge: challenge.challenge, clientValue: answer.clientValue, clientProof: answer.clientProof, request: await code(EmailCodePurpose.finishSetup), code: mailbox.codeFor('carla@example.org'), instanceUrl: testInstanceUrl, address: 'carla@example.org', language: 'en', ); expect(proof.account, carla); expect(proof.needsAuthenticator, isTrue); }); }); }); group('deleting an account', () { test('deletes it here and tells every member, keeping no notice ' 'once they confirmed', () async { await remote.deleteAccount(session, ben); expect(await AuthUser.db.findById(session, ben), isNull); expect(await EmailIdentity.db.count(session), 0); expect(await GitHubAccount.db.count(session), 0); expect(members.told, [(_todo, ben)]); expect(await AccountDeletionNotice.db.count(session), 0); expect(await remote.knownAccounts(session, {ben}), isEmpty); }); test( 'tells a member that does not answer again until it does', () async { members.answers = (member) => member != _todo; await remote.deleteAccount(session, ben); expect(await AuthUser.db.findById(session, ben), isNull); final kept = await AccountDeletionNotice.db.find(session); expect(kept.single.account, ben); expect(kept.single.member, '$_todo'); expect(kept.single.attempts, 1); expect(kept.single.lastAttemptAt, isNotNull); expect(await deliverDeletionNotices(session), 0); expect( (await AccountDeletionNotice.db.find(session)).single.attempts, 2, ); members.answers = (_) => true; expect(await deliverDeletionNotices(session), 1); expect(await AccountDeletionNotice.db.count(session), 0); expect(members.told, [(_todo, ben), (_todo, ben), (_todo, ben)]); }, ); test('tells the members of an account this server no longer has, ' 'as one may still hold its data', () async { final gone = const Uuid().v7obj(); await remote.deleteAccount(session, gone); expect(members.told, [(_todo, gone)]); }); test('tells nobody of an anonymous person, a member never having ' 'had them', () async { final anna = await newAnonymousPerson(session); await remote.deleteAccount(session, anna); expect(await AuthUser.db.findById(session, anna), isNull); expect(members.told, isEmpty); expect(await AccountDeletionNotice.db.count(session), 0); }); test( 'drops the notices of a member the operator no longer names', () async { members.answers = (_) => false; useBase( sessionBuilder, accounts: HoldsRegister(members: [_todo, _train]), ); await remote.deleteAccount(session, ben); expect(await AccountDeletionNotice.db.count(session), 2); useBase(sessionBuilder, accounts: HoldsRegister(members: [_train])); await deliverDeletionNotices(session); expect( (await AccountDeletionNotice.db.find(session)).single.member, '$_train', ); useBase(sessionBuilder); await deliverDeletionNotices(session); expect(await AccountDeletionNotice.db.count(session), 0); }, ); }); }, ); group('A member asking the register', () { late List requests; /// A register answering every request with [status] and [body]. RemoteRegister answering(int status, String body) { requests = []; return RemoteRegister( Uri.parse('http://learn.test:8082'), secret: _secret, client: () => MockClient((request) async { requests.add(request); return http.Response(body, status); }), ); } test( 'posts JSON to the operation with the secret as its bearer token', () async { final account = const Uuid().v7obj(); final register = answering(200, '{"account": "${account.uuid}"}'); expect(await register.accountWithGitHub(_noSession, '1001'), account); final request = requests.single; expect(request.method, 'POST'); expect( '${request.url}', 'http://learn.test:8082/halkyon-base/register/account-with-github', ); expect(request.headers['authorization'], 'Bearer $_secret'); expect(jsonDecode(request.body), {'gitHubUserId': '1001'}); }, ); test( 'fails as unavailable for an answer it cannot take, never guessing', () async { for (final (status, body) in [ (500, '{}'), (401, '{"error": "secret"}'), (200, 'not JSON'), (200, '[]'), (409, '{"refusal": {"kind": "email", "reason": "unheardOf"}}'), (409, '{"refusal": "no"}'), (404, '{"error": "not here"}'), ]) { await expectLater( answering(status, body).accountWithGitHub(_noSession, '1001'), throwsA(isA()), reason: '$status $body', ); } await expectLater( RemoteRegister( Uri.parse('http://learn.test:8082'), secret: _secret, client: () => MockClient( (_) async => throw http.ClientException('Connection refused'), ), ).accountWithGitHub(_noSession, '1001'), throwsA(isA()), ); }, ); }); group('The register telling a member', () { test("posts the account to the member's path with the secret, and " 'counts only a 200 as confirmed', () async { final account = const Uuid().v7obj(); final requests = []; var status = 200; final notifier = HttpMemberNotifier( secret: _secret, client: MockClient((request) async { requests.add(request); return http.Response('{}', status); }), ); expect(await notifier.accountGone(_todo, account), isTrue); final request = requests.single; expect(request.method, 'POST'); expect('${request.url}', 'http://todo.test:8082$accountGonePath'); expect(request.headers['authorization'], 'Bearer $_secret'); expect(jsonDecode(request.body), {'account': account.uuid}); status = 401; expect(await notifier.accountGone(_todo, account), isFalse); }); test('counts a failed request as not confirmed, and sends nothing ' 'without a secret', () async { final account = const Uuid().v7obj(); var sent = 0; expect( await HttpMemberNotifier( secret: _secret, client: MockClient((_) { sent++; return Future.error(http.ClientException('Connection refused')); }), ).accountGone(_todo, account), isFalse, ); expect( await HttpMemberNotifier( secret: null, client: MockClient((_) async { sent++; return http.Response('{}', 200); }), ).accountGone(_todo, account), isFalse, ); expect(sent, 1); }); }); } /// No session: [RemoteRegister] only passes it on. final Session _noSession = _NoSession(); final class _NoSession implements Session { @override Object? noSuchMethod(Invocation invocation) => throw UnsupportedError('no session in this test'); } Future _deleteAll(Session session) async { await OidcAttempt.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await SignInChallenge.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0); await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0); await TotpAuthenticator.db.deleteWhere( session, where: (t) => t.id.notEquals(null), ); await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0); await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0); await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0); 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)); }