feat(auth): ways in for accounts without a working password

ADR 0017, step 3. Signing up takes an address a mailed code proves and
a password's setup, without a session (`passwordSignIn.signUp`): an
address nobody has becomes a new identified account, and an account
with the address but no password (codes by mail before; no keys exist,
so nothing waits) takes the password, keeping its ID. A setup never
finished signs in with its password and a code mailed to the address it
began with, until an authenticator is confirmed, which also calls off a
reset of the account waiting; any other account takes no code by mail
in place of its second factor and leaves the code unused. A reset of
the whole account waiting no longer counts as having had an
authenticator, so its owner isn't locked out until it completes.

Members forward both through the register; the tests' shared helpers
move into `support/password_support.dart`.

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 14:41:16 +02:00
commit f8076dcacf
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 849 additions and 80 deletions

View file

@ -10,24 +10,9 @@ import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
import '../test_tools/serverpod_test_tools.dart';
Matcher refused(PasswordRefusal reason) => throwsA(
isA<PasswordRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
Matcher refusedAsEmail(EmailSignInRefusal reason) => throwsA(
isA<EmailSignInRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
void main() {
withServerpod(
'A password and an authenticator',
@ -1797,35 +1782,3 @@ void main() {
},
);
}
/// Deletes everything the tests make.
Future<void> deleteAll(Session session) async {
await SignInChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
await TotpAuthenticator.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
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));
}

View file

