fix(auth): an account whose second factor doesn't work deletes itself

An account whose setup never finished, or whose authenticators' reset took
effect, could not delete itself (the re-review's eighth finding): deletion
asked for a TOTP or recovery code it had no way to give. Such an account
now deletes itself with the password's proof and a code by mail to its
address asked for deleting (EmailCodePurpose.deletion, which nothing else
takes), the address checked against the account's as everywhere after the
password; an account whose second factor works still takes a code from it.
The app's deletion sheet asks for the password and mails the code there,
then takes it.

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 23:30:22 +02:00
commit 293ec6b103
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 752 additions and 185 deletions

View file

@ -710,6 +710,133 @@ void main() {
},
);
test(
'of an account whose setup never finished takes the password and a '
'code by mail asked for deleting, nothing less, nor one asked for '
'anything else',
() async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
final bens = await PasswordDevice.make(seed: 7);
TestSessionBuilder asBen() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{identified},
authId: bensLaptop.uuid,
),
);
final setUp = await mailedCode('ben@example.org');
await endpoints.password.set(
asBen(),
setup: bens.setup(),
request: setUp.request,
code: setUp.code,
);
Future<void> deleteBen({
EmailCodePurpose? mailedFor,
String? totpCode,
}) => atNow(() async {
final mailed = mailedFor == null
? null
: await mailedCode('ben@example.org', purpose: mailedFor);
final challenge = await endpoints.password.beginChange(asBen());
final answer = bens.answer(challenge);
await endpoints.account.delete(
asBen(),
proof: DeletionProof(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode,
request: mailed?.request,
code: mailed?.code,
host: testHost,
address: 'ben@example.org',
language: 'en',
),
);
});
await expectLater(
deleteBen(),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
deleteBen(totpCode: '123456'),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
deleteBen(mailedFor: EmailCodePurpose.signUp),
refusedAsEmail(EmailSignInRefusal.codeExpired),
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
await deleteBen(mailedFor: EmailCodePurpose.deletion);
expect(await AuthUser.db.findById(session, ben), isNull);
},
);
test(
'once the reset of the authenticators took effect, takes the '
'password and a code by mail asked for deleting',
() async {
await atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.authenticatorReset,
);
await endpoints.accountRecovery.resetAuthenticator(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
address: 'anna@example.org',
language: 'en',
request: code.request,
code: code.code,
host: testHost,
);
});
// Before it takes effect, the second factor works: no code
// from it, no deleting.
await expectLater(
delete(withoutCode: true),
refused(PasswordRefusal.wrongCredentials),
);
now = now.add(resetDelay);
await atNow(() async {
final mailed = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.deletion,
);
final challenge = await endpoints.password.beginChange(
onPhone(),
);
final answer = device.answer(challenge);
await endpoints.account.delete(
onPhone(),
proof: DeletionProof(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
request: mailed.request,
code: mailed.code,
host: testHost,
address: 'anna@example.org',
language: 'en',
),
);
});
expect(await AuthUser.db.findById(session, anna), isNull);
},
);
test(
'of an anonymous person takes its session alone, as before',
() async {

View file

@ -14,7 +14,6 @@
import 'dart:async' as _ida;
import 'dart:io' as _idi;
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/auth/email_code_purpose.dart'
as _i5775c0l;
import 'package:halkyon_base_server/src/generated/auth/sign_in_ways.dart'
@ -293,10 +292,12 @@ class _AccountEndpoint {
parameters: _ist.testObjectToJson({'proof': proof}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -321,10 +322,12 @@ class _AccountEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_ik4ah9vo.SignInWays>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_ik4ah9vo.SignInWays>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -361,10 +364,12 @@ class _AnonymousIdpEndpoint {
parameters: _ist.testObjectToJson({'token': token}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -407,10 +412,12 @@ class _EmailCodeEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_is.UuidValue>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_is.UuidValue>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -453,10 +460,12 @@ class _GitHubSignInEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -499,10 +508,12 @@ class _GitHubUpgradeEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -538,10 +549,12 @@ class _InstanceEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_ihusd8tw.InstanceBasics>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_ihusd8tw.InstanceBasics>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -598,10 +611,12 @@ class _AccountRecoveryEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isakxto6.ResetPending>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isakxto6.ResetPending>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -627,10 +642,12 @@ class _AccountRecoveryEndpoint {
parameters: _ist.testObjectToJson({'address': address}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iaysae70.KeyProofChallenge>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iaysae70.KeyProofChallenge>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -672,10 +689,12 @@ class _AccountRecoveryEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -709,10 +728,12 @@ class _AccountRecoveryEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isakxto6.ResetPending>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isakxto6.ResetPending>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -746,10 +767,12 @@ class _AccountRecoveryEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -790,10 +813,12 @@ class _AuthenticatorEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_id229ey1.AuthenticatorEnrolment>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_id229ey1.AuthenticatorEnrolment>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -825,10 +850,12 @@ class _AuthenticatorEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<List<String>>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<String>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -853,10 +880,12 @@ class _AuthenticatorEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<List<_ip34xal6.Authenticator>>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_ip34xal6.Authenticator>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -886,10 +915,12 @@ class _AuthenticatorEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -915,10 +946,12 @@ class _AuthenticatorEndpoint {
parameters: _ist.testObjectToJson({'code': code}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<List<String>>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<String>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -961,10 +994,12 @@ class _PasswordEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -989,10 +1024,12 @@ class _PasswordEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isz0atlx.PasswordChallenge>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isz0atlx.PasswordChallenge>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1036,10 +1073,12 @@ class _PasswordEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1085,10 +1124,12 @@ class _PasswordEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1125,10 +1166,12 @@ class _PasswordSignInEndpoint {
parameters: _ist.testObjectToJson({'address': address}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isz0atlx.PasswordChallenge>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isz0atlx.PasswordChallenge>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1174,10 +1217,12 @@ class _PasswordSignInEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_ikpyb5fh.PasswordSignedIn>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_ikpyb5fh.PasswordSignedIn>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1209,10 +1254,12 @@ class _PasswordSignInEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1248,10 +1295,12 @@ class _ProfileEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile?>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_it4tom37.Profile?>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1285,10 +1334,12 @@ class _ProfileEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_it4tom37.Profile>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1313,10 +1364,12 @@ class _ProfileEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_it4tom37.Profile>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1342,10 +1395,12 @@ class _ProfileEndpoint {
parameters: _ist.testObjectToJson({'handle': handle}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_idszjcpz.ProfileRefusal?>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_idszjcpz.ProfileRefusal?>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1371,10 +1426,12 @@ class _ProfileEndpoint {
parameters: _ist.testObjectToJson({'png': png}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_it4tom37.Profile>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1399,10 +1456,12 @@ class _ProfileEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_it4tom37.Profile>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1425,10 +1484,12 @@ class _ProfileEndpoint {
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1465,10 +1526,12 @@ class _ProfilesEndpoint {
parameters: _ist.testObjectToJson({'handle': handle}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_it4tom37.Profile>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();