fix(auth)!: a first password takes a fresh proof of the account's way in

The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).

- An account with an address proves it anew: a code mailed to that
  address for this (EmailCodePurpose.firstPassword, its own mail), never
  the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
  a GitHub sign-in (the session's method and creation time, where the
  session lives; members tell the register), refused signInAgain before
  any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
  they add.

The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 11:31:22 +02:00
commit f0413531ff
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 762 additions and 66 deletions

View file

@ -30,6 +30,7 @@ export 'src/password/key_proof.dart'
export 'src/password/password_config.dart';
export 'src/password/passwords.dart'
show
gitHubSignInFreshFor,
newPasswordCosts,
passwordGroup,
passwordMethod,

View file

@ -102,6 +102,7 @@ final class LogCodeMailer implements CodeMailer {
EmailCodePurpose.authenticatorReset => 'um deine Authenticator-App abzuschalten',
EmailCodePurpose.accountReset => 'um dein Konto zurückzusetzen',
EmailCodePurpose.deletion => 'um dein Konto zu löschen',
EmailCodePurpose.firstPassword => 'um deinem Konto ein Passwort zu geben',
}}',
body:
'$code\n'
@ -117,6 +118,7 @@ final class LogCodeMailer implements CodeMailer {
'und neue Schlüssel; was mit den alten verschlüsselt war, ist '
'dann verloren',
EmailCodePurpose.deletion => 'um dein Konto auf $instance mit allem darin zu löschen',
EmailCodePurpose.firstPassword => 'um deinem Konto auf $instance sein erstes Passwort zu geben',
}}. Er gilt 15 Minuten.\n'
'\n'
'Gib ihn nur in $app auf $instance ein, nirgends sonst, auch wenn '
@ -136,6 +138,7 @@ final class LogCodeMailer implements CodeMailer {
EmailCodePurpose.authenticatorReset => 'switch off your authenticator',
EmailCodePurpose.accountReset => 'reset your account',
EmailCodePurpose.deletion => 'delete your account',
EmailCodePurpose.firstPassword => 'give your account a password',
}}',
body:
'$code\n'
@ -149,6 +152,7 @@ final class LogCodeMailer implements CodeMailer {
EmailCodePurpose.accountReset => 'to reset your account on $instance: a new password and new '
'keys, and what was encrypted with the old ones is lost',
EmailCodePurpose.deletion => 'to delete your account on $instance with everything in it',
EmailCodePurpose.firstPassword => 'to give your account on $instance its first password',
}}. It is valid for 15 minutes.\n'
'\n'
'Type it only in $app on $instance, nowhere else, even if a page or '

View file

@ -17,3 +17,6 @@ values:
- accountReset
### Deleting an account whose setup never finished, with the password.
- deletion
### Proving, signed in, the account's own address to give it its first
### password (the fourth reading: a session alone sets none).
- firstPassword

View file

