feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
All checks were successful
CI / check (push) Successful in 30m13s
CI / deploy (push) Has been skipped

The server's half of D25: every account has a way in, and the anonymous
accounts left from before go with the daily pruning.

- The module signs nobody in anonymously: `anonymousIdp` and
  `gitHubUpgrade` are gone, and so is linking GitHub at the register.
- A password is set only by someone whose address is theirs (a
  `firstPassword` code) or who signs in with GitHub alone (a fresh
  sign-in and a `signUp` code); an anonymous account is refused
  `wrongCredentials` before any code is used.
- SimpleLogin refuses to link onto a caller without `identified`
  (`wrongCaller`), the member's endpoint too, so nothing turns an
  anonymous account into one with a way in.
- `pruneAnonymousPeople` deletes every anonymous account left, however
  new, never one identified. Learn and Todo schedule it as
  `prune-accounts` and cancel the old `prune-abandoned-anonymous-people`
  entry once.
- Learn: someone without `identified` keeps no files;
  `FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds
  up to 22 start with an anonymous account.
- Learn's sync tests make their person and session by SQL in the
  development database, as Serverpod stores sessions.
- Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended.

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-24 16:07:27 +02:00
commit 87671208df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 837 additions and 1353 deletions

View file

@ -168,8 +168,8 @@ void main() {
(await RateLimitedRequestAttempt.db.insertRow(
session,
RateLimitedRequestAttempt(
domain: 'anonymous',
source: 'account_creation',
domain: 'password_sign_in',
source: 'proofs_per_address',
key: '203.0.113.7',
ipAddress: '203.0.113.7',
attemptedAt: now.subtract(ago),
@ -198,7 +198,10 @@ void main() {
});
});
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
withServerpod('Pruning anonymous people (ADR 0020, D25)', (
sessionBuilder,
_,
) {
late Session session;
final now = DateTime.now().toUtc();
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
@ -220,45 +223,70 @@ void main() {
session = sessionBuilder.build();
});
test('the module signs nobody in anonymously', () {
expect(
session.serverpod.endpoints.getConnectorByName('anonymousIdp'),
isNull,
);
});
test(
'deletes anonymous people unused for 90 days, and only them, with '
"the host's data of theirs",
'deletes every anonymous account left, however new or lately used, '
"with the host's data of theirs",
() async {
final unused = await newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
await _lastUsed(session, unused, daysAgo(95));
final neverSignedIn = await newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
final old = await newAnonymousPerson(session, createdAt: daysAgo(100));
final recentlyUsed = await newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
await _lastUsed(session, recentlyUsed, daysAgo(10));
final newcomer = await newAnonymousPerson(
session,
createdAt: daysAgo(5),
);
final identifiedPerson = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await _lastUsed(session, identifiedPerson, daysAgo(365));
await _lastUsed(session, recentlyUsed, daysAgo(1));
final newcomer = await newAnonymousPerson(session);
await signInNewDevice(session, newcomer);
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
// Others may have been left by earlier runs.
expect(await pruneAnonymousPeople(session), greaterThanOrEqualTo(3));
expect(pruned, 2);
expect(await AuthUser.db.findById(session, unused), isNull);
expect(await AuthUser.db.findById(session, neverSignedIn), isNull);
for (final kept in [recentlyUsed, newcomer, identifiedPerson]) {
expect(await AuthUser.db.findById(session, kept), isNotNull);
for (final gone in [old, recentlyUsed, newcomer]) {
expect(await AuthUser.db.findById(session, gone), isNull);
expect(await _sessionsOf(session, gone), 0);
}
expect(deletedByHost, unorderedEquals([unused, neverSignedIn]));
expect(deletedByHost, containsAll([old, recentlyUsed, newcomer]));
},
);
test('deletes nobody but anonymous accounts: never someone identified, '
"even with an anonymous account's row left, nor anyone without "
'one', () async {
final identifiedPerson = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
// INSERT INTO "serverpod_auth_idp_anonymous_account" (…) VALUES (…)
await AnonymousAccount.db.insertRow(
session,
AnonymousAccount(authUserId: identifiedPerson, createdAt: daysAgo(400)),
);
await _lastUsed(session, identifiedPerson, daysAgo(365));
final noAnonymousAccount = (await AuthServices.instance.authUsers.create(
session,
)).id;
await pruneAnonymousPeople(session);
for (final kept in [identifiedPerson, noAnonymousAccount]) {
expect(await AuthUser.db.findById(session, kept), isNotNull);
expect(deletedByHost, isNot(contains(kept)));
}
expect(await _sessionsOf(session, identifiedPerson), 1);
});
test('deletes at most as many as it is given at once', () async {
for (var i = 0; i < 3; i++) {
await newAnonymousPerson(session);
}
expect(await pruneAnonymousPeople(session, limit: 2), 2);
});
});
}

View file

@ -1,77 +0,0 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod(
'Anonymous sign-in',
// Signing in runs concurrent transactions (the rate limit and the new
// account), which the test's database rollback can't wrap. So these tests
// don't roll back; they delete what they create instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
setUp(() async {
useBase(sessionBuilder);
await _deleteAll(sessionBuilder.build());
});
tearDown(() => _deleteAll(sessionBuilder.build()));
test('creates a person', () async {
final authSuccess = await endpoints.anonymousIdp.login(sessionBuilder);
final authUser = await AuthServices.instance.authUsers.get(
sessionBuilder.build(),
authUserId: authSuccess.authUserId,
);
expect(authUser.id, authSuccess.authUserId);
});
test("counts a new account once, by the visitor's network, not as "
"Serverpod's own limit counts the connection's", () async {
await endpoints.anonymousIdp.login(sessionBuilder);
final notes = await RateLimitedRequestAttempt.db.find(
sessionBuilder.build(),
);
expect(
notes.map((note) => (note.domain, note.source, note.key)),
// `networkOf`'s key for a test's session, which has no request.
[('anonymous', 'accounts_per_network', 'null')],
);
});
test(
'refuses the 31st account from one IP address within an hour',
() async {
for (var i = 0; i < 30; i++) {
await endpoints.anonymousIdp.login(sessionBuilder);
}
await expectLater(
endpoints.anonymousIdp.login(sessionBuilder),
throwsA(
isA<AnonymousAccountBlockedException>().having(
(e) => e.reason,
'reason',
AnonymousAccountBlockedExceptionReason.tooManyAttempts,
),
),
);
},
);
},
);
}
/// Deletes every rate-limit record and every person; their anonymous accounts
/// and sessions go with them (`ON DELETE CASCADE`).
Future<void> _deleteAll(Session session) async {
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}

View file

@ -86,7 +86,8 @@ void main() {
late Session session;
late _Mailbox mailbox;
/// Anonymous, signed in on her phone.
/// Signed in on her phone without an address: an anonymous account
/// left from before (ADR 0020, D25), asking for codes as anyone may.
late UuidValue anna;
late UuidValue annasPhone;
@ -103,11 +104,7 @@ void main() {
mailbox = _Mailbox();
EmailCodes.of(session.serverpod).mailer = mailbox;
anna = (await AuthServices.instance.authUsers.create(session)).id;
await AnonymousAccount.db.insertRow(
session,
AnonymousAccount(authUserId: anna),
);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,

View file

@ -12,18 +12,12 @@ void main() {
withServerpod('GitHub sign-in', (sessionBuilder, endpoints) {
late Session session;
/// Anonymous, signed in on her phone.
late UuidValue anna;
late UuidValue annasPhone;
/// Identified, with the GitHub account 1001.
late UuidValue ben;
setUp(() async {
useBase(sessionBuilder);
session = sessionBuilder.build();
anna = await _newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
@ -39,35 +33,8 @@ void main() {
);
group('refuses', () {
test('upgrading when not signed in', () async {
await expectLater(
endpoints.gitHubUpgrade.upgrade(
notSignedIn(),
code: 'code',
codeVerifier: 'verifier',
redirectUri: _redirectUri,
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('both ways on an instance without a GitHub OAuth app', () async {
test('signing in on an instance without a GitHub OAuth app', () async {
// The test configuration has no githubClientId/githubClientSecret.
await expectLater(
endpoints.gitHubUpgrade.upgrade(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
),
code: 'code',
codeVerifier: 'verifier',
redirectUri: _redirectUri,
),
throwsA(_refusal(GitHubSignInRefusal.unavailable)),
);
await expectLater(
endpoints.gitHubSignIn.signIn(
notSignedIn(),
@ -79,37 +46,6 @@ void main() {
);
});
test('linking a GitHub account that belongs to someone else; nothing '
'changes', () async {
await expectLater(
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '1001'),
throwsA(_refusal(GitHubSignInRefusal.takenByAnotherPerson)),
);
expect(await _gitHubAccountsOf(session, anna), isEmpty);
expect(await _isAnonymous(session, anna), isTrue);
expect(
await _scopesOf(session, anna),
isNot(contains(identified.name)),
);
expect(
[
for (final account in await _gitHubAccountsOf(session, ben))
account.userIdentifier,
],
['1001'],
);
});
test('a second GitHub account for the same person', () async {
await expectLater(
linkGitHubAccount(session, authUserId: ben, gitHubUserId: '2002'),
throwsA(_refusal(GitHubSignInRefusal.otherGitHubAccountLinked)),
);
expect(await _gitHubAccount(session, '2002'), isNull);
});
test('signing in with a GitHub account nobody has; nobody is '
'created', () async {
final people = await AuthUser.db.count(session);
@ -124,33 +60,12 @@ void main() {
});
});
test('linking keeps the person and makes them identified, on every '
'session', () async {
await linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002');
test('adds GitHub to nobody: the module has no way to (ADR 0020, '
'D25)', () {
final pod = session.serverpod;
final account = await _gitHubAccount(session, '2002');
// The same person, so everything they have stays where it is.
expect(account?.authUserId, anna);
// Only the GitHub ID is kept.
expect(account?.email, isNull);
expect(await _scopesOf(session, anna), contains(identified.name));
expect(
(await ServerSideSession.db.findById(session, annasPhone))?.scopeNames,
contains(identified.name),
);
expect(await _isAnonymous(session, anna), isFalse);
});
test('linking the account a person already has changes nothing', () async {
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
expect(
[
for (final account in await _gitHubAccountsOf(session, ben))
account.userIdentifier,
],
['1001'],
);
expect(pod.endpoints.getConnectorByName('gitHubUpgrade'), isNull);
expect(pod.endpoints.getConnectorByName('gitHubSignIn'), isNotNull);
});
test('signing in with a known GitHub account starts a session for that '
@ -179,38 +94,15 @@ void main() {
useGitHubSignInEnds(session.serverpod, null);
});
test('links no new GitHub account where people sign in with a '
'password, and still signs in those who have one', () async {
test('still signs in those who have it where people sign in with a '
'password', () async {
usePasswordKeys(session.serverpod, testPasswordKeys);
EmailCodes.of(session.serverpod).mailer = const LogCodeMailer();
await expectLater(
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
throwsA(_refusal(GitHubSignInRefusal.deprecated)),
);
expect(await _isAnonymous(session, anna), isTrue);
expect(await _gitHubAccount(session, '2002'), isNull);
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
expect(await gitHubAccountToSignIn(session, '1001'), ben);
});
test(
"tells an anonymous person whose GitHub account is another's so, "
'as linking nothing, for the app to switch to that account',
() async {
usePasswordKeys(session.serverpod, testPasswordKeys);
EmailCodes.of(session.serverpod).mailer = const LogCodeMailer();
await expectLater(
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '1001'),
throwsA(_refusal(GitHubSignInRefusal.takenByAnotherPerson)),
);
expect(await _isAnonymous(session, anna), isTrue);
expect(await gitHubAccountToSignIn(session, '1001'), ben);
},
);
test('signs nobody in and links nothing from the day it ends, after '
test('signs nobody in from the day it ends, after '
'which pruning forgets every GitHub ID', () async {
final ends = DateTime.utc(2027, 1, 22);
useGitHubSignInEnds(session.serverpod, ends);
@ -232,21 +124,6 @@ void main() {
),
throwsA(_refusal(GitHubSignInRefusal.ended)),
);
await expectLater(
endpoints.gitHubUpgrade.upgrade(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
),
code: 'code',
codeVerifier: 'verifier',
redirectUri: _redirectUri,
),
throwsA(_refusal(GitHubSignInRefusal.ended)),
);
});
// For 30 days after, nothing goes: a date set wrong can be put
// right meanwhile.
@ -431,34 +308,6 @@ Matcher _refusal(GitHubSignInRefusal reason) =>
reason,
);
/// A person as the app's first launch creates them: anonymous.
Future<UuidValue> _newAnonymousPerson(Session session) async {
final person = (await AuthServices.instance.authUsers.create(session)).id;
await AnonymousAccount.db.insertRow(
session,
AnonymousAccount(authUserId: person),
);
return person;
}
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0;
Future<Set<String>> _scopesOf(Session session, UuidValue person) async =>
(await AuthServices.instance.authUsers.get(
session,
authUserId: person,
)).scopeNames;
Future<List<GitHubAccount>> _gitHubAccountsOf(
Session session,
UuidValue person,
) => GitHubAccount.db.find(session, where: (t) => t.authUserId.equals(person));
Future<GitHubAccount?> _gitHubAccount(Session session, String gitHubUserId) =>
GitHubAccount.db.findFirstRow(
session,