feat(auth)!: codes by mail no longer sign in

ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.

The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.

BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.

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-20 19:31:49 +02:00
commit ae57cbe864
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
@ -60,7 +59,7 @@ final class _Refusing implements CodeMailer {
void main() {
withServerpod(
'Email sign-in',
'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.
@ -75,11 +74,9 @@ void main() {
/// Identified, signing in with ben@example.org.
late UuidValue ben;
late UuidValue bensLaptop;
/// Identified, signing in with GitHub.
late UuidValue carla;
late UuidValue carlasPhone;
setUp(() async {
useBase(sessionBuilder);
@ -106,7 +103,7 @@ void main() {
createdAt: clock.now(),
),
);
bensLaptop = await signInNewDevice(
await signInNewDevice(
session,
ben,
scopes: {identified},
@ -119,7 +116,7 @@ void main() {
session,
GitHubAccount(userIdentifier: '1001', authUserId: carla),
);
carlasPhone = await signInNewDevice(
await signInNewDevice(
session,
carla,
scopes: {identified},
@ -142,50 +139,31 @@ void main() {
),
);
TestSessionBuilder asAnna() => signedIn(anna, annasPhone);
TestSessionBuilder asBen() =>
signedIn(ben, bensLaptop, isIdentified: true);
TestSessionBuilder asCarla() =>
signedIn(carla, carlasPhone, isIdentified: true);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
/// Mails a code to [address] for [as] and types it.
Future<EmailCodeAccepted> signInWith(
TestSessionBuilder as,
String address,
) async {
final request = await endpoints.emailCode.sendCode(
as,
address: address,
language: 'en',
);
return await endpoints.emailCode.verifyCode(
as,
request: request,
code: mailbox.codeFor(address.trim().toLowerCase()),
);
}
group('refuses', () {
test('mailing and accepting codes when not signed in', () async {
await expectLater(
endpoints.emailCode.sendCode(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
),
throwsA(isA<ServerpodUnauthenticatedException>()),
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',
);
await expectLater(
endpoints.emailCode.verifyCode(
notSignedIn(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
expect(
await proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
addressHash(session.serverpod, 'anna@example.org'),
);
expect(mailbox.sent, isEmpty);
expect(await _addressesOf(session, anna), isEmpty);
expect(await ServerSideSession.db.count(session), 3);
expect(await EmailCodeRequest.db.count(session), 0);
});
test(
@ -247,8 +225,8 @@ void main() {
);
for (var i = 0; i < 3; i++) {
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: request,
code: 'AAAA-AAAA',
),
@ -256,8 +234,8 @@ void main() {
);
}
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
),
@ -277,8 +255,8 @@ void main() {
await withClock(
Clock.fixed(clock.now().add(const Duration(minutes: 16))),
() => expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: late,
code: lateCode,
),
@ -298,8 +276,8 @@ void main() {
language: 'en',
);
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: replaced,
code: replacedCode,
),
@ -307,14 +285,14 @@ void main() {
);
final newerCode = mailbox.codeFor('anna@example.org');
await endpoints.emailCode.verifyCode(
asAnna(),
await proveAddressCode(
session,
request: newer,
code: newerCode,
);
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: newer,
code: newerCode,
),
@ -322,19 +300,6 @@ void main() {
);
});
test(
'a second address for a person who has one; nothing changes',
() async {
await expectLater(
signInWith(asBen(), 'ben@elsewhere.org'),
throwsA(_refusal(EmailSignInRefusal.otherAddressLinked)),
);
expect(await _addressesOf(session, ben), [
addressHash(session.serverpod, 'ben@example.org'),
]);
},
);
test('a fourth code for one address within an hour, and an '
'eleventh from one network', () async {
for (var i = 0; i < 3; i++) {
@ -370,21 +335,6 @@ void main() {
expect(mailbox.sent, hasLength(10));
});
test('a switch token used twice, or made up', () async {
final token = (await signInWith(
asAnna(),
'ben@example.org',
)).switchToken!;
await endpoints.emailSignIn.signIn(notSignedIn(), switchToken: token);
for (final used in [token, 'made-up']) {
await expectLater(
endpoints.emailSignIn.signIn(notSignedIn(), switchToken: used),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
}
});
test('saying how someone signs in when not signed in', () async {
await expectLater(
endpoints.account.signInWays(notSignedIn()),
@ -417,106 +367,6 @@ void main() {
);
});
test("a code for an address nobody has makes it the person's: they "
'become identified, on every session, and stop being anonymous; '
'the instance keeps only a keyed hash of it', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: ' Anna@Example.org',
language: 'en',
);
final code = mailbox.codeFor('anna@example.org');
final accepted = await endpoints.emailCode.verifyCode(
asAnna(),
request: request,
// As typed off the mail: lower case, a space for the dash.
code: code.toLowerCase().replaceAll('-', ' '),
);
expect(accepted.switchToken, isNull);
final hash = addressHash(session.serverpod, 'anna@example.org');
expect(hash, matches(RegExp(r'^[0-9a-f]{64}$')));
expect(await _addressesOf(session, anna), [hash]);
final person = await AuthServices.instance.authUsers.get(
session,
authUserId: anna,
);
expect(person.scopeNames, contains(identified.name));
expect(
(await ServerSideSession.db.findById(
session,
annasPhone,
))?.scopeNames,
contains(identified.name),
);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(await EmailCodeRequest.db.count(session), 0);
final kept = jsonEncode([
...await EmailIdentity.db.find(session),
...await EmailCodeRequest.db.find(session),
...await RateLimitedRequestAttempt.db.find(session),
]);
expect(kept.toLowerCase(), isNot(contains('anna')));
expect(kept, isNot(contains(code)));
});
test('a code for the address the person signs in with changes '
'nothing', () async {
final accepted = await signInWith(asBen(), 'ben@example.org');
expect(accepted.switchToken, isNull);
expect(await EmailIdentity.db.count(session), 1);
});
test("a code for another person's address changes nothing here, and "
'gives a token that signs in as them, without a session', () async {
final accepted = await signInWith(asAnna(), 'ben@example.org');
expect(accepted.switchToken, isNotNull);
expect(await _addressesOf(session, anna), isEmpty);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
final signedIn = await endpoints.emailSignIn.signIn(
notSignedIn(),
switchToken: accepted.switchToken!,
);
expect(signedIn.authUserId, ben);
expect(signedIn.scopeNames, contains(identified.name));
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben) & t.method.equals('email'),
);
expect(sessions, hasLength(1));
expect(await EmailCodeRequest.db.count(session), 0);
});
test('a person who signs in with GitHub adds an address', () async {
Future<(bool, bool)> waysOf(TestSessionBuilder person) async {
final ways = await endpoints.account.signInWays(person);
return (ways.gitHub, ways.email);
}
expect(await waysOf(asCarla()), (true, false));
final accepted = await signInWith(asCarla(), 'carla@example.org');
expect(accepted.switchToken, isNull);
expect(await waysOf(asCarla()), (true, true));
expect(await waysOf(asAnna()), (false, false));
});
test('tells the app that this instance offers it', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,