halkyon/packages/halkyon_base_server/test/integration/auth/email_test.dart

500 lines
16 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
/// Keeps every code mailed, in place of a mail service.
final class _Mailbox implements CodeMailer {
final sent = <({String to, String code, String language})>[];
@override
Future<void> send(
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async {
sent.add((to: to, code: code, language: language));
}
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async {}
fix(auth): codes wait after ten wrong in a row, and the owner is told Whoever knew the password could guess codes at 10 an hour forever, unnoticed, each guess checked against every authenticator (the second reading's M4). Every code an account's second factor refuses after a right password (sign-in, a phrase's reset, a change) or from a signed-in device (removing an authenticator, new recovery codes, the code from one confirmed before) now counts, per account, from every network; after every tenth in a row no code is taken (codesWait, saying until when), an hour, then twice as long each time, a day at the most (longestCodeWait, second_factor_waits.dart): at a day, ten guesses a day still have about a 1 % chance a year against one authenticator, so the first wait mails the owner that someone knows their password. The instance keeps no address, so sign-in and the phrase's reset now send the address the device typed (and its language), used only for that mail after its hash is checked against the account's. A code accepted clears it all. Migration (module, Learn's and Todo's servers, Learn's device schema): CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY, "authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits" bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT NULL) with a unique index on "authUserId": a new table, nothing touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
@override
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
}) async {}
feat(base,app,learn): invitations to shared spaces by mail and by link The instance's half of Halkyon's ADR 0044, step 5 (D37 to D40). An invitation by mail (sharedSpace.inviteByEmail) goes out through the register (send-space-invitation), naming who invites by their handle and nothing of what is shared, with a link to /join/<token>. The instance keeps only the address's keyed hash, as for sign-in, and the SHA-256 of the token. Whoever opens it joins with a code mailed to that address (EmailCodePurpose.joinSpace, proven through the register, prove-address-code), so a forwarded mail admits nobody else; a code proving another address is refused. It lapses after 14 days, and the hourly pruning forgets it; inviting the address again replaces it. A space's own link (setJoinLink, the Owner's) admits whoever opens it as a Viewer (joinByLink) while it is on; switched off and on, it gets a new token. Its token is kept as it is, so that whoever may invite copies the link again (joinLink). The page at /join/<token>, in English and German, says who invites and as whom; preview tells the app the same. A membership someone else made is new to its member until they mark it seen (isNew, addedBy; mine names who shared it), for the Inbox. halkyon_app learns the two new refusals (handleNeeded, otherAddress), which Learn reports as not allowed until its screens offer these ways in. The ADR records D37 to D40 and how the instance's half is built. The migration, the same in the module and in each server including it (Learn, Meditate, Todo, Train): ALTER TABLE "shared_space" ADD COLUMN "joinToken" text; CREATE UNIQUE INDEX "shared_space_join_token_idx" ON "shared_space" ("joinToken"); CREATE TABLE "shared_space_email_invitation" (…), with its unique indexes on the space and address hash, and on the token's hash, and its foreign key to "shared_space" ON DELETE CASCADE; ALTER TABLE "shared_space_member" ADD COLUMN "addedBy" uuid; ALTER TABLE "shared_space_member" ADD COLUMN "isNew" boolean NOT NULL DEFAULT false; Every space gets no token, and the unique index allows any number of nulls; every membership gets no one who added it and isn't new, so nothing already shared shows in an Inbox. The new table starts empty. None of it rewrites a table, and the server before the switch names its own columns, so it keeps working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 18:28:13 +01:00
@override
Future<void> sendInvitation(
Session session, {
required String to,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String language,
}) async {}
/// The last code mailed to [address].
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
}
/// A mail service that takes no mail.
final class _Refusing implements CodeMailer {
@override
Future<void> send(
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async => throw const SocketException('Connection refused');
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async => throw const SocketException('Connection refused');
fix(auth): codes wait after ten wrong in a row, and the owner is told Whoever knew the password could guess codes at 10 an hour forever, unnoticed, each guess checked against every authenticator (the second reading's M4). Every code an account's second factor refuses after a right password (sign-in, a phrase's reset, a change) or from a signed-in device (removing an authenticator, new recovery codes, the code from one confirmed before) now counts, per account, from every network; after every tenth in a row no code is taken (codesWait, saying until when), an hour, then twice as long each time, a day at the most (longestCodeWait, second_factor_waits.dart): at a day, ten guesses a day still have about a 1 % chance a year against one authenticator, so the first wait mails the owner that someone knows their password. The instance keeps no address, so sign-in and the phrase's reset now send the address the device typed (and its language), used only for that mail after its hash is checked against the account's. A code accepted clears it all. Migration (module, Learn's and Todo's servers, Learn's device schema): CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY, "authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits" bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT NULL) with a unique index on "authUserId": a new table, nothing touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
@override
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
}) async => throw const SocketException('Connection refused');
feat(base,app,learn): invitations to shared spaces by mail and by link The instance's half of Halkyon's ADR 0044, step 5 (D37 to D40). An invitation by mail (sharedSpace.inviteByEmail) goes out through the register (send-space-invitation), naming who invites by their handle and nothing of what is shared, with a link to /join/<token>. The instance keeps only the address's keyed hash, as for sign-in, and the SHA-256 of the token. Whoever opens it joins with a code mailed to that address (EmailCodePurpose.joinSpace, proven through the register, prove-address-code), so a forwarded mail admits nobody else; a code proving another address is refused. It lapses after 14 days, and the hourly pruning forgets it; inviting the address again replaces it. A space's own link (setJoinLink, the Owner's) admits whoever opens it as a Viewer (joinByLink) while it is on; switched off and on, it gets a new token. Its token is kept as it is, so that whoever may invite copies the link again (joinLink). The page at /join/<token>, in English and German, says who invites and as whom; preview tells the app the same. A membership someone else made is new to its member until they mark it seen (isNew, addedBy; mine names who shared it), for the Inbox. halkyon_app learns the two new refusals (handleNeeded, otherAddress), which Learn reports as not allowed until its screens offer these ways in. The ADR records D37 to D40 and how the instance's half is built. The migration, the same in the module and in each server including it (Learn, Meditate, Todo, Train): ALTER TABLE "shared_space" ADD COLUMN "joinToken" text; CREATE UNIQUE INDEX "shared_space_join_token_idx" ON "shared_space" ("joinToken"); CREATE TABLE "shared_space_email_invitation" (…), with its unique indexes on the space and address hash, and on the token's hash, and its foreign key to "shared_space" ON DELETE CASCADE; ALTER TABLE "shared_space_member" ADD COLUMN "addedBy" uuid; ALTER TABLE "shared_space_member" ADD COLUMN "isNew" boolean NOT NULL DEFAULT false; Every space gets no token, and the unique index allows any number of nulls; every membership gets no one who added it and isn't new, so nothing already shared shows in an Inbox. The new table starts empty. None of it rewrites a table, and the server before the switch names its own columns, so it keeps working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 18:28:13 +01:00
@override
Future<void> sendInvitation(
Session session, {
required String to,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String language,
}) async => throw const SocketException('Connection refused');
}
void main() {
withServerpod(
'Codes by mail',
// Mailing and accepting codes count attempts in transactions of their
// own (Serverpod's rate limiter), which the test's database rollback
// can't wrap. So these tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late _Mailbox mailbox;
/// Signed in on her phone without an address: an anonymous account
/// left from before (ADR 0020, D25), asking for codes as anyone may.
late UuidValue anna;
late UuidValue annasPhone;
/// Identified, signing in with ben@example.org.
late UuidValue ben;
setUp(() async {
useBase(sessionBuilder);
session = sessionBuilder.build();
await _deleteAll(session);
mailbox = _Mailbox();
EmailCodes.of(session.serverpod).mailer = mailbox;
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(session.serverpod, 'ben@example.org'),
createdAt: clock.now(),
),
);
await signInNewDevice(
session,
ben,
scopes: {identified},
);
});
tearDown(() async {
EmailCodes.of(session.serverpod).mailer = null;
await _deleteAll(sessionBuilder.build());
});
TestSessionBuilder signedIn(
UuidValue person,
UuidValue sessionId, {
bool isIdentified = false,
}) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{if (isIdentified) identified},
authId: sessionId.uuid,
),
);
TestSessionBuilder asAnna() => signedIn(anna, annasPhone);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
/// Mails a code to [address] for [as] and types it.
group('refuses', () {
test('nothing that turns a code into a session: a code signs nobody '
"in, and makes no address anyone's", () async {
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
expect(
await proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
purpose: EmailCodePurpose.signUp,
),
addressHash(session.serverpod, 'anna@example.org'),
);
expect(await _addressesOf(session, anna), isEmpty);
feat(base,app)!: GitHub signs nobody in any more (ADR 0020, D36) Every account signs in with SimpleLogin or a password now, so GitHub goes from the module, the shared app package, Learn and Todo, ahead of ADR 0017's GITHUB_SIGN_IN_ENDS. - halkyon_base: no `gitHubSignIn` endpoint, GitHub identity provider or GitHub refusals; no `githubClientId`/`githubClientSecret`, no `GITHUB_SIGN_IN_ENDS` and no `forgetGitHubAccounts`; the register loses `account-with-github`. `SignInWays.gitHub`, the basics' `gitHubClientId` and `gitHubSignInEnds`, and `PasswordRefusal`'s `signInAgain` and `noAddress` go. A first password takes a code mailed to the person's own address; an account without one sets none (`wrongCredentials`). - Learn's server: `instance.info()` without `gitHubClientId`, the pruning without GitHub, and `minimumAppBuild` 24, as builds up to 23 (v0.20.0) require `SignInWays.gitHub`: the server deploys with the release of build 24. - halkyon_app: no GitHub button or problems on the start screen, no password offered after a GitHub sign-in, and `ServerpodAuthGateway` without the browser half only GitHub used (`(client, base:)`). The account section offers a first password only to an account with an address. `LoopbackWebAuth` and the apps' schemes stay, SimpleLogin's ways back. - Learn's Profile lists no GitHub; the texts go (English, German). - Serverpod's `serverpod_auth_idp_github_account` stays, as its migrations make it; a row goes with its account, and ADR 0020's D36 gives operators the SQL to delete those left. - deploy/: compose.yaml and .env.example without GitHub. The READMEs, AGENTS.md files and ADR 0017 amended. BREAKING CHANGE: builds up to 23 can't read the instance's answers about an account's ways in; minimumAppBuild is 24. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-25 12:52:52 +01:00
expect(await ServerSideSession.db.count(session), 2);
expect(await EmailCodeRequest.db.count(session), 0);
});
test(
'everything on an instance that sends no mail, and says so',
() async {
EmailCodes.of(session.serverpod).mailer = null;
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.unavailable)),
);
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
isFalse,
);
},
);
test('a code it could not mail, keeping nothing of it', () async {
EmailCodes.of(session.serverpod).mailer = _Refusing();
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.mailFailed)),
);
expect(await EmailCodeRequest.db.count(session), 0);
expect(await SecretChallenge.db.count(session), 0);
});
test('what is no address', () async {
for (final typed in ['anna', 'anna@', '@example.org', 'a b@c.de']) {
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: typed,
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.notAnAddress)),
reason: typed,
);
}
expect(mailbox.sent, isEmpty);
});
test('a wrong code, and after three wrong ones even the right one; '
'nothing changes', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
for (var i = 0; i < 3; i++) {
await expectLater(
proveAddressCode(
session,
request: request,
code: 'AAAA-AAAA',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.wrongCode)),
);
}
await expectLater(
proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.tooManyTries)),
);
expect(await _addressesOf(session, anna), isEmpty);
});
test('a code past its 15 minutes, a code used, and the oldest of '
'four: up to three are valid at once, so codes others ask for '
"don't void the one the owner is typing", () async {
final late = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
final lateCode = mailbox.codeFor('anna@example.org');
await withClock(
Clock.fixed(clock.now().add(const Duration(minutes: 16))),
() => expectLater(
proveAddressCode(
session,
request: late,
code: lateCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
),
);
Future<(UuidValue, String)> ask(String network) async {
final request = await sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: network,
purpose: EmailCodePurpose.signUp,
);
return (request, mailbox.codeFor('anna@example.org'));
}
final (owners, ownersCode) = await ask('203.0.113.1');
final (second, secondCode) = await ask('198.51.100.7');
final (third, thirdCode) = await ask('198.51.100.7');
// Three valid: the owner's still is.
await proveAddressCode(
session,
request: owners,
code: ownersCode,
purpose: EmailCodePurpose.signUp,
);
final (fourth, fourthCode) = await ask('198.51.100.7');
final (fifth, _) = await ask('192.0.2.9');
// Five asked for, three valid: the two oldest left went.
await expectLater(
proveAddressCode(
session,
request: second,
code: secondCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
await proveAddressCode(
session,
request: third,
code: thirdCode,
purpose: EmailCodePurpose.signUp,
);
await proveAddressCode(
session,
request: fourth,
code: fourthCode,
purpose: EmailCodePurpose.signUp,
);
await expectLater(
proveAddressCode(
session,
request: fourth,
code: fourthCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
expect(fifth, isNotNull);
});
test('a fourth code for one address from one network within an '
'hour, an eleventh for one address from any, and an eleventh '
'from one network', () async {
Future<void> ask(String address, String network) => sendSignInCode(
session,
address: address,
language: 'en',
network: network,
purpose: EmailCodePurpose.signUp,
);
for (var i = 0; i < 3; i++) {
await ask('anna@example.org', '203.0.113.1');
}
await expectLater(
ask('Anna@Example.org', '203.0.113.1'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
// Another network still gets one: whoever filled the first count
// holds back no other, the owner's among them.
for (var i = 0; i < 7; i++) {
await ask('anna@example.org', '198.51.100.${i ~/ 3}');
}
// Ten mailed to one address in the hour: no more, from anywhere.
await expectLater(
ask('anna@example.org', '192.0.2.200'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
for (var i = 0; i < 10; i++) {
await ask('anna$i@example.org', '192.0.2.9');
}
await expectLater(
ask('anna.else@example.org', '192.0.2.9'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
expect(mailbox.sent, hasLength(20));
});
test('saying how someone signs in when not signed in', () async {
await expectLater(
endpoints.account.signInWays(notSignedIn()),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
});
test('mails a code in the language asked for, the same whether anybody '
'has the address or not', () async {
final known = await endpoints.emailCode.sendCode(
asAnna(),
address: 'ben@example.org',
language: 'de',
purpose: EmailCodePurpose.signUp,
);
final unknown = await endpoints.emailCode.sendCode(
asAnna(),
address: ' Nobody@Example.org ',
language: 'fr',
purpose: EmailCodePurpose.signUp,
);
expect(known, isNot(unknown));
expect(
[for (final mail in mailbox.sent) (mail.to, mail.language)],
[('ben@example.org', 'de'), ('nobody@example.org', 'en')],
);
expect(
mailbox.sent.map((mail) => mail.code),
everyElement(matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$'))),
);
});
test('tells the app that this instance offers it', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
true,
);
});
test('deleting a person deletes their address', () async {
await deletePerson(session, ben);
expect(await EmailIdentity.db.count(session), 0);
});
test('forgets the codes past their time, hourly', () async {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
final now = clock.now();
expect(await forgetExpiredEmailCodes(session, now: now), 0);
expect(
await forgetExpiredEmailCodes(
session,
now: now.add(const Duration(minutes: 16)),
),
1,
);
expect(await EmailCodeRequest.db.count(session), 0);
expect(await SecretChallenge.db.count(session), 0);
});
},
);
}
Matcher _refusal(EmailSignInRefusal reason) =>
isA<EmailSignInRefusedException>().having(
(exception) => exception.reason,
'reason',
reason,
);
/// The hashes of the addresses [person] signs in with.
Future<List<String>> _addressesOf(Session session, UuidValue person) async => [
for (final identity in await EmailIdentity.db.find(
session,
where: (t) => t.authUserId.equals(person),
))
identity.addressHash,
];
/// Deletes every rate-limit record, code, address and person; their
/// sessions and other identities go with them (`ON DELETE CASCADE`).
Future<void> _deleteAll(Session session) async {
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailCodeRequest.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await SecretChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}