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
3 changed files with 762 additions and 66 deletions

View file

@ -111,14 +111,14 @@ void main() {
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(
bloc.stream,
emitsThrough(
const SessionStateActive(
SignedIn(authUserId: 'github-person', isIdentified: true),
),
),
emitsThrough(const SessionStateGitHubSignedIn(person)),
);
bloc.add(const SessionEventPasswordOfferDone());
await expectLater(bloc.stream, emits(const SessionStateActive(person)));
expect(gateway.signIns, 0);
expect(mark.signedOut, isFalse);
});

View file

@ -119,10 +119,27 @@ void main() {
gateway.gitHubError = null;
await tester.tap(find.text('Sign in with GitHub'));
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(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 {
gateway.offersGitHub = false;
dependencies.passwordGateway.offered = false;

View file

@ -210,6 +210,69 @@ void main() {
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 "
'the address', () async {
gateway.addAccount(