halkyon/packages/halkyon_base_server/test/integration/simplelogin/simplelogin_test.dart

1417 lines
46 KiB
Dart
Raw Normal View History

feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
import 'dart:typed_data';
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';
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
import '../support/keys_device.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,
back: SimpleLoginReturn.loopback,
);
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(),
back: SimpleLoginReturn.loopback,
),
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(),
back: SimpleLoginReturn.loopback,
),
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 way back for the forwarding page', () async {
final loopback = await endpoints.simpleLogin.begin(
notSignedIn(),
back: SimpleLoginReturn.loopback,
);
final scheme = await endpoints.simpleLogin.begin(
notSignedIn(),
back: SimpleLoginReturn.scheme,
);
final url = loopback.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(loopback.state, startsWith('learn-loopback.'));
expect(scheme.state, startsWith('learn-scheme.'));
expect(
(await OidcAttempt.db.findById(
session,
loopback.attempt,
))!.callerId,
isNull,
);
});
test('signed in, notes who began it', () async {
final start = await endpoints.simpleLogin.begin(
asAnna(),
back: SimpleLoginReturn.scheme,
);
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(),
back: SimpleLoginReturn.loopback,
),
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(),
back: SimpleLoginReturn.loopback,
);
}
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
back: SimpleLoginReturn.loopback,
),
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);
});
});
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
group('a sign-in stands for ten minutes (D20)', () {
test(
'signed out or in, under its attempt, for the account that '
'signed in alone, and says whether it has keys and its handle',
() async {
await giveKeys(session, ben, await KeysDevice.make());
await AccountRegister.of(pod).saveProfile(
session,
ben,
handle: 'Ben',
displayName: '',
bio: '',
links: const [],
);
final idp = AuthServices.instance.oidcIdp;
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
final annas = await allowed(asAnna(), subject: 'sl-anna');
final signedIn = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: bens.attempt,
code: bens.code,
);
final linked = await endpoints.simpleLogin.complete(
asAnna(),
attempt: annas.attempt,
code: annas.code,
);
expect((signedIn.hasKeys, signedIn.handle), (true, 'Ben'));
expect((linked.hasKeys, linked.handle), (false, null));
for (final (attempt, person) in [
(bens.attempt, ben),
(annas.attempt, anna),
]) {
expect(
await idp.isKept(session, attempt: attempt, caller: person),
isTrue,
);
expect(await idp.isKept(session, attempt: attempt), isFalse);
expect(
await withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() => idp.isKept(session, attempt: attempt, caller: person),
),
isFalse,
);
}
},
);
});
group('signing up someone new (D18)', () {
late KeysDevice device;
setUp(() async => device = await KeysDevice.make());
/// Carla, new here, allowed at SimpleLogin: the attempt her identity
/// waits under.
Future<UuidValue> newcomer({String subject = 'sl-carla'}) async {
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: subject,
name: 'Carla Müller',
);
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
);
return attempt;
}
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
isA<ProfileRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
test('makes the account with its handle and keys, identified, signed '
'in with SimpleLogin, its identity taken once', () async {
final attempt = await newcomer();
final signedUp = await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'Carla_M',
setup: device.setup(),
);
final carla = signedUp.authUserId;
expect(
(await AuthUser.db.findById(session, carla))!.scopeNames,
{identified.name},
);
expect((await identitiesOf(carla)).single.subject, 'sl-carla');
final profile = (await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(carla),
))!;
expect((profile.handle, profile.handleKey), ('Carla_M', 'carla_m'));
expect(profile.displayName, isEmpty);
final keys = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(carla),
))!;
expect(keys.verifier, isNull);
expect(
Uint8List.sublistView(keys.passwordProofKey!),
device.passwordProofKey,
);
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
simpleLoginMethod,
);
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'Carla_N',
setup: device.setup(),
),
refused(OidcRefusal.unknownAttempt),
);
final again = await allowed(notSignedIn(), subject: 'sl-carla');
final signedIn = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: again.attempt,
code: again.code,
);
expect(signedIn.session!.authUserId, carla);
expect((signedIn.hasKeys, signedIn.handle), (true, 'Carla_M'));
});
test('refuses a handle someone has, whatever its case, and one nobody '
'may have, the identity still waiting', () async {
await AccountRegister.of(pod).saveProfile(
session,
ben,
handle: 'Ben',
displayName: '',
bio: '',
links: const [],
);
final attempt = await newcomer();
for (final (handle, reason) in [
('BEN', ProfileRefusal.handleTaken),
('admin', ProfileRefusal.handleReserved),
('carla müller', ProfileRefusal.handleInvalid),
]) {
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: handle,
setup: device.setup(),
),
refusedAsProfile(reason),
reason: handle,
);
}
await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: device.setup(),
);
});
test('refuses keys out of range, the identity still waiting', () async {
final attempt = await newcomer();
final setup = device.setup();
for (final wrong in [
setup.copyWith(
costs: setup.costs.copyWith(opsLimit: 1),
),
setup.copyWith(salt: ByteData(15)),
setup.copyWith(passwordProofKey: ByteData(31)),
setup.copyWith(
passwordKeys: setup.passwordKeys.copyWith(nonce: ByteData(12)),
),
]) {
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: wrong,
),
throwsA(
isA<PasswordRefusedException>().having(
(e) => e.reason,
'reason',
PasswordRefusal.invalidSetup,
),
),
);
}
await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: setup,
);
});
test('refuses an attempt nobody new waits under: unknown, past its '
"half hour, or an account's sign-in", () async {
final late = await newcomer();
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: bens.attempt,
code: bens.code,
);
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: const Uuid().v4obj(),
handle: 'carla',
setup: device.setup(),
),
refused(OidcRefusal.unknownAttempt),
);
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 31))),
() => endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: late,
handle: 'carla',
setup: device.setup(),
),
),
refused(OidcRefusal.unknownAttempt),
);
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: bens.attempt,
handle: 'ben2',
setup: device.setup(),
),
refused(OidcRefusal.wrongCaller),
);
expect(await identitiesOf(ben), hasLength(1));
});
test('refuses a signed-in caller, whose session it would leave for '
"another person's", () async {
final attempt = await newcomer();
await expectLater(
endpoints.simpleLogin.signUp(
asAnna(),
attempt: attempt,
handle: 'carla',
setup: device.setup(),
),
refused(OidcRefusal.wrongCaller),
);
await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: device.setup(),
);
});
test('makes one account of an identity waiting twice', () async {
final first = await newcomer();
final second = await newcomer();
final carla = (await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: first,
handle: 'carla',
setup: device.setup(),
)).authUserId;
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: second,
handle: 'carla2',
setup: device.setup(),
),
refused(OidcRefusal.takenByAnotherPerson),
);
expect(await identitiesOf(carla), hasLength(1));
expect(
await PublicProfile.db.count(
session,
where: (t) => t.handleKey.equals('carla2'),
),
0,
);
});
test('signs up nobody where SimpleLogin is not offered', () async {
final attempt = await newcomer();
useSimpleLogin(sessionBuilder, null);
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: device.setup(),
),
refused(OidcRefusal.unknownProvider),
);
});
group('checking a handle', () {
test('says whether it is free as saving a profile would, whatever '
'its case, taking nothing', () async {
await AccountRegister.of(pod).saveProfile(
session,
ben,
handle: 'Ben',
displayName: '',
bio: '',
links: const [],
);
final attempt = await newcomer();
for (final (handle, reason) in [
('carla', null),
('bEn', ProfileRefusal.handleTaken),
('support', ProfileRefusal.handleReserved),
('carla!', ProfileRefusal.handleInvalid),
]) {
expect(
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: handle,
),
reason,
reason: handle,
);
}
await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: device.setup(),
);
});
test('only for an attempt someone new waits under', () async {
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: bens.attempt,
code: bens.code,
);
for (final attempt in [const Uuid().v4obj(), bens.attempt]) {
await expectLater(
endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: 'carla',
),
refused(OidcRefusal.unknownAttempt),
);
}
});
test('at most 30 times an hour for an attempt, and 60 from one '
'network', () async {
final first = await newcomer();
for (var i = 0; i < 30; i++) {
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: first,
handle: 'carla$i',
);
}
await expectLater(
endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: first,
handle: 'carla',
),
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
);
// A check refused counts for neither: the network has 30.
final second = await newcomer(subject: 'sl-dora');
for (var i = 0; i < 30; i++) {
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: second,
handle: 'dora$i',
);
}
final third = await newcomer(subject: 'sl-emil');
await expectLater(
endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: third,
handle: 'emil',
),
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
);
});
test('counts signing up with a handle, which says as much; the '
'identity left waiting when refused', () async {
final attempt = await newcomer();
for (var i = 0; i < 30; i++) {
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: 'carla$i',
);
}
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: device.setup(),
),
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
);
expect(
await AuthServices.instance.oidcIdp.isKept(
session,
attempt: attempt,
),
isTrue,
);
});
test("doesn't count a handle that is none, which says nothing of "
'anyone', () async {
final attempt = await newcomer();
for (var i = 0; i < 30; i++) {
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: 'carla$i',
);
}
expect(
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: 'carla!',
),
ProfileRefusal.handleInvalid,
);
});
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
});
});
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7) Someone whose SimpleLogin identity is new to the register but who has an account ("I already have one") names their handle and proves the account's recovery phrase; the new identity takes the old one's place. - `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the phrase's key, the public keys and a nonce, for the account with that handle (whatever its case). Only an account with keys and a SimpleLogin identity is mended, a link it had, as nobody adds SimpleLogin signed out (D10); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`, the same each time. At most 10 an hour per handle and 30 per network. - `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`, binding the attempt (`mendLinkDigest`, on both sides), so a proof relayed from another sign-in links nobody's identity but the device's own; checked for the waiting identity first, which leaves the proof unspent. Every session of the account ends, in every service, and the sign-in stands ten minutes for a change, such as a lost password. - SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a SimpleLogin app and without it (`checkSimpleLoginMending`). - Members ask the register for both; `RemoteRegister` and the instance API carry them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
group('mending a broken link (D7)', () {
/// With keys and the handle `Dora`, her SimpleLogin link broken:
/// SimpleLogin gives her `sl-dora-new` now, the account keeps
/// `sl-dora-old`. Signed in on her laptop.
late UuidValue dora;
late UuidValue dorasLaptop;
late KeysDevice dorasKeys;
setUp(() async {
usePasswordKeys(pod, testPasswordKeys);
dorasKeys = await KeysDevice.make(seed: 5);
dora = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await giveKeys(session, dora, dorasKeys);
await AccountRegister.of(pod).saveProfile(
session,
dora,
handle: 'Dora',
displayName: '',
bio: '',
links: const [],
);
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: dora,
provider: simpleLoginProvider,
subject: 'sl-dora-old',
),
);
dorasLaptop = await signInNewDevice(
session,
dora,
method: simpleLoginMethod,
);
});
/// The attempt Dora's new identity waits under, someone new to the
/// register.
Future<UuidValue> waiting({String subject = 'sl-dora-new'}) async {
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: subject,
);
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
);
return attempt;
}
Future<KeyProofChallenge> beginMend(
UuidValue attempt, {
String handle = 'Dora',
}) => endpoints.simpleLogin.beginMend(
notSignedIn(),
attempt: attempt,
handle: handle,
);
Matcher refusedAsPassword(PasswordRefusal reason) => throwsA(
isA<PasswordRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
test('takes the handle, whatever its case, and the recovery phrase: '
'the new identity in place of the old, every other session '
'ended, signed in for a change, a lost password too', () async {
final attempt = await waiting();
final challenge = await beginMend(attempt, handle: 'DORA');
expect(
challenge.recoveryKeys.toJson(),
dorasKeys.recoveryKeys.toJson(),
);
final mended = await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: await dorasKeys.provePhrase(
challenge,
digest: mendLinkDigest(attempt),
),
);
expect(mended.outcome, SimpleLoginOutcome.signedIn);
expect(mended.session!.authUserId, dora);
expect((mended.hasKeys, mended.handle), (true, 'Dora'));
expect((await identitiesOf(dora)).single.subject, 'sl-dora-new');
expect(
await ServerSideSession.db.findById(session, dorasLaptop),
isNull,
);
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(dora),
);
expect(sessions.single.method, simpleLoginMethod);
// The sign-in stands ten minutes: her password lost too, the
// phrase sets a new one.
final onPhone = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
dora.uuid,
{identified},
authId: sessions.single.id!.uuid,
),
);
final next = await KeysDevice.make(seed: 6);
await endpoints.keys.resetPassword(
onPhone,
proof: await dorasKeys.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone),
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(next.password()),
),
password: next.password(),
);
// And SimpleLogin signs her in with the new identity.
final again = await allowed(notSignedIn(), subject: 'sl-dora-new');
expect(
(await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: again.attempt,
code: again.code,
)).session!.authUserId,
dora,
);
});
test('answers a handle nobody has, or one of an account it would not '
'mend, with a decoy of its own, refusing its proof '
'alike', () async {
// Ben: no keys. Carla: keys, but never a SimpleLogin link (nobody
// adds SimpleLogin signed out, D10).
await AccountRegister.of(pod).saveProfile(
session,
ben,
handle: 'Ben',
displayName: '',
bio: '',
links: const [],
);
final carla = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await giveKeys(session, carla, await KeysDevice.make(seed: 8));
await AccountRegister.of(pod).saveProfile(
session,
carla,
handle: 'Carla',
displayName: '',
bio: '',
links: const [],
);
final attempt = await waiting();
for (final handle in ['nobody', 'Ben', 'Carla']) {
final decoy = await beginMend(attempt, handle: handle);
expect(
(await beginMend(attempt, handle: handle)).recoveryKeys.toJson(),
decoy.recoveryKeys.toJson(),
reason: '$handle: the same decoy every time',
);
expect(
decoy.recoveryKeys.cipherText.lengthInBytes,
dorasKeys.recoveryKeys.cipherText.lengthInBytes,
);
await expectLater(
endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: await dorasKeys.provePhrase(
decoy,
digest: mendLinkDigest(attempt),
),
),
refusedAsPassword(PasswordRefusal.wrongCredentials),
reason: handle,
);
}
expect((await identitiesOf(dora)).single.subject, 'sl-dora-old');
});
test('refuses a proof by another key, for another attempt or '
'another instance, the identity still waiting', () async {
final attempt = await waiting();
final other = await waiting(subject: 'sl-someone');
for (final (proof, reason) in [
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
keys: dorasKeys.passwordProofKeys,
),
PasswordRefusal.wrongCredentials,
),
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(other),
),
PasswordRefusal.wrongCredentials,
),
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
instanceUrl: 'https://evil.example/',
claimedUrl: testInstanceUrl,
),
PasswordRefusal.wrongCredentials,
),
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
instanceUrl: 'https://evil.example/',
),
PasswordRefusal.otherInstance,
),
]) {
await expectLater(
endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: proof,
),
refusedAsPassword(reason),
);
}
expect((await identitiesOf(dora)).single.subject, 'sl-dora-old');
await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
),
);
});
test('refuses an attempt nobody new waits under, and a signed-in '
'caller', () async {
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: bens.attempt,
code: bens.code,
);
for (final attempt in [const Uuid().v4obj(), bens.attempt]) {
await expectLater(
beginMend(attempt),
refused(OidcRefusal.unknownAttempt),
);
}
final attempt = await waiting();
final challenge = await beginMend(attempt);
await expectLater(
endpoints.simpleLogin.beginMend(
asAnna(),
attempt: attempt,
handle: 'Dora',
),
refused(OidcRefusal.wrongCaller),
);
final proof = await dorasKeys.provePhrase(
challenge,
digest: mendLinkDigest(attempt),
);
await expectLater(
endpoints.simpleLogin.mend(
asAnna(),
attempt: attempt,
proof: proof,
),
refused(OidcRefusal.wrongCaller),
);
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 31))),
() => endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: proof,
),
),
refused(OidcRefusal.unknownAttempt),
);
// Checked before the proof, which still answers.
await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: proof,
);
});
test('begins at most 10 times an hour for a handle, whatever its '
'case, and 30 from a network', () async {
final attempt = await waiting();
for (var i = 0; i < 10; i++) {
await beginMend(attempt, handle: i.isEven ? 'dora' : 'DORA');
}
await expectLater(
beginMend(attempt),
refusedAsPassword(PasswordRefusal.tooManyAttempts),
);
// One refused counts for neither: the network has 10.
for (var i = 0; i < 20; i++) {
await beginMend(attempt, handle: 'someone$i');
}
await expectLater(
beginMend(attempt, handle: 'someone'),
refusedAsPassword(PasswordRefusal.tooManyAttempts),
);
});
test('takes the key for decoys, without which a server with a '
'SimpleLogin app stops as it starts', () async {
final attempt = await waiting();
checkSimpleLoginMending(pod);
usePasswordKeys(pod, null);
await expectLater(
beginMend(attempt),
refusedAsPassword(PasswordRefusal.unavailable),
);
expect(() => checkSimpleLoginMending(pod), throwsStateError);
useSimpleLoginApp(pod, null);
checkSimpleLoginMending(pod);
});
});
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);
}