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

View file

@ -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(

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),

View file

@ -0,0 +1,602 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:halkyon_oidc_server/testing.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
import '../test_tools/serverpod_test_tools.dart';
// Signing in with SimpleLogin on the instance's register (ADR 0020, 3 to 5;
// steps 3 and 4 make accounts for newcomers): an attempt begun, signed out
// or in, SimpleLogin's code ending it once, a session for the account with
// that identity, the identity added to the caller's account, or someone new
// whose identity waits half an hour with a handle suggested from their name.
// Against a fake SimpleLogin signing real ID tokens (halkyon_oidc's
// testing.dart); every way an ID token is refused is the module's test.
void main() {
late TestKey key;
setUpAll(() => key = TestKey.generate('sl', seed: 11));
withServerpod(
'SimpleLogin on the register',
// Sessions issued, and the rate limiter's attempts in transactions of
// their own: these tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late FakeProvider simpleLogin;
/// Anonymous, signed in on her phone.
late UuidValue anna;
late UuidValue annasPhone;
/// Identified, signing in with SimpleLogin's ID `sl-ben`.
late UuidValue ben;
setUp(() async {
useBase(sessionBuilder, app: 'learn');
session = sessionBuilder.build();
pod = session.serverpod;
await _deleteAll(session);
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);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: ben,
provider: simpleLoginProvider,
subject: 'sl-ben',
),
);
});
tearDown(() async {
useSimpleLoginApp(pod, null);
await _deleteAll(sessionBuilder.build());
});
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
Matcher refused(OidcRefusal reason) => throwsA(
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
);
/// A sign-in begun as [as] and allowed at SimpleLogin by [subject],
/// who shares [name]: the attempt and SimpleLogin's code.
Future<({UuidValue attempt, String code})> allowed(
TestSessionBuilder as, {
required String subject,
String? name,
}) async {
final start = await endpoints.simpleLogin.begin(
as,
platform: SimpleLoginPlatform.desktop,
);
return (
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: subject,
claims: {'name': ?name},
),
);
}
Future<List<OidcIdentity>> identitiesOf(UuidValue person) =>
OidcIdentity.db.find(
session,
where: (t) => t.authUserId.equals(person),
);
group('is offered', () {
test('where the register has its SimpleLogin app', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isTrue,
);
});
test('not without one, beginning nothing', () async {
useSimpleLoginApp(pod, null);
useSimpleLogin(sessionBuilder, null);
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
),
refused(OidcRefusal.unknownProvider),
);
});
test('not where the operator offers passwords alone, nor ends what '
'was begun before', () async {
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: 'sl-ben',
);
useOfferedWays(pod, {OfferedWay.password});
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
),
refused(OidcRefusal.unknownProvider),
);
await expectLater(
endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
),
refused(OidcRefusal.unknownProvider),
);
});
test('alone where the operator offers it alone: no password sign-in, '
'its keys there or not', () async {
EmailCodes.of(pod).mailer = Mailbox();
usePasswordKeys(pod, testPasswordKeys);
addTearDown(() {
EmailCodes.of(pod).mailer = null;
usePasswordKeys(pod, null);
});
expect(
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
isTrue,
);
useOfferedWays(pod, {OfferedWay.simpleLogin});
final basics = await endpoints.instance.basics(notSignedIn());
expect(basics.passwordSignIn, isFalse);
expect(basics.simpleLogin, isTrue);
});
});
group('beginning', () {
test('sends the person to SimpleLogin, the state marked with the app '
'and its platform for the forwarding page', () async {
final desktop = await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
);
final phone = await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.phone,
);
final url = desktop.authorizationUrl;
expect(
'${url.origin}${url.path}',
'https://app.simplelogin.io/oauth2/authorize',
);
expect(url.queryParameters['client_id'], 'halkyon-learn');
expect(
url.queryParameters['redirect_uri'],
'https://learn.test/simplelogin-callback',
);
expect(desktop.state, startsWith('learn-desktop.'));
expect(phone.state, startsWith('learn-phone.'));
expect(
(await OidcAttempt.db.findById(session, desktop.attempt))!.callerId,
isNull,
);
});
test('signed in, notes who began it', () async {
final start = await endpoints.simpleLogin.begin(
asAnna(),
platform: SimpleLoginPlatform.phone,
);
expect(
(await OidcAttempt.db.findById(session, start.attempt))!.callerId,
anna,
);
});
test('for an app the forwarding page has no way back to is its '
"host's mistake", () async {
// The tests' host, which the forwarding page knows nothing of.
useBase(sessionBuilder);
useSimpleLogin(sessionBuilder, simpleLogin);
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
),
throwsA(isA<StateError>()),
);
});
test('at most 30 times an hour from one network', () async {
for (var i = 0; i < 30; i++) {
await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
);
}
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
),
refused(OidcRefusal.tooManyAttempts),
);
});
});
group('signed out', () {
test('signs in to the account with that identity, a session of '
"SimpleLogin's", () async {
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: 'sl-ben',
name: 'Ben Berg',
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
);
expect(completed.outcome, SimpleLoginOutcome.signedIn);
expect(completed.suggestedHandle, isNull);
expect(completed.session!.authUserId, ben);
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
);
expect(sessions.single.method, simpleLoginMethod);
expect(sessions.single.scopeNames, contains(identified.name));
});
test('someone new gets no account yet: their identity waits half an '
'hour, a handle suggested from their name', () async {
final people = await AuthUser.db.count(session);
final now = DateTime.now().toUtc();
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: 'sl-carla',
name: 'Carla Müller',
);
final completed = await withClock(
Clock.fixed(now),
() => endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
),
);
expect(completed.outcome, SimpleLoginOutcome.newcomer);
expect(completed.session, isNull);
expect(completed.suggestedHandle, 'carla_mueller');
expect(await AuthUser.db.count(session), people);
expect(await OidcIdentity.db.count(session), 1);
final kept = (await OidcAttempt.db.findById(session, attempt))!;
expect(kept.subject, 'sl-carla');
expect(kept.expiresAt, now.add(const Duration(minutes: 30)));
});
test('the handle suggested is one nobody has, whatever its case, and '
'none without a name', () async {
await AccountRegister.of(pod).saveProfile(
session,
ben,
handle: 'Carla_Mueller',
displayName: 'Ben',
bio: '',
links: const [],
);
final taken = await allowed(
notSignedIn(),
subject: 'sl-carla',
name: 'Carla Müller',
);
final nameless = await allowed(notSignedIn(), subject: 'sl-dora');
expect(
(await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: taken.attempt,
code: taken.code,
)).suggestedHandle,
'carla_mueller2',
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: nameless.attempt,
code: nameless.code,
);
expect(completed.outcome, SimpleLoginOutcome.newcomer);
expect(completed.suggestedHandle, isNull);
});
test('ends an attempt once', () async {
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: 'sl-ben',
);
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
);
await expectLater(
endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
),
refused(OidcRefusal.unknownAttempt),
);
});
test('refuses an attempt begun signed in, signing nobody in', () async {
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-ben');
await expectLater(
endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
),
refused(OidcRefusal.wrongCaller),
);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
0,
);
});
test('refuses a code SimpleLogin refuses, and an attempt whose ten '
'minutes are over', () async {
final first = await allowed(notSignedIn(), subject: 'sl-ben');
final second = await allowed(notSignedIn(), subject: 'sl-ben');
await expectLater(
endpoints.simpleLogin.complete(
notSignedIn(),
attempt: first.attempt,
code: second.code,
),
refused(OidcRefusal.rejected),
);
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() => endpoints.simpleLogin.complete(
notSignedIn(),
attempt: second.attempt,
code: second.code,
),
),
refused(OidcRefusal.unknownAttempt),
);
});
});
group('signed in', () {
test('adds SimpleLogin to the caller, who becomes identified and is '
'anonymous no longer', () async {
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
final completed = await endpoints.simpleLogin.complete(
asAnna(),
attempt: attempt,
code: code,
);
expect(completed.outcome, SimpleLoginOutcome.linked);
expect(completed.session, isNull);
expect((await identitiesOf(anna)).single.subject, 'sl-anna');
expect(
(await endpoints.account.signInWays(asAnna())).simpleLogin,
isTrue,
);
expect(
(await AuthUser.db.findById(session, anna))!.scopeNames,
contains(identified.name),
);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
test('the identity they have already changes nothing', () async {
final first = await allowed(asAnna(), subject: 'sl-anna');
await endpoints.simpleLogin.complete(
asAnna(),
attempt: first.attempt,
code: first.code,
);
final again = await allowed(asAnna(), subject: 'sl-anna');
expect(
(await endpoints.simpleLogin.complete(
asAnna(),
attempt: again.attempt,
code: again.code,
)).outcome,
SimpleLoginOutcome.linked,
);
expect(await identitiesOf(anna), hasLength(1));
});
test("refuses another person's identity, switching nobody and "
'changing nothing', () async {
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-ben');
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
attempt: attempt,
code: code,
),
refused(OidcRefusal.takenByAnotherPerson),
);
expect(await identitiesOf(anna), isEmpty);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
});
test('refuses a second SimpleLogin identity', () async {
final first = await allowed(asAnna(), subject: 'sl-anna');
await endpoints.simpleLogin.complete(
asAnna(),
attempt: first.attempt,
code: first.code,
);
final other = await allowed(asAnna(), subject: 'sl-anna-2');
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
attempt: other.attempt,
code: other.code,
),
refused(OidcRefusal.otherIdentityLinked),
);
expect((await identitiesOf(anna)).single.subject, 'sl-anna');
});
test("refuses an attempt begun signed out, or by someone else's "
'session', () async {
final signedOut = await allowed(notSignedIn(), subject: 'sl-anna');
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
attempt: signedOut.attempt,
code: signedOut.code,
),
refused(OidcRefusal.wrongCaller),
);
expect(await identitiesOf(anna), isEmpty);
});
});
test('the identity goes with the account', () async {
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
await endpoints.simpleLogin.complete(
asAnna(),
attempt: attempt,
code: code,
);
await deletePerson(session, anna);
expect(await identitiesOf(anna), isEmpty);
expect(await identitiesOf(ben), hasLength(1));
});
test('no new GitHub account is linked where SimpleLogin is offered: '
'GitHub is deprecated', () async {
await expectLater(
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
throwsA(
isA<GitHubSignInRefusedException>().having(
(e) => e.reason,
'reason',
GitHubSignInRefusal.deprecated,
),
),
);
});
test("forgets the attempts whose time ran out, a newcomer's too, and "
'says how many', () async {
await allowed(notSignedIn(), subject: 'sl-ben');
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: 'sl-carla',
);
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
);
final now = DateTime.now();
expect(
await forgetUnfinishedSimpleLogins(
session,
now: now.add(const Duration(minutes: 11)),
),
1,
);
expect(
await forgetUnfinishedSimpleLogins(
session,
now: now.add(const Duration(minutes: 31)),
),
1,
);
expect(await OidcAttempt.db.count(session), 0);
});
},
);
}
Future<void> _deleteAll(Session session) async {
await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
await deleteAll(session);
}

View file

@ -1,4 +1,5 @@
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_oidc_server/testing.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -17,13 +18,14 @@ const testUpstreamSourceCodeUrl = 'https://code.example.org/halkyon_test';
const testMinimumAppBuild = 7;
/// Sets the module up on the pod [sessionBuilder]'s sessions run on, as a
/// host does before its pod starts, with [deletePersonData] as its
/// deletions and [profilePageNote] at the foot of profile pages, and
/// host does before its pod starts, for [app], with [deletePersonData] as
/// its deletions and [profilePageNote] at the foot of profile pages, and
/// authentication exactly as hosts run it
/// (`auth_config.dart`). Not on `Serverpod.instance`: that is the pod made
/// last, and a test file with several `withServerpod` groups has a pod each.
void useBase(
TestSessionBuilder sessionBuilder, {
String app = testApp,
List<PersonDataDeletion> deletePersonData = const [],
ProfilePageNote? profilePageNote,
AccountsMode accounts = const HoldsRegister(),
@ -32,7 +34,7 @@ void useBase(
HalkyonBase.configure(
pod,
HalkyonBase(
app: testApp,
app: app,
appName: testAppName,
upstreamSourceCodeUrl: testUpstreamSourceCodeUrl,
minimumAppBuild: testMinimumAppBuild,
@ -41,13 +43,38 @@ void useBase(
accounts: accounts,
),
);
// No password sign-in unless a test gives it keys, and no SimpleLogin
// unless a test gives it an app, whatever the test run's passwords and
// environment hold.
usePasswordKeys(pod, null);
useOfferedWays(pod, null);
useSimpleLoginApp(pod, null);
AuthServices.set(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders(pod),
);
}
/// SimpleLogin on the pod [sessionBuilder]'s sessions run on (ADR 0020), the
/// instance's app answered by [fake] (none for null), and authentication set
/// up anew as hosts run it.
void useSimpleLogin(TestSessionBuilder sessionBuilder, FakeProvider? fake) {
final pod = sessionBuilder.build().serverpod;
useSimpleLoginApp(
pod,
fake == null
? null
: (
clientId: fake.clientId,
clientSecret: fake.clientSecret,
callbackUrl: fake.callbackUrl,
),
client: fake?.client,
);
AuthServices.set(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders(pod),
);
// No password sign-in unless a test gives it keys, whatever the test
// run's passwords hold.
usePasswordKeys(pod, null);
}
/// Signs [person] in on a new device and returns the new session's id.

View file

@ -46,6 +46,11 @@ import 'package:halkyon_base_server/src/generated/profile/profile.dart'
as _it4tom37;
import 'package:halkyon_base_server/src/generated/profile/profile_refusal.dart'
as _idszjcpz;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_completed.dart'
as _i8g1l2iv;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_platform.dart'
as _ixrtiuh0;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
@ -206,6 +211,8 @@ class TestEndpoints {
late final _ProfileEndpoint profile;
late final _ProfilesEndpoint profiles;
late final _SimpleLoginEndpoint simpleLogin;
}
class _InternalTestEndpoints extends TestEndpoints
@ -263,6 +270,10 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
simpleLogin = _SimpleLoginEndpoint(
endpoints,
serializationManager,
);
}
}
@ -1574,3 +1585,80 @@ class _ProfilesEndpoint {
});
}
}
class _SimpleLoginEndpoint {
_SimpleLoginEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_ipoqfiqr.OidcStart> begin(
_ist.TestSessionBuilder sessionBuilder, {
required _ixrtiuh0.SimpleLoginPlatform platform,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'simpleLogin',
method: 'begin',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'simpleLogin',
methodName: 'begin',
parameters: _ist.testObjectToJson({'platform': platform}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_ipoqfiqr.OidcStart>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_i8g1l2iv.SimpleLoginCompleted> complete(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue attempt,
required String code,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'simpleLogin',
method: 'complete',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'simpleLogin',
methodName: 'complete',
parameters: _ist.testObjectToJson({
'attempt': attempt,
'code': code,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_i8g1l2iv.SimpleLoginCompleted>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}

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)),
);
}
}
});
}