@ -0,0 +1,336 @@
import 'dart:typed_data';
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 '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
import '../test_tools/serverpod_test_tools.dart';
/// The ways into an account that has no working password yet (ADR 0017,
/// step 3): signing up with a code by mail, and a setup never finished
/// signing in with the password and a code by mail.
void main() {
withServerpod(
'Ways in without a working password',
// Rate limits, codes and taken challenges write in transactions of their
// own: these tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late Mailbox mailbox;
late PasswordDevice device;
late DateTime now;
setUp(() async {
useBase(sessionBuilder);
session = sessionBuilder.build();
pod = session.serverpod;
await deleteAll(session);
mailbox = Mailbox();
EmailCodes.of(pod).mailer = mailbox;
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
device = await PasswordDevice.make();
});
tearDown(() async {
EmailCodes.of(pod).mailer = null;
usePasswordKeys(pod, null);
await deleteAll(sessionBuilder.build());
});
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
TestSessionBuilder as(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified},
),
);
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
final request = await endpoints.accountRecovery.sendCode(
notSignedIn(),
address: address,
language: 'en',
);
return (request: request, code: mailbox.codeFor(address));
}
Future<AuthSuccess> signUp(String address, {PasswordDevice? with_}) =>
atNow(() async {
final code = await mailedCode(address);
return await endpoints.passwordSignIn.signUp(
notSignedIn(),
request: code.request,
code: code.code,
setup: (with_ ?? device).setup(),
);
});
/// Signs in as a device would, both steps, with a code by mail where
/// [mailedTo] names its address.
Future<PasswordSignedIn> signIn({
String address = 'anna@example.org',
String? totpCode,
String? mailedTo,
String? wrongMailedCode,
BigInt? privateValue,
}) => atNow(() async {
final mailed = mailedTo == null ? null : await mailedCode(mailedTo);
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: address,
);
final answer = device.answer(challenge, privateValue: privateValue);
return await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode,
request: mailed?.request,
code: wrongMailedCode ?? mailed?.code,
);
});
Future<int> countOf(UuidValue person) => AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(person),
);
group('signing up with a code by mail', () {
test('makes an address nobody has a new identified account with the '
'password, signed in, its authenticator next', () async {
final signedUp = await signUp('anna@example.org');
final anna = signedUp.authUserId;
expect(signedUp.scopeNames, contains(identified.name));
expect(
(await AuthUser.db.findById(session, anna))!.scopeNames,
contains(identified.name),
);
expect(
(await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!.addressHash,
addressHash(pod, 'anna@example.org'),
);
expect(await countOf(anna), 1);
final ways = await endpoints.account.signInWays(as(anna));
expect(ways.email, isTrue);
expect(ways.password, isTrue);
expect(ways.authenticator, isFalse);
expect(ways.passwordOnly, isFalse);
// The session it gave sets up the authenticator.
final enrolment = await endpoints.authenticator.enrol(
as(anna),
name: 'Phone',
label: 'anna@example.org',
);
final codes = await atNow(
() => endpoints.authenticator.confirm(
as(anna),
authenticator: enrolment.authenticator,
code: codeOf(enrolment, at: now),
),
);
expect(codes, hasLength(10));
});
test('gives an account with the address and no password (codes by '
'mail before) the password, keeping its ID', () async {
final ben = await newAnonymousPerson(session);
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(pod, 'ben@example.org'),
createdAt: now,
),
);
final signedUp = await signUp('ben@example.org');
expect(signedUp.authUserId, ben);
expect(await countOf(ben), 1);
expect(
(await AuthUser.db.findById(session, ben))!.scopeNames,
contains(identified.name),
);
expect(await AuthUser.db.count(session), 1);
});
test('is refused for an address whose account has a password, '
'finished or not, keeping it as it was', () async {
final anna = (await signUp('anna@example.org')).authUserId;
final before = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
final other = await PasswordDevice.make(seed: 99);
await expectLater(
signUp('anna@example.org', with_: other),
refused(PasswordRefusal.passwordExists),
);
final after = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
expect(
after.salt.buffer.asUint8List(),
before.salt.buffer.asUint8List(),
);
expect(await AuthUser.db.count(session), 1);
});
test('is refused for a wrong code, a setup out of range, and where '
'the instance has no keys, making nobody', () async {
final code = await mailedCode('anna@example.org');
await expectLater(
endpoints.passwordSignIn.signUp(
notSignedIn(),
request: code.request,
code: 'WRONGCOD',
setup: device.setup(),
),
refusedAsEmail(EmailSignInRefusal.wrongCode),
);
final short = device.setup().copyWith(
salt: ByteData(8),
);
await expectLater(
endpoints.passwordSignIn.signUp(
notSignedIn(),
request: code.request,
code: code.code,
setup: short,
),
refused(PasswordRefusal.invalidSetup),
);
usePasswordKeys(pod, null);
await expectLater(
endpoints.passwordSignIn.signUp(
notSignedIn(),
request: code.request,
code: code.code,
setup: device.setup(),
),
refused(PasswordRefusal.unavailable),
);
expect(await AuthUser.db.count(session), 0);
expect(await AccountPassword.db.count(session), 0);
});
});
group('a setup never finished', () {
late UuidValue anna;
setUp(() async {
anna = (await signUp('anna@example.org')).authUserId;
});
test('signs in with the password and a code mailed to its address, '
'the authenticator still to set up', () async {
await expectLater(
signIn(),
refused(PasswordRefusal.setupIncomplete),
);
final signedIn = await signIn(mailedTo: 'anna@example.org');
expect(signedIn.authUserId, anna);
expect(signedIn.needsAuthenticator, isTrue);
expect(signedIn.scopeNames, contains(identified.name));
});
test('is refused alike for a wrong password, and for a code to '
'another address; a wrong code as email refuses it', () async {
await expectLater(
signIn(
mailedTo: 'anna@example.org',
privateValue: BigInt.from(4242),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
signIn(mailedTo: 'ben@example.org'),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
signIn(mailedTo: 'anna@example.org', wrongMailedCode: 'WRONGCOD'),
refusedAsEmail(EmailSignInRefusal.wrongCode),
);
});
test('with an account reset waiting, still signs in with the '
'password and a code, which calls the reset off', () async {
final code = await mailedCode('anna@example.org');
await atNow(
() => endpoints.accountRecovery.startAccountReset(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
request: code.request,
code: code.code,
),
);
expect(
(await endpoints.account.signInWays(as(anna))).passwordOnly,
isFalse,
);
final signedIn = await signIn(mailedTo: 'anna@example.org');
expect(signedIn.needsAuthenticator, isTrue);
expect(
await AccountReset.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
test('takes no code by mail in place of the second factor once an '
'authenticator was confirmed, and leaves it unused', () async {
final enrolment = await endpoints.authenticator.enrol(
as(anna),
name: 'Phone',
label: 'anna@example.org',
);
await atNow(
() => endpoints.authenticator.confirm(
as(anna),
authenticator: enrolment.authenticator,
code: codeOf(enrolment, at: now),
),
);
now = now.add(totpPeriod);
await expectLater(
signIn(mailedTo: 'anna@example.org'),
refused(PasswordRefusal.wrongCredentials),
);
// The code mailed is still valid for what it was asked for.
expect(await EmailCodeRequest.db.count(session), 1);
});
});
},
);
}

View file

@ -269,8 +269,23 @@ final class _Register implements AccountRegister {
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
UuidValue? request,
String? code,
}) => _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, {
@ -708,6 +723,29 @@ void main() {
);
});
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);

