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
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue