fix(auth)!: a first password takes a fresh proof of the account's way in

The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).

- An account with an address proves it anew: a code mailed to that
  address for this (EmailCodePurpose.firstPassword, its own mail), never
  the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
  a GitHub sign-in (the session's method and creation time, where the
  session lives; members tell the register), refused signInAgain before
  any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
  they add.

The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.

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 11:31:22 +02:00
commit f0413531ff
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
38 changed files with 762 additions and 66 deletions

View file

@ -143,6 +143,9 @@ void main() {
await tester.tap(find.text('Sign in with GitHub')); await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// A password is offered first (ADR 0017, D2); put off, the app.
await tester.tap(find.text('Later'));
await tester.pumpAndSettle();
expect( expect(
gateway.status, gateway.status,

View file

@ -215,5 +215,10 @@
"passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.", "passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.",
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.", "passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, 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", "passwordDeleteSendCode": "Code per E-Mail schicken",
"passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an." "passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.",
"passwordSignInAgain": "Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.",
"passwordSignInWithGitHubAgain": "Wieder mit GitHub anmelden",
"passwordOfferTitle": "Richte ein Passwort ein",
"passwordOfferBody": "Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.",
"passwordOfferLater": "Später"
} }

View file

@ -462,5 +462,10 @@
"passwordAddressUnknown": "Enter your account's email address: the code goes there.", "passwordAddressUnknown": "Enter your account's email address: the code goes there.",
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.", "passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.",
"passwordDeleteSendCode": "Email me a code", "passwordDeleteSendCode": "Email me a code",
"passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password." "passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password.",
"passwordSignInAgain": "To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.",
"passwordSignInWithGitHubAgain": "Sign in with GitHub again",
"passwordOfferTitle": "Set up a password",
"passwordOfferBody": "Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.",
"passwordOfferLater": "Later"
} }

View file

@ -1398,6 +1398,36 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'** /// **'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'**
String get passwordChangedSignInAgain; String get passwordChangedSignInAgain;
/// No description provided for @passwordSignInAgain.
///
/// In en, this message translates to:
/// **'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.'**
String get passwordSignInAgain;
/// No description provided for @passwordSignInWithGitHubAgain.
///
/// In en, this message translates to:
/// **'Sign in with GitHub again'**
String get passwordSignInWithGitHubAgain;
/// No description provided for @passwordOfferTitle.
///
/// In en, this message translates to:
/// **'Set up a password'**
String get passwordOfferTitle;
/// No description provided for @passwordOfferBody.
///
/// In en, this message translates to:
/// **'Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.'**
String get passwordOfferBody;
/// No description provided for @passwordOfferLater.
///
/// In en, this message translates to:
/// **'Later'**
String get passwordOfferLater;
} }
class _HalkyonAppLocalizationsDelegate class _HalkyonAppLocalizationsDelegate

View file

@ -811,4 +811,21 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override @override
String get passwordChangedSignInAgain => String get passwordChangedSignInAgain =>
'Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.'; 'Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.';
@override
String get passwordSignInAgain =>
'Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.';
@override
String get passwordSignInWithGitHubAgain => 'Wieder mit GitHub anmelden';
@override
String get passwordOfferTitle => 'Richte ein Passwort ein';
@override
String get passwordOfferBody =>
'Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.';
@override
String get passwordOfferLater => 'Später';
} }

View file