View file

@ -685,6 +685,67 @@ void main() {
});
});
group('ways in without a working password (ADR 0017)', () {
late DateTime now;
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
setUp(() {
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
});
test("cross for a member's device: signing up with a code, and a "
'setup never finished signing in with the password and a code, '
'with what the register refuses', () async {
final device = await PasswordDevice.make();
await atNow(() async {
Future<UuidValue> code() => remote.sendSignInCode(
session,
address: 'carla@example.org',
language: 'en',
network: '203.0.113.7',
);
final carla = await remote.signUpWithCode(
session,
request: await code(),
code: mailbox.codeFor('carla@example.org'),
setup: device.setup(),
);
expect(
(await AuthUser.db.findById(session, carla))!.scopeNames,
contains(identified.name),
);
await expectLater(
remote.signUpWithCode(
session,
request: await code(),
code: mailbox.codeFor('carla@example.org'),
setup: device.setup(),
),
refused(PasswordRefusal.passwordExists),
);
final challenge = await remote.beginPasswordSignIn(
session,
address: 'carla@example.org',
network: '203.0.113.7',
);
final answer = device.answer(challenge);
final proof = await remote.completePasswordSignIn(
session,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
request: await code(),
code: mailbox.codeFor('carla@example.org'),
);
expect(proof.account, carla);
expect(proof.needsAuthenticator, isTrue);
});
});
});
group('deleting an account', () {
test('deletes it here and tells every member, keeping no notice '
'once they confirmed', () async {

View file

@ -0,0 +1,54 @@
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';
/// Throws a [PasswordRefusedException] for [reason].
Matcher refused(PasswordRefusal reason) => throwsA(
isA<PasswordRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
/// Throws an [EmailSignInRefusedException] for [reason].
Matcher refusedAsEmail(EmailSignInRefusal reason) => throwsA(
isA<EmailSignInRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
/// Deletes everything the tests make.
Future<void> deleteAll(Session session) async {
await SignInChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
await TotpAuthenticator.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
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));
}

View file

@ -1157,6 +1157,8 @@ class _PasswordSignInEndpoint {
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
_is.UuidValue? request,
String? code,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1175,6 +1177,8 @@ class _PasswordSignInEndpoint {
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'request': request,
'code': code,
}),
serializationManager: _serializationManager,
);
@ -1190,6 +1194,43 @@ class _PasswordSignInEndpoint {
}
});
}
_ida.Future<_iacs.AuthSuccess> signUp(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue request,
required String code,
required _ikhyee47.PasswordSetup setup,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'passwordSignIn',
method: 'signUp',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'passwordSignIn',
methodName: 'signUp',
parameters: _ist.testObjectToJson({
'request': request,
'code': code,
'setup': setup,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _ProfileEndpoint {