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

@ -9,6 +9,8 @@ import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
show sessionNoticesDelivered;
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:halkyon_oidc_server/testing.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:serverpod/serverpod.dart';
@ -463,6 +465,162 @@ void main() {
);
});
group('SimpleLogin (ADR 0020)', () {
late TestKey key;
late FakeProvider simpleLogin;
setUpAll(() => key = TestKey.generate('sl', seed: 12));
setUp(() {
simpleLogin = FakeProvider.simpleLogin(
key: key,
clientId: 'halkyon-learn',
clientSecret: 'a secret of the app',
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
);
useSimpleLogin(sessionBuilder, simpleLogin);
});
tearDown(() => useSimpleLoginApp(pod, null));
test('is offered as the register offers it', () async {
expect(await remote.simpleLoginOffered(session), isTrue);
useSimpleLogin(sessionBuilder, null);
expect(await remote.simpleLoginOffered(session), isFalse);
});
test("begins for a member's app, signed in or out, and signs in "
'the account with that identity', () async {
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: ben,
provider: simpleLoginProvider,
subject: 'sl-ben',
),
);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-ben',
),
);
expect(start.state, startsWith('todo-desktop.'));
expect(proof.outcome, SimpleLoginOutcome.signedIn);
expect(proof.account, ben);
});
test("adds SimpleLogin to a member's person, who becomes an account "
'under the same ID', () async {
final annaOnTodo = const Uuid().v7obj();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-phone',
caller: annaOnTodo,
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-anna',
),
caller: annaOnTodo,
);
expect(proof.outcome, SimpleLoginOutcome.linked);
expect(proof.account, annaOnTodo);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
expect(
(await remote.signInWaysOf(session, annaOnTodo)).simpleLogin,
isTrue,
);
});
test('passes on the handle suggested to someone new', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
claims: {'name': 'Carla Müller'},
),
);
expect(proof.outcome, SimpleLoginOutcome.newcomer);
expect(proof.account, isNull);
expect(proof.suggestedHandle, 'carla_mueller');
});
test("refuses as the register refuses, halkyon_oidc's refusals "
'crossing whole', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
final code = simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
);
await expectLater(
remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: code,
caller: const Uuid().v7obj(),
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
await expectLater(
remote.completeSimpleLogin(
session,
attempt: const Uuid().v7obj(),
code: code,
),
refused(OidcRefusal.unknownAttempt),
);
});
test('refuses where the register has no SimpleLogin app', () async {
useSimpleLogin(sessionBuilder, null);
await expectLater(
remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
),
refused(OidcRefusal.unknownProvider),
);
});
});
group('passwords (ADR 0017)', () {
late DateTime now;
Future<T> atNow<T>(Future<T> Function() body) =>
@ -1242,6 +1400,10 @@ final class _NoSession implements Session {
}
Future<void> _deleteAll(Session session) async {
await OidcAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await SignInChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),