@ -32,7 +32,11 @@ enum EmailCodePurpose implements _is.SerializableModel {
accountReset,
/// Deleting an account whose setup never finished, with the password.
deletion;
deletion,
/// Proving, signed in, the account's own address to give it its first
/// password (the fourth reading: a session alone sets none).
firstPassword;
static EmailCodePurpose fromJson(String name) {
switch (name) {
@ -46,6 +50,8 @@ enum EmailCodePurpose implements _is.SerializableModel {
return EmailCodePurpose.accountReset;
case 'deletion':
return EmailCodePurpose.deletion;
case 'firstPassword':
return EmailCodePurpose.firstPassword;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "EmailCodePurpose"',

View file

@ -88,7 +88,12 @@ enum PasswordRefusal implements _is.SerializableModel {
/// couldn't open a session for it yet (its clock is behind the
/// register's): sign in again with the new password. Never "this
/// instance has no password sign-in" (the re-review's tenth finding).
passwordChanged;
passwordChanged,
/// A first password for an account that signs in with GitHub alone takes
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
/// alone sets none): sign in with GitHub again, then set it.
signInAgain;
static PasswordRefusal fromJson(String name) {
switch (name) {
@ -132,6 +137,8 @@ enum PasswordRefusal implements _is.SerializableModel {
return PasswordRefusal.sessionEnded;
case 'passwordChanged':
return PasswordRefusal.passwordChanged;
case 'signInAgain':
return PasswordRefusal.signInAgain;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"',

View file

@ -1,9 +1,12 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
@ -19,9 +22,12 @@ class PasswordEndpoint extends Endpoint {
/// Gives the caller the password [setup] describes (all of it made on the
/// device), with the address [code] proves for [request] (mailed with
/// `emailCode.sendCode`) unless the caller has one; refusals as
/// `setPassword`'s. It signs in once an authenticator is confirmed
/// (`authenticator.confirm`).
/// `emailCode.sendCode`): the caller's own where they have one
/// (`EmailCodePurpose.firstPassword`), else the one they add
/// (`EmailCodePurpose.signUp`); someone who signs in with GitHub alone
/// does it within [gitHubSignInFreshFor] of signing in with GitHub on this
/// session. Refusals as `setPassword`'s. It signs in once an authenticator
/// is confirmed (`authenticator.confirm`).
Future<void> set(
Session session, {
required PasswordSetup setup,
@ -35,6 +41,7 @@ class PasswordEndpoint extends Endpoint {
setup: setup,
request: request,
code: code,
freshGitHubSignIn: await _signedInWithGitHubJustNow(session),
);
if (isMember(session.serverpod)) {
await becomeIdentified(session, person);
@ -120,6 +127,23 @@ class PasswordEndpoint extends Endpoint {
}
}
/// Whether [session]'s own session (this server's) was made by a GitHub
/// sign-in within [gitHubSignInFreshFor] (the fourth reading).
Future<bool> _signedInWithGitHubJustNow(Session session) async {
final authId = session.authenticated!.authId;
if (!Uuid.isValidUUID(fromString: authId)) {
return false;
}
// SELECT * FROM "serverpod_auth_core_session" WHERE "id" = $1
final row = await ServerSideSession.db.findById(
session,
UuidValue.fromString(authId),
);
return row != null &&
row.method == gitHubMethod &&
!clock.now().isAfter(row.createdAt.add(gitHubSignInFreshFor));
}
/// How many proofs of the password one signed-in session begins in an
/// hour: a bound on the work it can ask for, holding back that session
/// alone.

View file

@ -58,3 +58,7 @@ values:
### register's): sign in again with the new password. Never "this
### instance has no password sign-in" (the re-review's tenth finding).
- passwordChanged
### A first password for an account that signs in with GitHub alone takes
### a GitHub sign-in of the last 10 minutes (the fourth reading: a session
### alone sets none): sign in with GitHub again, then set it.
- signInAgain

View file

@ -309,6 +309,10 @@ AccountPassword passwordRowOf(
updatedAt: now,
);
/// How recent a GitHub sign-in must be for an account that signs in with
/// GitHub alone to take its first password ([setPassword]).
const gitHubSignInFreshFor = Duration(minutes: 10);
/// Gives [person] a password ([setup], ADR 0017). A password needs an
/// address, for resets: the person's own, or the one [code] proves for
/// [request] (mailed with `sendSignInCode`), which becomes theirs as email
@ -319,31 +323,78 @@ AccountPassword passwordRowOf(
/// ([hadConfirmedAuthenticator]: after a reset, a new password comes through
/// the recovery phrase or the account's reset alone).
///
/// A session alone sets no first password (the fourth reading: whoever
/// took one would make the account theirs): an account with an address
/// proves it anew, by a code mailed to that address for this
/// ([EmailCodePurpose.firstPassword]); one that signs in with GitHub alone
/// by a GitHub sign-in of the last [gitHubSignInFreshFor]
/// ([freshGitHubSignIn], which the server holding the session tells), with
/// the address it adds proved as an anonymous person proves theirs
/// ([EmailCodePurpose.signUp]).
///
/// Refused ([PasswordRefusedException]) where the instance has no password
/// sign-in, for a setup out of range, without an address, for an address
/// another account has, and once the account has a password and had a
/// confirmed authenticator ([PasswordRefusal.passwordExists]); a code's
/// refusals as `proveAddressCode`'s, and a second address as email sign-in
/// refuses it ([EmailSignInRefusedException]). Nothing changes then.
/// another account has, once the account has a password and had a
/// confirmed authenticator ([PasswordRefusal.passwordExists]), without the
/// code its own address takes ([PasswordRefusal.wrongCredentials]), and
/// for GitHub without a recent sign-in ([PasswordRefusal.signInAgain],
/// before any code is used); a code's refusals as `proveAddressCode`'s, and
/// another address than the account's as email sign-in refuses it
/// ([EmailSignInRefusedException]). Nothing changes then.
Future<void> setPassword(
Session session, {
required UuidValue person,
required PasswordSetup setup,
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
}) async {
requirePasswordKeys(session.serverpod);
if (!isPasswordSetup(setup)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
final proven = request != null && code != null
? await proveAddressCode(
// SELECT * FROM "email_identity" WHERE "authUserId" = $1
final own = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
final String? proven;
if (own != null) {
if (request == null || code == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
proven = await proveAddressCode(
session,
request: request,
code: code,
purpose: EmailCodePurpose.firstPassword,
);
if (proven != own.addressHash) {
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.otherAddressLinked,
);
}
} else {
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
// WHERE "authUserId" = $1
final gitHub =
await GitHubAccount.db.count(
session,
request: request,
code: code,
purpose: EmailCodePurpose.signUp,
)
: null;
where: (t) => t.authUserId.equals(person),
) >
0;
if (gitHub && !freshGitHubSignIn) {
throw refusedFor(PasswordRefusal.signInAgain);
}
proven = request != null && code != null
? await proveAddressCode(
session,
request: request,
code: code,
purpose: EmailCodePurpose.signUp,
)
: null;
}
try {
await session.db.transaction((transaction) async {
// SELECT * FROM "email_identity"
@ -358,7 +409,7 @@ Future<void> setPassword(
final mine = identities
.where((identity) => identity.authUserId == person)
.firstOrNull;
if (proven != null) {
if (proven case final proven?) {
if (identities.any(
(identity) =>
identity.addressHash == proven && identity.authUserId != person,
@ -392,8 +443,14 @@ Future<void> setPassword(
transaction: transaction,
);
}
} else if (mine == null) {
throw refusedFor(PasswordRefusal.noAddress);
} else {
// No address proved: none at all, or one that came meanwhile, whose
// code wasn't given.
throw refusedFor(
mine == null
? PasswordRefusal.noAddress
: PasswordRefusal.wrongCredentials,
);
}
// SELECT * FROM "account_password" WHERE "authUserId" = $1
final existing = await AccountPassword.db.findFirstRow(

View file

@ -141,13 +141,17 @@ abstract interface class AccountRegister {
Future<bool> passwordSignInOffered(Session session);
/// Gives [person] the password [setup] describes, with the address [code]
/// proves for [request] unless they have one. Refusals as `setPassword`'s.
/// proves for [request] (their own where they have one), and for one who
/// signs in with GitHub alone a GitHub sign-in of theirs made recently
/// ([freshGitHubSignIn], of the session asking). Refusals as
/// `setPassword`'s.
Future<void> setPassword(
Session session, {
required UuidValue person,
required PasswordSetup setup,
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
});
/// A password sign-in's first step for [address], the sign-ins begun from

View file

@ -224,6 +224,8 @@ Future<Map<String, Object?>?> _answer(
setup: arguments.model('setup', PasswordSetup.fromJson),
request: arguments.optionalId('request'),
code: arguments.optionalString('code'),
// The member tells it of its own session (the fourth reading).
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
);
return const {};
case RegisterOperation.beginPasswordSignIn:
@ -503,6 +505,13 @@ extension type const _Arguments(Map<String, Object?> _map) {
/// Whether the argument [name] holds anything (not null).
bool has(String name) => _map[name] != null;
/// The flag [name]: false where it is missing.
bool flag(String name) => switch (_map[name]) {
null => false,
final bool value => value,
_ => throw FormatException('$name is no flag'),
};
/// The model [read] makes of the object [name]; a [FormatException] for
/// what isn't one.
T model<T>(String name, T Function(Map<String, dynamic> json) read) {

View file

@ -404,12 +404,14 @@ final class LocalRegister implements AccountRegister {
required PasswordSetup setup,
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
}) => passwords.setPassword(
session,
person: person,
setup: setup,
request: request,
code: code,
freshGitHubSignIn: freshGitHubSignIn,
);
@override

View file

@ -291,11 +291,13 @@ final class RemoteRegister implements AccountRegister {
required PasswordSetup setup,
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
}) => _ask(RegisterOperation.setPassword, {
'person': person.uuid,
'setup': setup.toJson(),
'request': request?.uuid,
'code': code,
'freshGitHubSignIn': freshGitHubSignIn,
});
@override

View file

@ -117,6 +117,25 @@ void main() {
);
}
/// A code mailed to Anna's own address, for a password set while
/// signed in (the fourth reading: a session alone sets none).
Future<({UuidValue request, String code})> ownAddressCode() => mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
/// Anna's password set anew from her phone (until an authenticator is
/// confirmed), with a code to her own address.
Future<void> annaSetsAgain(PasswordDevice with_) async {
final code = await ownAddressCode();
await endpoints.password.set(
asAnna(),
setup: with_.setup(),
request: code.request,
code: code.code,
);
}
/// Anna's password and a confirmed authenticator: its enrolment, and
/// the recovery codes it brought.
Future<({AuthenticatorEnrolment enrolment, List<String> recoveryCodes})>
@ -208,14 +227,15 @@ void main() {
expect(ways.authenticator, isFalse);
});
test('needs no code from someone whose address is theirs', () async {
test('takes, from someone whose address is theirs, a code mailed to '
'that address for it', () async {
await annaSetsPassword();
await AccountPassword.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
await endpoints.password.set(asAnna(), setup: device.setup());
await annaSetsAgain(device);
expect(
await AccountPassword.db.count(
@ -224,6 +244,127 @@ void main() {
),
1,
);
expect(mailbox.codes.last.purpose, EmailCodePurpose.firstPassword);
});
test('is refused to a session alone where the account has an '
'address, and for a code asked for anything else or another '
'address (the fourth reading)', () async {
// An account with an address and no password, as codes by mail
// left them: whoever took its session sets nothing.
await annaSetsPassword();
await AccountPassword.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
final thief = await PasswordDevice.make(seed: 99);
await expectLater(
endpoints.password.set(asAnna(), setup: thief.setup()),
refused(PasswordRefusal.wrongCredentials),
);
final signUp = await mailedCode('anna@example.org');
await expectLater(
endpoints.password.set(
asAnna(),
setup: thief.setup(),
request: signUp.request,
code: signUp.code,
),
refusedAsEmail(EmailSignInRefusal.codeExpired),
);
final theirs = await mailedCode(
'thief@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await expectLater(
endpoints.password.set(
asAnna(),
setup: thief.setup(),
request: theirs.request,
code: theirs.code,
),
refusedAsEmail(EmailSignInRefusal.otherAddressLinked),
);
expect(await AccountPassword.db.count(session), 0);
expect(
(await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!.addressHash,
addressHash(pod, 'anna@example.org'),
);
});
group('for someone who signs in with GitHub alone', () {
setUp(() async {
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: anna),
);
await grantIdentified(session, anna);
});
TestSessionBuilder from(UuidValue device) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{identified},
authId: device.uuid,
),
);
test('is refused to a session not made by a GitHub sign-in of the '
'last ten minutes, before the code is used, so a stolen '
'session claims nothing (the fourth reading)', () async {
final thief = await PasswordDevice.make(seed: 99);
final code = await mailedCode('thief@example.org');
final stale = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
now = now.add(gitHubSignInFreshFor + const Duration(seconds: 1));
for (final device in [annasPhone, stale]) {
await expectLater(
atNow(
() => endpoints.password.set(
from(device),
setup: thief.setup(),
request: code.request,
code: code.code,
),
),
refused(PasswordRefusal.signInAgain),
);
}
expect(await AccountPassword.db.count(session), 0);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(await gitHubAccountToSignIn(session, '1001'), anna);
// Signed in with GitHub just now, the same code still serves.
final fresh = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await atNow(
() => endpoints.password.set(
from(fresh),
setup: device.setup(),
request: code.request,
code: code.code,
),
);
expect(
await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
});
});
test('is refused without an address, keeping nothing', () async {
@ -346,7 +487,7 @@ void main() {
'then is refused', () async {
await annaSetsPassword();
final other = await PasswordDevice.make(seed: 99);
await endpoints.password.set(asAnna(), setup: other.setup());
await annaSetsAgain(other);
expect(
Uint8List.sublistView(
(await AccountPassword.db.findFirstRow(
@ -370,7 +511,7 @@ void main() {
});
await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()),
annaSetsAgain(device),
refused(PasswordRefusal.passwordExists),
);
});
@ -1866,7 +2007,7 @@ void main() {
final other = await PasswordDevice.make(seed: 99);
await expectLater(
endpoints.password.set(asAnna(), setup: other.setup()),
annaSetsAgain(other),
refused(PasswordRefusal.passwordExists),
);
expect(
@ -2647,10 +2788,10 @@ void main() {
fresh.signingPublicKey,
);
await expectLater(finish(fresh), refused(PasswordRefusal.noReset));
// A session alone replaces neither, before an authenticator is
// confirmed again.
// A session (even with a code to the address) replaces neither,
// before an authenticator is confirmed again.
await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()),
annaSetsAgain(device),
refused(PasswordRefusal.passwordExists),
);
expect(

View file

@ -1,6 +1,7 @@
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';
@ -43,6 +44,10 @@ final class _Register implements AccountRegister {
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>{};
/// What a password sign-in proves; null refuses it.
PasswordProof? proof;
@ -205,9 +210,13 @@ final class _Register implements AccountRegister {
required PasswordSetup setup,
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
}) => _answer('setPassword', () {
add(person);
passwords.add(person);
if (freshGitHubSignIn) {
freshGitHubSignIns.add(person);
}
});
@override
@ -733,6 +742,7 @@ void main() {
);
expect(register.passwords, {anna});
expect(register.freshGitHubSignIns, isEmpty);
expect(await scopesOf(anna), contains(identified.name));
expect(
await AnonymousAccount.db.count(
@ -743,6 +753,38 @@ void main() {
);
});
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(

View file

@ -55,6 +55,7 @@ Future<UuidValue> signInNewDevice(
Session session,
UuidValue person, {
Set<Scope> scopes = const {},
String method = 'test',
}) async {
Future<Set<UuidValue>> sessionIds() async => {
for (final serverSideSession in await ServerSideSession.db.find(
@ -68,7 +69,7 @@ Future<UuidValue> signInNewDevice(
await AuthServices.instance.tokenManager.createToken(
session,
authUserId: person,
method: 'test',
method: method,
scopes: scopes,
);
return (await sessionIds()).difference(before).single;

View file

@ -127,6 +127,8 @@ void main() {
"to switch off your authenticator app's codes",
EmailCodePurpose.accountReset: 'to reset your account',
EmailCodePurpose.deletion: 'to delete your account',
EmailCodePurpose.firstPassword:
'to give your account on learn.halkyon.eu its first password',
};
const german = {
EmailCodePurpose.signUp: 'um dein Konto auf learn.halkyon.eu anzulegen',
@ -135,6 +137,9 @@ void main() {
'um die Codes deiner Authenticator-App',
EmailCodePurpose.accountReset: 'zurückzusetzen',
EmailCodePurpose.deletion: 'mit allem darin zu löschen',
EmailCodePurpose.firstPassword:
'um deinem Konto auf learn.halkyon.eu sein erstes Passwort zu '
'geben',
};
for (final purpose in EmailCodePurpose.values) {
final en = mailFor(purpose, 'en').body;
@ -179,6 +184,9 @@ void main() {
'K7QX-M2PD: your Halkyon Learn code to reset your account',
EmailCodePurpose.deletion:
'K7QX-M2PD: your Halkyon Learn code to delete your account',
EmailCodePurpose.firstPassword:
'K7QX-M2PD: your Halkyon Learn code to give your account a '
'password',
};
const german = {
EmailCodePurpose.signUp:
@ -193,6 +201,9 @@ void main() {
'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zurückzusetzen',
EmailCodePurpose.deletion:
'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zu löschen',
EmailCodePurpose.firstPassword:
'K7QX-M2PD: dein Halkyon-Learn-Code, um deinem Konto ein Passwort '
'zu geben',
};
for (final purpose in EmailCodePurpose.values) {
expect(subjectOf(purpose, 'en'), english[purpose]);