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