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
19 changed files with 752 additions and 185 deletions

View file

@ -212,5 +212,7 @@
"passwordCodesWait": "Zu viele falsche Codes hintereinander: Bis {date} wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann.",
"passwordDeleteIntro": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Gib zur Bestätigung dein Passwort und einen Code deiner Authenticator-App ein.",
"passwordSessionEnded": "Zu viele falsche Codes hintereinander von diesem Gerät: Es wurde abgemeldet. Melde dich wieder mit deinem Passwort und einem Code an.",
"passwordAddressUnknown": "Dieses Gerät kennt die Adresse deines Kontos nicht, die das braucht. Melde dich ab und wieder mit deiner Adresse und deinem Passwort an."
"passwordAddressUnknown": "Dieses Gerät kennt die Adresse deines Kontos nicht, die das braucht. Melde dich ab und wieder mit deiner Adresse und deinem Passwort an.",
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Seine Authenticator-App ist nicht eingerichtet, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
"passwordDeleteSendCode": "Code per E-Mail schicken"
}

View file

@ -459,5 +459,7 @@
},
"passwordDeleteIntro": "Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app.",
"passwordSessionEnded": "Too many wrong codes in a row from this device: it was signed out. Sign in again with your password and a code.",
"passwordAddressUnknown": "This device doesn't know your account's address, which this takes. Sign out, then sign in again with your address and password."
"passwordAddressUnknown": "This device doesn't know your account's address, which this takes. Sign out, then sign in again with your address and password.",
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. Its authenticator isn't set up, so a code by mail confirms it: type your password, then the code we email to your address.",
"passwordDeleteSendCode": "Email me a code"
}

View file

@ -1380,6 +1380,18 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'This device doesn\'t know your account\'s address, which this takes. Sign out, then sign in again with your address and password.'**
String get passwordAddressUnknown;
/// No description provided for @passwordDeleteIntroByMail.
///
/// In en, this message translates to:
/// **'Your account goes with everything in it, in every service of this instance. Its authenticator isn\'t set up, so a code by mail confirms it: type your password, then the code we email to your address.'**
String get passwordDeleteIntroByMail;
/// No description provided for @passwordDeleteSendCode.
///
/// In en, this message translates to:
/// **'Email me a code'**
String get passwordDeleteSendCode;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -800,4 +800,11 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get passwordAddressUnknown =>
'Dieses Gerät kennt die Adresse deines Kontos nicht, die das braucht. Melde dich ab und wieder mit deiner Adresse und deinem Passwort an.';
@override
String get passwordDeleteIntroByMail =>
'Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Seine Authenticator-App ist nicht eingerichtet, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.';
@override
String get passwordDeleteSendCode => 'Code per E-Mail schicken';
}

View file

@ -783,6 +783,13 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get passwordAddressUnknown =>
'This device doesn\'t know your account\'s address, which this takes. Sign out, then sign in again with your address and password.';
@override
String get passwordDeleteIntroByMail =>
'Your account goes with everything in it, in every service of this instance. Its authenticator isn\'t set up, so a code by mail confirms it: type your password, then the code we email to your address.';
@override
String get passwordDeleteSendCode => 'Email me a code';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -4,6 +4,7 @@ import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart';
import 'package:halkyon_app/src/password/model/account_security.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/widget/delete_account_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -125,18 +126,22 @@ class const AccountTiles({
)) {
return;
}
final bool hasPassword;
final AccountSecurity? security;
try {
hasPassword = identified && (await passwords.security()).hasPassword;
security = identified ? await passwords.security() : null;
} on PasswordFailed {
// Unreachable: deleting would fail too, as the session bloc says.
session.add(const SessionEventDeleteRequested());
return;
}
if (!hasPassword) {
if (security == null || !security.hasPassword) {
session.add(const SessionEventDeleteRequested());
} else if (context.mounted) {
await showDeleteAccountWithPassword(context);
// Without a second factor that works, a code by mail confirms it.
await showDeleteAccountWithPassword(
context,
byMail: !security.finished,
);
}
}
}

View file