@ -794,6 +794,23 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override @override
String get passwordChangedSignInAgain => String get passwordChangedSignInAgain =>
'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'; 'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.';
@override
String get passwordSignInAgain =>
'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.';
@override
String get passwordSignInWithGitHubAgain => 'Sign in with GitHub again';
@override
String get passwordOfferTitle => 'Set up a password';
@override
String get passwordOfferBody =>
'Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.';
@override
String get passwordOfferLater => 'Later';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -19,6 +19,9 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
on<SessionEventSignInRequested>(_onSignInRequested); on<SessionEventSignInRequested>(_onSignInRequested);
on<SessionEventStartedAnonymously>(_onStartedAnonymously); on<SessionEventStartedAnonymously>(_onStartedAnonymously);
on<SessionEventGitHubRequested>(_onGitHubRequested); on<SessionEventGitHubRequested>(_onGitHubRequested);
on<SessionEventPasswordOfferDone>(
(event, emit) => emit(_stateOf(_auth.status)),
);
on<SessionEventSignedOut>( on<SessionEventSignedOut>(
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)), (event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
); );
@ -84,12 +87,19 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
Emitter<SessionState> emit, Emitter<SessionState> emit,
) async { ) async {
emit(const SessionStateSignedOut(busy: true)); emit(const SessionStateSignedOut(busy: true));
// The person signed in with GitHub is offered a password first (ADR
// 0017, D2: "at their next sign-in"), while that sign-in is recent
// enough for the instance to take one (the fourth reading).
_offerPassword = true;
try { try {
if (!await _auth.switchToGitHub()) { if (!await _auth.switchToGitHub()) {
_offerPassword = false;
emit(const SessionStateSignedOut()); emit(const SessionStateSignedOut());
} }
// Signed in: the status change shows the person's app. // Signed in: the status change shows the offer, then the person's
// app.
} on GitHubSignInFailed catch (e) { } on GitHubSignInFailed catch (e) {
_offerPassword = false;
emit( emit(
SessionStateSignedOut( SessionStateSignedOut(
problem: e.problem == GitHubSignInProblem.unknownAccount problem: e.problem == GitHubSignInProblem.unknownAccount
@ -98,11 +108,16 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
), ),
); );
} on Object catch (error, st) { } on Object catch (error, st) {
_offerPassword = false;
addError(error, st); addError(error, st);
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed)); emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
} }
} }
/// Whether the next person signed in comes from a GitHub sign-in, and is
/// offered a password before their app ([SessionStateGitHubSignedIn]).
bool _offerPassword = false;
Future<void> _onSignOutRequested( Future<void> _onSignOutRequested(
SessionEventSignOutRequested event, SessionEventSignOutRequested event,
Emitter<SessionState> emit, Emitter<SessionState> emit,
@ -132,9 +147,15 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
} }
SessionState _stateOf(AuthStatus status) => switch (status) { SessionState _stateOf(AuthStatus status) => switch (status) {
SignedIn() when _offerPassword => _offered(status),
SignedIn() => SessionStateActive(status), SignedIn() => SessionStateActive(status),
SignedOut() => const SessionStateSignedOut(), SignedOut() => const SessionStateSignedOut(),
}; };
SessionState _offered(SignedIn person) {
_offerPassword = false;
return SessionStateGitHubSignedIn(person);
}
} }
/// What happens to the session. /// What happens to the session.
@ -163,6 +184,10 @@ final class const SessionEventSignedOut({final SignedOutProblem? problem})
List<Object?> get props => [problem]; List<Object?> get props => [problem];
} }
/// The password offered after a GitHub sign-in was set up, put off, or not
/// needed: on to the person's app.
final class const SessionEventPasswordOfferDone() extends SessionEvent;
/// The person confirmed that they want to sign out. /// The person confirmed that they want to sign out.
final class const SessionEventSignOutRequested() extends SessionEvent; final class const SessionEventSignOutRequested() extends SessionEvent;
@ -195,6 +220,17 @@ final class const SessionStateActive(
List<Object?> get props => [person]; List<Object?> get props => [person];
} }
/// [person] signed in with GitHub just now: before their app, a password
/// is offered where the instance offers one and they have none (ADR 0017,
/// D2), while the sign-in is recent enough to set it.
final class const SessionStateGitHubSignedIn(
/// Who signed in.
final SignedIn person,
) extends SessionState {
@override
List<Object?> get props => [person];
}
/// Nobody is signed in since signing out: the app asks how to start. /// Nobody is signed in since signing out: the app asks how to start.
final class const SessionStateSignedOut({ final class const SessionStateSignedOut({
/// Whether a way to start is under way. /// Whether a way to start is under way.

View file

@ -5,6 +5,12 @@ import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart'; import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart'; import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/widget/start_screen.dart'; import 'package:halkyon_app/src/auth/widget/start_screen.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
import 'package:halkyon_app/src/password/model/account_security.dart';
import 'package:halkyon_app/src/password/widget/account_setup_sheet.dart';
import 'package:halkyon_app/src/password/widget/password_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// Shows the app of whoever is signed in, signing in anonymously at a /// Shows the app of whoever is signed in, signing in anonymously at a
@ -46,6 +52,10 @@ class const SessionGate({
look: look(context), look: look(context),
home: StartScreen(state: signedOut), home: StartScreen(state: signedOut),
), ),
SessionStateGitHubSignedIn() => PlainApp(
look: look(context),
home: const _PasswordOffer(),
),
SessionStateActive(:final person) => KeyedSubtree( SessionStateActive(:final person) => KeyedSubtree(
key: ValueKey(person.authUserId), key: ValueKey(person.authUserId),
child: app(person), child: app(person),
@ -78,3 +88,106 @@ class const _SignInFailed() extends StatelessWidget {
); );
} }
} }
/// After a GitHub sign-in, a password for an account that has none, where
/// the instance offers them (ADR 0017, D2: GitHub sign-in ends), while the
/// sign-in is recent enough for the instance to take one; else, or once
/// set up or put off, on to the person's app.
class const _PasswordOffer() extends StatefulWidget {
@override
State<_PasswordOffer> createState() => _PasswordOfferState();
}
class _PasswordOfferState() extends State<_PasswordOffer> {
late final Future<AccountSecurity?> _security =
AppScope.of(
context,
).passwords.security().then<AccountSecurity?>(
(security) => security,
onError: (Object _) => null,
);
void _done() =>
context.read<SessionBloc>().add(const SessionEventPasswordOfferDone());
Future<void> _setUp(AccountSecurity security) async {
final address = security.hasAddress
? await AppScope.of(context).auth.emailAddress()
: null;
if (!mounted) {
return;
}
await showAccountSetupSheet(
context,
mode: AccountSetupMode.setPassword,
instance:
InstanceScope.maybeOf(context)?.choice.current?.name ??
AppScope.of(context).serverName,
hasAddress: security.hasAddress,
address: address,
);
if (mounted) {
_done();
}
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
return FutureBuilder<AccountSecurity?>(
future: _security,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(body: Center(child: AppLoadingIndicator()));
}
final security = snapshot.data;
if (security == null || !security.offered || security.hasPassword) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_done();
}
});
return const Scaffold(body: Center(child: AppLoadingIndicator()));
}
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.passwordOfferTitle,
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 12),
Text(switch (security.gitHubEnds) {
final ends? => l10n.passwordSectionGitHubEnds(
passwordMoment(context, ends),
),
null => l10n.passwordOfferBody,
}),
const SizedBox(height: 24),
FilledButton(
onPressed: () => _setUp(security),
child: Text(l10n.passwordSectionSetUp),
),
const SizedBox(height: 8),
TextButton(
onPressed: _done,
child: Text(l10n.passwordOfferLater),
),
],
),
),
),
),
);
},
);
}
}

