halkyon_base: a Serverpod module for sign-in, accounts and instance basics

What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.

The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.

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-19 14:28:09 +02:00
commit af1de799f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 17704 additions and 3 deletions

View file

@ -0,0 +1,282 @@
import 'package:halkyon_base_server/base.dart';
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('Deleting an account', (sessionBuilder, endpoints) {
late Session session;
late UuidValue anna;
late UuidValue annasPhone;
late UuidValue ben;
/// What the host's deletions saw, in the order they ran.
late List<String> seen;
/// The host's deletions: each notes what it finds of the person while it
/// runs, and what runs once the deletion holds.
List<PersonDataDeletion> noting(String name) => [
(session, person, transaction) async {
final sessions = await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
final stillThere = await AuthUser.db.findById(
session,
person,
transaction: transaction,
);
seen.add(
'$name: $sessions sessions, '
'person ${stillThere == null ? 'gone' : 'there'}',
);
return () async {
final gone = await AuthUser.db.findById(session, person) == null;
seen.add('after $name: person ${gone ? 'gone' : 'there'}');
};
},
];
Future<void> deleteAnna() => endpoints.account.delete(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
),
);
setUp(() async {
seen = [];
useBase(
sessionBuilder,
deletePersonData: [...noting('first'), ...noting('second')],
);
session = sessionBuilder.build();
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
await signInNewDevice(session, anna);
ben = await newAnonymousPerson(session);
await signInNewDevice(session, ben);
});
test('refuses callers who are not signed in', () async {
await expectLater(
endpoints.account.delete(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(seen, isEmpty);
});
test('deletes the caller with their sessions and ways in, and nobody '
'else', () async {
await EmailIdentity.db.insert(session, [
for (final (person, hash) in [(anna, 'a' * 64), (ben, 'b' * 64)])
EmailIdentity(
authUserId: person,
addressHash: hash,
createdAt: DateTime.now().toUtc(),
),
]);
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '4711', authUserId: anna),
);
await deleteAnna();
expect(await AuthUser.db.findById(session, anna), isNull);
expect(await _sessionsOf(session, anna), 0);
expect(await _anonymousAccountsOf(session, anna), 0);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(
await GitHubAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await _sessionsOf(session, ben), 1);
expect(await _anonymousAccountsOf(session, ben), 1);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
1,
);
});
test("runs the host's deletions in order, once the sessions ended and "
'before the person goes, and what they leave for afterwards once '
'it holds', () async {
await deleteAnna();
expect(seen, [
'first: 0 sessions, person there',
'second: 0 sessions, person there',
'after first: person gone',
'after second: person gone',
]);
});
test("leaves everything as it was when a host's deletion fails, and "
'runs nothing afterwards', () async {
useBase(
sessionBuilder,
deletePersonData: [
...noting('first'),
(_, _, _) async => throw StateError('the host failed'),
],
);
await expectLater(deleteAnna(), throwsA(isA<StateError>()));
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(await _sessionsOf(session, anna), 2);
expect(await _anonymousAccountsOf(session, anna), 1);
expect(seen, ['first: 0 sessions, person there']);
});
});
withServerpod('Forgetting sign-up attempts', (sessionBuilder, _) {
late Session session;
final now = DateTime.now().toUtc();
setUp(() => session = sessionBuilder.build());
test('deletes those the rate limit is done with, with their IP '
'addresses, and keeps the rest', () async {
Future<UuidValue> attempt(Duration ago) async =>
(await RateLimitedRequestAttempt.db.insertRow(
session,
RateLimitedRequestAttempt(
domain: 'anonymous',
source: 'account_creation',
key: '203.0.113.7',
ipAddress: '203.0.113.7',
attemptedAt: now.subtract(ago),
),
)).id!;
final old = await attempt(const Duration(days: 3));
final done = await attempt(const Duration(minutes: 61));
final recent = await attempt(const Duration(minutes: 59));
// Others may have been left by earlier runs.
expect(
await forgetOldSignUpAttempts(session, now: now),
greaterThanOrEqualTo(2),
);
for (final forgotten in [old, done]) {
expect(
await RateLimitedRequestAttempt.db.findById(session, forgotten),
isNull,
);
}
expect(
await RateLimitedRequestAttempt.db.findById(session, recent),
isNotNull,
);
});
});
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
late Session session;
final now = DateTime.now().toUtc();
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
/// Whom the host's deletion was asked to delete.
late List<UuidValue> deletedByHost;
setUp(() {
deletedByHost = [];
useBase(
sessionBuilder,
deletePersonData: [
(_, person, _) async {
deletedByHost.add(person);
return null;
},
],
);
session = sessionBuilder.build();
});
test(
'deletes anonymous people unused for 90 days, and only them, 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 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));
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
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);
}
expect(deletedByHost, unorderedEquals([unused, neverSignedIn]));
},
);
});
}
/// Signs [person] in on a device last used at [at].
Future<void> _lastUsed(Session session, UuidValue person, DateTime at) async {
final device = await signInNewDevice(session, person);
final row = await ServerSideSession.db.findById(session, device);
await ServerSideSession.db.updateRow(session, row!.copyWith(lastUsedAt: at));
}
Future<int> _sessionsOf(Session session, UuidValue person) =>
ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(person),
);
Future<int> _anonymousAccountsOf(Session session, UuidValue person) =>
AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
);

