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
13 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,

View file

@ -184,10 +184,13 @@ void main() {
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
device = await PasswordDevice.make();
anna = await newAnonymousPerson(session);
anna = await newPersonWithAddress(session, 'anna@example.org');
annasPhone = await signInNewDevice(session, anna);
annasLaptop = await signInNewDevice(session, anna);
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
onPhone(),
setup: device.setup(),
@ -296,7 +299,7 @@ void main() {
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
change(
@ -539,7 +542,10 @@ void main() {
test('which nobody else reads or dismisses, nor anyone signed '
'out', () async {
final ended = await endPhone();
final bert = await newAnonymousPerson(session);
final bert = await newPersonWithAddress(
session,
'bert@example.org',
);
final bertsPhone = await signInNewDevice(session, bert);
final asBert = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
@ -615,7 +621,7 @@ void main() {
});
test('is begun only by someone with a password', () async {
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
@ -844,7 +850,7 @@ void main() {
newPhrase(purpose: PasswordProofPurpose.newPassword),
refused(PasswordRefusal.wrongCredentials),
);
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
newPhrase(
@ -969,7 +975,7 @@ void main() {
'code by mail asked for deleting, nothing less, nor one asked for '
'anything else',
() async {
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
final bens = await PasswordDevice.make(seed: 7);
TestSessionBuilder asBen() => sessionBuilder.copyWith(
@ -979,7 +985,10 @@ void main() {
authId: bensLaptop.uuid,
),
);
final setUp = await mailedCode('ben@example.org');
final setUp = await mailedCode(
'ben@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asBen(),
setup: bens.setup(),
@ -1132,7 +1141,8 @@ void main() {
});
test(
'of an anonymous person takes its session alone, as before',
'of an anonymous account left from before takes its session '
'alone, as the app deletes it (ADR 0020, D25)',
() async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);

View file

@ -34,7 +34,8 @@ void main() {
/// The tests' time, moved on by hand.
late DateTime now;
/// Anonymous, signed in on her phone.
/// Signs in with a code mailed to anna@example.org, as accounts did
/// before passwords (ADR 0017), on her phone.
late UuidValue anna;
late UuidValue annasPhone;
@ -47,8 +48,12 @@ void main() {
EmailCodes.of(pod).mailer = mailbox;
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
anna = await newPersonWithAddress(session, 'anna@example.org');
annasPhone = await signInNewDevice(
session,
anna,
scopes: {identified},
);
device = await PasswordDevice.make();
});
tearDown(() async {
@ -126,9 +131,13 @@ void main() {
return (request: request, code: mailbox.codeFor(address));
}
/// Anna's password, with anna@example.org made hers by a code.
/// Anna's password, anna@example.org proved hers by a code mailed
/// there (the fourth reading: a session alone sets none).
Future<void> annaSetsPassword({PasswordDevice? with_}) async {
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asAnna(),
setup: (with_ ?? device).setup(),
@ -209,24 +218,14 @@ void main() {
(await AuthUser.db.findById(session, person))!.scopeNames;
group('setting a password', () {
test('makes an anonymous person identified with the address a code '
'proved, keeping their ID, sessions and data', () async {
test('gives someone whose address is theirs a password, keeping '
'their ID, sessions and data', () async {
await annaSetsPassword();
expect(await scopesOf(anna), contains(identified.name));
expect(
(await ServerSideSession.db.findById(
session,
annasPhone,
))!.scopeNames,
contains(identified.name),
);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
expect(
(await EmailIdentity.db.findFirstRow(
@ -317,13 +316,58 @@ void main() {
);
});
test('is refused to an anonymous account left from before '
'(ADR 0020, D25): a code to any address sets nothing, and stays '
'unused', () async {
final ben = await newAnonymousPerson(session);
final bensPhone = await signInNewDevice(session, ben);
final code = await mailedCode('ben@example.org');
await expectLater(
endpoints.password.set(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
authId: bensPhone.uuid,
),
),
setup: device.setup(),
request: code.request,
code: code.code,
),
refused(PasswordRefusal.wrongCredentials),
);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
0,
);
expect(await AccountPassword.db.count(session), 0);
expect(await scopesOf(ben), isNot(contains(identified.name)));
// The code still signs up whoever asked for it.
final signedUp = await endpoints.passwordSignIn.signUp(
notSignedIn(),
request: code.request,
code: code.code,
setup: device.setup(),
);
expect(signedUp.authUserId, isNot(ben));
});
group('for someone who signs in with GitHub alone', () {
setUp(() async {
// Anna has no address here: GitHub is her only way in.
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: anna),
);
await grantIdentified(session, anna);
});
TestSessionBuilder from(UuidValue device) => sessionBuilder.copyWith(
@ -386,15 +430,58 @@ void main() {
1,
);
});
test('is refused without an address, keeping nothing', () async {
final fresh = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await expectLater(
atNow(
() =>
endpoints.password.set(from(fresh), setup: device.setup()),
),
refused(PasswordRefusal.noAddress),
);
expect(await AccountPassword.db.count(session), 0);
});
test('is refused for an address another account has, keeping '
'nothing', () async {
await newPersonWithAddress(session, 'ben@example.org');
final code = await mailedCode('ben@example.org');
final fresh = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await expectLater(
atNow(
() => endpoints.password.set(
from(fresh),
setup: device.setup(),
request: code.request,
code: code.code,
),
),
refused(PasswordRefusal.addressTaken),
);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(await AccountPassword.db.count(session), 0);
});
});
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(
test('is refused to someone without a way in (a GitHub ID '
'forgotten): a session alone and a code to any address set '
'nothing', () async {
// Anna, identified, without an address, her GitHub ID forgotten
// (the full reading's M1): a session of hers, stolen, outlived it.
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
@ -425,45 +512,8 @@ void main() {
);
});
test('is refused without an address, keeping nothing', () async {
await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()),
refused(PasswordRefusal.noAddress),
);
expect(await AccountPassword.db.count(session), 0);
});
test('is refused for an address another account has, the person '
'staying anonymous', () async {
final ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(pod, 'ben@example.org'),
createdAt: clock.now(),
),
);
final code = await mailedCode('ben@example.org');
await expectLater(
endpoints.password.set(
asAnna(),
setup: device.setup(),
request: code.request,
code: code.code,
),
refused(PasswordRefusal.addressTaken),
);
expect(await scopesOf(anna), isNot(contains(identified.name)));
expect(await AccountPassword.db.count(session), 0);
});
test('is refused for a wrong code, keeping nothing', () async {
final code = await mailedCode('anna@example.org');
final code = await ownAddressCode();
await expectLater(
endpoints.password.set(
@ -475,7 +525,7 @@ void main() {
refusedAsEmail(EmailSignInRefusal.wrongCode),
);
expect(await AccountPassword.db.count(session), 0);
expect(await EmailIdentity.db.count(session), 0);
expect(await EmailIdentity.db.count(session), 1);
});
test('is refused for a setup out of range, part by part', () async {
@ -524,7 +574,7 @@ void main() {
),
),
};
final code = await mailedCode('anna@example.org');
final code = await ownAddressCode();
for (final MapEntry(key: what, value: setup) in wrong.entries) {
await expectLater(
endpoints.password.set(
@ -538,7 +588,14 @@ void main() {
);
}
expect(await AccountPassword.db.count(session), 0);
expect(await scopesOf(anna), isNot(contains(identified.name)));
// Refused before the code was used: it still serves.
await endpoints.password.set(
asAnna(),
setup: setup,
request: code.request,
code: code.code,
);
expect(await AccountPassword.db.count(session), 1);
});
test('replaced before an authenticator is confirmed, ends every '
@ -1384,7 +1441,7 @@ void main() {
proof: proof,
),
);
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
@ -1394,7 +1451,10 @@ void main() {
),
);
final bensDevice = await PasswordDevice.make(seed: 7);
final bensCode = await mailedCode('ben@example.org');
final bensCode = await mailedCode(
'ben@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asBen,
setup: bensDevice.setup(),
@ -1753,7 +1813,7 @@ void main() {
refused(PasswordRefusal.unknownAuthenticator),
);
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
@ -1762,7 +1822,10 @@ void main() {
authId: bensLaptop.uuid,
),
);
final code = await mailedCode('ben@example.org');
final code = await mailedCode(
'ben@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asBen,
setup: (await PasswordDevice.make(seed: 7)).setup(),

View file

@ -36,7 +36,8 @@ void main() {
/// The tests' time, moved on by hand.
late DateTime now;
/// Anonymous, signed in on her phone.
/// Signs in with a code mailed to anna@example.org, as accounts did
/// before passwords (ADR 0017), on her phone.
late UuidValue anna;
late UuidValue annasPhone;
@ -49,8 +50,12 @@ void main() {
EmailCodes.of(pod).mailer = mailbox;
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
anna = await newPersonWithAddress(session, 'anna@example.org');
annasPhone = await signInNewDevice(
session,
anna,
scopes: {identified},
);
device = await PasswordDevice.make();
});
tearDown(() async {
@ -103,7 +108,10 @@ void main() {
/// Anna: a password and a confirmed authenticator.
Future<AuthenticatorEnrolment> annaComplete() => atNow(() async {
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asAnna(),
setup: device.setup(),
@ -787,7 +795,7 @@ void main() {
resetLapsesAfter + const Duration(minutes: 1),
);
// Bob's authenticators' reset, carried out long ago.
final bob = await newAnonymousPerson(session);
final bob = await newPersonWithAddress(session, 'bob@example.org');
await AccountReset.db.insertRow(
session,
AccountReset(
@ -799,7 +807,10 @@ void main() {
);
// Carl's account reset, asked 31 days ago and moved on by
// hold-outs past now: lapsed all the same.
final carl = await newAnonymousPerson(session);
final carl = await newPersonWithAddress(
session,
'carl@example.org',
);
await AccountReset.db.insertRow(
session,
AccountReset(

View file

@ -173,15 +173,7 @@ void main() {
test('gives an account with the address and no password (codes by '
'mail before) the password, keeping its ID', () async {
final ben = await newAnonymousPerson(session);
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(pod, 'ben@example.org'),
createdAt: now,
),
);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final signedUp = await signUp('ben@example.org');

View file

@ -67,9 +67,6 @@ final class _Register implements AccountRegister {
/// When the session dismissing a warning was made, by person.
final dismissedMadeAt = <UuidValue, DateTime?>{};
/// Those [setPassword] was told are anonymous on the member.
final anonymousHere_ = <UuidValue>{};
/// What a password sign-in proves; null refuses it.
PasswordProof? proof;
@ -209,16 +206,6 @@ final class _Register implements AccountRegister {
return request;
});
@override
Future<void> linkGitHub(
Session session, {
required UuidValue person,
required String gitHubUserId,
}) => _answer('linkGitHub', () {
accounts.add(person);
gitHub[gitHubUserId] = person;
});
@override
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
_answer('accountWithGitHub', () => gitHub[gitHubUserId]);
@ -477,7 +464,6 @@ final class _Register implements AccountRegister {
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
bool anonymousHere = false,
UuidValue? keepSession,
}) => _answer('setPassword', () {
add(person);
@ -485,9 +471,6 @@ final class _Register implements AccountRegister {
if (freshGitHubSignIn) {
freshGitHubSignIns.add(person);
}
if (anonymousHere) {
anonymousHere_.add(person);
}
});
@override
@ -750,7 +733,9 @@ void main() {
late Serverpod pod;
late _Register register;
/// Anonymous here, signed in on her phone.
/// Not identified here, signed in on her phone: an anonymous account
/// left from before (ADR 0020, D25), or one the register linked whose
/// becoming identified here failed (the full reading's L7).
late UuidValue anna;
late UuidValue annasPhone;
@ -1095,17 +1080,7 @@ void main() {
expect(register.passwords, {anna});
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 AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
test(
@ -1201,16 +1176,44 @@ void main() {
test('begins signed in for the caller', () async {
await endpoints.simpleLogin.begin(
asAnna(),
asBen(),
back: SimpleLoginReturn.loopback,
);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-loopback',
caller: anna,
caller: ben,
));
});
test('refuses a caller not identified here, asking the register '
'nothing (ADR 0020, D25)', () async {
Matcher wrongCaller() => throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
);
await expectLater(
endpoints.simpleLogin.begin(
asAnna(),
back: SimpleLoginReturn.loopback,
),
wrongCaller(),
);
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
attempt: const Uuid().v7obj(),
code: 'code',
),
wrongCaller(),
);
expect(register.asked, isEmpty);
});
test('signs the account the register found in here, made a person '
'here the first time', () async {
final carla = const Uuid().v7obj();
@ -1241,31 +1244,24 @@ void main() {
);
});
test('makes the caller identified here once the register added '
'SimpleLogin to their account', () async {
test("adds SimpleLogin at the register to the caller's account, "
'who stays identified here', () async {
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.linked,
);
final completed = await endpoints.simpleLogin.complete(
asAnna(),
asBen(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(completed.outcome, SimpleLoginOutcome.linked);
expect(completed.session, isNull);
expect(register.simpleLoginLinked, {anna});
expect(await scopesOf(anna), contains(identified.name));
expect(register.simpleLoginLinked, {ben});
expect(await scopesOf(ben), contains(identified.name));
expect(
await AnonymousAccount.db.find(
session,
where: (t) => t.authUserId.equals(anna),
),
isEmpty,
);
expect(
(await endpoints.account.signInWays(asAnna())).simpleLogin,
(await endpoints.account.signInWays(asBen())).simpleLogin,
isTrue,
);
});
@ -1297,7 +1293,7 @@ void main() {
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
asBen(),
attempt: const Uuid().v7obj(),
code: 'code',
),
@ -1309,7 +1305,7 @@ void main() {
),
),
);
expect(await scopesOf(anna), isNot(contains(identified.name)));
expect(register.simpleLoginLinked, isEmpty);
});
test('checks a handle for someone new at the register, the device '
@ -1759,8 +1755,8 @@ void main() {
expect(register.deletedWithKeys.single?.attempt, attempt);
});
test('of an anonymous person deletes it here alone, once the '
'register says it has no such account', () async {
test('of an anonymous account left from before deletes it here '
'alone, once the register says it has no such account', () async {
await endpoints.account.delete(asAnna());
expect(register.asked, ['knownAccounts']);

View file

@ -435,56 +435,62 @@ void main() {
expect(mailbox.sent, hasLength(11));
});
test("a code proves the address for a member's password, and a "
'wrong one refuses it, keeping nothing', () async {
test("a code to a member's person's address proves it for their "
'password, a wrong one refusing it, and someone without a way '
'in sets none, before the code is used', () async {
usePasswordKeys(pod, testPasswordKeys);
addTearDown(() => usePasswordKeys(pod, null));
final device = await PasswordDevice.make();
final annaOnTodo = const Uuid().v7obj();
final annaOnTodo = await newPersonWithAddress(
session,
'anna@example.org',
);
final request = await remote.sendSignInCode(
session,
address: 'Anna@example.org',
language: 'de',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
purpose: EmailCodePurpose.firstPassword,
);
Future<int> annasPasswords() => AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(annaOnTodo),
);
await expectLater(
remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: 'AAAA-AAAA',
),
refused(EmailSignInRefusal.wrongCode),
);
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).
expect(await annasPasswords(), 0);
// Someone without a way in (an anonymous account left from
// before, ADR 0020, D25, or a GitHub ID forgotten, the full
// reading's M1) is refused before the code is used.
final carlOnTodo = const Uuid().v7obj();
await expectLater(
remote.setPassword(
session,
person: annaOnTodo,
person: carlOnTodo,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(await AuthUser.db.findById(session, carlOnTodo), isNull);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
expect(await annasPasswords(), 1);
expect(await remote.knownAccounts(session, {annaOnTodo, ben}), {
annaOnTodo,
ben,
@ -493,41 +499,11 @@ void main() {
});
group('GitHub', () {
test("links a member's person, who becomes an account under the "
'same ID, found by it afterwards', () async {
final annaOnTodo = const Uuid().v7obj();
await remote.linkGitHub(
session,
person: annaOnTodo,
gitHubUserId: '2002',
);
expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo);
test('finds the account a GitHub account signs into, and nobody for '
'one nobody has', () async {
expect(await remote.accountWithGitHub(session, '1001'), ben);
expect(await remote.accountWithGitHub(session, '3003'), isNull);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
});
test(
"refuses another person's GitHub account, keeping nothing",
() async {
final annaOnTodo = const Uuid().v7obj();
await expectLater(
remote.linkGitHub(
session,
person: annaOnTodo,
gitHubUserId: '1001',
),
refused(GitHubSignInRefusal.takenByAnotherPerson),
);
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
},
);
});
group('SimpleLogin (ADR 0020)', () {
@ -1109,18 +1085,20 @@ void main() {
'register refuses', () async {
final device = await PasswordDevice.make();
await atNow(() async {
final annaOnTodo = const Uuid().v7obj();
final annaOnTodo = await newPersonWithAddress(
session,
'anna@example.org',
);
final request = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
purpose: EmailCodePurpose.firstPassword,
);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
@ -1409,19 +1387,21 @@ void main() {
() async {
final device = await PasswordDevice.make();
await atNow(() async {
final annaOnTodo = const Uuid().v7obj();
final annaOnTodo = await newPersonWithAddress(
session,
'anna@example.org',
);
final annasPhone = const Uuid().v7obj();
final request = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
purpose: EmailCodePurpose.firstPassword,
);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
@ -1691,8 +1671,8 @@ void main() {
expect(members.told, [(_todo, gone)]);
});
test('tells nobody of an anonymous person, a member never having '
'had them', () async {
test('tells nobody of an anonymous account left from before, a member '
'never having had it', () async {
final anna = await newAnonymousPerson(session);
await remote.deleteAccount(session, anna);

View file

@ -38,7 +38,9 @@ void main() {
late Serverpod pod;
late FakeProvider simpleLogin;
/// Anonymous, signed in on her phone.
/// Signs in with a code mailed to anna@example.org, as accounts did
/// before passwords (ADR 0017), on her phone: SimpleLogin is hers to
/// add.
late UuidValue anna;
late UuidValue annasPhone;
@ -57,8 +59,12 @@ void main() {
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
);
useSimpleLogin(sessionBuilder, simpleLogin);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
anna = await newPersonWithAddress(session, 'anna@example.org');
annasPhone = await signInNewDevice(
session,
anna,
scopes: {identified},
);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
@ -80,7 +86,7 @@ void main() {
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
{identified},
authId: annasPhone.uuid,
),
);
@ -431,8 +437,7 @@ void main() {
});
group('signed in', () {
test('adds SimpleLogin to the caller, who becomes identified and is '
'anonymous no longer', () async {
test("adds SimpleLogin to the caller's account", () async {
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
final completed = await endpoints.simpleLogin.complete(
@ -452,12 +457,45 @@ void main() {
(await AuthUser.db.findById(session, anna))!.scopeNames,
contains(identified.name),
);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
});
test('refuses a caller signed in without `identified`, an anonymous '
'account left from before (ADR 0020, D25), beginning and adding '
'nothing', () async {
final carl = await newAnonymousPerson(session);
final carlsPhone = await signInNewDevice(session, carl);
final asCarl = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
carl.uuid,
{},
authId: carlsPhone.uuid,
),
0,
);
await expectLater(
endpoints.simpleLogin.begin(
asCarl,
back: SimpleLoginReturn.loopback,
),
refused(OidcRefusal.wrongCaller),
);
// Nor does a sign-in begun signed out end on their account.
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: 'sl-carl',
);
await expectLater(
endpoints.simpleLogin.complete(
asCarl,
attempt: attempt,
code: code,
),
refused(OidcRefusal.wrongCaller),
);
expect(await identitiesOf(carl), isEmpty);
expect(
(await AuthUser.db.findById(session, carl))!.scopeNames,
isNot(contains(identified.name)),
);
});
@ -494,13 +532,6 @@ void main() {
refused(OidcRefusal.takenByAnotherPerson),
);
expect(await identitiesOf(anna), isEmpty);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
});
test('refuses a second SimpleLogin identity', () async {
@ -1362,20 +1393,6 @@ void main() {
expect(await identitiesOf(ben), hasLength(1));
});
test('no new GitHub account is linked where SimpleLogin is offered: '
'GitHub is deprecated', () async {
await expectLater(
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
throwsA(
isA<GitHubSignInRefusedException>().having(
(e) => e.reason,
'reason',
GitHubSignInRefusal.deprecated,
),
),
);
});
test("forgets the attempts whose time ran out, a newcomer's too, and "
'says how many', () async {
await allowed(notSignedIn(), subject: 'sl-ben');

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_oidc_server/testing.dart';
import 'package:serverpod/serverpod.dart';
@ -102,7 +103,28 @@ Future<UuidValue> signInNewDevice(
return (await sessionIds()).difference(before).single;
}
/// A person as an app's first launch creates them: anonymous.
/// A person as codes by mail left them before passwords (ADR 0017):
/// identified, with [address] as their only way in, and no password.
Future<UuidValue> newPersonWithAddress(Session session, String address) async {
final person = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
// INSERT INTO "email_identity" ("authUserId", "addressHash", "createdAt")
// VALUES ($1, $2, $3)
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: person,
addressHash: addressHash(session.serverpod, address),
createdAt: clock.now(),
),
);
return person;
}
/// An anonymous account, as apps made them on their first start until
/// ADR 0020, D25, left from before.
Future<UuidValue> newAnonymousPerson(
Session session, {
DateTime? createdAt,

View file

@ -202,14 +202,10 @@ void withServerpod(
class TestEndpoints {
late final _AccountEndpoint account;
late final _AnonymousIdpEndpoint anonymousIdp;
late final _EmailCodeEndpoint emailCode;
late final _GitHubSignInEndpoint gitHubSignIn;
late final _GitHubUpgradeEndpoint gitHubUpgrade;
late final _InstanceEndpoint instance;
late final _KeysEndpoint keys;
@ -240,10 +236,6 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
anonymousIdp = _AnonymousIdpEndpoint(
endpoints,
serializationManager,
);
emailCode = _EmailCodeEndpoint(
endpoints,
serializationManager,
@ -252,10 +244,6 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
gitHubUpgrade = _GitHubUpgradeEndpoint(
endpoints,
serializationManager,
);
instance = _InstanceEndpoint(
endpoints,
serializationManager,
@ -371,48 +359,6 @@ class _AccountEndpoint {
}
}
class _AnonymousIdpEndpoint {
_AnonymousIdpEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_iacs.AuthSuccess> login(
_ist.TestSessionBuilder sessionBuilder, {
String? token,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'anonymousIdp',
method: 'login',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'anonymousIdp',
methodName: 'login',
parameters: _ist.testObjectToJson({'token': token}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _EmailCodeEndpoint {
_EmailCodeEndpoint(
this._endpointDispatch,
@ -509,54 +455,6 @@ class _GitHubSignInEndpoint {
}
}
class _GitHubUpgradeEndpoint {
_GitHubUpgradeEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<void> upgrade(
_ist.TestSessionBuilder sessionBuilder, {
required String code,
required String codeVerifier,
required String redirectUri,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'gitHubUpgrade',
method: 'upgrade',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'gitHubUpgrade',
methodName: 'upgrade',
parameters: _ist.testObjectToJson({
'code': code,
'codeVerifier': codeVerifier,
'redirectUri': redirectUri,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _InstanceEndpoint {
_InstanceEndpoint(
this._endpointDispatch,