fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing

The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.

issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).

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 15:56:43 +02:00
commit b0e1557153
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 180 additions and 176 deletions

View file

@ -84,12 +84,6 @@ enum PasswordRefusal implements _is.SerializableModel {
/// sign in again.
sessionEnded,
/// The password was changed (a reset carried out), but this service
/// couldn't open a session for it yet (its clock is behind the
/// register's): sign in again with the new password. Never "this
/// instance has no password sign-in" (the re-review's tenth finding).
passwordChanged,
/// A first password for an account that signs in with GitHub alone takes
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
/// alone sets none): sign in with GitHub again, then set it.
@ -141,8 +135,6 @@ enum PasswordRefusal implements _is.SerializableModel {
return PasswordRefusal.codesWait;
case 'sessionEnded':
return PasswordRefusal.sessionEnded;
case 'passwordChanged':
return PasswordRefusal.passwordChanged;
case 'signInAgain':
return PasswordRefusal.signInAgain;
case 'signedInBefore':

View file

@ -53,11 +53,6 @@ values:
### code, `OwnerProof`) were wrong: the session ended (the third reading);
### sign in again.
- sessionEnded
### The password was changed (a reset carried out), but this service
### couldn't open a session for it yet (its clock is behind the
### register's): sign in again with the new password. Never "this
### instance has no password sign-in" (the re-review's tenth finding).
- passwordChanged
### A first password for an account that signs in with GitHub alone takes
### a GitHub sign-in of the last 10 minutes (the fourth reading: a session
### alone sets none): sign in with GitHub again, then set it.

View file

@ -36,7 +36,9 @@ abstract interface class MemberNotifier {
Future<bool> accountGone(Uri member, UuidValue account);
/// Tells [member] that [account]'s sessions created before [endedBefore]
/// ended, but [keepSession]; whether it confirmed having ended them.
/// ended, but [keepSession]; whether it confirmed having ended them. Sent
/// with this server's clock (`sentAt`), so the member takes the moment by
/// its own (the reading of the fixes' C1).
Future<bool> sessionsEnded(
Uri member,
UuidValue account, {
@ -70,6 +72,7 @@ final class HttpMemberNotifier implements MemberNotifier {
}) => _tell(member, sessionsEndedPath, {
'account': account.uuid,
'endedBefore': endedBefore.toUtc().toIso8601String(),
'sentAt': clock.now().toUtc().toIso8601String(),
'keepSession': keepSession?.uuid,
});

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/account.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
@ -472,11 +473,13 @@ Future<InstanceAnswer> answerSessionsEnded(
}
final UuidValue account;
final DateTime endedBefore;
final DateTime? sentAt;
final UuidValue? keepSession;
try {
final read = _Arguments(arguments);
account = read.id('account');
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
sentAt = read.optionalTime('sentAt');
keepSession = read.optionalId('keepSession');
} on FormatException {
return _unreadable;
@ -484,12 +487,26 @@ Future<InstanceAnswer> answerSessionsEnded(
await endSessionsHere(
session,
account,
endedBefore: endedBefore,
endedBefore: _byThisClock(endedBefore, sentAt: sentAt),
keepSession: keepSession,
);
return (status: 200, body: const <String, Object?>{});
}
/// The register's moment [endedBefore] by this server's clock: as long
/// before now as it was before the register sent it ([sentAt], by the
/// register's clock), so the clocks' offset drops out (the reading of the
/// fixes' C1; one way's travel makes it a moment later, ending a session
/// made in that instant too). A moment sent before it was taken (a clock set
/// back) is taken as now; without [sentAt] (an older register), as sent.
DateTime _byThisClock(DateTime endedBefore, {required DateTime? sentAt}) {
if (sentAt == null) {
return endedBefore;
}
final age = sentAt.difference(endedBefore);
return clock.now().toUtc().subtract(age.isNegative ? Duration.zero : age);
}
/// A request's arguments, each read as what it must be; a [FormatException]
/// for anything else.
extension type const _Arguments(Map<String, Object?> _map) {

View file

@ -1,7 +1,6 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -70,56 +69,28 @@ Future<AuthSuccess> issueSession(
/// A new session for [account] here, by [method], after the register ended
/// its sessions created before [endedBefore] (ADR 0017: a reset of the
/// password or of the account): those here end first, as the register's
/// notice to this server will end them, and the new one is made only once
/// this server's clock is past that moment, so the notice, whenever it
/// arrives, leaves it. A clock more than [maxClockSkew] behind, or ahead
/// (the full reading's L10: sessions made here in the gap would outlive the
/// notice), is refused ([PasswordRefusal.passwordChanged]): the reset is
/// done, and the device signs in again with the new password.
/// password or of the account), by the register's clock: every session of
/// 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.
Future<AuthSuccess> issueSessionAfter(
Session session,
UuidValue account, {
required String method,
required DateTime endedBefore,
}) async {
await endSessionsHere(session, account, endedBefore: endedBefore);
final wait = endedBefore.difference(clock.now());
if (wait > maxClockSkew) {
// Its type of trouble only, never whose: the clocks need setting right.
session.log(
"This server's clock is ${wait.inSeconds} s behind the register's: "
'set both by NTP.',
level: LogLevel.warning,
);
// The reset is done: the device signs in again, with the new password.
throw PasswordRefusedException(reason: PasswordRefusal.passwordChanged);
}
// Ahead as far, sessions made here in the gap before the register's
// moment would outlive its notice (the full reading's L10): refused alike.
// (The register answered moments ago: its moment can't be further back
// than that, but for a clock running ahead.)
if (-wait > maxClockSkew) {
session.log(
"This server's clock is ${(-wait).inSeconds} s ahead of the "
"register's: set both by NTP.",
level: LogLevel.warning,
);
throw PasswordRefusedException(reason: PasswordRefusal.passwordChanged);
}
if (!wait.isNegative) {
await Future<void>.delayed(wait + const Duration(milliseconds: 1));
}
final now = clock.now().toUtc();
await endSessionsHere(
session,
account,
endedBefore: endedBefore.isAfter(now) ? endedBefore : now,
);
return await issueSession(session, account, method: method);
}
/// How far behind the register's clock a member's may run for
/// [issueSessionAfter] to wait for it: further, the request would hang as
/// long, so it is refused; as far ahead, alike. Clocks set by NTP differ by
/// milliseconds; a member's clock running ahead of the register's by up to
/// this much leaves sessions made in that gap, which the ADR accepts.
const maxClockSkew = Duration(seconds: 5);
/// What [person] becomes here once a way in is theirs, as linking makes them
/// on the register in the same transaction: `identified` (on their sessions
/// too), and no longer anonymous. A member does it after the register linked