fix(auth): GitHub-only accounts go at GitHub's end; only the anonymous add an address

The full reading's M1 and L6: once GitHub IDs were forgotten at
GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone
counted as anonymous, so a stolen session and any mailbox gave it a first
password and authenticator; and such accounts, with no way in left, were
never pruned. Now:

- at the end, the daily run that forgets the GitHub IDs first deletes the
  accounts that sign in with GitHub alone (no address, so never a
  password), with everything they have, members told (deletePerson); the
  app tells them for the three months before that their account goes then
  unless they set up a password (passwordSectionGitHubEndsDeletes, en/de);
- password.set adds an address without GitHub only for someone anonymous
  where the session lives (anonymousHere, which a member tells the
  register, as it tells freshGitHubSignIn); anyone else is refused before
  any code is used.

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 13:23:40 +02:00
commit 5eb74639b6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 202 additions and 13 deletions

View file

@ -113,6 +113,12 @@ void main() {
await openProfile(tester); await openProfile(tester);
expect(find.textContaining('Sign-in with GitHub ends on'), findsOneWidget); expect(find.textContaining('Sign-in with GitHub ends on'), findsOneWidget);
// Without an address, the account goes at the end (the full reading's
// M1).
expect(
find.textContaining('will be deleted then, unless you set up a password'),
findsOneWidget,
);
expect(find.text('Set up a password'), findsOneWidget); expect(find.text('Set up a password'), findsOneWidget);
}); });

View file

@ -231,5 +231,6 @@
"passwordResetsAccount": "Am {date} bekommt dein Konto ein neues Passwort und neue Schlüssel, und was mit den alten verschlüsselt ist, geht verloren: Jemand hat es mit einem Code per Mail angefordert. Warst du das nicht, sag es ab.", "passwordResetsAccount": "Am {date} bekommt dein Konto ein neues Passwort und neue Schlüssel, und was mit den alten verschlüsselt ist, geht verloren: Jemand hat es mit einem Code per Mail angefordert. Warst du das nicht, sag es ab.",
"passwordResetsCallOff": "Absagen", "passwordResetsCallOff": "Absagen",
"passwordCallOffTitle": "Zurücksetzen absagen", "passwordCallOffTitle": "Zurücksetzen absagen",
"passwordCallOffIntro": "Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab." "passwordCallOffIntro": "Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.",
"passwordSectionGitHubEndsDeletes": "Die Anmeldung mit GitHub endet am {date}. Dann wird dein Konto mit allem darin gelöscht, es sei denn, du richtest vorher ein Passwort ein."
} }

View file

@ -499,5 +499,13 @@
}, },
"passwordResetsCallOff": "Call it off", "passwordResetsCallOff": "Call it off",
"passwordCallOffTitle": "Call off the reset", "passwordCallOffTitle": "Call off the reset",
"passwordCallOffIntro": "Your password and a code call off every reset waiting." "passwordCallOffIntro": "Your password and a code call off every reset waiting.",
"passwordSectionGitHubEndsDeletes": "Sign-in with GitHub ends on {date}. Your account, with everything in it, will be deleted then, unless you set up a password before.",
"@passwordSectionGitHubEndsDeletes": {
"placeholders": {
"date": {
"type": "String"
}
}
}
} }

View file

@ -1494,6 +1494,12 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your password and a code call off every reset waiting.'** /// **'Your password and a code call off every reset waiting.'**
String get passwordCallOffIntro; String get passwordCallOffIntro;
/// No description provided for @passwordSectionGitHubEndsDeletes.
///
/// In en, this message translates to:
/// **'Sign-in with GitHub ends on {date}. Your account, with everything in it, will be deleted then, unless you set up a password before.'**
String passwordSectionGitHubEndsDeletes(String date);
} }
class _HalkyonAppLocalizationsDelegate class _HalkyonAppLocalizationsDelegate

View file

@ -869,4 +869,9 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override @override
String get passwordCallOffIntro => String get passwordCallOffIntro =>
'Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.'; 'Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.';
@override
String passwordSectionGitHubEndsDeletes(String date) {
return 'Die Anmeldung mit GitHub endet am $date. Dann wird dein Konto mit allem darin gelöscht, es sei denn, du richtest vorher ein Passwort ein.';
}
} }

