fix(auth): a member asks the register whose account it is before a deletion's proof counts

The reading of the full reading's fixes found (B1) that a member asked
the register knownAccounts inside the owner-proof try, so ten tries to
delete while the register was out of reach ended the owner's session,
though no proof ever left the member; and that a person anonymous on a
member could no longer delete their account while the register was down.

The member now asks first, outside the try, which wraps only what checks
the proof. While the register is out of reach, a person still anonymous
here (becoming identified ends that) is none of its and is deleted here;
anyone else waits for the register.

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 16:00:23 +02:00
commit 9c2b1cd6e0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 81 additions and 16 deletions

View file

@ -74,8 +74,6 @@ class EndpointAccount extends _isc.EndpointRef {
{'proof': proof}, {'proof': proof},
); );
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
_ida.Future<_ifld8s4r.SignInWays> signInWays() => _ida.Future<_ifld8s4r.SignInWays> signInWays() =>
caller.callServerEndpoint<_ifld8s4r.SignInWays>( caller.callServerEndpoint<_ifld8s4r.SignInWays>(
'halkyon_base.account', 'halkyon_base.account',

View file

@ -5,6 +5,7 @@ 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/owner_proof_tries.dart';
import 'package:halkyon_base_server/src/password/password_change.dart'; import 'package:halkyon_base_server/src/password/password_change.dart';
import 'package:halkyon_base_server/src/register/account_register.dart'; import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
@ -29,21 +30,23 @@ class AccountEndpoint extends Endpoint {
instanceUrl: deviceInstanceUrlOn(pod, proof.instanceUrl), instanceUrl: deviceInstanceUrlOn(pod, proof.instanceUrl),
); );
// The proof counts for this session (`asOwnerProofTry`), as every // The proof counts for this session (`asOwnerProofTry`), as every
// change of how the account is secured does. // change of how the account is secured does: only around what checks
await asOwnerProofTry(session, bound, () async { // it, so asking the register whose account it is spends no try (the
if (!isMember(pod)) { // reading of the fixes' B1).
await requireOwnerToDelete(session, person, bound); if (!isMember(pod)) {
return; await asOwnerProofTry(
} session,
// An account of the register's whatever this session's scopes say bound,
// (the full reading's L7: a person the register linked whose () => requireOwnerToDelete(session, person, bound),
// becoming identified here failed was deleted here on the session );
// alone): the register decides, with the proof it takes. } else if (await _registersAccount(session, person)) {
final register = AccountRegister.of(pod); final register = AccountRegister.of(pod);
if ((await register.knownAccounts(session, {person})).contains(person)) { await asOwnerProofTry(
await register.deleteAccount(session, person, proof: bound); session,
} bound,
}); () => register.deleteAccount(session, person, proof: bound),
);
}
await deletePerson(session, person); await deletePerson(session, person);
await session.messages.authenticationRevoked( await session.messages.authenticationRevoked(
person.uuid, person.uuid,
@ -53,6 +56,35 @@ class AccountEndpoint extends Endpoint {
/// How the caller signs in besides their sessions: with GitHub, with an /// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous). /// email address, both, or neither (anonymous).
/// Whether [person], on a member, is an account of the register's,
/// whatever this session's scopes say (the full reading's L7: a person
/// the register linked whose becoming identified here failed was deleted
/// here on the session alone): the register decides, with the proof it
/// takes. While the register is out of reach, a person still anonymous
/// here (becoming identified ends that) is none of its, and is deleted
/// here; anyone else waits for it ([RegisterUnavailableException]).
static Future<bool> _registersAccount(
Session session,
UuidValue person,
) async {
try {
return (await AccountRegister.of(
session.serverpod,
).knownAccounts(session, {person})).contains(person);
} on RegisterUnavailableException {
// SELECT count(*) FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
if (await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0) {
return false;
}
rethrow;
}
}
Future<SignInWays> signInWays(Session session) => AccountRegister.of( Future<SignInWays> signInWays(Session session) => AccountRegister.of(
session.serverpod, session.serverpod,
).signInWaysOf(session, session.authenticated!.authUserId); ).signInWaysOf(session, session.authenticated!.authUserId);

View file

@ -1060,6 +1060,41 @@ void main() {
); );
expect(hostDeleted, isEmpty); expect(hostDeleted, isEmpty);
}); });
test('asks the register first: tries while it is out of reach count '
"for nothing, as no proof left here (the reading of the fixes' "
'B1)', () async {
register.failing = RegisterUnavailableException('test');
DeletionProof proof() => DeletionProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
instanceUrl: testInstanceUrl,
);
for (var i = 0; i < 11; i++) {
await expectLater(
endpoints.account.delete(asBen(), proof: proof()),
throwsA(isA<RegisterUnavailableException>()),
);
}
expect(register.asked.toSet(), {'knownAccounts'});
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNotNull,
);
});
test('of a person anonymous here deletes it here while the register '
'is out of reach: becoming identified ends being anonymous, so '
"none is the register's", () async {
register.failing = RegisterUnavailableException('test');
await endpoints.account.delete(asAnna());
expect(await AuthUser.db.findById(session, anna), isNull);
expect(hostDeleted, [anna]);
});
}); });
group('told by the register that an account is gone', () { group('told by the register that an account is gone', () {