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
11 changed files with 849 additions and 80 deletions

View file

@ -805,6 +805,16 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String?>(),
nullable: true,
),
'request': _is.ParameterDescription(
name: 'request',
type: _is.getType<_is.UuidValue?>(),
nullable: true,
),
'code': _is.ParameterDescription(
name: 'code',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
@ -820,6 +830,41 @@ class Endpoints extends _is.EndpointDispatch {
clientProof: params['clientProof'],
totpCode: params['totpCode'],
recoveryCode: params['recoveryCode'],
request: params['request'],
code: params['code'],
),
),
'signUp': _is.MethodConnector(
name: 'signUp',
params: {
'request': _is.ParameterDescription(
name: 'request',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'code': _is.ParameterDescription(
name: 'code',
type: _is.getType<String>(),
nullable: false,
),
'setup': _is.ParameterDescription(
name: 'setup',
type: _is.getType<_ikhyee47.PasswordSetup>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['passwordSignIn']
as _ik13m562.PasswordSignInEndpoint)
.signUp(
session,
request: params['request'],
code: params['code'],
setup: params['setup'],
),
),
},

View file

@ -30,9 +30,8 @@ enum PasswordRefusal implements _is.SerializableModel {
/// The step began too long ago, or was finished already: begin again.
challengeExpired,
/// The password is right, but no authenticator was confirmed yet: finish
/// setting one up where the password was set (or, while email codes still
/// sign in, signed in with a code by mail).
/// The password is right, but no authenticator was confirmed yet: sign in
/// with the password and a code mailed to the address, then set one up.
setupIncomplete,
/// What the device sent isn't a password's setup: a verifier, a key, a

View file

@ -32,6 +32,7 @@ password:
passwordSignIn:
- begin:
- complete:
- signUp:
profile:
- mine:
- save:

View file

@ -14,9 +14,8 @@ values:
- tooManyAttempts
### The step began too long ago, or was finished already: begin again.
- challengeExpired
### The password is right, but no authenticator was confirmed yet: finish
### setting one up where the password was set (or, while email codes still
### sign in, signed in with a code by mail).
### The password is right, but no authenticator was confirmed yet: sign in
### with the password and a code mailed to the address, then set one up.
- setupIncomplete
### What the device sent isn't a password's setup: a verifier, a key, a
### salt or costs out of range.

View file

@ -6,6 +6,7 @@ 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';
import 'package:serverpod_auth_idp_server/core.dart';
/// Signing in with an address, a password and a code from an authenticator
/// (ADR 0017), in SRP-6a's two steps. Called without a session: Serverpod
@ -26,8 +27,9 @@ class PasswordSignInEndpoint extends Endpoint {
);
/// The second step: the device's A ([clientValue]) and proof M1
/// ([clientProof]) for [challenge], and a [totpCode] or a [recoveryCode].
/// Signed in, SRP's M2 (which the device checks before trusting anything
/// ([clientProof]) for [challenge], and a [totpCode] or a [recoveryCode]
/// (or, for a setup never finished, a [code] mailed for [request] to the
/// address the sign-in began with). Signed in, SRP's M2 (which the device checks before trusting anything
/// else), the account's keys as the register keeps them, and the session.
/// Refusals as `completePasswordSignIn`'s.
Future<PasswordSignedIn> complete(
@ -37,6 +39,8 @@ class PasswordSignInEndpoint extends Endpoint {
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
UuidValue? request,
String? code,
}) async {
final proof = await AccountRegister.of(session.serverpod)
.completePasswordSignIn(
@ -46,6 +50,8 @@ class PasswordSignInEndpoint extends Endpoint {
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
request: request,
code: code,
);
final signedIn = await issueSession(
session,
@ -66,4 +72,22 @@ class PasswordSignInEndpoint extends Endpoint {
scopeNames: signedIn.scopeNames,
);
}
/// Signs up with the address a [code] mailed for [request]
/// (`emailCode.sendCode`) proves and the password [setup] describes (all
/// of it made on the device): a new account, or the account with that
/// address that has no password yet. Signed in, the device sets up an
/// authenticator next, before the password signs in elsewhere. Refusals as
/// `signUpWithCode`'s.
Future<AuthSuccess> signUp(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
}) async {
final account = await AccountRegister.of(
session.serverpod,
).signUpWithCode(session, request: request, code: code, setup: setup);
return await issueSession(session, account, method: passwordMethod);
}
}

View file

@ -6,6 +6,7 @@ import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/scopes.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/password_config.dart';
@ -139,11 +140,15 @@ Future<bool> signsInWithPassword(
await hadConfirmedAuthenticator(session, person, transaction: transaction);
/// Whether [person]'s account had a confirmed authenticator: it has one, or
/// a reset of it or its authenticators stands (waiting, or carried out with
/// none confirmed since, which leaves an `account_reset` row until one is).
/// Such an account's password is set up for good: a session alone never
/// replaces it ([setPassword]), as the 72 hours of a reset would otherwise
/// be skipped by whoever signs in another way.
/// a reset of its authenticators stands (waiting, or carried out with none
/// confirmed since, which leaves its `account_reset` row until one is; the
/// account's reset carried out leaves one too). Such an account's password
/// is set up for good: a session alone never replaces it ([setPassword]),
/// and a code by mail never signs in in place of its second factor
/// ([completePasswordSignIn]), as the 72 hours of a reset would otherwise
/// be skipped by whoever has the mailbox. A reset of the whole account
/// waiting doesn't count: it may wait on a setup never finished, whose
/// owner still signs in with the password and a code by mail.
Future<bool> hadConfirmedAuthenticator(
Session session,
UuidValue person, {
@ -154,10 +159,13 @@ Future<bool> hadConfirmedAuthenticator(
person,
transaction: transaction,
) ||
// SELECT count(*) FROM "account_reset" WHERE "authUserId" = $1
// SELECT count(*) FROM "account_reset"
// WHERE "authUserId" = $1 AND "kind" = 'authenticator'
await AccountReset.db.count(
session,
where: (t) => t.authUserId.equals(person),
where: (t) =>
t.authUserId.equals(person) &
t.kind.equals(AccountResetKind.authenticator),
transaction: transaction,
) >
0;
@ -363,6 +371,100 @@ Future<void> setPassword(
}
}
/// Signs up with the address [code] proves for [request] (mailed with
/// `sendSignInCode`) and the password [setup] describes, without a session
/// (ADR 0017): an address nobody has becomes a new account, identified,
/// whose ID is returned; an account with the address and no password (one
/// that signed in with codes by mail; no keys exist, so nothing waits)
/// takes the password, keeping its ID and everything it has. Either way
/// the password signs in once an authenticator is confirmed, which the
/// session the caller issues sets up.
///
/// Refused ([PasswordRefusedException]) where the instance has no password
/// sign-in, for a setup out of range, and for an address whose account has
/// a password already ([PasswordRefusal.passwordExists]: it signs in with
/// it, or resets it); a code's refusals as `proveAddressCode`'s
/// ([EmailSignInRefusedException]). Nothing changes then.
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
}) async {
requirePasswordKeys(session.serverpod);
if (!isPasswordSetup(setup)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
final proven = await proveAddressCode(session, request: request, code: code);
try {
return await session.db.transaction((transaction) async {
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.addressHash.equals(proven),
transaction: transaction,
);
final now = clock.now().toUtc();
final UuidValue person;
if (identity == null) {
person = const Uuid().v7obj();
await ensurePerson(
session,
person,
scopes: {identified},
transaction: transaction,
);
// INSERT INTO "email_identity" ("authUserId", "addressHash",
// "createdAt") VALUES ($1, $2, $3)
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: person,
addressHash: proven,
createdAt: now,
),
transaction: transaction,
);
} else {
person = identity.authUserId;
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
if (await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
) >
0) {
throw refusedFor(PasswordRefusal.passwordExists);
}
// An address's person is identified already, and no longer
// anonymous; as email sign-in left them.
await grantIdentified(session, person, transaction: transaction);
// DELETE FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
await AnonymousAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
}
// INSERT INTO "account_password" (…) VALUES (…)
await AccountPassword.db.insertRow(
session,
passwordRowOf(person, setup, now: now),
transaction: transaction,
);
return person;
});
} on DatabaseQueryException catch (error) {
// The same address signed up at the same moment: the unique index
// refused the second (unique_violation).
if (error.code == '23505') {
throw refusedFor(PasswordRefusal.passwordExists);
}
rethrow;
}
}
/// The first step of signing in with a password: SRP's B for the account
/// with [address], with its salt and costs, all alike for an address nobody
/// has (see above). At most 10 sign-ins begin for an address and 30 from a
@ -416,12 +518,21 @@ Future<PasswordChallenge> beginPasswordSignIn(
/// account with SRP's M2 for the device and its keys as the register keeps
/// them; the caller issues the session.
///
/// A setup never finished (a password, never a confirmed authenticator:
/// [hadConfirmedAuthenticator]) signs in with a [code] mailed for
/// [request] to the address the sign-in began with in place of the second
/// factor, until an authenticator is confirmed; it calls off a reset of the
/// account waiting, as any sign-in with the password does. For any other
/// account a code by mail is no second factor, and is left unused.
///
/// Refused ([PasswordRefusedException]) for a challenge unknown, used or
/// older than [signInChallengeLifetime]; for a wrong password, an address
/// nobody has, a wrong or used code, all alike
/// ([PasswordRefusal.wrongCredentials]); and only once the password proved
/// right, for an account whose authenticator isn't confirmed yet or whose
/// reset hasn't taken effect when no code came ([secondFactor]).
/// nobody has, a wrong or used code, a code by mail to another address, all
/// alike ([PasswordRefusal.wrongCredentials]); and only once the password
/// proved right, for an account whose authenticator isn't confirmed yet
/// (without a code by mail) or whose reset hasn't taken effect when no code
/// came ([secondFactor]); a code by mail's own refusals as
/// `proveAddressCode`'s ([EmailSignInRefusedException]).
Future<PasswordProof> completePasswordSignIn(
Session session, {
required UuidValue challenge,
@ -429,6 +540,8 @@ Future<PasswordProof> completePasswordSignIn(
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
UuidValue? request,
String? code,
}) async {
final keys = requirePasswordKeys(session.serverpod);
final proved = await provePassword(
@ -438,13 +551,30 @@ Future<PasswordProof> completePasswordSignIn(
clientValue: clientValue,
clientProof: clientProof,
);
final needsAuthenticator = await secondFactor(
session,
keys,
proved.password.authUserId,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
final account = proved.password.authUserId;
final bool needsAuthenticator;
if (request != null &&
code != null &&
!await hadConfirmedAuthenticator(session, account)) {
final proven = await proveAddressCode(
session,
request: request,
code: code,
);
if (proven != proved.addressHash) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
await _callOffResets(session, account);
needsAuthenticator = true;
} else {
needsAuthenticator = await secondFactor(
session,
keys,
account,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
}
final password = proved.password;
return PasswordProof(
account: password.authUserId,

View file

@ -175,7 +175,9 @@ abstract interface class AccountRegister {
});
/// A password sign-in's second step: the account it proved, for the
/// caller's session. Refusals as `completePasswordSignIn`'s.
/// caller's session; a [code] mailed for [request] in place of the second
/// factor of a setup never finished. Refusals as
/// `completePasswordSignIn`'s.
Future<PasswordProof> completePasswordSignIn(
Session session, {
required UuidValue challenge,
@ -183,6 +185,18 @@ abstract interface class AccountRegister {
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
UuidValue? request,
String? code,
});
/// Signs up with the address [code] proves for [request] and the password
/// [setup] describes: the account, new or one with the address and no
/// password, for the caller's session. Refusals as `signUpWithCode`'s.
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
});
/// A new authenticator of [person]. Refusals as `enrolAuthenticator`'s.

View file

@ -247,8 +247,18 @@ Future<Map<String, Object?>?> _answer(
clientProof: arguments.bytes('clientProof'),
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
request: arguments.optionalId('request'),
code: arguments.optionalString('code'),
);
return {'proof': proof.toJson()};
case RegisterOperation.signUpWithCode:
final account = await register.signUpWithCode(
session,
request: arguments.id('request'),
code: arguments.string('code'),
setup: arguments.model('setup', PasswordSetup.fromJson),
);
return {'account': account.uuid};
case RegisterOperation.enrolAuthenticator:
final enrolment = await register.enrolAuthenticator(
session,

View file

@ -444,6 +444,8 @@ final class LocalRegister implements AccountRegister {
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
UuidValue? request,
String? code,
}) => passwords.completePasswordSignIn(
session,
challenge: challenge,
@ -451,6 +453,21 @@ final class LocalRegister implements AccountRegister {
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
request: request,
code: code,
);
@override
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
}) => passwords.signUpWithCode(
session,
request: request,
code: code,
setup: setup,
);
@override

View file

@ -37,6 +37,7 @@ abstract final class RegisterOperation {
static const setPassword = 'set-password';
static const beginPasswordSignIn = 'begin-password-sign-in';
static const completePasswordSignIn = 'complete-password-sign-in';
static const signUpWithCode = 'sign-up-with-code';
static const enrolAuthenticator = 'enrol-authenticator';
static const confirmAuthenticator = 'confirm-authenticator';
static const authenticatorsOf = 'authenticators-of';

View file

@ -345,6 +345,8 @@ final class RemoteRegister implements AccountRegister {
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
UuidValue? request,
String? code,
}) async => _model<PasswordProof>(
RegisterOperation.completePasswordSignIn,
await _ask(RegisterOperation.completePasswordSignIn, {
@ -353,11 +355,31 @@ final class RemoteRegister implements AccountRegister {
'clientProof': _bytes(clientProof),
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'request': request?.uuid,
'code': code,
}),
'proof',
PasswordProof.fromJson,
);
@override
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
}) async =>
_id(
(await _ask(RegisterOperation.signUpWithCode, {
'request': request.uuid,
'code': code,
'setup': setup.toJson(),
}))['account'],
) ??
(throw RegisterUnavailableException(
'${RegisterOperation.signUpWithCode}: no answer',
));
@override
Future<AuthenticatorEnrolment> enrolAuthenticator(
Session session, {