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
3 changed files with 3017 additions and 44 deletions

View file

@ -0,0 +1,32 @@
import 'package:halkyon_base_server/base.dart';
import 'package:test/test.dart';
// SIGN_IN_WAYS (ADR 0020, 1): the ways in an operator offers.
void main() {
test('unset or empty, every way whose keys are there', () {
expect(offeredWaysFrom(null), isNull);
expect(offeredWaysFrom(''), isNull);
expect(offeredWaysFrom(' '), isNull);
});
test('names one way or both, by commas or spaces, whatever their case', () {
expect(offeredWaysFrom('simplelogin'), {OfferedWay.simpleLogin});
expect(offeredWaysFrom('password'), {OfferedWay.password});
expect(offeredWaysFrom('password, SimpleLogin'), {
OfferedWay.password,
OfferedWay.simpleLogin,
});
expect(offeredWaysFrom('simplelogin password'), hasLength(2));
});
test('refuses a way it does not know, so the server stops as it starts', () {
for (final value in ['github', 'simplelogin,email', 'passwords']) {
expect(
() => offeredWaysFrom(value),
throwsA(isA<StateError>()),
reason: value,
);
}
});
}

View file

@ -0,0 +1,73 @@
import 'package:halkyon_base_server/base.dart';
import 'package:test/test.dart';
// The handle suggested from the name SimpleLogin shares (ADR 0020, D5 and
// D17): `anna_mueller` for Anna Müller, which the person confirms or changes.
void main() {
group('A handle made of a name', () {
test('is in lower case, its words joined by underscores', () {
expect(handleFromName('Anna Mueller'), 'anna_mueller');
expect(handleFromName(' Ben de la Cruz '), 'ben_de_la_cruz');
expect(handleFromName('Dr. Carla Weiß-Berg'), 'dr_carla_weiss_berg');
expect(handleFromName('R2 D2'), 'r2_d2');
});
test("writes German's letters as German writes them without, ligatures "
'and thorn as their letters, and every other letter without its '
'mark', () {
expect(handleFromName('Anna Müller'), 'anna_mueller');
expect(handleFromName('Jürgen Größe'), 'juergen_groesse');
expect(handleFromName('ÄÖÜ ẞ'), 'aeoeue_ss');
expect(handleFromName('José Núñez'), 'jose_nunez');
expect(handleFromName('Françoise Lefèvre'), 'francoise_lefevre');
expect(handleFromName('Ægir Þórsson'), 'aegir_thorsson');
expect(handleFromName('Søren Ångström'), 'soren_angstroem');
expect(handleFromName('Łucja Đorđević'), 'lucja_dordevic');
});
test('drops apostrophes inside a word', () {
expect(handleFromName("Seán O'Brien"), 'sean_obrien');
expect(handleFromName('D’Angelo'), 'dangelo');
});
test('is cut to 30 characters, never ending in an underscore', () {
final handle = handleFromName(
'Maximiliane Theodora von Hohenberg-Stauffen',
)!;
expect(handle, 'maximiliane_theodora_von_hohen');
expect(isHandle(handle), isTrue);
expect(handleFromName('${'a' * 29} b'), 'a' * 29);
});
test('is none where nothing of the name can be written so', () {
expect(handleFromName(null), isNull);
expect(handleFromName(''), isNull);
expect(handleFromName(' '), isNull);
expect(handleFromName('山田太郎'), isNull);
expect(handleFromName('Αλέξης'), isNull);
expect(handleFromName('!!! ???'), isNull);
});
test('keeps the Latin part of a name in two scripts', () {
expect(handleFromName('Yamada 山田'), 'yamada');
});
});
group('The handles tried for a name', () {
test('are the name, then with a number, the base cut to make room', () {
final tried = handlesToTry('anna_mueller').take(3).toList();
expect(tried, ['anna_mueller', 'anna_mueller2', 'anna_mueller3']);
final long = 'a' * 30;
expect(handlesToTry(long).elementAt(1), '${'a' * 29}2');
expect(handlesToTry(long).elementAt(9), '${'a' * 28}10');
expect(handlesToTry('${'a' * 28}_b').elementAt(1), '${'a' * 28}2');
});
test('are 99 at most, every one a handle', () {
final tried = handlesToTry('b' * 30).toList();
expect(tried, hasLength(99));
expect(tried.every(isHandle), isTrue);
});
});
}