View file

@ -852,6 +852,11 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override @override
String get passwordCallOffIntro => String get passwordCallOffIntro =>
'Your password and a code call off every reset waiting.'; 'Your password and a code call off every reset waiting.';
@override
String passwordSectionGitHubEndsDeletes(String date) {
return 'Sign-in with GitHub ends on $date. Your account, with everything in it, will be deleted then, unless you set up a password before.';
}
} }
/// 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

@ -166,9 +166,12 @@ class _PasswordOfferState() extends State<_PasswordOffer> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(switch (security.gitHubEnds) { Text(switch (security.gitHubEnds) {
final ends? => l10n.passwordSectionGitHubEnds( final ends? =>
passwordMoment(context, ends), (security.hasAddress
), ? l10n.passwordSectionGitHubEnds
: l10n.passwordSectionGitHubEndsDeletes)(
passwordMoment(context, ends),
),
null => l10n.passwordOfferBody, null => l10n.passwordOfferBody,
}), }),
const SizedBox(height: 24), const SizedBox(height: 24),

View file

@ -76,7 +76,11 @@ class const _AccountSecurity() extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
l10n.passwordSectionGitHubEnds( // Without an address, nothing is left to sign in with: the
// account goes at the end (the full reading's M1).
(security.hasAddress
? l10n.passwordSectionGitHubEnds
: l10n.passwordSectionGitHubEndsDeletes)(
passwordMoment(context, security.gitHubEnds!), passwordMoment(context, security.gitHubEnds!),
), ),
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(

View file

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/account.dart';
import 'package:halkyon_base_server/src/auth/identified.dart'; import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/generated/protocol.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/password_config.dart';
@ -72,6 +73,13 @@ final _endsOf = Expando<DateTime>('GitHub sign-in ends');
/// at [now] ([gitHubSignInEnds]), and returns how many: nobody signs in with /// at [now] ([gitHubSignInEnds]), and returns how many: nobody signs in with
/// them any more, so nothing is kept of them. Daily, with the pruning; on the /// them any more, so nothing is kept of them. Daily, with the pruning; on the
/// register, which keeps them (ADR 0016). /// register, which keeps them (ADR 0016).
///
/// First the accounts that sign in with GitHub alone (no address, so never
/// a password) go, with everything they have, every member told
/// (`deletePerson`): no way in is left to them, and an account nobody can
/// enter would stay for good, or be claimed by whoever holds a session of
/// it (the full reading's M1 and L6). The app tells them for the three
/// months before (ADR 0017, D2).
Future<int> forgetGitHubAccounts( Future<int> forgetGitHubAccounts(
Session session, { Session session, {
required DateTime now, required DateTime now,
@ -80,12 +88,36 @@ Future<int> forgetGitHubAccounts(
if (ends == null || now.isBefore(ends)) { if (ends == null || now.isBefore(ends)) {
return 0; return 0;
} }
// SELECT * FROM "serverpod_auth_idp_github_account"
final gitHubAccounts = await GitHubAccount.db.find(session);
final gitHubPeople = {
for (final account in gitHubAccounts) account.authUserId,
};
if (gitHubPeople.isNotEmpty) {
// SELECT * FROM "email_identity" WHERE "authUserId" = ANY($1)
final withAddress = {
for (final identity in await EmailIdentity.db.find(
session,
where: (t) => t.authUserId.inSet(gitHubPeople),
))
identity.authUserId,
};
final gitHubOnly = gitHubPeople.difference(withAddress);
for (final person in gitHubOnly) {
await deletePerson(session, person);
}
session.log(
'Deleted ${gitHubOnly.length} accounts that signed in with GitHub '
'alone, at its end.',
);
}
// DELETE FROM "serverpod_auth_idp_github_account" // DELETE FROM "serverpod_auth_idp_github_account"
final forgotten = await GitHubAccount.db.deleteWhere( await GitHubAccount.db.deleteWhere(
session, session,
where: (t) => t.id.notEquals(null), where: (t) => t.id.notEquals(null),
); );
return forgotten.length; // Those deleted with their accounts counted too.
return gitHubAccounts.length;
} }
/// The numeric GitHub ID that [code], from the app's GitHub sign-in, proves. /// The numeric GitHub ID that [code], from the app's GitHub sign-in, proves.

View file

@ -42,6 +42,14 @@ class PasswordEndpoint extends Endpoint {
request: request, request: request,
code: code, code: code,
freshGitHubSignIn: await _signedInWithGitHubJustNow(session), freshGitHubSignIn: await _signedInWithGitHubJustNow(session),
// SELECT count(*) FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
anonymousHere:
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
); );
if (isMember(session.serverpod)) { if (isMember(session.serverpod)) {
await becomeIdentified(session, person); await becomeIdentified(session, person);

View file

@ -349,7 +349,11 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
/// by a GitHub sign-in of the last [gitHubSignInFreshFor] /// by a GitHub sign-in of the last [gitHubSignInFreshFor]
/// ([freshGitHubSignIn], which the server holding the session tells), with /// ([freshGitHubSignIn], which the server holding the session tells), with
/// the address it adds proved as an anonymous person proves theirs /// the address it adds proved as an anonymous person proves theirs
/// ([EmailCodePurpose.signUp]). /// ([EmailCodePurpose.signUp]). Only someone anonymous where the session
/// lives ([anonymousHere], which that server tells) adds an address so
/// without either: anyone else has no way in left to prove (a GitHub ID
/// forgotten at its end, the full reading's M1), and a session of theirs
/// alone would make the account a thief's.
/// ///
/// Refused ([PasswordRefusedException]) where the instance has no password /// Refused ([PasswordRefusedException]) where the instance has no password
/// sign-in, for a setup out of range, without an address, for an address /// sign-in, for a setup out of range, without an address, for an address
@ -357,7 +361,9 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
/// confirmed authenticator ([PasswordRefusal.passwordExists]), without the /// confirmed authenticator ([PasswordRefusal.passwordExists]), without the
/// code its own address takes ([PasswordRefusal.wrongCredentials]), and /// code its own address takes ([PasswordRefusal.wrongCredentials]), and
/// for GitHub without a recent sign-in ([PasswordRefusal.signInAgain], /// for GitHub without a recent sign-in ([PasswordRefusal.signInAgain],
/// before any code is used); a code's refusals as `proveAddressCode`'s, and /// before any code is used), and for someone neither anonymous nor with a
/// way in ([PasswordRefusal.wrongCredentials], before any code is used); a
/// code's refusals as `proveAddressCode`'s, and
/// another address than the account's as email sign-in refuses it /// another address than the account's as email sign-in refuses it
/// ([EmailSignInRefusedException]). Nothing changes then. /// ([EmailSignInRefusedException]). Nothing changes then.
Future<void> setPassword( Future<void> setPassword(
@ -367,6 +373,7 @@ Future<void> setPassword(
UuidValue? request, UuidValue? request,
String? code, String? code,
bool freshGitHubSignIn = false, bool freshGitHubSignIn = false,
bool anonymousHere = false,
}) async { }) async {
requirePasswordKeys(session.serverpod); requirePasswordKeys(session.serverpod);
if (!isPasswordSetup(setup)) { if (!isPasswordSetup(setup)) {
@ -405,6 +412,9 @@ Future<void> setPassword(
if (gitHub && !freshGitHubSignIn) { if (gitHub && !freshGitHubSignIn) {
throw refusedFor(PasswordRefusal.signInAgain); throw refusedFor(PasswordRefusal.signInAgain);
} }
if (!gitHub && !anonymousHere) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
proven = request != null && code != null proven = request != null && code != null
? await proveAddressCode( ? await proveAddressCode(
session, session,

View file

@ -152,8 +152,9 @@ abstract interface class AccountRegister {
/// Gives [person] the password [setup] describes, with the address [code] /// Gives [person] the password [setup] describes, with the address [code]
/// proves for [request] (their own where they have one), and for one who /// proves for [request] (their own where they have one), and for one who
/// signs in with GitHub alone a GitHub sign-in of theirs made recently /// signs in with GitHub alone a GitHub sign-in of theirs made recently
/// ([freshGitHubSignIn], of the session asking). Refusals as /// ([freshGitHubSignIn], of the session asking); someone with neither
/// `setPassword`'s. /// only while anonymous where the session lives ([anonymousHere]).
/// Refusals as `setPassword`'s.
Future<void> setPassword( Future<void> setPassword(
Session session, { Session session, {
required UuidValue person, required UuidValue person,
@ -161,6 +162,7 @@ abstract interface class AccountRegister {
UuidValue? request, UuidValue? request,
String? code, String? code,
bool freshGitHubSignIn = false, bool freshGitHubSignIn = false,
bool anonymousHere = false,
}); });
/// A password sign-in's first step for [address], the sign-ins begun from /// A password sign-in's first step for [address], the sign-ins begun from

View file

@ -233,8 +233,11 @@ Future<Map<String, Object?>?> _answer(
setup: arguments.model('setup', PasswordSetup.fromJson), setup: arguments.model('setup', PasswordSetup.fromJson),
request: arguments.optionalId('request'), request: arguments.optionalId('request'),
code: arguments.optionalString('code'), code: arguments.optionalString('code'),
// The member tells it of its own session (the fourth reading). // The member tells it of its own session (the fourth reading) and
// person (the full reading's M1): the register keeps no anonymous
// person of a member's.
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'), freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
anonymousHere: arguments.flag('anonymousHere'),
); );
return const {}; return const {};
case RegisterOperation.beginPasswordSignIn: case RegisterOperation.beginPasswordSignIn:

View file

@ -423,6 +423,7 @@ final class LocalRegister implements AccountRegister {
UuidValue? request, UuidValue? request,
String? code, String? code,
bool freshGitHubSignIn = false, bool freshGitHubSignIn = false,
bool anonymousHere = false,
}) => passwords.setPassword( }) => passwords.setPassword(
session, session,
person: person, person: person,
@ -430,6 +431,7 @@ final class LocalRegister implements AccountRegister {
request: request, request: request,
code: code, code: code,
freshGitHubSignIn: freshGitHubSignIn, freshGitHubSignIn: freshGitHubSignIn,
anonymousHere: anonymousHere,
); );
@override @override

View file

@ -306,12 +306,14 @@ final class RemoteRegister implements AccountRegister {
UuidValue? request, UuidValue? request,
String? code, String? code,
bool freshGitHubSignIn = false, bool freshGitHubSignIn = false,
bool anonymousHere = false,
}) => _ask(RegisterOperation.setPassword, { }) => _ask(RegisterOperation.setPassword, {
'person': person.uuid, 'person': person.uuid,
'setup': setup.toJson(), 'setup': setup.toJson(),
'request': request?.uuid, 'request': request?.uuid,
'code': code, 'code': code,
'freshGitHubSignIn': freshGitHubSignIn, 'freshGitHubSignIn': freshGitHubSignIn,
'anonymousHere': anonymousHere,
}); });
@override @override

View file

@ -234,6 +234,35 @@ void main() {
}); });
expect(await forgetGitHubAccounts(session, now: ends), 1); expect(await forgetGitHubAccounts(session, now: ends), 1);
expect(await GitHubAccount.db.count(session), 0); expect(await GitHubAccount.db.count(session), 0);
// Ben signed in with GitHub alone and never set a password up: his
// account goes with it (the full reading's M1), no way in left.
expect(await AuthUser.db.findById(session, ben), isNull);
});
test('at its end keeps the account of someone with an address too, '
'forgetting only the GitHub ID', () async {
final carl = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '3003', authUserId: carl),
);
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: carl,
addressHash: addressHash(session.serverpod, 'carl@example.org'),
createdAt: DateTime.utc(2026, 10),
),
);
final ends = DateTime.utc(2027, 1, 22);
useGitHubSignInEnds(session.serverpod, ends);
expect(await forgetGitHubAccounts(session, now: ends), 2);
expect(await AuthUser.db.findById(session, carl), isNotNull);
expect(await AuthUser.db.findById(session, ben), isNull);
expect(await GitHubAccount.db.count(session), 0);
}); });
}); });
}); });

