halkyon/packages/halkyon_base_server/test/integration/register/member_test.dart
Tizian 「ティツィアーン」 090d901e92
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.

- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
  waits under the attempt gets, in one transaction, an account
  (identified), the link, a profile with the handle and the keys; then a
  session (D18). Refused, the identity still waiting, for a setup out of
  range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
  take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
  its handle, and keeps the sign-in ten minutes under its attempt, the
  account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
  password is proven with its proof key, for a nonce, bound to the base
  URL the device addressed; wrong proofs counted per session as ADR
  0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
  instance API carry the four new operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:48 +02:00

2234 lines
73 KiB
Dart

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_oidc_server/oidc.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 _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 = <UuidValue>{};
final addresses = <String, UuidValue>{};
final gitHub = <String, UuidValue>{};
final profiles = <UuidValue, Profile>{};
final codes = <UuidValue, ({String address, String code})>{};
/// The calls asked, by name, in their order.
final asked = <String>[];
final networks = <String>[];
final deleted = <UuidValue>[];
/// What [knownAccounts] was asked about, call by call.
final askedAbout = <Set<UuidValue>>[];
bool offered = true;
/// Whether it offers password sign-in.
bool passwordOffered = true;
/// Whether it offers SimpleLogin, the sign-ins begun with it, the accounts
/// it was added to, and what the next sign-in proves.
bool simpleLoginIsOffered = true;
final simpleLoginsBegun = <({String marker, UuidValue? caller})>[];
final simpleLoginLinked = <UuidValue>{};
SimpleLoginProof? simpleLoginProof;
/// The handles checked for someone new and what each check says, the
/// sign-ups of people new with SimpleLogin, and the proofs keys were
/// unlocked with, as members bound them.
final handlesChecked = <String>[];
ProfileRefusal? handleRefusal;
final simpleLoginSignUps = <({UuidValue attempt, String handle})>[];
final keysProofs = <KeysProof>[];
/// The accounts with a password, and those with an authenticator too.
final passwords = <UuidValue>{};
final authenticated = <UuidValue>{};
/// The people a password was set for with a GitHub sign-in of the
/// member's made just now.
final freshGitHubSignIns = <UuidValue>{};
/// When the session dismissing a warning was made, by person.
final dismissedMadeAt = <UuidValue, DateTime?>{};
/// Those [setPassword] was told are anonymous on the member.
final anonymousHere_ = <UuidValue>{};
/// 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 = <String>[];
/// The accounts warned that a session was ended for its wrong proofs, and
/// those whose warning was dismissed.
final warned = <UuidValue>{};
final dismissed = <UuidValue>{};
/// Thrown by [noteSessionEndedForProofs] alone while set (as [failing]
/// throws from everything): the register down as a member tells it.
Exception? notingFails;
Future<T> _answer<T>(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<Profile?> profileOf(Session session, UuidValue person) =>
_answer('profileOf', () => profiles[person]);
@override
Future<Map<UuidValue, Profile>> profilesOf(
Session session,
Set<UuidValue> people,
) => _answer(
'profilesOf',
() => {
for (final person in people) person: ?profiles[person],
},
);
@override
Future<Profile?> profileByHandle(Session session, String handle) => _answer(
'profileByHandle',
() => profiles.values
.where((profile) => profile.handle.toLowerCase() == handle)
.firstOrNull,
);
@override
Future<UuidValue?> personWithHandle(Session session, String handle) =>
_answer(
'personWithHandle',
() => profiles.entries
.where((entry) => entry.value.handle.toLowerCase() == handle)
.firstOrNull
?.key,
);
@override
Future<Profile> saveProfile(
Session session,
UuidValue person, {
required String handle,
required String displayName,
required String bio,
required List<String> 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<ProfileRefusal?> checkHandle(
Session session,
UuidValue person,
String handle,
) => _answer('checkHandle', () => null);
@override
Future<Profile> checkLinks(Session session, UuidValue person) =>
_answer('checkLinks', () => profiles[person]!);
@override
Future<Profile> setAvatar(Session session, UuidValue person, ByteData png) =>
_answer('setAvatar', () => profiles[person]!);
@override
Future<Profile> removeAvatar(Session session, UuidValue person) =>
_answer('removeAvatar', () => profiles[person]!);
@override
Future<void> removeProfile(Session session, UuidValue person) =>
_answer('removeProfile', () => profiles.remove(person));
@override
Future<void> fixHandle(Session session, UuidValue person) =>
_answer('fixHandle', () {});
@override
Future<bool> emailSignInOffered(Session session) =>
_answer('emailSignInOffered', () => offered);
@override
Future<UuidValue> 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<void> linkGitHub(
Session session, {
required UuidValue person,
required String gitHubUserId,
}) => _answer('linkGitHub', () {
accounts.add(person);
gitHub[gitHubUserId] = person;
});
@override
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
_answer('accountWithGitHub', () => gitHub[gitHubUserId]);
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) => _answer(
'signInWaysOf',
() => SignInWays(
gitHub: gitHub.containsValue(person),
email: addresses.containsValue(person),
simpleLogin: simpleLoginLinked.contains(person),
password: passwords.contains(person),
authenticator: authenticated.contains(person),
passwordOnly:
passwords.contains(person) && authenticated.contains(person),
),
);
@override
Future<void> 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<void> dismissSessionEndedWarning(
Session session,
UuidValue person, {
required DateTime? sessionMadeAt,
}) => _answer('dismissSessionEndedWarning', () {
dismissed.add(person);
dismissedMadeAt[person] = sessionMadeAt;
});
@override
Future<bool> passwordSignInOffered(Session session) =>
_answer('passwordSignInOffered', () => passwordOffered);
@override
Future<bool> simpleLoginOffered(Session session) =>
_answer('simpleLoginOffered', () => simpleLoginIsOffered);
@override
Future<OidcStart> beginSimpleLogin(
Session session, {
required String marker,
UuidValue? caller,
required String network,
}) => _answer('beginSimpleLogin', () {
networks.add(network);
simpleLoginsBegun.add((marker: marker, caller: caller));
return OidcStart(
attempt: const Uuid().v7obj(),
authorizationUrl: Uri.parse(
'https://app.simplelogin.io/oauth2/authorize',
),
state: '$marker.state',
);
});
@override
Future<SimpleLoginProof> completeSimpleLogin(
Session session, {
required UuidValue attempt,
required String code,
UuidValue? caller,
}) => _answer('completeSimpleLogin', () {
final proof = simpleLoginProof!;
if (proof.outcome == SimpleLoginOutcome.linked) {
simpleLoginLinked.add(caller!);
return proof.copyWith(account: caller);
}
return proof;
});
@override
Future<ProfileRefusal?> checkSimpleLoginHandle(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) => _answer('checkSimpleLoginHandle', () {
networks.add(network);
handlesChecked.add(handle);
return handleRefusal;
});
@override
Future<UuidValue> signUpWithSimpleLogin(
Session session, {
required UuidValue attempt,
required String handle,
required KeysSetup setup,
}) => _answer('signUpWithSimpleLogin', () {
final account = const Uuid().v7obj();
add(account);
simpleLoginSignUps.add((attempt: attempt, handle: handle));
return account;
});
@override
Future<KeysChallenge> beginPasswordKeyProof(
Session session,
UuidValue person,
) => _answer(
'beginPasswordKeyProof',
() => KeysChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
salt: ByteData(16),
costs: newPasswordCosts,
),
);
@override
Future<DeviceKeys> unlockKeys(
Session session,
UuidValue person,
KeysProof proof,
) {
keysProofs.add(proof);
return _answer(
'unlockKeys',
() => DeviceKeys(
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
);
}
@override
Future<void> 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<PasswordChallenge> 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<PasswordProof> 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<UuidValue> 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<AuthenticatorEnrolment> 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<List<String>> 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<List<Authenticator>> authenticatorsOf(
Session session,
UuidValue person,
) => _answer('authenticatorsOf', () => const <Authenticator>[]);
@override
Future<void> removeAuthenticator(
Session session, {
required UuidValue person,
required UuidValue authenticator,
OwnerProof? proof,
}) => _answer('removeAuthenticator', () {});
@override
Future<List<String>> newRecoveryCodes(
Session session, {
required UuidValue person,
required OwnerProof proof,
}) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']);
@override
Future<ResetPending> 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<KeyProofChallenge> 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<AccountSessionsEnded> 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<ResetPending> 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<AccountSessionsEnded> 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 = <UuidValue, UuidValue?>{};
@override
Future<PasswordChallenge> beginPasswordChange(
Session session, {
required UuidValue person,
}) => _answer(
'beginPasswordChange',
() => PasswordChallenge(
challenge: const Uuid().v7obj(),
salt: ByteData(16),
costs: newPasswordCosts,
serverValue: ByteData(384),
),
);
@override
Future<void> changePassword(
Session session, {
required UuidValue person,
required OwnerProof proof,
required NewPassword password,
UuidValue? keepSession,
}) => _answer('changePassword', () {
changed[person] = keepSession;
changedWith.add(proof);
});
@override
Future<void> callOffResets(
Session session, {
required UuidValue person,
required OwnerProof proof,
}) => _answer('callOffResets', () {});
@override
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required OwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) => _answer('setRecoveryKeys', () {});
@override
Future<void> 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 = <DeletionProof?>[];
final changedWith = <OwnerProof>[];
@override
Future<Set<UuidValue>> knownAccounts(
Session session,
Set<UuidValue> 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<UuidValue> 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<Set<String>> 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<EmailSignInRefusedException>().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<RegisterUnavailableException>()),
);
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<PasswordRefusedException>().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<void> 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<GitHubSignInRefusedException>().having(
(error) => error.reason,
'reason',
GitHubSignInRefusal.usePassword,
),
),
);
});
});
group('SimpleLogin, by asking the register (ADR 0020)', () {
test('begins for this app and platform, signed out, the device '
'network passed on', () async {
final start = await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.phone,
);
expect(register.asked, ['beginSimpleLogin']);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-phone',
caller: null,
));
expect(register.networks, hasLength(1));
expect(start.state, startsWith('$testApp-phone.'));
});
test('begins signed in for the caller', () async {
await endpoints.simpleLogin.begin(
asAnna(),
platform: SimpleLoginPlatform.desktop,
);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-desktop',
caller: anna,
));
});
test('signs the account the register found in here, made a person '
'here the first time', () async {
final carla = const Uuid().v7obj();
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.signedIn,
account: carla,
hasKeys: true,
handle: 'Carla',
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(register.asked, ['completeSimpleLogin']);
expect(completed.outcome, SimpleLoginOutcome.signedIn);
expect(completed.session!.authUserId, carla);
expect((completed.hasKeys, completed.handle), (true, 'Carla'));
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
simpleLoginMethod,
);
});
test('makes the caller identified here once the register added '
'SimpleLogin to their account', () async {
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.linked,
);
final completed = await endpoints.simpleLogin.complete(
asAnna(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(completed.outcome, SimpleLoginOutcome.linked);
expect(completed.session, isNull);
expect(register.simpleLoginLinked, {anna});
expect(await scopesOf(anna), contains(identified.name));
expect(
await AnonymousAccount.db.find(
session,
where: (t) => t.authUserId.equals(anna),
),
isEmpty,
);
expect(
(await endpoints.account.signInWays(asAnna())).simpleLogin,
isTrue,
);
});
test('passes on the handle suggested to someone new, with no session '
'and no person here', () async {
final people = await AuthUser.db.count(session);
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.newcomer,
suggestedHandle: 'carla_mueller',
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(completed.outcome, SimpleLoginOutcome.newcomer);
expect(completed.session, isNull);
expect(completed.suggestedHandle, 'carla_mueller');
expect(await AuthUser.db.count(session), people);
});
test('refuses as the register refuses', () async {
register.failing = OidcRefusedException(
reason: OidcRefusal.takenByAnotherPerson,
);
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
attempt: const Uuid().v7obj(),
code: 'code',
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.takenByAnotherPerson,
),
),
);
expect(await scopesOf(anna), isNot(contains(identified.name)));
});
test('checks a handle for someone new at the register, the device '
'network passed on', () async {
final attempt = const Uuid().v7obj();
register.handleRefusal = ProfileRefusal.handleTaken;
expect(
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: 'carla',
),
ProfileRefusal.handleTaken,
);
expect(register.asked, ['checkSimpleLoginHandle']);
expect(register.handlesChecked, ['carla']);
expect(register.networks, hasLength(1));
});
test('signs up someone new at the register, a person here with a '
'session of this server', () async {
final attempt = const Uuid().v7obj();
final signedUp = await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: (await KeysDevice.make()).setup(),
);
final carla = signedUp.authUserId;
expect(register.asked, ['signUpWithSimpleLogin']);
expect(register.simpleLoginSignUps.single, (
attempt: attempt,
handle: 'carla',
));
expect(register.accounts, contains(carla));
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
simpleLoginMethod,
);
});
test('signs up nobody for a signed-in caller, nor as the register '
'refuses, making no person here', () async {
final people = await AuthUser.db.count(session);
final setup = (await KeysDevice.make()).setup();
await expectLater(
endpoints.simpleLogin.signUp(
asAnna(),
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: setup,
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
expect(register.asked, isEmpty);
register.failing = ProfileRefusedException(
reason: ProfileRefusal.handleTaken,
);
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: setup,
),
throwsA(
isA<ProfileRefusedException>().having(
(error) => error.reason,
'reason',
ProfileRefusal.handleTaken,
),
),
);
expect(await AuthUser.db.count(session), people);
});
test('is offered as the register offers it, and not while it is out '
'of reach', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isTrue,
);
register.simpleLoginIsOffered = false;
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
register
..simpleLoginIsOffered = true
..failing = RegisterUnavailableException('test');
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
});
});
group('keys without a password that signs in, by asking the register '
'(ADR 0020, D19)', () {
KeysProof wrongProof() => KeysProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: testInstanceUrl,
);
test('are begun and unlocked at the register, the proof bound to '
'the base URL the device addressed here', () async {
final challenge = await endpoints.keys.begin(asBen());
final keys = await endpoints.keys.unlock(
asBen(),
KeysProof(
challenge: challenge.challenge,
signature: ByteData(64),
instanceUrl: testInstanceUrl,
),
);
expect(register.asked, ['beginPasswordKeyProof', 'unlockKeys']);
expect(keys.signingPublicKey.lengthInBytes, 32);
expect(register.keysProofs.single.challenge, challenge.challenge);
expect(register.keysProofs.single.instanceUrl, testInstanceUrl);
});
test('refuse a proof for another instance without asking the '
'register', () async {
await expectLater(
endpoints.keys.unlock(
asBen(),
wrongProof().copyWith(instanceUrl: 'https://evil.example/'),
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
);
expect(register.asked, isEmpty);
});
test("count the session's own wrong proofs, which the register "
'refuses: the tenth in a row ends the session here', () async {
register.failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
);
for (var i = 0; i < 10; i++) {
await expectLater(
endpoints.keys.unlock(asBen(), wrongProof()),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
i < 9
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
),
);
}
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
expect(register.warned, {ben});
});
});
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<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
1,
);
expect(hostDeleted, isEmpty);
});
test('asks the register first: tries while it is out of reach count '
"for nothing, as no proof left here (the reading of the fixes' "
'B1)', () async {
register.failing = RegisterUnavailableException('test');
DeletionProof proof() => DeletionProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
instanceUrl: testInstanceUrl,
);
for (var i = 0; i < 11; i++) {
await expectLater(
endpoints.account.delete(asBen(), proof: proof()),
throwsA(isA<RegisterUnavailableException>()),
);
}
expect(register.asked.toSet(), {'knownAccounts'});
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNotNull,
);
});
test('of a person anonymous here is refused too while the register '
'is out of reach, deleting nothing here: the register may have '
'linked her while her becoming identified here failed (the '
'short reading, L2)', () async {
register.failing = RegisterUnavailableException('test');
await expectLater(
endpoints.account.delete(asAnna()),
throwsA(isA<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
expect(hostDeleted, isEmpty);
});
});
group('told by the register that an account is gone', () {
Future<InstanceAnswer> 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<InstanceAnswer> tell(
Map<String, Object?> body, {
String? authorization = 'Bearer $_secret',
}) => answerSessionsEnded(
session,
jsonEncode(body),
authorization: authorization,
);
Future<DateTime> 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<void>.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<UuidValue> 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<List<String>> 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<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.wrongCredentials,
),
),
);
}
expect(register.warned, isEmpty);
await expectLater(
newCodes(),
throwsA(
isA<PasswordRefusedException>().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<PasswordRefusedException>().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<ServerpodUnauthenticatedException>()),
);
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<ServerpodUnauthenticatedException>()),
);
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<AuthSuccess> 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("the register's notice of a reset, however long it took to "
'arrive, leaves the session made here for it; a notice of a '
'later moment ends it (the short reading, M2)', () async {
final endedBefore = DateTime.now().toUtc();
register
..proof = bensProof()
..endedBefore = endedBefore;
final signedIn = await resetBensPassword();
final made = (await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
)).single.id!;
expect(signedIn.authUserId, ben);
// Its way here took longer than the reset's answer: sent at once,
// it arrives after the session was made.
await Future<void>.delayed(const Duration(milliseconds: 20));
final answer = await answerSessionsEnded(
session,
jsonEncode({
'account': ben.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': endedBefore.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(answer.status, 200);
expect(await ServerSideSession.db.findById(session, made), isNotNull);
final later = DateTime.now().toUtc();
await answerSessionsEnded(
session,
jsonEncode({
'account': ben.uuid,
'endedBefore': later.toIso8601String(),
'sentAt': later.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(await ServerSideSession.db.findById(session, made), isNull);
});
test("a notice of the reset's moment for another account keeps "
"nothing of Ben's", () async {
final endedBefore = DateTime.now().toUtc();
register
..proof = bensProof()
..endedBefore = endedBefore;
await resetBensPassword();
final annasTablet = await signInNewDevice(session, anna);
await Future<void>.delayed(const Duration(milliseconds: 5));
await answerSessionsEnded(
session,
jsonEncode({
'account': anna.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': endedBefore.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(
await ServerSideSession.db.findById(session, annasTablet),
isNull,
);
});
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<RegisterUnavailableException>()),
);
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<RemoteRegister>()
.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<HttpMemberNotifier>().having(
(notifier) => notifier.secret,
'secret',
isNull,
),
);
});
},
);
}
Future<void> _deleteAll(Session session) async {
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}