View file

@ -0,0 +1,63 @@
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(
'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

@ -0,0 +1,574 @@
import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
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';
/// Keeps every code mailed, in place of a mail service.
final class _Mailbox implements CodeMailer {
final sent = <({String to, String code, String language})>[];
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async {
sent.add((to: to, code: code, language: language));
}
/// The last code mailed to [address].
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
}
/// A mail service that takes no mail.
final class _Refusing implements CodeMailer {
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async => throw const SocketException('Connection refused');
}
void main() {
withServerpod(
'Email sign-in',
// Mailing and accepting codes count attempts in transactions of their
// own (Serverpod's rate limiter), which the test's database rollback
// can't wrap. So these tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late _Mailbox mailbox;
/// Anonymous, signed in on her phone.
late UuidValue anna;
late UuidValue annasPhone;
/// Identified, signing in with ben@example.org.
late UuidValue ben;
late UuidValue bensLaptop;
/// Identified, signing in with GitHub.
late UuidValue carla;
late UuidValue carlasPhone;
setUp(() async {
useBase(sessionBuilder);
session = sessionBuilder.build();
await _deleteAll(session);
mailbox = _Mailbox();
EmailCodes.of(session.serverpod).mailer = mailbox;
anna = (await AuthServices.instance.authUsers.create(session)).id;
await AnonymousAccount.db.insertRow(
session,
AnonymousAccount(authUserId: anna),
);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(session.serverpod, 'ben@example.org'),
createdAt: clock.now(),
),
);
bensLaptop = await signInNewDevice(
session,
ben,
scopes: {identified},
);
carla = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: carla),
);
carlasPhone = await signInNewDevice(
session,
carla,
scopes: {identified},
);
});
tearDown(() async {
EmailCodes.of(session.serverpod).mailer = null;
await _deleteAll(sessionBuilder.build());
});
TestSessionBuilder signedIn(
UuidValue person,
UuidValue sessionId, {
bool isIdentified = false,
}) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{if (isIdentified) identified},
authId: sessionId.uuid,
),
);
TestSessionBuilder asAnna() => signedIn(anna, annasPhone);
TestSessionBuilder asBen() =>
signedIn(ben, bensLaptop, isIdentified: true);
TestSessionBuilder asCarla() =>
signedIn(carla, carlasPhone, isIdentified: true);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
/// Mails a code to [address] for [as] and types it.
Future<EmailCodeAccepted> signInWith(
TestSessionBuilder as,
String address,
) async {
final request = await endpoints.emailCode.sendCode(
as,
address: address,
language: 'en',
);
return await endpoints.emailCode.verifyCode(
as,
request: request,
code: mailbox.codeFor(address.trim().toLowerCase()),
);
}
group('refuses', () {
test('mailing and accepting codes when not signed in', () async {
await expectLater(
endpoints.emailCode.sendCode(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
await expectLater(
endpoints.emailCode.verifyCode(
notSignedIn(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(mailbox.sent, isEmpty);
});
test(
'everything on an instance that sends no mail, and says so',
() async {
EmailCodes.of(session.serverpod).mailer = null;
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.unavailable)),
);
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
isFalse,
);
},
);
test('a code it could not mail, keeping nothing of it', () async {
EmailCodes.of(session.serverpod).mailer = _Refusing();
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.mailFailed)),
);
expect(await EmailCodeRequest.db.count(session), 0);
expect(await SecretChallenge.db.count(session), 0);
});
test('what is no address', () async {
for (final typed in ['anna', 'anna@', '@example.org', 'a b@c.de']) {
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: typed,
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.notAnAddress)),
reason: typed,
);
}
expect(mailbox.sent, isEmpty);
});
test('a wrong code, and after three wrong ones even the right one; '
'nothing changes', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
for (var i = 0; i < 3; i++) {
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
request: request,
code: 'AAAA-AAAA',
),
throwsA(_refusal(EmailSignInRefusal.wrongCode)),
);
}
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
request: request,
code: mailbox.codeFor('anna@example.org'),
),
throwsA(_refusal(EmailSignInRefusal.tooManyTries)),
);
expect(await _addressesOf(session, anna), isEmpty);
});
test('a code past its 15 minutes, a code used, and one replaced by '
'a newer code', () async {
final late = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
final lateCode = mailbox.codeFor('anna@example.org');
await withClock(
Clock.fixed(clock.now().add(const Duration(minutes: 16))),
() => expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
request: late,
code: lateCode,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
),
);
final replaced = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
final replacedCode = mailbox.codeFor('anna@example.org');
final newer = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
request: replaced,
code: replacedCode,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
final newerCode = mailbox.codeFor('anna@example.org');
await endpoints.emailCode.verifyCode(
asAnna(),
request: newer,
code: newerCode,
);
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
request: newer,
code: newerCode,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
});
test(
'a second address for a person who has one; nothing changes',
() async {
await expectLater(
signInWith(asBen(), 'ben@elsewhere.org'),
throwsA(_refusal(EmailSignInRefusal.otherAddressLinked)),
);
expect(await _addressesOf(session, ben), [
addressHash(session.serverpod, 'ben@example.org'),
]);
},
);
test('a fourth code for one address within an hour, and an '
'eleventh from one network', () async {
for (var i = 0; i < 3; i++) {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
}
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'Anna@Example.org',
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
for (var i = 0; i < 7; i++) {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna$i@example.org',
language: 'en',
);
}
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna.else@example.org',
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
expect(mailbox.sent, hasLength(10));
});
test('a switch token used twice, or made up', () async {
final token = (await signInWith(
asAnna(),
'ben@example.org',
)).switchToken!;
await endpoints.emailSignIn.signIn(notSignedIn(), switchToken: token);
for (final used in [token, 'made-up']) {
await expectLater(
endpoints.emailSignIn.signIn(notSignedIn(), switchToken: used),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
}
});
test('saying how someone signs in when not signed in', () async {
await expectLater(
endpoints.account.signInWays(notSignedIn()),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
});
test('mails a code in the language asked for, the same whether anybody '
'has the address or not', () async {
final known = await endpoints.emailCode.sendCode(
asAnna(),
address: 'ben@example.org',
language: 'de',
);
final unknown = await endpoints.emailCode.sendCode(
asAnna(),
address: ' Nobody@Example.org ',
language: 'fr',
);
expect(known, isNot(unknown));
expect(
[for (final mail in mailbox.sent) (mail.to, mail.language)],
[('ben@example.org', 'de'), ('nobody@example.org', 'en')],
);
expect(
mailbox.sent.map((mail) => mail.code),
everyElement(matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$'))),
);
});
test("a code for an address nobody has makes it the person's: they "
'become identified, on every session, and stop being anonymous; '
'the instance keeps only a keyed hash of it', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: ' Anna@Example.org',
language: 'en',
);
final code = mailbox.codeFor('anna@example.org');
final accepted = await endpoints.emailCode.verifyCode(
asAnna(),
request: request,
// As typed off the mail: lower case, a space for the dash.
code: code.toLowerCase().replaceAll('-', ' '),
);
expect(accepted.switchToken, isNull);
final hash = addressHash(session.serverpod, 'anna@example.org');
expect(hash, matches(RegExp(r'^[0-9a-f]{64}$')));
expect(await _addressesOf(session, anna), [hash]);
final person = await AuthServices.instance.authUsers.get(
session,
authUserId: anna,
);
expect(person.scopeNames, 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,
);
expect(await EmailCodeRequest.db.count(session), 0);
final kept = jsonEncode([
...await EmailIdentity.db.find(session),
...await EmailCodeRequest.db.find(session),
...await RateLimitedRequestAttempt.db.find(session),
]);
expect(kept.toLowerCase(), isNot(contains('anna')));
expect(kept, isNot(contains(code)));
});
test('a code for the address the person signs in with changes '
'nothing', () async {
final accepted = await signInWith(asBen(), 'ben@example.org');
expect(accepted.switchToken, isNull);
expect(await EmailIdentity.db.count(session), 1);
});
test("a code for another person's address changes nothing here, and "
'gives a token that signs in as them, without a session', () async {
final accepted = await signInWith(asAnna(), 'ben@example.org');
expect(accepted.switchToken, isNotNull);
expect(await _addressesOf(session, anna), isEmpty);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
final signedIn = await endpoints.emailSignIn.signIn(
notSignedIn(),
switchToken: accepted.switchToken!,
);
expect(signedIn.authUserId, ben);
expect(signedIn.scopeNames, contains(identified.name));
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben) & t.method.equals('email'),
);
expect(sessions, hasLength(1));
expect(await EmailCodeRequest.db.count(session), 0);
});
test('a person who signs in with GitHub adds an address', () async {
Future<(bool, bool)> waysOf(TestSessionBuilder person) async {
final ways = await endpoints.account.signInWays(person);
return (ways.gitHub, ways.email);
}
expect(await waysOf(asCarla()), (true, false));
final accepted = await signInWith(asCarla(), 'carla@example.org');
expect(accepted.switchToken, isNull);
expect(await waysOf(asCarla()), (true, true));
expect(await waysOf(asAnna()), (false, false));
});
test('tells the app that this instance offers it', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
true,
);
});
test('deleting a person deletes their address', () async {
await deletePerson(session, ben);
expect(await EmailIdentity.db.count(session), 0);
});
test('forgets the codes past their time, hourly', () async {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
final now = clock.now();
expect(await forgetExpiredEmailCodes(session, now: now), 0);
expect(
await forgetExpiredEmailCodes(
session,
now: now.add(const Duration(minutes: 16)),
),
1,
);
expect(await EmailCodeRequest.db.count(session), 0);
expect(await SecretChallenge.db.count(session), 0);
});
},
);
}
Matcher _refusal(EmailSignInRefusal reason) =>
isA<EmailSignInRefusedException>().having(
(exception) => exception.reason,
'reason',
reason,
);
/// The hashes of the addresses [person] signs in with.
Future<List<String>> _addressesOf(Session session, UuidValue person) async => [
for (final identity in await EmailIdentity.db.find(
session,
where: (t) => t.authUserId.equals(person),
))
identity.addressHash,
];
/// Deletes every rate-limit record, code, address and person; their
/// sessions and other identities go with them (`ON DELETE CASCADE`).
Future<void> _deleteAll(Session session) async {
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailCodeRequest.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await SecretChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}