View file

@ -0,0 +1,121 @@
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
// The instance's SimpleLogin app (ADR 0020, 1): its client from the
// passwords, the callback SimpleLogin sends sign-ins back to.
ServerConfig _webServer({
String scheme = 'https',
String host = 'learn.example',
int port = 443,
}) => ServerConfig(
port: 8082,
publicScheme: scheme,
publicHost: host,
publicPort: port,
);
void main() {
final passwords = {
'simpleLoginClientId': 'halkyon-abc',
'simpleLoginClientSecret': 'secret-xyz',
};
test('is read from the passwords, both or none', () {
expect(
simpleLoginAppFrom((name) => passwords[name], webServer: _webServer()),
(
clientId: 'halkyon-abc',
clientSecret: 'secret-xyz',
callbackUrl: Uri.parse('https://learn.example/simplelogin-callback'),
),
);
for (final missing in passwords.keys) {
final some = {...passwords}..remove(missing);
expect(
simpleLoginAppFrom((name) => some[name], webServer: _webServer()),
isNull,
reason: missing,
);
final empty = {...passwords, missing: ''};
expect(
simpleLoginAppFrom((name) => empty[name], webServer: _webServer()),
isNull,
reason: '$missing empty, as Compose passes an unset one',
);
}
});
test("calls back to the web server's own page, its port where it is "
'not the usual one', () {
expect(
simpleLoginAppFrom(
(name) => passwords[name],
webServer: _webServer(scheme: 'http', host: 'localhost', port: 8082),
)!.callbackUrl,
Uri.parse('http://localhost:8082/simplelogin-callback'),
);
});
test('calls back where SIMPLELOGIN_CALLBACK_URL says, as development '
"does to learn.halkyon.eu's page", () {
expect(
simpleLoginAppFrom(
(name) => passwords[name],
callbackUrl: 'https://learn.halkyon.eu/simplelogin-callback',
webServer: _webServer(scheme: 'http', host: 'localhost', port: 8082),
)!.callbackUrl,
Uri.parse('https://learn.halkyon.eu/simplelogin-callback'),
);
expect(
simpleLoginAppFrom(
(name) => passwords[name],
callbackUrl: '',
webServer: _webServer(),
)!.callbackUrl,
Uri.parse('https://learn.example/simplelogin-callback'),
);
});
test('is refused, so the server stops, with a callback SimpleLogin would '
'refuse or none at all', () {
for (final callback in [
'http://learn.example/simplelogin-callback',
'learn.example/simplelogin-callback',
'https:///simplelogin-callback',
]) {
expect(
() => simpleLoginAppFrom(
(name) => passwords[name],
callbackUrl: callback,
webServer: _webServer(),
),
throwsA(isA<StateError>()),
reason: callback,
);
}
expect(
() => simpleLoginAppFrom((name) => passwords[name], webServer: null),
throwsA(isA<StateError>()),
);
expect(simpleLoginAppFrom((name) => null, webServer: null), isNull);
});
test('marks a sign-in with the app and the platform, which the forwarding '
'page knows', () {
expect(
simpleLoginMarker('learn', SimpleLoginPlatform.desktop),
'learn-desktop',
);
expect(simpleLoginMarker('todo', SimpleLoginPlatform.phone), 'todo-phone');
for (final app in ['learn', 'todo']) {
for (final platform in SimpleLoginPlatform.values) {
expect(
simpleLoginReturns,
contains(simpleLoginMarker(app, platform)),
);
}
}
});
}