feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)

The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.

The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.

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-23 21:03:15 +02:00
commit 42d06f7ee8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -7,6 +7,7 @@ import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/instance_api.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
@ -39,6 +40,13 @@ final class _Register implements AccountRegister {
/// Whether it offers password sign-in.
bool passwordOffered = true;
/// Whether it offers SimpleLogin, the sign-ins begun with it, the accounts
/// it was added to, and what the next sign-in proves.
bool simpleLoginIsOffered = true;
final simpleLoginsBegun = <({String marker, UuidValue? caller})>[];
final simpleLoginLinked = <UuidValue>{};
SimpleLoginProof? simpleLoginProof;
/// The accounts with a password, and those with an authenticator too.
final passwords = <UuidValue>{};
final authenticated = <UuidValue>{};
@ -206,6 +214,7 @@ final class _Register implements AccountRegister {
() => SignInWays(
gitHub: gitHub.containsValue(person),
email: addresses.containsValue(person),
simpleLogin: simpleLoginLinked.contains(person),
password: passwords.contains(person),
authenticator: authenticated.contains(person),
passwordOnly:
@ -241,6 +250,43 @@ final class _Register implements AccountRegister {
Future<bool> passwordSignInOffered(Session session) =>
_answer('passwordSignInOffered', () => passwordOffered);
@override
Future<bool> simpleLoginOffered(Session session) =>
_answer('simpleLoginOffered', () => simpleLoginIsOffered);
@override
Future<OidcStart> beginSimpleLogin(
Session session, {
required String marker,
UuidValue? caller,
required String network,
}) => _answer('beginSimpleLogin', () {
networks.add(network);
simpleLoginsBegun.add((marker: marker, caller: caller));
return OidcStart(
attempt: const Uuid().v7obj(),
authorizationUrl: Uri.parse(
'https://app.simplelogin.io/oauth2/authorize',
),
state: '$marker.state',
);
});
@override
Future<SimpleLoginProof> completeSimpleLogin(
Session session, {
required UuidValue attempt,
required String code,
UuidValue? caller,
}) => _answer('completeSimpleLogin', () {
final proof = simpleLoginProof!;
if (proof.outcome == SimpleLoginOutcome.linked) {
simpleLoginLinked.add(caller!);
return SimpleLoginProof(outcome: proof.outcome, account: caller);
}
return proof;
});
@override
Future<void> setPassword(
Session session, {
@ -951,6 +997,154 @@ void main() {
});
});
group('SimpleLogin, by asking the register (ADR 0020)', () {
test('begins for this app and platform, signed out, the device '
'network passed on', () async {
final start = await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.phone,
);
expect(register.asked, ['beginSimpleLogin']);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-phone',
caller: null,
));
expect(register.networks, hasLength(1));
expect(start.state, startsWith('$testApp-phone.'));
});
test('begins signed in for the caller', () async {
await endpoints.simpleLogin.begin(
asAnna(),
platform: SimpleLoginPlatform.desktop,
);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-desktop',
caller: anna,
));
});
test('signs the account the register found in here, made a person '
'here the first time', () async {
final carla = const Uuid().v7obj();
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.signedIn,
account: carla,
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(register.asked, ['completeSimpleLogin']);
expect(completed.outcome, SimpleLoginOutcome.signedIn);
expect(completed.session!.authUserId, carla);
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
simpleLoginMethod,
);
});
test('makes the caller identified here once the register added '
'SimpleLogin to their account', () async {
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.linked,
);
final completed = await endpoints.simpleLogin.complete(
asAnna(),
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(
await AnonymousAccount.db.find(
session,
where: (t) => t.authUserId.equals(anna),
),
isEmpty,
);
expect(
(await endpoints.account.signInWays(asAnna())).simpleLogin,
isTrue,
);
});
test('passes on the handle suggested to someone new, with no session '
'and no person here', () async {
final people = await AuthUser.db.count(session);
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.newcomer,
suggestedHandle: 'carla_mueller',
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(completed.outcome, SimpleLoginOutcome.newcomer);
expect(completed.session, isNull);
expect(completed.suggestedHandle, 'carla_mueller');
expect(await AuthUser.db.count(session), people);
});
test('refuses as the register refuses', () async {
register.failing = OidcRefusedException(
reason: OidcRefusal.takenByAnotherPerson,
);
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
attempt: const Uuid().v7obj(),
code: 'code',
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.takenByAnotherPerson,
),
),
);
expect(await scopesOf(anna), isNot(contains(identified.name)));
});
test('is offered as the register offers it, and not while it is out '
'of reach', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isTrue,
);
register.simpleLoginIsOffered = false;
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
register
..simpleLoginIsOffered = true
..failing = RegisterUnavailableException('test');
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
});
});
group('profiles', () {
test('are saved and found at the register', () async {
final saved = await endpoints.profile.save(