fix(auth): a member keeps the session a reset signed in when its notice comes late

A member remembers the session issueSessionAfter made, by the account and
the register's moment, and keeps it when the register's notice of that
moment arrives (the short reading's M2). The notice's moment, taken by the
member's clock, is later by its way there; when that took longer than the
reset's answer took to come back and the session to be made, the notice
ended the very session the reset had signed in, and the device had to sign
in again.

Kept in memory for a day, 10,000 at most: notices go out at once and
hourly until confirmed. A member restarted in between ends that session as
before. endSessionsHere takes a set of sessions to keep.

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 17:45:43 +02:00
commit b83fa04594
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 141 additions and 14 deletions

View file

@ -453,8 +453,9 @@ Future<InstanceAnswer> answerRegister(
/// A member answering the register that [body]'s account's sessions ended
/// ([sessionsEndedPath], ADR 0017): those here created before the moment it
/// names end, but the one it keeps, and the register is told so. On the
/// register itself, nothing is answered.
/// names end, but the one it keeps and the one made here for that moment
/// (`sessionMadeAfter`), and the register is told so. On the register
/// itself, nothing is answered.
Future<InstanceAnswer> answerSessionsEnded(
Session session,
String body, {
@ -488,7 +489,10 @@ Future<InstanceAnswer> answerSessionsEnded(
session,
account,
endedBefore: _byThisClock(endedBefore, sentAt: sentAt),
keepSession: keepSession,
keepSessions: {
?keepSession,
?sessionMadeAfter(account, endedBefore),
},
);
return (status: 200, body: const <String, Object?>{});
}

View file

@ -73,9 +73,10 @@ Future<AuthSuccess> issueSession(
/// the account here made before that moment or before now, whichever is
/// later, ends first, then the new one is made. So a clock here running
/// ahead of the register's leaves no session made in its gap (the reading
/// of the fixes' C1), and one behind neither waits nor refuses. The
/// register's notice, whenever it arrives, takes its moment by this
/// server's own clock (`answerSessionsEnded`), so it leaves the new one.
/// of the fixes' C1), and one behind neither waits nor refuses. The new one
/// is remembered with the register's moment ([rememberSessionMadeAfter]),
/// so the register's notice of it, whenever it arrives, leaves it (the
/// short reading's M2).
Future<AuthSuccess> issueSessionAfter(
Session session,
UuidValue account, {
@ -88,7 +89,17 @@ Future<AuthSuccess> issueSessionAfter(
account,
endedBefore: endedBefore.isAfter(now) ? endedBefore : now,
);
return await issueSession(session, account, method: method);
final issued = await issueSession(session, account, method: method);
final UuidValue made;
try {
made = issued.serverSideSessionId;
} on FormatException {
// Not a session of this server's own (no token to read it from): there
// is nothing here for the notice to keep.
return issued;
}
rememberSessionMadeAfter(account, endedBefore, made);
return issued;
}
/// What [person] becomes here once a way in is theirs, as linking makes them

View file

@ -183,25 +183,26 @@ Future<int> deliverSessionNotices(
return confirmed;
}
/// Ends here [account]'s sessions created before [endedBefore] but
/// [keepSession], as the register told this member, and tells their devices;
/// how many ended.
/// Ends here [account]'s sessions created before [endedBefore] but those of
/// [keepSessions], as the register told this member, and tells their
/// devices; how many ended.
Future<int> endSessionsHere(
Session session,
UuidValue account, {
required DateTime endedBefore,
UuidValue? keepSession,
Set<UuidValue> keepSessions = const {},
}) async {
// DELETE FROM "serverpod_auth_core_session" WHERE "authUserId" = $1
// AND "createdAt" < $2 AND "id" <> $3 (without $3 where none is kept)
// AND "createdAt" < $2 AND "id" <> ALL($3) (without $3 where none is
// kept)
final ended = await ServerSideSession.db.deleteWhere(
session,
where: (t) {
final before =
t.authUserId.equals(account) & (t.createdAt < endedBefore.toUtc());
return keepSession == null
return keepSessions.isEmpty
? before
: before & t.id.notEquals(keepSession);
: before & t.id.notInSet(keepSessions);
},
);
if (ended.isNotEmpty) {
@ -212,3 +213,49 @@ Future<int> endSessionsHere(
}
return ended.length;
}
/// The sessions made here after the register ended an account's sessions
/// (`issueSessionAfter`), by the account and the register's moment, so the
/// register's notice of that moment keeps them (the short reading's M2):
/// taken by this server's clock, the notice's moment is later by its way
/// here, which may take longer than the reset's answer took to come back
/// and the session to be made, and would end the very session the reset
/// signed in. Kept in memory for [_madeAfterKeptFor], at most
/// [_madeAfterKeptAtMost] (the oldest go first): notices go out at once and
/// hourly until confirmed, and a member restarted meanwhile ends that
/// session as before, its device signing in again.
final _madeAfter = <String, ({UuidValue session, DateTime madeAt})>{};
/// How long [_madeAfter] keeps a session.
const _madeAfterKeptFor = Duration(days: 1);
/// How many sessions [_madeAfter] keeps at most.
const _madeAfterKeptAtMost = 10000;
String _madeAfterKey(UuidValue account, DateTime endedBefore) =>
'${account.uuid}|${endedBefore.toUtc().microsecondsSinceEpoch}';
/// Remembers that [sessionId] was made here for [account] after the
/// register ended its sessions at [endedBefore] (by the register's clock).
void rememberSessionMadeAfter(
UuidValue account,
DateTime endedBefore,
UuidValue sessionId,
) {
final now = clock.now().toUtc();
_madeAfter.removeWhere(
(_, made) => !made.madeAt.add(_madeAfterKeptFor).isAfter(now),
);
while (_madeAfter.length >= _madeAfterKeptAtMost) {
_madeAfter.remove(_madeAfter.keys.first);
}
_madeAfter[_madeAfterKey(account, endedBefore)] = (
session: sessionId,
madeAt: now,
);
}
/// The session made here for [account] after the register ended its
/// sessions at [endedBefore] ([rememberSessionMadeAfter]), if remembered.
UuidValue? sessionMadeAfter(UuidValue account, DateTime endedBefore) =>
_madeAfter[_madeAfterKey(account, endedBefore)]?.session;

View file

@ -1592,6 +1592,71 @@ void main() {
);
});
test("the register's notice of a reset, however long it took to "
'arrive, leaves the session made here for it; a notice of a '
'later moment ends it (the short reading, M2)', () async {
final endedBefore = DateTime.now().toUtc();
register
..proof = bensProof()
..endedBefore = endedBefore;
final signedIn = await resetBensPassword();
final made = (await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
)).single.id!;
expect(signedIn.authUserId, ben);
// Its way here took longer than the reset's answer: sent at once,
// it arrives after the session was made.
await Future<void>.delayed(const Duration(milliseconds: 20));
final answer = await answerSessionsEnded(
session,
jsonEncode({
'account': ben.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': endedBefore.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(answer.status, 200);
expect(await ServerSideSession.db.findById(session, made), isNotNull);
final later = DateTime.now().toUtc();
await answerSessionsEnded(
session,
jsonEncode({
'account': ben.uuid,
'endedBefore': later.toIso8601String(),
'sentAt': later.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(await ServerSideSession.db.findById(session, made), isNull);
});
test("a notice of the reset's moment for another account keeps "
"nothing of Ben's", () async {
final endedBefore = DateTime.now().toUtc();
register
..proof = bensProof()
..endedBefore = endedBefore;
await resetBensPassword();
final annasTablet = await signInNewDevice(session, anna);
await Future<void>.delayed(const Duration(milliseconds: 5));
await answerSessionsEnded(
session,
jsonEncode({
'account': anna.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': endedBefore.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(
await ServerSideSession.db.findById(session, annasTablet),
isNull,
);
});
test("a clock ahead of the register's ends the sessions made here in "
"its gap too (the reading of the fixes' C1)", () async {
final now = DateTime.now().toUtc();