View file

@ -14,7 +14,9 @@ enum AccountSetupMode() {
signUp, signUp,
/// A password for the signed-in person (anonymous, or signing in with /// A password for the signed-in person (anonymous, or signing in with
/// GitHub or codes by mail): they keep everything. /// GitHub or codes by mail): they keep everything. A session alone sets
/// none: an address of theirs takes a code mailed to it, GitHub a recent
/// sign-in (the fourth reading).
setPassword, setPassword,
/// The reset of a whole account, once due: a new password, and new keys /// The reset of a whole account, once due: a new password, and new keys
@ -123,15 +125,16 @@ class AccountSetupCubit(
/// person's own); null where the setup asks for it. /// person's own); null where the setup asks for it.
final String? address, final String? address,
/// Whether the signed-in person has an address already, so no code is /// Whether the signed-in person has an address already, which the code
/// needed ([AccountSetupMode.setPassword]). /// then goes to as proof that they are its owner
/// ([AccountSetupMode.setPassword], [MailCodePurpose.firstPassword]).
final bool _hasAddress = false, final bool _hasAddress = false,
final String? _phrase, final String? _phrase,
}) extends Cubit<AccountSetupState> { }) extends Cubit<AccountSetupState> {
this this
: super( : super(
AccountSetupState( AccountSetupState(
step: address == null && !_hasAddress step: address == null
? AccountSetupStep.address ? AccountSetupStep.address
: AccountSetupStep.password, : AccountSetupStep.password,
address: address ?? '', address: address ?? '',
@ -221,13 +224,9 @@ class AccountSetupCubit(
await _run(_toCode); await _run(_toCode);
} }
/// The code mailed to the address, where one is needed; else the setup /// The code mailed to the address: nothing goes to the instance without
/// goes to the instance at once. /// it.
Future<void> _toCode() async { Future<void> _toCode() async {
if (_mode == AccountSetupMode.setPassword && _hasAddress) {
await _submit();
return;
}
_request = await _passwords.sendCode( _request = await _passwords.sendCode(
state.address, state.address,
language: _language, language: _language,
@ -237,9 +236,12 @@ class AccountSetupCubit(
} }
/// What the codes mailed here are for. /// What the codes mailed here are for.
MailCodePurpose get _codePurpose => _mode == AccountSetupMode.finishReset MailCodePurpose get _codePurpose => switch (_mode) {
? MailCodePurpose.accountReset AccountSetupMode.finishReset => MailCodePurpose.accountReset,
: MailCodePurpose.signUp; AccountSetupMode.setPassword when _hasAddress =>
MailCodePurpose.firstPassword,
_ => MailCodePurpose.signUp,
};
/// Mails a new code. /// Mails a new code.
Future<void> mailCodeResent() => _run(() async { Future<void> mailCodeResent() => _run(() async {
@ -273,7 +275,7 @@ class AccountSetupCubit(
case AccountSetupMode.setPassword: case AccountSetupMode.setPassword:
await _passwords.setPassword( await _passwords.setPassword(
material, material,
address: _hasAddress ? null : state.address, address: state.address,
request: _request, request: _request,
code: code, code: code,
); );

View file

@ -55,6 +55,8 @@ class ServerpodPasswordGateway(
protocol.EmailCodePurpose.authenticatorReset, protocol.EmailCodePurpose.authenticatorReset,
MailCodePurpose.accountReset => protocol.EmailCodePurpose.accountReset, MailCodePurpose.accountReset => protocol.EmailCodePurpose.accountReset,
MailCodePurpose.deletion => protocol.EmailCodePurpose.deletion, MailCodePurpose.deletion => protocol.EmailCodePurpose.deletion,
MailCodePurpose.firstPassword =>
protocol.EmailCodePurpose.firstPassword,
}, },
)).uuid, )).uuid,
); );
@ -449,6 +451,7 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait, protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded, protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged, protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged,
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
}; };
/// Keeps one session in memory, for a client used once. /// Keeps one session in memory, for a client used once.

View file

@ -17,4 +17,8 @@ enum MailCodePurpose() {
/// Deleting an account whose setup never finished. /// Deleting an account whose setup never finished.
deletion, deletion,
/// Proving, signed in, the account's own address to give it its first
/// password: a session alone sets none.
firstPassword,
} }

View file

@ -29,6 +29,11 @@ enum PasswordProblem() {
/// session for it yet: sign in again with the new password. /// session for it yet: sign in again with the new password.
passwordChanged, passwordChanged,
/// An account that signs in with GitHub alone takes its first password
/// only within minutes of a GitHub sign-in: sign in with GitHub again,
/// then go on.
signInAgain,
/// No reset waits for the address. /// No reset waits for the address.
noReset, noReset,

View file

@ -152,10 +152,10 @@ class const _AccountSecurity() extends StatelessWidget {
final instance = final instance =
InstanceScope.maybeOf(context)?.choice.current?.name ?? InstanceScope.maybeOf(context)?.choice.current?.name ??
AppScope.of(context).serverName; AppScope.of(context).serverName;
// The address this device knows the person by, to name the account in // The address this device knows the person by, which the code goes to;
// their authenticator app. // the setup asks for it where the device doesn't know it.
final address = security.hasAddress final address = security.hasAddress
? await AppScope.of(context).auth.emailAddress() ?? '' ? await AppScope.of(context).auth.emailAddress()
: null; : null;
if (!context.mounted) { if (!context.mounted) {
return; return;

View file

@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart'; import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart'; import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/widget/password_texts.dart'; import 'package:halkyon_app/src/password/widget/password_texts.dart';
import 'package:halkyon_app/src/password/widget/password_widgets.dart'; import 'package:halkyon_app/src/password/widget/password_widgets.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -9,8 +10,9 @@ import 'package:material_ui/material_ui.dart';
/// Sets up an account's password in a sheet (ADR 0017), as [mode] says: a /// Sets up an account's password in a sheet (ADR 0017), as [mode] says: a
/// new account on [instance] from a device signed out, a password for the /// new account on [instance] from a device signed out, a password for the
/// signed-in person ([hasAddress]: whether they have an address, which /// signed-in person ([hasAddress]: whether they have an address, which the
/// then needs no code), or the reset of the account at [address] once due /// code then goes to; GitHub's people sign in with GitHub again should the
/// instance ask), or the reset of the account at [address] once due
/// ([phrase]: the recovery phrase that keeps its keys, else new ones). /// ([phrase]: the recovery phrase that keeps its keys, else new ones).
/// True once done; the sheet can't be swiped away, as an account half set /// True once done; the sheet can't be swiped away, as an account half set
/// up would otherwise be left behind unnoticed. /// up would otherwise be left behind unnoticed.
@ -76,6 +78,15 @@ class _AccountSetupState() extends State<_AccountSetup> {
AccountSetupCubit get _cubit => context.read<AccountSetupCubit>(); AccountSetupCubit get _cubit => context.read<AccountSetupCubit>();
/// A GitHub sign-in made anew, then the code again (the instance takes a
/// first password for GitHub's people only within minutes of one).
Future<void> _signInWithGitHubAgain() async {
final cubit = _cubit;
if (await AppScope.of(context).auth.switchToGitHub() && mounted) {
await cubit.mailCodeEntered(_code.text);
}
}
void _submit(AccountSetupState state) { void _submit(AccountSetupState state) {
switch (state.step) { switch (state.step) {
case AccountSetupStep.address: case AccountSetupStep.address:
@ -259,6 +270,14 @@ class _AccountSetupState() extends State<_AccountSetup> {
child: Text(l10n.passwordResendCode), child: Text(l10n.passwordResendCode),
), ),
), ),
if (state.problem?.problem == PasswordProblem.signInAgain)
Align(
alignment: AlignmentDirectional.centerStart,
child: FilledButton.tonal(
onPressed: enabled ? _signInWithGitHubAgain : null,
child: Text(l10n.passwordSignInWithGitHubAgain),
),
),
], ],
), ),
AccountSetupStep.authenticator => AuthenticatorSetupView( AccountSetupStep.authenticator => AuthenticatorSetupView(

View file

@ -26,6 +26,7 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
}, },
PasswordProblem.sessionEnded => l10n.passwordSessionEnded, PasswordProblem.sessionEnded => l10n.passwordSessionEnded,
PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain, PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain,
PasswordProblem.signInAgain => l10n.passwordSignInAgain,
PasswordProblem.noReset => l10n.passwordNoReset, PasswordProblem.noReset => l10n.passwordNoReset,
PasswordProblem.passwordExists => l10n.passwordExists, PasswordProblem.passwordExists => l10n.passwordExists,
PasswordProblem.addressTaken => l10n.passwordAddressTaken, PasswordProblem.addressTaken => l10n.passwordAddressTaken,

View file

@ -76,6 +76,12 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
/// here. /// here.
final gitHubPeople = <String>{}; final gitHubPeople = <String>{};
/// The people of [gitHubPeople] whose GitHub sign-in on this device is
/// too old for a first password (the fourth reading): refused
/// [PasswordProblem.signInAgain] until they are taken out, as signing in
/// with GitHub anew does in a test.
final staleGitHubSignIns = <String>{};
/// The codes mailed, in order. /// The codes mailed, in order.
final mailed = final mailed =
< <
@ -396,12 +402,28 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
}) => _call('setPassword', () { }) => _call('setPassword', () {
final person = _signedIn(); final person = _signedIn();
var account = _accountOfPerson(person); var account = _accountOfPerson(person);
if (request != null && code != null) { if (account != null) {
final address = _proven(request, code, MailCodePurpose.signUp); // A session alone sets none: their own address proves them (the
if (accounts[address] case final other? when other.person != person) { // fourth reading).
throw const PasswordFailed(PasswordProblem.addressTaken); if (request == null || code == null) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
final address = _proven(request, code, MailCodePurpose.firstPassword);
if (address != account.address) {
throw const PasswordFailed(PasswordProblem.otherAddressLinked);
}
} else {
if (gitHubPeople.contains(person) &&
staleGitHubSignIns.contains(person)) {
throw const PasswordFailed(PasswordProblem.signInAgain);
}
if (request != null && code != null) {
final address = _proven(request, code, MailCodePurpose.signUp);
if (accounts[address] case final other? when other.person != person) {
throw const PasswordFailed(PasswordProblem.addressTaken);
}
account = accounts[address] = FakePasswordAccount(person, address);
} }
account ??= accounts[address] = FakePasswordAccount(person, address);
} }
if (account == null) { if (account == null) {
throw const PasswordFailed(PasswordProblem.noAddress); throw const PasswordFailed(PasswordProblem.noAddress);

View file

@ -111,14 +111,14 @@ void main() {
bloc.add(const SessionEventGitHubRequested()); bloc.add(const SessionEventGitHubRequested());
// A password is offered first (ADR 0017, D2), then the person's app.
const person = SignedIn(authUserId: 'github-person', isIdentified: true);
await expectLater( await expectLater(
bloc.stream, bloc.stream,
emitsThrough( emitsThrough(const SessionStateGitHubSignedIn(person)),
const SessionStateActive(
SignedIn(authUserId: 'github-person', isIdentified: true),
),
),
); );
bloc.add(const SessionEventPasswordOfferDone());
await expectLater(bloc.stream, emits(const SessionStateActive(person)));
expect(gateway.signIns, 0); expect(gateway.signIns, 0);
expect(mark.signedOut, isFalse); expect(mark.signedOut, isFalse);
}); });

View file

@ -119,10 +119,27 @@ void main() {
gateway.gitHubError = null; gateway.gitHubError = null;
await tester.tap(find.text('Sign in with GitHub')); await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// A password is offered first (ADR 0017, D2), while the sign-in is
// recent enough to set one; put off, the person's app.
expect(find.text('Set up a password'), findsWidgets);
await tester.tap(find.text('Later'));
await tester.pumpAndSettle();
expect(find.text('App of github-person'), findsOneWidget); expect(find.text('App of github-person'), findsOneWidget);
expect(gateway.signIns, 0); expect(gateway.signIns, 0);
}); });
testWidgets('after GitHub, offers no password to someone who has one, or '
'where the instance offers none', (tester) async {
dependencies.passwordGateway.offered = false;
await dependencies.signedOut.write(signedOut: true);
await start(tester);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(find.text('Later'), findsNothing);
expect(find.text('App of github-person'), findsOneWidget);
});
testWidgets('offers only the ways the instance has', (tester) async { testWidgets('offers only the ways the instance has', (tester) async {
gateway.offersGitHub = false; gateway.offersGitHub = false;
dependencies.passwordGateway.offered = false; dependencies.passwordGateway.offered = false;

View file

@ -210,6 +210,69 @@ void main() {
expect((auth.status as SignedIn).authUserId, person); expect((auth.status as SignedIn).authUserId, person);
}); });
test('gives a signed-in person with an address their first password only '
'with a code mailed to that address for it, never with the session '
'alone (the fourth reading)', () async {
await auth.signInAnonymously();
final person = (auth.status as SignedIn).authUserId;
final first = setUpAs(AccountSetupMode.setPassword)
..addressEntered('anna@example.org');
await first.passwordEntered('correct horse', 'correct horse');
first.phraseWrittenDown();
await first.phraseChecked(wordsAsked(first.state));
await first.mailCodeEntered(gateway.codeFor('anna@example.org'));
// An address and no password, as codes by mail left accounts.
gateway.accountOf(person)
..password = null
..authenticators.clear();
final cubit = setUpAs(
AccountSetupMode.setPassword,
address: 'anna@example.org',
hasAddress: true,
);
expect(cubit.state, at(AccountSetupStep.password));
await cubit.passwordEntered('another horse', 'another horse');
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
expect(cubit.state, at(AccountSetupStep.mailCode));
expect(gateway.mailed.last.purpose, MailCodePurpose.firstPassword);
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
expect(cubit.state, at(AccountSetupStep.authenticator));
expect(gateway.accountOf(person).password, 'another horse');
});
test('asks for the address a signed-in person has where the device '
"doesn't know it", () {
final cubit = setUpAs(AccountSetupMode.setPassword, hasAddress: true);
expect(cubit.state, at(AccountSetupStep.address));
});
test('asks GitHub people to sign in with GitHub again where theirs is '
'too old, keeping the code for after', () async {
await auth.switchToGitHub();
gateway
..gitHubPeople.add('github-person')
..staleGitHubSignIns.add('github-person');
final cubit = setUpAs(AccountSetupMode.setPassword)
..addressEntered('gina@example.org');
await cubit.passwordEntered('correct horse', 'correct horse');
cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state));
final code = gateway.codeFor('gina@example.org');
await cubit.mailCodeEntered(code);
expect(
cubit.state,
at(AccountSetupStep.mailCode, problem: PasswordProblem.signInAgain),
);
gateway.staleGitHubSignIns.clear();
await cubit.mailCodeEntered(code);
expect(cubit.state, at(AccountSetupStep.authenticator));
});
test("refuses another account's address to a signed-in person, back at " test("refuses another account's address to a signed-in person, back at "
'the address', () async { 'the address', () async {
gateway.addAccount( gateway.addAccount(

View file

@ -32,7 +32,11 @@ enum EmailCodePurpose implements _isc.SerializableModel {
accountReset, accountReset,
/// Deleting an account whose setup never finished, with the password. /// Deleting an account whose setup never finished, with the password.
deletion; deletion,
/// Proving, signed in, the account's own address to give it its first
/// password (the fourth reading: a session alone sets none).
firstPassword;
static EmailCodePurpose fromJson(String name) { static EmailCodePurpose fromJson(String name) {
switch (name) { switch (name) {
@ -46,6 +50,8 @@ enum EmailCodePurpose implements _isc.SerializableModel {
return EmailCodePurpose.accountReset; return EmailCodePurpose.accountReset;
case 'deletion': case 'deletion':
return EmailCodePurpose.deletion; return EmailCodePurpose.deletion;
case 'firstPassword':
return EmailCodePurpose.firstPassword;
default: default:
throw ArgumentError( throw ArgumentError(
'Value "$name" cannot be converted to "EmailCodePurpose"', 'Value "$name" cannot be converted to "EmailCodePurpose"',

Some files were not shown because too many files have changed in this diff Show more