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:
parent
a0842e401d
commit
42d06f7ee8
3 changed files with 3017 additions and 44 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue