feat(app): signing in with SimpleLogin on the device, its data layer (ADR 0020)

SimpleLoginRepository signs in with SimpleLogin in the browser
(OidcBrowser: the app's scheme on Android, iOS and macOS, the app's port
on the loopback on Linux and Windows, D9), then makes the account's keys
and signs someone new up, opens an account's keys with the password, gives
an account of before its first keys, or links a new SimpleLogin account in
place of a lost one with the recovery phrase (D7), which opens the keys on
the device first: a phrase that doesn't fit sends no proof, and no
password is asked after. An anonymous person adding SimpleLogin keeps the
keys their device holds. Nothing is the device's until `enter`.

SimpleLoginGateway calls halkyon_base's `simpleLogin` and `keys`
endpoints (ServerpodSimpleLoginGateway), with a fake for tests, as does
FakeOidcBrowser. The calls made on clients of their own (signed out, or as
a session not yet the device's) move from the password gateway into
BaseCalls, which both share.

Fixes BaseProtocol, the client of halkyon_base alone, which didn't read
halkyon_oidc's classes: every refusal of SimpleLogin's sign-in on a
signed-out call would have reached the app as a failure. OidcBrowser keeps
its loopback private, so that a fake can implement it.

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-24 00:17:04 +02:00
commit e87b156676
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1874 additions and 64 deletions

View file

@ -3,18 +3,21 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'
show OidcRefusal, OidcRefusedException;
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// [refusal] as an instance sends it: named by its module.
String sent(SerializableException refusal) => jsonEncode({
'className':
'halkyon_base.${switch (refusal) {
EmailSignInRefusedException() => 'EmailSignInRefusedException',
GitHubSignInRefusedException() => 'GitHubSignInRefusedException',
PasswordRefusedException() => 'PasswordRefusedException',
_ => throw ArgumentError.value(refusal),
}}',
'className': switch (refusal) {
EmailSignInRefusedException() => 'halkyon_base.EmailSignInRefusedException',
GitHubSignInRefusedException() =>
'halkyon_base.GitHubSignInRefusedException',
PasswordRefusedException() => 'halkyon_base.PasswordRefusedException',
OidcRefusedException() => 'halkyon_oidc.OidcRefusedException',
_ => throw ArgumentError.value(refusal),
},
'data': (refusal as SerializableModel).toJson(),
});
@ -51,6 +54,20 @@ void main() {
);
});
test("reads OpenID Connect's refusals under its own module's name, which "
"halkyon_base's SimpleLogin passes on", () {
expect(
BaseProtocol().decodeWithType(
sent(OidcRefusedException(reason: OidcRefusal.unknownAttempt)),
),
isA<OidcRefusedException>().having(
(refusal) => refusal.reason,
'reason',
OidcRefusal.unknownAttempt,
),
);
});
test("calls halkyon_base's endpoints at the instance, and passes its "
'refusals on as themselves', () async {
final asked = <String>[];

View file

@ -0,0 +1,260 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'
show OidcRefusal, OidcRefusedException, OidcStart;
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// A session kept in memory, as no test reaches the system's storage.
final class _MemoryStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async => _session = data;
}
/// An instance's halkyon_base as the network shows it: a sign-in begun is
/// [attempt], and it ends with someone new; or each call refuses as
/// [refusals] says, by its endpoint and method.
final class _Instance() {
static final attempt = UuidValue.withValidation(
'0191f0c4-4444-7000-8000-000000000004',
);
/// What each call refuses with, by its endpoint and method.
final refusals = <String, Object>{};
/// Each call, by its endpoint and method, with the session it carried.
final calls = <({String call, String? session})>[];
http.Client client() => MockClient((request) async {
final call = request.url.path.substring(1);
calls.add((call: call, session: request.headers['authorization']));
if (refusals[call] case final refusal?) {
return switch (refusal) {
final int status => http.Response('', status),
// As the instance names it: by its module.
final SerializableModel model => http.Response(
jsonEncode({
'className': switch (refusal) {
OidcRefusedException() => 'halkyon_oidc.OidcRefusedException',
ProfileRefusedException() =>
'halkyon_base.ProfileRefusedException',
PasswordRefusedException() =>
'halkyon_base.PasswordRefusedException',
_ => throw ArgumentError.value(refusal),
},
'data': model.toJson(),
}),
400,
),
_ => throw ArgumentError.value(refusal),
};
}
String sent(Object? body) => jsonEncode(body);
return switch (call) {
'halkyon_base.simpleLogin/begin' => http.Response(
sent(
OidcStart(
attempt: attempt,
authorizationUrl: Uri.parse(
'https://app.simplelogin.io/oauth2/authorize?state=s',
),
state: 's',
).toJson(),
),
200,
),
'halkyon_base.simpleLogin/complete' => http.Response(
sent(
SimpleLoginCompleted(
outcome: SimpleLoginOutcome.newcomer,
suggestedHandle: 'anna_mueller',
hasKeys: false,
).toJson(),
),
200,
),
'halkyon_base.simpleLogin/checkHandle' => http.Response(
sent(ProfileRefusal.handleTaken.toJson()),
200,
),
_ => http.Response('', 404),
};
});
}
Matcher failsWith(SimpleLoginProblem problem) => throwsA(
isA<SimpleLoginFailed>().having(
(failed) => failed.problem,
'problem',
problem,
),
);
void main() {
late _Instance instance;
late BaseClient app;
late ServerpodSimpleLoginGateway gateway;
setUp(() async {
instance = _Instance();
app =
BaseClient(
'https://learn.example.org/',
httpClientOverride: instance.client(),
)
..authSessionManager = FlutterAuthSessionManager(
storage: _MemoryStorage(),
);
await app.auth.initialize();
await app.auth.updateSignedInUser(
AuthSuccess(
authStrategy: 'session',
token: 'device-token',
authUserId: UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001',
),
scopeNames: {},
),
);
gateway = ServerpodSimpleLoginGateway(
app,
base: app.modules,
httpClient: instance.client,
);
addTearDown(app.close);
});
String? sessionOf(String call) =>
instance.calls.singleWhere((each) => each.call == call).session;
test("begins and ends a sign-in without the device's session, which "
'stays signed in as it was', () async {
final start = await gateway.begin(SimpleLoginReturn.scheme);
final arrival = await gateway.complete(start.attempt, 'code');
expect(
arrival,
SimpleLoginNewcomer(
attempt: _Instance.attempt,
suggestedHandle: 'anna_mueller',
),
);
expect(sessionOf('halkyon_base.simpleLogin/begin'), isNull);
expect(sessionOf('halkyon_base.simpleLogin/complete'), isNull);
});
test("links SimpleLogin to the device's person on their session", () async {
final start = await gateway.begin(SimpleLoginReturn.scheme, link: true);
await gateway.complete(start.attempt, 'code', link: true);
expect(
sessionOf('halkyon_base.simpleLogin/begin'),
contains('device-token'),
);
expect(
sessionOf('halkyon_base.simpleLogin/complete'),
contains('device-token'),
);
});
test("opens an account's keys on its own session, not yet the device's, "
"and a wrong password is the keys' refusal", () async {
instance.refusals['halkyon_base.keys/unlock'] = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
);
final grant = SessionGrant(
'0191f0c4-2222-7000-8000-000000000002',
AuthSuccess(
authStrategy: 'session',
token: 'anna-token',
authUserId: UuidValue.withValidation(
'0191f0c4-2222-7000-8000-000000000002',
),
scopeNames: {'identified'},
),
);
await expectLater(
gateway.unlock(
KeysProof(
challenge: _Instance.attempt,
signature: ByteData(64),
instanceUrl: gateway.instanceUrl,
),
as: grant,
),
throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
PasswordProblem.wrongCredentials,
),
),
);
expect(sessionOf('halkyon_base.keys/unlock'), contains('anna-token'));
});
test("puts the instance's refusals of SimpleLogin as the app's problems", () {
expect(
{
for (final reason in OidcRefusal.values)
reason: simpleLoginProblemOf(reason),
},
{
OidcRefusal.unknownProvider: SimpleLoginProblem.unavailable,
OidcRefusal.unknownAttempt: SimpleLoginProblem.startAgain,
OidcRefusal.wrongCaller: SimpleLoginProblem.startAgain,
OidcRefusal.rejected: SimpleLoginProblem.rejected,
OidcRefusal.providerUnavailable: SimpleLoginProblem.providerUnavailable,
OidcRefusal.takenByAnotherPerson:
SimpleLoginProblem.takenByAnotherPerson,
OidcRefusal.otherIdentityLinked: SimpleLoginProblem.otherIdentityLinked,
OidcRefusal.tooManyAttempts: SimpleLoginProblem.tooManyAttempts,
},
);
});
test('a sign-in whose time ran out begins again', () async {
instance.refusals['halkyon_base.simpleLogin/complete'] =
OidcRefusedException(reason: OidcRefusal.unknownAttempt);
await expectLater(
gateway.complete(_Instance.attempt, 'code'),
failsWith(SimpleLoginProblem.startAgain),
);
});
test('says why a handle is not to be had, too many tries among the '
'answers', () async {
expect(
await gateway.checkHandle(_Instance.attempt, 'anna'),
SimpleLoginProblem.handleTaken,
);
instance.refusals['halkyon_base.simpleLogin/checkHandle'] =
ProfileRefusedException(reason: ProfileRefusal.tooManyHandleChecks);
expect(
await gateway.checkHandle(_Instance.attempt, 'anna'),
SimpleLoginProblem.tooManyHandleChecks,
);
});
test("fails where the instance can't be reached or fails", () async {
instance.refusals['halkyon_base.simpleLogin/begin'] = 500;
await expectLater(
gateway.begin(SimpleLoginReturn.loopback),
failsWith(SimpleLoginProblem.failed),
);
});
}

View file

@ -0,0 +1,439 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
Matcher failsWith(SimpleLoginProblem problem) => throwsA(
isA<SimpleLoginFailed>().having(
(failed) => failed.problem,
'problem',
problem,
),
);
Matcher keysFailWith(PasswordProblem problem) => throwsA(
isA<PasswordFailed>().having((failed) => failed.problem, 'problem', problem),
);
const password = 'correct horse battery';
void main() {
late FakeAuthGateway auth;
late FakeSimpleLoginGateway gateway;
late FakeOidcBrowser browser;
late FakeAccountCrypto crypto;
late MemoryAccountKeyStore keys;
SimpleLoginRepository repositoryOn(TargetPlatform platform) =>
SimpleLoginRepository(
gateway,
browser,
crypto,
keys,
auth,
callbackScheme: 'app.halkyon.test',
loopbackPort: 43823,
platform: platform,
);
late SimpleLoginRepository simpleLogin;
setUp(() {
auth = FakeAuthGateway();
gateway = FakeSimpleLoginGateway(auth);
browser = FakeOidcBrowser();
crypto = FakeAccountCrypto();
keys = MemoryAccountKeyStore();
simpleLogin = repositoryOn(TargetPlatform.android);
});
group('the way back (D9)', () {
for (final platform in [
TargetPlatform.android,
TargetPlatform.iOS,
TargetPlatform.macOS,
]) {
test("on ${platform.name}, by the app's scheme", () async {
await repositoryOn(platform).signIn();
expect(gateway.begunBack, [protocol.SimpleLoginReturn.scheme]);
expect(
browser.opened.single.back,
isA<OidcSchemeReturn>().having(
(back) => back.scheme,
'scheme',
'app.halkyon.test',
),
);
});
}
for (final platform in [TargetPlatform.linux, TargetPlatform.windows]) {
test("on ${platform.name}, by the loopback at the app's port", () async {
await repositoryOn(platform).signIn();
expect(gateway.begunBack, [protocol.SimpleLoginReturn.loopback]);
expect(
browser.opened.single.back,
isA<OidcLoopbackReturn>().having(
(back) => back.address,
'address',
'http://127.0.0.1:43823/simplelogin-callback',
),
);
});
}
test('opens the page the instance began the sign-in with', () async {
await simpleLogin.signIn();
final opened = browser.opened.single.start;
expect(opened.authorizationUrl.queryParameters['state'], opened.state);
expect(opened.state, startsWith('test-scheme.'));
});
});
group('the browser', () {
test(
'closed, nothing signs in and the instance hears nothing more',
() async {
browser.comesBack = () => null;
expect(await simpleLogin.signIn(), isNull);
expect(gateway.calls, ['begin']);
},
);
test(
"refused on SimpleLogin's page, the sign-in fails as denied",
() async {
browser.comesBack = () => const OidcDenied('access_denied');
await expectLater(
simpleLogin.signIn(),
failsWith(SimpleLoginProblem.denied),
);
expect(gateway.calls, ['begin']);
},
);
test('back for another sign-in, it begins again', () async {
browser.comesBack = () =>
throw const OidcCallbackException(OidcCallbackProblem.wrongState);
await expectLater(
simpleLogin.signIn(),
failsWith(SimpleLoginProblem.startAgain),
);
expect(gateway.calls, ['begin']);
});
test('missing, the sign-in says so', () async {
browser.comesBack = () =>
throw const OidcCallbackException(OidcCallbackProblem.noBrowser);
await expectLater(
simpleLogin.signIn(),
failsWith(SimpleLoginProblem.noBrowser),
);
});
test('back with neither a code nor a refusal, the sign-in fails', () async {
browser.comesBack = () =>
throw const OidcCallbackException(OidcCallbackProblem.malformed);
await expectLater(
simpleLogin.signIn(),
failsWith(SimpleLoginProblem.failed),
);
});
test('is no longer waited for once cancelled', () async {
await simpleLogin.cancel();
expect(browser.cancelled, 1);
});
});
group('an instance without SimpleLogin', () {
setUp(() => gateway.isOffered = false);
test('offers none, and opens no browser for it', () async {
expect(await simpleLogin.offered(), isFalse);
await expectLater(
simpleLogin.signIn(),
failsWith(SimpleLoginProblem.unavailable),
);
expect(browser.opened, isEmpty);
});
});
group('someone new', () {
late SimpleLoginNewcomer newcomer;
setUp(() async {
newcomer = (await simpleLogin.signIn())! as SimpleLoginNewcomer;
});
test('is suggested a handle by the name SimpleLogin shared', () {
expect(newcomer.suggestedHandle, 'anna_mueller');
});
test('signs up with a handle and new keys: once entered, the session and '
"the keys are the device's", () async {
final made = await simpleLogin.makeKeys(password);
final pending = await simpleLogin.signUp(
newcomer,
handle: 'anna_mueller',
made: made,
);
expect(auth.status, const SignedOut());
expect(keys.kept, isEmpty);
await simpleLogin.enter(pending);
final person = pending.grant!.authUserId;
expect(auth.usedSessions, [pending.grant]);
expect(keys.kept, {person: made.keys});
expect(gateway.accounts[person]!.handle, 'anna_mueller');
expect(gateway.accounts[person]!.keys, made.setup);
expect(gateway.linked['simplelogin-anna'], person);
});
test("learns which handles can't be had, and why", () async {
gateway.addAccount('person-9', subject: 'other', handle: 'taken_one');
expect(await simpleLogin.checkHandle(newcomer, 'anna_mueller'), isNull);
expect(
await simpleLogin.checkHandle(newcomer, 'taken_one'),
SimpleLoginProblem.handleTaken,
);
expect(
await simpleLogin.checkHandle(newcomer, 'admin'),
SimpleLoginProblem.handleReserved,
);
expect(
await simpleLogin.checkHandle(newcomer, 'A'),
SimpleLoginProblem.handleInvalid,
);
});
test('is refused a password too short on the device, before anything '
'reaches the instance', () async {
await expectLater(
simpleLogin.makeKeys('too short'),
keysFailWith(PasswordProblem.passwordTooShort),
);
expect(gateway.calls, ['begin', 'complete']);
});
test('is refused a handle someone took meanwhile, and nothing is the '
"device's", () async {
final made = await simpleLogin.makeKeys(password);
gateway.addAccount('person-9', subject: 'other', handle: 'anna_mueller');
await expectLater(
simpleLogin.signUp(newcomer, handle: 'anna_mueller', made: made),
failsWith(SimpleLoginProblem.handleTaken),
);
expect(auth.status, const SignedOut());
expect(keys.kept, isEmpty);
});
});
group('an account with its keys', () {
late NewKeysMaterial material;
late SimpleLoginAccount account;
setUp(() async {
material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
account = (await simpleLogin.signIn())! as SimpleLoginAccount;
});
test('opens them with the password: once entered, the session and the '
"keys are the device's", () async {
expect(account.hasKeys, isTrue);
expect(account.handle, 'anna');
final pending = await simpleLogin.unlock(account, password);
expect(auth.status, const SignedOut());
await simpleLogin.enter(pending);
expect(auth.usedSessions, [account.grant]);
expect(
auth.status,
const SignedIn(authUserId: 'person-9', isIdentified: true),
);
expect(keys.kept, {'person-9': material.keys});
});
test('forgets what the password unlocked once the keys are open', () async {
await simpleLogin.unlock(account, password);
expect(crypto.unlocks.single.isForgotten, isTrue);
});
test('opens nothing with a wrong password, and no session is the '
"device's", () async {
await expectLater(
simpleLogin.unlock(account, 'wrong horse battery'),
keysFailWith(PasswordProblem.wrongCredentials),
);
expect(auth.usedSessions, isEmpty);
expect(auth.status, const SignedOut());
expect(keys.kept, isEmpty);
expect(crypto.unlocks.single.isForgotten, isTrue);
});
});
group('an account of before, without keys', () {
late SimpleLoginAccount account;
setUp(() async {
gateway.addAccount('person-9', subject: 'simplelogin-anna');
account = (await simpleLogin.signIn())! as SimpleLoginAccount;
});
test('makes new ones, with the handle it lacks', () async {
expect(account.hasKeys, isFalse);
expect(account.handle, isNull);
final made = await simpleLogin.makeKeys(password, into: account);
final pending = await simpleLogin.setFirstKeys(
account,
made: made,
handle: 'anna',
);
await simpleLogin.enter(pending);
expect(gateway.accounts['person-9']!.keys, made.setup);
expect(gateway.accounts['person-9']!.handle, 'anna');
expect(auth.usedSessions, [account.grant]);
expect(keys.kept, {'person-9': made.keys});
});
test('keeps no keys the device holds for someone else', () async {
await auth.signInAnonymously();
keys.kept['anonymous-1'] = crypto.keysNumbered(1);
final made = await simpleLogin.makeKeys(password, into: account);
expect(made.keys, isNot(crypto.keysNumbered(1)));
});
});
group('a lost SimpleLogin account, linked again by the recovery phrase '
'(D7)', () {
late NewKeysMaterial material;
late SimpleLoginNewcomer newcomer;
setUp(() async {
material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-lost',
handle: 'anna',
keys: material.setup,
);
browser.subject = 'simplelogin-new';
newcomer = (await simpleLogin.signIn())! as SimpleLoginNewcomer;
});
test('takes the new one in place of the lost one; the phrase opens the '
'keys, so no password is asked', () async {
final pending = await simpleLogin.mend(
newcomer,
handle: 'anna',
phrase: fakePhrase,
);
await simpleLogin.enter(pending);
expect(gateway.linked, {'simplelogin-new': 'person-9'});
expect(auth.usedSessions, [pending.grant]);
expect(pending.grant!.authUserId, 'person-9');
expect(keys.kept, {'person-9': material.keys});
});
test("refuses a phrase that doesn't open the keys on the device: the "
'instance gets no proof to count', () async {
await expectLater(
simpleLogin.mend(newcomer, handle: 'anna', phrase: otherFakePhrase),
keysFailWith(PasswordProblem.phraseDoesNotFit),
);
expect(gateway.calls, isNot(contains('mend')));
expect(gateway.linked, {'simplelogin-lost': 'person-9'});
});
test('refuses a handle nobody has alike', () async {
await expectLater(
simpleLogin.mend(newcomer, handle: 'nobody', phrase: fakePhrase),
keysFailWith(PasswordProblem.phraseDoesNotFit),
);
expect(gateway.calls, isNot(contains('mend')));
});
test('refuses what is no recovery phrase', () async {
await expectLater(
simpleLogin.mend(newcomer, handle: 'anna', phrase: 'not a phrase'),
keysFailWith(PasswordProblem.notAPhrase),
);
expect(gateway.calls, isNot(contains('mend')));
});
});
group("SimpleLogin added to the device's person's account", () {
setUp(() async {
await auth.signInAnonymously();
});
test('an anonymous person keeps the keys their device holds, which '
'encrypt their private studysets (ADR 0017, 9)', () async {
final deviceKeys = crypto.keysNumbered(1);
keys.kept['anonymous-1'] = deviceKeys;
final account =
(await simpleLogin.signIn(link: true))! as SimpleLoginAccount;
expect(account.grant, isNull);
expect(account.hasKeys, isFalse);
final made = await simpleLogin.makeKeys(password, into: account);
expect(made.keys, deviceKeys);
final pending = await simpleLogin.setFirstKeys(
account,
made: made,
handle: 'anna',
);
await simpleLogin.enter(pending);
expect(auth.usedSessions, isEmpty);
expect(
auth.status,
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
);
expect(keys.kept, {'anonymous-1': deviceKeys});
expect(gateway.linked, {'simplelogin-anna': 'anonymous-1'});
expect(gateway.accounts['anonymous-1']!.keys, made.setup);
});
test("is refused a SimpleLogin account that is someone else's", () async {
gateway.addAccount('person-9', subject: 'simplelogin-anna');
await expectLater(
simpleLogin.signIn(link: true),
failsWith(SimpleLoginProblem.takenByAnotherPerson),
);
expect(gateway.linked, {'simplelogin-anna': 'person-9'});
});
});
}