View file

@ -380,6 +380,43 @@ void main() {
}); });
}); });
test('is refused to someone neither anonymous nor with a way in (a '
'GitHub ID forgotten): a session alone and a code to any address '
'set nothing', () async {
// Anna, identified, her GitHub ID forgotten (the full reading's
// M1): a session of hers, stolen, outlived it.
await grantIdentified(session, anna);
await AnonymousAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
final thief = await PasswordDevice.make(seed: 99);
final code = await mailedCode('thief@example.org');
await expectLater(
endpoints.password.set(
asAnna(),
setup: thief.setup(),
request: code.request,
code: code.code,
),
refused(PasswordRefusal.wrongCredentials),
);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(
await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
test('is refused without an address, keeping nothing', () async { test('is refused without an address, keeping nothing', () async {
await expectLater( await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()), endpoints.password.set(asAnna(), setup: device.setup()),

View file

@ -48,6 +48,9 @@ final class _Register implements AccountRegister {
/// member's made just now. /// member's made just now.
final freshGitHubSignIns = <UuidValue>{}; final freshGitHubSignIns = <UuidValue>{};
/// Those [setPassword] was told are anonymous on the member.
final anonymousHere_ = <UuidValue>{};
/// What a password sign-in proves; null refuses it. /// What a password sign-in proves; null refuses it.
PasswordProof? proof; PasswordProof? proof;
@ -240,12 +243,16 @@ final class _Register implements AccountRegister {
UuidValue? request, UuidValue? request,
String? code, String? code,
bool freshGitHubSignIn = false, bool freshGitHubSignIn = false,
bool anonymousHere = false,
}) => _answer('setPassword', () { }) => _answer('setPassword', () {
add(person); add(person);
passwords.add(person); passwords.add(person);
if (freshGitHubSignIn) { if (freshGitHubSignIn) {
freshGitHubSignIns.add(person); freshGitHubSignIns.add(person);
} }
if (anonymousHere) {
anonymousHere_.add(person);
}
}); });
@override @override
@ -850,6 +857,9 @@ void main() {
expect(register.passwords, {anna}); expect(register.passwords, {anna});
expect(register.freshGitHubSignIns, isEmpty); expect(register.freshGitHubSignIns, isEmpty);
// Anna was anonymous here, which the register can't know (the
// full reading's M1).
expect(register.anonymousHere_, {anna});
expect(await scopesOf(anna), contains(identified.name)); expect(await scopesOf(anna), contains(identified.name));
expect( expect(
await AnonymousAccount.db.count( await AnonymousAccount.db.count(

View file

@ -384,6 +384,7 @@ void main() {
remote.setPassword( remote.setPassword(
session, session,
person: annaOnTodo, person: annaOnTodo,
anonymousHere: true,
setup: device.setup(), setup: device.setup(),
request: request, request: request,
code: 'AAAA-AAAA', code: 'AAAA-AAAA',
@ -391,9 +392,22 @@ void main() {
refused(EmailSignInRefusal.wrongCode), refused(EmailSignInRefusal.wrongCode),
); );
expect(await AuthUser.db.findById(session, annaOnTodo), isNull); expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
// Without the member saying Anna is anonymous there, refused
// before her code is used (the full reading's M1).
await expectLater(
remote.setPassword(
session,
person: annaOnTodo,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
),
refused(PasswordRefusal.wrongCredentials),
);
await remote.setPassword( await remote.setPassword(
session, session,
person: annaOnTodo, person: annaOnTodo,
anonymousHere: true,
setup: device.setup(), setup: device.setup(),
request: request, request: request,
code: mailbox.codeFor('anna@example.org'), code: mailbox.codeFor('anna@example.org'),
@ -486,6 +500,7 @@ void main() {
await remote.setPassword( await remote.setPassword(
session, session,
person: annaOnTodo, person: annaOnTodo,
anonymousHere: true,
setup: device.setup(), setup: device.setup(),
request: request, request: request,
code: mailbox.codeFor('anna@example.org'), code: mailbox.codeFor('anna@example.org'),
@ -755,6 +770,7 @@ void main() {
await remote.setPassword( await remote.setPassword(
session, session,
person: annaOnTodo, person: annaOnTodo,
anonymousHere: true,
setup: device.setup(), setup: device.setup(),
request: request, request: request,
code: mailbox.codeFor('anna@example.org'), code: mailbox.codeFor('anna@example.org'),