@ -113,13 +113,17 @@ abstract interface class PasswordGateway() {
/// Deletes the signed-in person's account, which has a password: [answer]
/// to [challenge] ([beginChange]) proves it, with a [totpCode] or
/// [recoveryCode], with the account's [address] and [language] (as a
/// change takes them); this device then forgets its session.
/// [recoveryCode], or where no second factor works a [code] mailed for
/// [request] asked for deleting, with the account's [address] and
/// [language] (as a change takes them); this device then forgets its
/// session.
Future<void> deleteAccount(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
String? request,
String? code,
required String address,
required String language,
});

View file

@ -355,12 +355,16 @@ class PasswordRepository(
/// Deletes the signed-in person's account, which has a password, with
/// [password] and a [totpCode] or [recoveryCode], as a change of password
/// takes (a session alone deletes only an account without one): on the
/// instance and every service of it, then signed out here.
/// takes (a session alone deletes only an account without one), or where
/// no second factor works a [code] mailed for [request]
/// ([mailDeletionCode]): on the instance and every service of it, then
/// signed out here.
Future<void> deleteAccount({
required String password,
String? totpCode,
String? recoveryCode,
String? request,
String? code,
required String language,
}) async {
final address = await _addressHere();
@ -375,11 +379,31 @@ class PasswordRepository(
await _crypto.answer(unlock, challenge, host: _gateway.host),
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
request: request,
code: code,
address: address,
language: language,
);
}
/// Mails a code for deleting the signed-in person's account to the
/// address this device signed in with, where no second factor works (a
/// setup never finished): that address, and the request the code
/// answers.
Future<({String address, String request})> mailDeletionCode({
required String language,
}) async {
final address = await _addressHere();
return (
address: address,
request: await sendCode(
address,
language: language,
purpose: MailCodePurpose.deletion,
),
);
}
/// A new recovery phrase for the signed-in person's keys, to show; the
/// instance takes it with [keepPhrase].
Future<NewPhrase> newPhrase() async {

View file

@ -197,6 +197,8 @@ class ServerpodPasswordGateway(
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
String? request,
String? code,
required String address,
required String language,
}) => _failing(() async {
@ -207,6 +209,8 @@ class ServerpodPasswordGateway(
clientProof: answer.clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
request: _idOf(request),
code: code,
host: host,
address: address,
language: language,

View file

@ -9,16 +9,21 @@ import 'package:material_ui/material_ui.dart';
/// Deletes the signed-in person's account, which has a password: the
/// password and a code from an authenticator, as changing the password
/// takes (ADR 0017; the second reading). Once deleted, the person is signed
/// out here, and the app asks how to start.
Future<void> showDeleteAccountWithPassword(BuildContext context) =>
showAdaptiveSheet<void>(
/// takes (ADR 0017; the second reading), or [byMail], where no second
/// factor works (a setup never finished), the password and a code by mail
/// asked for deleting. Once deleted, the person is signed out here, and the
/// app asks how to start.
Future<void> showDeleteAccountWithPassword(
BuildContext context, {
bool byMail = false,
}) => showAdaptiveSheet<void>(
context,
swipeable: false,
builder: (context) => const _DeleteAccount(),
builder: (context) => _DeleteAccount(byMail: byMail),
);
class const _DeleteAccount() extends StatefulWidget {
class const _DeleteAccount({required final bool byMail})
extends StatefulWidget {
@override
State<_DeleteAccount> createState() => _DeleteAccountState();
}
@ -28,6 +33,9 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
final _code = TextEditingController();
late final _step = SecurityStepCubit<bool>();
/// The code mailed for deleting, once asked for: its address and request.
({String address, String request})? _mailed;
@override
void dispose() {
_password.dispose();
@ -39,10 +47,22 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
Future<void> _submit() async {
final passwords = AppScope.of(context).passwords;
final language = mailLanguageOf(context);
final mailed = _mailed;
if (widget.byMail && mailed == null) {
// The code first; the password goes with it.
await _step.run(() async {
final asked = await passwords.mailDeletionCode(language: language);
setState(() => _mailed = asked);
return false;
});
return;
}
await _step.run(() async {
await passwords.deleteAccount(
password: _password.text,
totpCode: _code.text,
totpCode: widget.byMail ? null : _code.text,
request: mailed?.request,
code: widget.byMail ? _code.text : null,
language: language,
);
return true;
@ -64,16 +84,34 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.passwordDeleteIntro),
SheetIntro(
widget.byMail
? l10n.passwordDeleteIntroByMail
: l10n.passwordDeleteIntro,
),
PasswordField(
controller: _password,
label: l10n.passwordCurrent,
autofocus: true,
),
const SizedBox(height: 8),
if (widget.byMail && _mailed == null)
if (state.problem case final failed?)
Text(
passwordProblemText(context, failed),
style: TextStyle(color: colors.error),
)
else
const SizedBox.shrink()
else ...[
if (_mailed case (:final address, request: _))
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(l10n.passwordMailCodeSent(address)),
),
CodeField(
controller: _code,
kind: CodeKind.totp,
kind: widget.byMail ? CodeKind.mail : CodeKind.totp,
errorText: switch (state.problem) {
final failed? => passwordProblemText(context, failed),
null => null,
@ -81,6 +119,7 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
onSubmitted: (_) => _submit(),
),
],
],
),
),
footer: FilledButton(
@ -89,7 +128,11 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
foregroundColor: colors.onError,
),
onPressed: state.busy ? null : _submit,
child: Text(l10n.accountDelete),
child: Text(
widget.byMail && _mailed == null
? l10n.passwordDeleteSendCode
: l10n.accountDelete,
),
),
),
);

View file

@ -546,13 +546,29 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
String? request,
String? code,
required String address,
required String language,
}) async {
final account = await _call(
'deleteAccount',
() => _ownerProved(challenge, answer, totpCode, recoveryCode, address),
);
final account = await _call('deleteAccount', () {
final proved = _proved(challenge, answer);
if (proved.finished) {
return _ownerCoded(proved, totpCode, recoveryCode, address);
}
// No second factor works: the password and a code by mail asked for
// deleting, as the register takes them.
if (proved.person != _signedIn()) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_requireAddress(proved, address);
if (request == null ||
(code ?? '').trim().isEmpty ||
_proven(request, code, MailCodePurpose.deletion) != proved.address) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
return proved;
});
accounts.remove(account.address);
await _auth.deleteAccount();
}
@ -581,8 +597,16 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
String? totpCode,
String? recoveryCode,
String address,
) => _ownerCoded(_proved(challenge, answer), totpCode, recoveryCode, address);
/// [account], whose password proved right, once it is the signed-in
/// person's and a code came: refused as the register refuses a change.
FakePasswordAccount _ownerCoded(
FakePasswordAccount account,
String? totpCode,
String? recoveryCode,
String address,
) {
final account = _proved(challenge, answer);
if (account.person != _signedIn() ||
((totpCode ?? '').isEmpty && (recoveryCode ?? '').isEmpty)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);

View file

@ -173,6 +173,53 @@ void main() {
expect(find.text('Signed out'), findsOneWidget);
});
testWidgets('deleting an account whose setup never finished asks for the '
'password and a code by mail', (tester) async {
// The first person the fake password gateway makes.
const person = '00000000-0000-4000-8000-000000000001';
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: person, isIdentified: true),
);
await start(tester);
dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
finished: false,
);
dependencies.addresses.addresses[person] = 'anna@example.org';
Finder field(String label) => find.widgetWithText(TextField, label);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(find.textContaining('a code by mail confirms it'), findsOneWidget);
await tester.enterText(field('Current password'), 'correct horse');
await tester.tap(find.text('Email me a code'));
await tester.pumpAndSettle();
expect(
dependencies.passwordGateway.mailed.single.purpose,
MailCodePurpose.deletion,
);
expect(find.textContaining('anna@example.org'), findsOneWidget);
expect(gateway.accountDeletions, 0);
await tester.enterText(
field('Code from the email'),
dependencies.passwordGateway.codeFor('anna@example.org'),
);
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(
dependencies.passwordGateway.accounts,
isNot(contains('anna@example.org')),
);
});
testWidgets('deleting an account with a password asks for it and a '
'code, which a wrong one fails, deleting nothing', (tester) async {
// The first person the fake password gateway makes.

View file

@ -166,6 +166,49 @@ void main() {
});
});
test('an account whose setup never finished deletes itself with the '
'password and a code by mail asked for deleting, nothing less', () async {
gateway.addAccount(
'carla@example.org',
password: 'battery staple',
crypto: crypto,
finished: false,
);
final unlocked = await passwords.unlock(
'carla@example.org',
'battery staple',
);
final request = await passwords.sendCode(
'carla@example.org',
language: 'en',
purpose: MailCodePurpose.finishSetup,
);
await passwords.enter(
await passwords.signIn(
unlocked,
request: request,
code: gateway.codeFor('carla@example.org'),
language: 'en',
),
);
await settle();
await expectLater(
passwords.deleteAccount(password: 'battery staple', language: 'en'),
failsWith(PasswordProblem.wrongCredentials),
);
final mailed = await passwords.mailDeletionCode(language: 'en');
expect(mailed.address, 'carla@example.org');
expect(gateway.mailed.last.purpose, MailCodePurpose.deletion);
await passwords.deleteAccount(
password: 'battery staple',
request: mailed.request,
code: gateway.codeFor('carla@example.org'),
language: 'en',
);
expect(gateway.accounts, isNot(contains('carla@example.org')));
});
group('signing up', () {
test('makes the account with the address a code proves, pending until '
'its authenticator is set up', () async {

View file

@ -26,6 +26,8 @@ abstract class DeletionProof
required this.clientProof,
this.totpCode,
this.recoveryCode,
this.request,
this.code,
required this.host,
required this.address,
required this.language,
@ -37,6 +39,8 @@ abstract class DeletionProof
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
_isc.UuidValue? request,
String? code,
required String host,
required String address,
required String language,
@ -55,6 +59,10 @@ abstract class DeletionProof
),
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
request: jsonSerialization['request'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
code: jsonSerialization['code'] as String?,
host: jsonSerialization['host'] as String,
address: jsonSerialization['address'] as String,
language: jsonSerialization['language'] as String,
@ -71,6 +79,14 @@ abstract class DeletionProof
String? recoveryCode;
/// A code by mail asked for deleting (`EmailCodePurpose.deletion`), in
/// place of the codes above where no second factor works: a setup never
/// finished, or the authenticators' reset taken effect (the re-review's
/// eighth finding).
_isc.UuidValue? request;
String? code;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
@ -91,6 +107,8 @@ abstract class DeletionProof
_idt.ByteData? clientProof,
String? totpCode,
String? recoveryCode,
_isc.UuidValue? request,
String? code,
String? host,
String? address,
String? language,
@ -104,6 +122,8 @@ abstract class DeletionProof
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'address': address,
'language': language,
@ -119,6 +139,8 @@ abstract class DeletionProof
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'address': address,
'language': language,
@ -140,6 +162,8 @@ class _DeletionProofImpl extends DeletionProof {
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
_isc.UuidValue? request,
String? code,
required String host,
required String address,
required String language,
@ -149,6 +173,8 @@ class _DeletionProofImpl extends DeletionProof {
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
request: request,
code: code,
host: host,
address: address,
language: language,
@ -164,6 +190,8 @@ class _DeletionProofImpl extends DeletionProof {
_idt.ByteData? clientProof,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
Object? request = _Undefined,
Object? code = _Undefined,
String? host,
String? address,
String? language,
@ -174,6 +202,8 @@ class _DeletionProofImpl extends DeletionProof {
clientProof: clientProof ?? this.clientProof.clone(),
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
request: request is _isc.UuidValue? ? request : this.request,
code: code is String? ? code : this.code,
host: host ?? this.host,
address: address ?? this.address,
language: language ?? this.language,

View file

@ -26,6 +26,8 @@ abstract class DeletionProof
required this.clientProof,
this.totpCode,
this.recoveryCode,
this.request,
this.code,
required this.host,
required this.address,
required this.language,
@ -37,6 +39,8 @@ abstract class DeletionProof
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
_is.UuidValue? request,
String? code,
required String host,
required String address,
required String language,
@ -55,6 +59,10 @@ abstract class DeletionProof
),
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
request: jsonSerialization['request'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
code: jsonSerialization['code'] as String?,
host: jsonSerialization['host'] as String,
address: jsonSerialization['address'] as String,
language: jsonSerialization['language'] as String,
@ -71,6 +79,14 @@ abstract class DeletionProof
String? recoveryCode;
/// A code by mail asked for deleting (`EmailCodePurpose.deletion`), in
/// place of the codes above where no second factor works: a setup never
/// finished, or the authenticators' reset taken effect (the re-review's
/// eighth finding).
_is.UuidValue? request;
String? code;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
@ -91,6 +107,8 @@ abstract class DeletionProof
_idt.ByteData? clientProof,
String? totpCode,
String? recoveryCode,
_is.UuidValue? request,
String? code,
String? host,
String? address,
String? language,
@ -104,6 +122,8 @@ abstract class DeletionProof
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'address': address,
'language': language,
@ -119,6 +139,8 @@ abstract class DeletionProof
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'address': address,
'language': language,
@ -140,6 +162,8 @@ class _DeletionProofImpl extends DeletionProof {
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
_is.UuidValue? request,
String? code,
required String host,
required String address,
required String language,
@ -149,6 +173,8 @@ class _DeletionProofImpl extends DeletionProof {
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
request: request,
code: code,
host: host,
address: address,
language: language,
@ -164,6 +190,8 @@ class _DeletionProofImpl extends DeletionProof {
_idt.ByteData? clientProof,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
Object? request = _Undefined,
Object? code = _Undefined,
String? host,
String? address,
String? language,
@ -174,6 +202,8 @@ class _DeletionProofImpl extends DeletionProof {
clientProof: clientProof ?? this.clientProof.clone(),
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
request: request is _is.UuidValue? ? request : this.request,
code: code is String? ? code : this.code,
host: host ?? this.host,
address: address ?? this.address,
language: language ?? this.language,

View file

@ -10,6 +10,12 @@ fields:
clientProof: ByteData
totpCode: String?
recoveryCode: String?
### A code by mail asked for deleting (`EmailCodePurpose.deletion`), in
### place of the codes above where no second factor works: a setup never
### finished, or the authenticators' reset taken effect (the re-review's
### eighth finding).
request: UuidValue?
code: String?
### The host the device addressed the instance by: the server it reached
### checks it is its own before the register binds the proof to it.
host: String

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
@ -136,12 +137,22 @@ Future<void> setRecoveryKeys(
}
/// Proves [person] may delete their account: one with a password only
/// with [proof] (the password's proof and a code, bound to the host the
/// device addressed, as a change of password takes: a session alone, or a
/// phone with its authenticator, would otherwise delete the account and
/// everything encrypted under its keys); one without, by its session alone,
/// as before. Refused as [changePassword] refuses, and without [proof]
/// where one is needed ([PasswordRefusal.wrongCredentials]).
/// with [proof] (the password's proof bound to the host the device
/// addressed, the account's address, and a code, as a change of password
/// takes: a session alone, or a phone with its authenticator, would
/// otherwise delete the account and everything encrypted under its keys);
/// one without, by its session alone, as before.
///
/// The code is a TOTP or recovery code while the account's second factor
/// works; where none does (a setup never finished, or the authenticators'
/// reset taken effect), a code by mail to the account's address asked for
/// deleting ([EmailCodePurpose.deletion]; the re-review's eighth finding:
/// such accounts couldn't delete themselves at all).
///
/// Refused as [changePassword] refuses, without [proof] where one is
/// needed, and without the code its account takes
/// ([PasswordRefusal.wrongCredentials]); a code by mail's own refusals as
/// `proveAddressCode`'s.
Future<void> requireOwnerToDelete(
Session session,
UuidValue person,
@ -159,6 +170,34 @@ Future<void> requireOwnerToDelete(
if (proof == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
if (!await _secondFactorWorks(session, person)) {
final proved = await _provePasswordOwner(
session,
keys,
person: person,
challenge: proof.challenge,
clientValue: proof.clientValue,
clientProof: proof.clientProof,
host: proof.host,
address: proof.address,
language: proof.language,
);
final request = proof.request;
final code = proof.code?.trim() ?? '';
if (request == null || code.isEmpty) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
final proven = await proveAddressCode(
session,
request: request,
code: code,
purpose: EmailCodePurpose.deletion,
);
if (proven != proved.addressHash) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
return;
}
await _proveOwner(
session,
keys,
@ -174,6 +213,22 @@ Future<void> requireOwnerToDelete(
);
}
/// Whether [person]'s second factor works: they have a confirmed
/// authenticator, and no reset of the authenticators took effect.
Future<bool> _secondFactorWorks(Session session, UuidValue person) async {
if (!await hasConfirmedAuthenticator(session, person)) {
return false;
}
// SELECT * FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
final reset = await AccountReset.db.findFirstRow(
session,
where: (t) =>
t.authUserId.equals(person) &
t.kind.equals(AccountResetKind.authenticator),
);
return reset == null || reset.takesEffectAt.isAfter(clock.now());
}
/// [person]'s password row, once they proved owning the account for a
/// change: the old password's proof for [challenge], [person]'s own, and a
/// [totpCode] or [recoveryCode] ([secondFactor], which also calls off a reset
@ -197,24 +252,15 @@ Future<AccountPassword> _proveOwner(
required String address,
required String language,
}) async {
final proved = await provePassword(
final proved = await _provePasswordOwner(
session,
keys,
person: person,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
host: host,
purpose: SignInChallengePurpose.change,
);
final row = proved.password;
if (row.authUserId != person) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
// Before any code is checked.
final notice = requireNoticeTo(
session.serverpod,
proved.addressHash,
typed: address,
address: address,
language: language,
);
if ((totpCode?.trim() ?? '').isEmpty &&
@ -229,9 +275,50 @@ Future<AccountPassword> _proveOwner(
person,
totpCode: totpCode,
recoveryCode: recoveryCode,
notice: notice,
notice: proved.notice,
)) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
return row;
return proved.password;
}
/// The password's proof for [challenge], [person]'s own and a change's,
/// with the account's [address] (checked here, before any code) for the
/// notice of codes waiting; refused ([PasswordRefusal.wrongCredentials])
/// for a wrong password, a challenge that isn't [person]'s, or an address
/// not theirs.
Future<({AccountPassword password, String addressHash, CodesWaitNotice notice})>
_provePasswordOwner(
Session session,
PasswordKeys keys, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
required String host,
required String address,
required String language,
}) async {
final proved = await provePassword(
session,
keys,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
host: host,
purpose: SignInChallengePurpose.change,
);
if (proved.password.authUserId != person) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
return (
password: proved.password,
addressHash: proved.addressHash,
notice: requireNoticeTo(
session.serverpod,
proved.addressHash,
typed: address,
language: language,
),
);
}

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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_ik4ah9vo.SignInWays>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_is.UuidValue>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_ihusd8tw.InstanceBasics>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isakxto6.ResetPending>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iaysae70.KeyProofChallenge>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isakxto6.ResetPending>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_id229ey1.AuthenticatorEnrolment>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<List<String>>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<List<_ip34xal6.Authenticator>>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<List<String>>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isz0atlx.PasswordChallenge>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
@ -1085,10 +1124,12 @@ class _PasswordEndpoint {
}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_isz0atlx.PasswordChallenge>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_ikpyb5fh.PasswordSignedIn>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_iacs.AuthSuccess>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile?>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_idszjcpz.ProfileRefusal?>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<void>);
)
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(
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _ida.Future<_it4tom37.Profile>);
)
as _ida.Future<_it4tom37.Profile>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();