fix(auth): members learn of ended sessions in the background

The full reading's L5: every change or reset of a password told the
members inside the request, up to 500 notices one after another with a
timeout of 10 seconds each, so a member out of reach held the change up
for minutes. Now announceSessionsEnded tells the members of this account's
notices alone, in the background on a session of its own; those not
confirmed wait for the hourly run as before.

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:28:03 +02:00
commit 76291e6ab4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 74 additions and 8 deletions

View file

@ -1,8 +1,11 @@
import 'dart:async';
import 'package:clock/clock.dart'; import 'package:clock/clock.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/halkyon_base.dart'; import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart'; import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart'; import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:meta/meta.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
@ -80,21 +83,55 @@ Future<DateTime> endSessionsEverywhere(
/// What follows [endSessionsEverywhere] once its transaction holds: the /// What follows [endSessionsEverywhere] once its transaction holds: the
/// account's devices here learn that their sessions ended, and the members /// account's devices here learn that their sessions ended, and the members
/// are told. /// are told, in the background (the full reading's L5: told inside the
/// request, a member out of reach held a change of password up for its
/// timeout, and the other notices waiting for minutes), this account's
/// notices first; those not confirmed are told again hourly
/// ([deliverSessionNotices], with the pruning).
Future<void> announceSessionsEnded(Session session, UuidValue account) async { Future<void> announceSessionsEnded(Session session, UuidValue account) async {
await session.messages.authenticationRevoked( await session.messages.authenticationRevoked(
account.uuid, account.uuid,
RevokedAuthenticationUser(), RevokedAuthenticationUser(),
); );
await deliverSessionNotices(session); final delivery = _deliverInBackground(session.serverpod, account);
_deliveries.add(delivery);
unawaited(delivery.whenComplete(() => _deliveries.remove(delivery)));
} }
/// The deliveries [announceSessionsEnded] began, until they end.
final _deliveries = <Future<void>>{};
/// Tells the members of [account]'s ended sessions, on a session of its
/// own: the request's may close first.
Future<void> _deliverInBackground(Serverpod pod, UuidValue account) async {
final session = await pod.createSession(enableLogging: false);
try {
await deliverSessionNotices(session, account: account);
} on Object catch (error) {
// Its type only: what a member answered tells nobody here more.
session.log(
'Telling members of ended sessions failed: ${error.runtimeType}',
level: LogLevel.warning,
);
} finally {
await session.close();
}
}
/// Waits for the deliveries [announceSessionsEnded] began: tests do.
@visibleForTesting
Future<void> sessionNoticesDelivered() => Future.wait([..._deliveries]);
/// Tells the members the sessions ended that they haven't confirmed yet, and /// Tells the members the sessions ended that they haven't confirmed yet, and
/// returns how many confirmed; the others stay, counted, for the next time /// returns how many confirmed; the others stay, counted, for the next time
/// (the register's hourly pruning, and at once after each change), those /// (the register's hourly pruning, and after each change [account]'s alone,
/// tried least first, 500 at a time. Notices to a member the operator no /// in the background), those tried least first, 500 at a time. Notices to a
/// longer names are dropped. /// member the operator no longer names are dropped.
Future<int> deliverSessionNotices(Session session, {DateTime? now}) async { Future<int> deliverSessionNotices(
Session session, {
DateTime? now,
UuidValue? account,
}) async {
final members = switch (HalkyonBase.maybeOf(session.serverpod)?.accounts) { final members = switch (HalkyonBase.maybeOf(session.serverpod)?.accounts) {
HoldsRegister(:final members) => {for (final member in members) '$member'}, HoldsRegister(:final members) => {for (final member in members) '$member'},
_ => const <String>{}, _ => const <String>{},
@ -105,10 +142,11 @@ Future<int> deliverSessionNotices(Session session, {DateTime? now}) async {
where: (t) => t.member.notInSet(members), where: (t) => t.member.notInSet(members),
); );
final notifier = MemberNotifier.of(session.serverpod); final notifier = MemberNotifier.of(session.serverpod);
// SELECT … FROM "sessions_ended_notice" ORDER BY "attempts", "id" // SELECT … FROM "sessions_ended_notice" [WHERE "account" = $1]
// LIMIT 500 // ORDER BY "attempts", "id" LIMIT 500
final notices = await SessionsEndedNotice.db.find( final notices = await SessionsEndedNotice.db.find(
session, session,
where: account == null ? null : (t) => t.account.equals(account),
orderByList: (t) => [t.attempts.asc(), t.id.asc()], orderByList: (t) => [t.attempts.asc(), t.id.asc()],
limit: 500, limit: 500,
); );

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
@ -7,6 +8,8 @@ import 'package:halkyon_base_server/src/password/password_endpoint.dart'
show changesBegunPerSession; show changesBegunPerSession;
import 'package:halkyon_base_server/src/password/passwords.dart' import 'package:halkyon_base_server/src/password/passwords.dart'
show writeNewPassword; show writeNewPassword;
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
show sessionNoticesDelivered;
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -24,6 +27,10 @@ final class _Members implements MemberNotifier {
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[]; final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
bool answering = true; bool answering = true;
/// What a member's answer waits for, as one out of reach keeps it
/// waiting; none by default.
Future<void>? answerAfter;
@override @override
Future<bool> accountGone(Uri member, UuidValue account) async => answering; Future<bool> accountGone(Uri member, UuidValue account) async => answering;
@ -34,6 +41,7 @@ final class _Members implements MemberNotifier {
required DateTime endedBefore, required DateTime endedBefore,
UuidValue? keepSession, UuidValue? keepSession,
}) async { }) async {
await answerAfter;
sessionsTold.add((member, account, endedBefore, keepSession)); sessionsTold.add((member, account, endedBefore, keepSession));
return answering; return answering;
} }
@ -241,6 +249,7 @@ void main() {
final sessions = await sessionsOf(anna); final sessions = await sessionsOf(anna);
expect(sessions, contains(annasPhone)); expect(sessions, contains(annasPhone));
expect(sessions, isNot(contains(annasLaptop))); expect(sessions, isNot(contains(annasLaptop)));
await sessionNoticesDelivered();
final (member, account, endedBefore, kept) = final (member, account, endedBefore, kept) =
members.sessionsTold.first; members.sessionsTold.first;
expect(member, _todo); expect(member, _todo);
@ -307,6 +316,7 @@ void main() {
kept, kept,
); );
expect(await sessionsOf(anna), {annasPhone, annasLaptop}); expect(await sessionsOf(anna), {annasPhone, annasLaptop});
await sessionNoticesDelivered();
expect(members.sessionsTold, isEmpty); expect(members.sessionsTold, isEmpty);
}); });
@ -566,10 +576,23 @@ void main() {
); );
}); });
test('is done without waiting for the members: one out of reach '
"holds no change up (the full reading's L5)", () async {
final reached = Completer<void>();
members.answerAfter = reached.future;
await change();
expect(members.sessionsTold, isEmpty);
reached.complete();
await sessionNoticesDelivered();
expect(members.sessionsTold, hasLength(1));
expect(await SessionsEndedNotice.db.count(session), 0);
});
test('tells a member that missed it again, hourly, until it ' test('tells a member that missed it again, hourly, until it '
'confirms', () async { 'confirms', () async {
members.answering = false; members.answering = false;
await change(); await change();
await sessionNoticesDelivered();
expect(await SessionsEndedNotice.db.count(session), 1); expect(await SessionsEndedNotice.db.count(session), 1);
members.answering = true; members.answering = true;
@ -1042,6 +1065,7 @@ void main() {
expect(sessions, hasLength(1)); expect(sessions, hasLength(1));
expect(sessions, isNot(contains(annasPhone))); expect(sessions, isNot(contains(annasPhone)));
expect(sessions, isNot(contains(annasLaptop))); expect(sessions, isNot(contains(annasLaptop)));
await sessionNoticesDelivered();
expect(members.sessionsTold.single.$2, anna); expect(members.sessionsTold.single.$2, anna);
expect(members.sessionsTold.single.$4, isNull); expect(members.sessionsTold.single.$4, isNull);
}); });

View file

@ -7,6 +7,8 @@ import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/instance_api.dart'; import 'package:halkyon_base_server/src/register/instance_api.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart'; import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart'; import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
show sessionNoticesDelivered;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/testing.dart'; import 'package:http/testing.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
@ -677,6 +679,7 @@ void main() {
(account: annaOnTodo, endedBefore: now), (account: annaOnTodo, endedBefore: now),
); );
// Every member is told to end the account's sessions too. // Every member is told to end the account's sessions too.
await sessionNoticesDelivered();
expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null)); expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null));
final forReset = await remote.beginPasswordSignIn( final forReset = await remote.beginPasswordSignIn(
@ -809,6 +812,7 @@ void main() {
password: device.newPassword(), password: device.newPassword(),
keepSession: annasPhone, keepSession: annasPhone,
); );
await sessionNoticesDelivered();
expect(members.sessionsTold.single, ( expect(members.sessionsTold.single, (
_todo, _todo,
annaOnTodo, annaOnTodo,