View file

@ -0,0 +1,214 @@
import 'package:halkyon_base_server/base.dart';
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('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},
)).id;
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: ben),
);
});
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
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 {
// 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(),
code: 'code',
codeVerifier: 'verifier',
redirectUri: _redirectUri,
),
throwsA(_refusal(GitHubSignInRefusal.unavailable)),
);
});
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);
await expectLater(
signInWithGitHubAccount(session, gitHubUserId: '3003'),
throwsA(_refusal(GitHubSignInRefusal.unknownAccount)),
);
expect(await AuthUser.db.count(session), people);
});
});
test('linking keeps the person and makes them identified, on every '
'session', () async {
await linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002');
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'],
);
});
test('signing in with a known GitHub account starts a session for that '
'person', () async {
final authSuccess = await signInWithGitHubAccount(
session,
gitHubUserId: '1001',
);
expect(authSuccess.authUserId, ben);
expect(authSuccess.scopeNames, contains(identified.name));
});
test('tells the app that this instance has no GitHub OAuth app', () async {
final info = await endpoints.instance.basics(notSignedIn());
expect(info.gitHubClientId, isNull);
});
});
}
/// The loopback callback the desktop apps use.
const _redirectUri = 'http://127.0.0.1:43823/github-callback';
Matcher _refusal(GitHubSignInRefusal reason) =>
isA<GitHubSignInRefusedException>().having(
(exception) => exception.reason,
'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,
where: (t) => t.userIdentifier.equals(gitHubUserId),
);

View file

@ -0,0 +1,26 @@
import 'package:halkyon_base_server/base.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('grantIdentified', (sessionBuilder, endpoints) {
setUp(() => useBase(sessionBuilder));
test("reaches the person's existing sessions, not only new ones", () async {
final session = sessionBuilder.build();
final authUsers = AuthServices.instance.authUsers;
final person = (await authUsers.create(session)).id;
final device = await signInNewDevice(session, person);
await grantIdentified(session, person);
final personAfter = await authUsers.get(session, authUserId: person);
expect(personAfter.scopeNames, contains(identified.name));
final deviceAfter = await ServerSideSession.db.findById(session, device);
expect(deviceAfter!.scopeNames, contains(identified.name));
});
});
}