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
1 changed files with 3017 additions and 44 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue