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
17 changed files with 1874 additions and 64 deletions

View file

@ -92,6 +92,11 @@ export 'src/password/widget/password_widgets.dart';
export 'src/password/widget/secret_screen.dart';
export 'src/person/person_folders.dart';
export 'src/privacy/content_cipher.dart';
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';
export 'src/simplelogin/data/simplelogin_gateway.dart';
export 'src/simplelogin/data/simplelogin_repository.dart';
export 'src/simplelogin/model/simplelogin_arrival.dart';
export 'src/simplelogin/model/simplelogin_failed.dart';
export 'src/sync/app_compatibility.dart';
export 'src/sync/bloc/sync_status_bloc.dart';
export 'src/sync/pending_changes_store.dart';

View file

@ -0,0 +1,71 @@
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:http/http.dart' as http;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// The calls a gateway makes on an instance's halkyon_base for someone
/// other than the device's person: without a session, through a client of
/// halkyon_base holding none (Serverpod issues no session for another
/// person inside a signed-in call), and with a session granted but not yet
/// the device's, through a client holding that one. Each client is the
/// call's own, closed after.
final class BaseCalls(
final ServerpodClientShared _client, {
/// The module's endpoints on the app's client, as the device's person.
required final protocol.Caller _base,
/// What the clients it makes for itself send through; tests give a fake.
final http.Client Function()? _httpClient,
}) {
/// What [call] gives on a client of halkyon_base without a session.
Future<T> signedOut<T>(Future<T> Function(BaseClient base) call) async {
final client = BaseClient(
_client.host,
httpClientOverride: _httpClient?.call(),
);
try {
return await call(client);
} finally {
client.close();
}
}
/// What [call] gives on the device's session, or on [grant]'s, kept in
/// memory by a client of its own.
Future<T> asGrant<T>(
SessionGrant? grant,
Future<T> Function(protocol.Caller base) call,
) async {
if (grant == null) {
return await call(_base);
}
final sessionManager = FlutterAuthSessionManager(
storage: _MemorySessionStorage(),
);
final client = BaseClient(
_client.host,
httpClientOverride: _httpClient?.call(),
)..authSessionManager = sessionManager;
try {
await sessionManager.updateSignedInUser(grant.session as AuthSuccess);
return await call(client.modules);
} finally {
client.close();
}
}
}
/// Keeps one session in memory, for a client used once.
final class _MemorySessionStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async {
_session = data;
}
}

View file

@ -1,4 +1,5 @@
import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as oidc;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as core;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
@ -38,12 +39,15 @@ class BaseClient(String host, {super.httpClientOverride})
}
/// How a [BaseClient] reads what the instance sends: halkyon_base's models,
/// and those of the auth modules beneath it. The instance names each by its
/// module (`halkyon_base.EmailSignInRefusedException`), as an app's own
/// generated protocol reads them.
/// and those of the modules beneath it (OpenID Connect's, whose refusals
/// SimpleLogin's sign-in passes on, and the auth modules'). The instance
/// names each by its module (`halkyon_base.EmailSignInRefusedException`,
/// `halkyon_oidc.OidcRefusedException`), as an app's own generated protocol
/// reads them; halkyon_base's own generated protocol reads only its own.
class BaseProtocol() extends SerializationManager {
static const _modules = {
'halkyon_base.': base_client.Protocol.new,
'halkyon_oidc.': oidc.Protocol.new,
'serverpod_auth_idp.': idp.Protocol.new,
'serverpod_auth_core.': core.Protocol.new,
};

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/base/base_calls.dart';
import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart';
@ -15,10 +16,7 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// [PasswordGateway] on an app's Serverpod client: the signed-in person's
/// steps through the module's endpoints on that client ([_base]), the
/// steps without a session through a client of halkyon_base holding none
/// (Serverpod issues no session for another person inside a signed-in
/// call), and those with a session granted but not yet the device's through
/// a client holding that one.
/// others on clients of their own ([BaseCalls]).
class ServerpodPasswordGateway(
final ServerpodClientShared _client, {
required final protocol.Caller _base,
@ -26,6 +24,8 @@ class ServerpodPasswordGateway(
/// What the clients it makes for itself send through; tests give a fake.
final http.Client Function()? _httpClient,
}) implements PasswordGateway {
late final _calls = BaseCalls(_client, base: _base, httpClient: _httpClient);
@override
String get instanceUrl => srp.canonicalInstanceUrl(_client.host);
@ -368,41 +368,13 @@ class ServerpodPasswordGateway(
/// What [call] gives on a client of halkyon_base without a session.
Future<T> _signedOut<T>(Future<T> Function(BaseClient base) call) =>
_failing(() async {
final client = BaseClient(
_client.host,
httpClientOverride: _httpClient?.call(),
);
try {
return await call(client);
} finally {
client.close();
}
});
_failing(() => _calls.signedOut(call));
/// What [call] gives on the device's session, or on [grant]'s, kept in
/// memory by a client of its own.
/// What [call] gives on the device's session, or on [grant]'s.
Future<T> _as<T>(
SessionGrant? grant,
Future<T> Function(protocol.Caller base) call,
) => _failing(() async {
if (grant == null) {
return await call(_base);
}
final sessionManager = FlutterAuthSessionManager(
storage: _MemorySessionStorage(),
);
final client = BaseClient(
_client.host,
httpClientOverride: _httpClient?.call(),
)..authSessionManager = sessionManager;
try {
await sessionManager.updateSignedInUser(grant.session as AuthSuccess);
return await call(client.modules);
} finally {
client.close();
}
});
) => _failing(() => _calls.asGrant(grant, call));
}
/// What [call] gives; the instance's refusals, and failing to reach it, as
@ -414,7 +386,7 @@ Future<T> _failing<T>(Future<T> Function() call) async {
rethrow;
} on protocol.PasswordRefusedException catch (refusal) {
throw PasswordFailed(
_problemOf(refusal.reason),
passwordProblemOf(refusal.reason),
takesEffectAt: refusal.takesEffectAt,
);
} on protocol.EmailSignInRefusedException catch (refusal) {
@ -438,7 +410,11 @@ Future<T> _failing<T>(Future<T> Function() call) async {
}
}
PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
/// What the instance's [reason] means for the person, wherever a password's
/// keys refuse (`PasswordRefusedException`).
PasswordProblem passwordProblemOf(
protocol.PasswordRefusal reason,
) => switch (reason) {
protocol.PasswordRefusal.unavailable => PasswordProblem.unavailable,
protocol.PasswordRefusal.wrongCredentials => PasswordProblem.wrongCredentials,
protocol.PasswordRefusal.tooManyAttempts => PasswordProblem.tooManyAttempts,
@ -467,16 +443,3 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
protocol.PasswordRefusal.signedInBefore => PasswordProblem.signedInBefore,
};
/// Keeps one session in memory, for a client used once.
final class _MemorySessionStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async {
_session = data;
}
}

View file

@ -0,0 +1,229 @@
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/base/base_calls.dart';
import 'package:halkyon_app/src/password/data/serverpod_password_gateway.dart'
show passwordProblemOf;
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/simplelogin/data/simplelogin_gateway.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_base_client/srp.dart' as srp;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'
show OidcRefusal, OidcRefusedException, OidcStart;
import 'package:http/http.dart' as http;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// [SimpleLoginGateway] on an app's Serverpod client: the device's person's
/// steps through the module's endpoints on that client ([_base]), the
/// others on clients of their own ([BaseCalls]).
class ServerpodSimpleLoginGateway(
final ServerpodClientShared _client, {
required final protocol.Caller _base,
/// What the clients it makes for itself send through; tests give a fake.
final http.Client Function()? _httpClient,
}) implements SimpleLoginGateway {
late final _calls = BaseCalls(_client, base: _base, httpClient: _httpClient);
@override
String get instanceUrl => srp.canonicalInstanceUrl(_client.host);
@override
Future<bool> offered() =>
_failing(() async => (await _base.instance.basics()).simpleLogin);
@override
Future<OidcStart> begin(
protocol.SimpleLoginReturn back, {
bool link = false,
}) => _failing(
() => link
? _base.simpleLogin.begin(back: back)
: _calls.signedOut(
(client) => client.modules.simpleLogin.begin(back: back),
),
);
@override
Future<SimpleLoginArrival> complete(
protocol.UuidValue attempt,
String code, {
bool link = false,
}) => _failing(() async {
final completed = link
? await _base.simpleLogin.complete(attempt: attempt, code: code)
: await _calls.signedOut(
(client) => client.modules.simpleLogin.complete(
attempt: attempt,
code: code,
),
);
return _arrivalOf(attempt, completed);
});
@override
Future<SimpleLoginProblem?> checkHandle(
protocol.UuidValue attempt,
String handle,
) async {
try {
final refusal = await _failing(
() => _calls.signedOut(
(client) => client.modules.simpleLogin.checkHandle(
attempt: attempt,
handle: handle,
),
),
);
return refusal == null ? null : _handleProblemOf(refusal);
} on SimpleLoginFailed catch (failed) {
// Too many tries are an answer to show at the handle, as its others.
if (failed.problem == SimpleLoginProblem.tooManyHandleChecks) {
return failed.problem;
}
rethrow;
}
}
@override
Future<SessionGrant> signUp(
protocol.UuidValue attempt,
String handle,
protocol.KeysSetup setup,
) => _failing(
() async => _grantOf(
await _calls.signedOut(
(client) => client.modules.simpleLogin.signUp(
attempt: attempt,
handle: handle,
setup: setup,
),
),
),
);
@override
Future<protocol.KeyProofChallenge> beginMend(
protocol.UuidValue attempt,
String handle,
) => _failing(
() => _calls.signedOut(
(client) => client.modules.simpleLogin.beginMend(
attempt: attempt,
handle: handle,
),
),
);
@override
Future<SimpleLoginAccount> mend(
protocol.UuidValue attempt,
protocol.KeysProof proof,
) => _failing(() async {
final completed = await _calls.signedOut(
(client) =>
client.modules.simpleLogin.mend(attempt: attempt, proof: proof),
);
return _arrivalOf(attempt, completed) as SimpleLoginAccount;
});
@override
Future<protocol.KeysChallenge> beginUnlock({SessionGrant? as}) =>
_failing(() => _calls.asGrant(as, (base) => base.keys.begin()));
@override
Future<protocol.DeviceKeys> unlock(
protocol.KeysProof proof, {
SessionGrant? as,
}) => _failing(
() => _calls.asGrant(as, (base) => base.keys.unlock(proof)),
);
@override
Future<void> setKeys(
protocol.UuidValue attempt,
protocol.KeysSetup setup, {
String? handle,
SessionGrant? as,
}) => _failing(
() => _calls.asGrant(
as,
(base) => base.keys.set(attempt: attempt, setup: setup, handle: handle),
),
);
static SessionGrant _grantOf(AuthSuccess session) =>
SessionGrant(session.authUserId.uuid, session);
static SimpleLoginArrival _arrivalOf(
protocol.UuidValue attempt,
protocol.SimpleLoginCompleted completed,
) => switch (completed.outcome) {
protocol.SimpleLoginOutcome.newcomer => SimpleLoginNewcomer(
attempt: attempt,
suggestedHandle: completed.suggestedHandle,
),
protocol.SimpleLoginOutcome.signedIn ||
protocol.SimpleLoginOutcome.linked => SimpleLoginAccount(
attempt: attempt,
grant: switch (completed.session) {
final AuthSuccess session => _grantOf(session),
null => null,
},
hasKeys: completed.hasKeys,
handle: completed.handle,
),
};
}
/// What [call] gives; the instance's refusals as [SimpleLoginFailed], or
/// as [PasswordFailed] for the keys', and failing to reach it as
/// [SimpleLoginProblem.failed].
Future<T> _failing<T>(Future<T> Function() call) async {
try {
return await call();
} on SimpleLoginFailed {
rethrow;
} on PasswordFailed {
rethrow;
} on OidcRefusedException catch (refusal) {
throw SimpleLoginFailed(simpleLoginProblemOf(refusal.reason));
} on protocol.ProfileRefusedException catch (refusal) {
throw SimpleLoginFailed(_handleProblemOf(refusal.reason));
} on protocol.PasswordRefusedException catch (refusal) {
throw PasswordFailed(
passwordProblemOf(refusal.reason),
takesEffectAt: refusal.takesEffectAt,
);
} on Exception {
// No connection, the instance's own error, a timeout.
throw const SimpleLoginFailed(SimpleLoginProblem.failed);
}
}
/// What the instance's [reason] means for the person.
SimpleLoginProblem simpleLoginProblemOf(OidcRefusal reason) => switch (reason) {
OidcRefusal.unknownProvider => SimpleLoginProblem.unavailable,
// Nobody began it, it ended, or its time ran out.
OidcRefusal.unknownAttempt => SimpleLoginProblem.startAgain,
// Begun signed in and ended signed out, or the other way round: the
// person signed in or out meanwhile.
OidcRefusal.wrongCaller => SimpleLoginProblem.startAgain,
OidcRefusal.rejected => SimpleLoginProblem.rejected,
OidcRefusal.providerUnavailable => SimpleLoginProblem.providerUnavailable,
OidcRefusal.takenByAnotherPerson => SimpleLoginProblem.takenByAnotherPerson,
OidcRefusal.otherIdentityLinked => SimpleLoginProblem.otherIdentityLinked,
OidcRefusal.tooManyAttempts => SimpleLoginProblem.tooManyAttempts,
};
SimpleLoginProblem _handleProblemOf(protocol.ProfileRefusal reason) =>
switch (reason) {
protocol.ProfileRefusal.handleInvalid => SimpleLoginProblem.handleInvalid,
protocol.ProfileRefusal.handleReserved =>
SimpleLoginProblem.handleReserved,
protocol.ProfileRefusal.handleTaken => SimpleLoginProblem.handleTaken,
protocol.ProfileRefusal.tooManyHandleChecks =>
SimpleLoginProblem.tooManyHandleChecks,
// None a sign-up's handle meets.
_ => SimpleLoginProblem.failed,
};

View file

@ -0,0 +1,88 @@
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart' show OidcStart;
/// An instance's sign-in with SimpleLogin (ADR 0020) and the keys of the
/// accounts that sign in with it (D19), behind an interface: the module's
/// `simpleLogin` and `keys` endpoints in the app, a fake in tests. Signed
/// out unless `link` says otherwise; a grant not yet the device's where
/// `as` names one. The instance's refusals are `SimpleLoginFailed`, and
/// the keys' `PasswordFailed`, as a password's sign-in has them.
abstract interface class SimpleLoginGateway() {
/// The instance's base URL as the device addresses it, which the proofs
/// are bound to.
String get instanceUrl;
/// Whether the instance offers SimpleLogin.
Future<bool> offered();
/// Begins a sign-in that comes [back] by the loopback or the app's scheme:
/// the attempt, SimpleLogin's page and the state. With [link], as the
/// device's person, to add SimpleLogin to their account.
Future<OidcStart> begin(
protocol.SimpleLoginReturn back, {
bool link = false,
});
/// Ends [attempt] with SimpleLogin's [code], begun alike ([link]).
Future<SimpleLoginArrival> complete(
protocol.UuidValue attempt,
String code, {
bool link = false,
});
/// Whether someone new, whose identity waits under [attempt], could take
/// [handle]: null where they could, else why not (one of the handle's
/// problems, `SimpleLoginProblem.tooManyHandleChecks` among them).
Future<SimpleLoginProblem?> checkHandle(
protocol.UuidValue attempt,
String handle,
);
/// Signs up someone new under [attempt], with [handle] and the keys
/// [setup] describes: a session for the new account.
Future<SessionGrant> signUp(
protocol.UuidValue attempt,
String handle,
protocol.KeysSetup setup,
);
/// The first step of linking a SimpleLogin identity waiting under
/// [attempt] to the account with [handle], in place of a lost one (D7):
/// the keys wrapped under the account's recovery phrase, and a nonce to
/// sign with its proof key.
Future<protocol.KeyProofChallenge> beginMend(
protocol.UuidValue attempt,
String handle,
);
/// Links the identity under [attempt] with the phrase's [proof]: every
/// other session of the account ends, and this one signs in.
Future<SimpleLoginAccount> mend(
protocol.UuidValue attempt,
protocol.KeysProof proof,
);
/// The first step of opening an account's keys: a nonce to sign with the
/// password's proof key, and the salt and costs it derives with.
Future<protocol.KeysChallenge> beginUnlock({SessionGrant? as});
/// The account's keys, wrapped under the password, once [proof] proved
/// it.
Future<protocol.DeviceKeys> unlock(
protocol.KeysProof proof, {
SessionGrant? as,
});
/// Gives an account without keys the keys [setup] describes, and
/// [handle] where it has none, with its SimpleLogin sign-in under
/// [attempt] of the last ten minutes.
Future<void> setKeys(
protocol.UuidValue attempt,
protocol.KeysSetup setup, {
String? handle,
SessionGrant? as,
});
}

View file

@ -0,0 +1,239 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/account_key_store.dart';
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
import 'package:halkyon_app/src/simplelogin/data/simplelogin_gateway.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
/// Where SimpleLogin's forwarding page sends the browser back to, on the
/// loopback ([SimpleLoginRepository]'s port) or by the app's scheme.
const simpleLoginCallbackPath = '/simplelogin-callback';
/// A sign-in with SimpleLogin that is through, with the account's keys
/// open, not yet the device's ([SimpleLoginRepository.enter]): [grant] the
/// session for its account, or null where SimpleLogin was added to the
/// device's person's.
final class const PendingSimpleLogin({
required final SessionGrant? grant,
required final UnlockedAccountKeys keys,
}) {
/// Never the keys.
@override
String toString() => 'PendingSimpleLogin(${grant?.authUserId})';
}
/// Signing in with SimpleLogin on the device (ADR 0020): SimpleLogin's page
/// in the browser and the way back ([OidcBrowser]), then the account's keys
/// made, opened with the password, or opened with the recovery phrase to
/// link a new SimpleLogin account in place of a lost one, and the session
/// put in place with them. Refusals are `SimpleLoginFailed`s, the keys'
/// `PasswordFailed`s.
class SimpleLoginRepository(
final SimpleLoginGateway _gateway,
final OidcBrowser _browser,
final AccountCrypto _crypto,
final AccountKeyStore _keys,
final AuthGateway _auth, {
/// The app's scheme, which the browser comes back by on Android, iOS and
/// macOS (`app.halkyon.learn`).
required final String callbackScheme,
/// The app's own port on the loopback, which the browser comes back to on
/// Linux and Windows, as the instance's forwarding page has it (Learn's
/// 43823, Todo's 43824).
required final int loopbackPort,
/// The platform the app runs on; tests name one.
final TargetPlatform? _platform,
}) {
/// Whether the instance offers SimpleLogin.
Future<bool> offered() => _gateway.offered();
/// Signs in with SimpleLogin in the browser: someone new, or an account
/// signed in to; with [link], SimpleLogin added to the device's person's
/// account. Null where the person closed the browser, or [cancel] ended
/// the wait.
Future<SimpleLoginArrival?> signIn({bool link = false}) async {
final loopback = switch (_platform ?? defaultTargetPlatform) {
TargetPlatform.linux || TargetPlatform.windows => true,
_ => false,
};
final start = await _gateway.begin(
loopback
? protocol.SimpleLoginReturn.loopback
: protocol.SimpleLoginReturn.scheme,
link: link,
);
final OidcCallback? back;
try {
back = await _browser.open(
start,
loopback
? OidcReturn.loopback(
port: loopbackPort,
path: simpleLoginCallbackPath,
)
: OidcReturn.scheme(callbackScheme),
);
} on OidcCallbackException catch (failed) {
throw SimpleLoginFailed(switch (failed.problem) {
OidcCallbackProblem.wrongState => SimpleLoginProblem.startAgain,
OidcCallbackProblem.noBrowser => SimpleLoginProblem.noBrowser,
OidcCallbackProblem.malformed ||
OidcCallbackProblem.failed => SimpleLoginProblem.failed,
});
}
return switch (back) {
null => null,
OidcDenied() => throw const SimpleLoginFailed(SimpleLoginProblem.denied),
OidcCode(:final code) => await _gateway.complete(
start.attempt,
code,
link: link,
),
};
}
/// Stops waiting for the browser on the loopback: [signIn] returns null.
Future<void> cancel() => _browser.cancel();
/// Whether someone new ([newcomer]) could take [handle]: null where they
/// could, else why not.
Future<SimpleLoginProblem?> checkHandle(
SimpleLoginNewcomer newcomer,
String handle,
) => _gateway.checkHandle(newcomer.attempt, handle);
/// An account's keys for [password], wrapped on the device with a new
/// recovery phrase (Argon2id, about a second): new key pairs, or, where
/// SimpleLogin was added to the device's person's account ([into]
/// without a grant), the keys the device keeps for them, which encrypt
/// their private studysets (ADR 0017, 9; ADR 0019).
Future<NewKeysMaterial> makeKeys(
String password, {
SimpleLoginAccount? into,
}) async {
if ((into, _auth.status) case (
SimpleLoginAccount(grant: null),
SignedIn(:final authUserId),
)) {
if (await _keys.read(authUserId) case final kept?) {
return await _crypto.newKeys(password, keys: kept);
}
}
return await _crypto.newKeys(password);
}
/// Signs up someone new ([newcomer]) with [handle] and the keys [made].
Future<PendingSimpleLogin> signUp(
SimpleLoginNewcomer newcomer, {
required String handle,
required NewKeysMaterial made,
}) async => PendingSimpleLogin(
grant: await _gateway.signUp(newcomer.attempt, handle, made.setup),
keys: made.keys,
);
/// Gives [account], which has no keys, the keys [made], with [handle]
/// where it has none.
Future<PendingSimpleLogin> setFirstKeys(
SimpleLoginAccount account, {
required NewKeysMaterial made,
String? handle,
}) async {
await _gateway.setKeys(
account.attempt,
made.setup,
handle: handle,
as: account.grant,
);
return PendingSimpleLogin(grant: account.grant, keys: made.keys);
}
/// Opens [account]'s keys with [password]: its proof first, then the keys
/// the register hands over, unwrapped on the device.
Future<PendingSimpleLogin> unlock(
SimpleLoginAccount account,
String password,
) async {
final challenge = await _gateway.beginUnlock(as: account.grant);
final proven = await _crypto.proveKeysPassword(
password,
challenge,
instanceUrl: _gateway.instanceUrl,
);
try {
final keys = await _gateway.unlock(proven.proof, as: account.grant);
return PendingSimpleLogin(
grant: account.grant,
keys: await _crypto.openKeys(
proven.unlock,
keys.passwordKeys,
encryptionPublicKey: Uint8List.sublistView(keys.encryptionPublicKey),
signingPublicKey: Uint8List.sublistView(keys.signingPublicKey),
),
);
} finally {
proven.unlock.forget();
}
}
/// Links the SimpleLogin account of someone new here ([newcomer]) to the
/// account with [handle] in place of a lost one (D7), by its recovery
/// [phrase], which opens the keys on the device first: a phrase that
/// doesn't open them proves nothing, and spends none of the instance's
/// tries. Every other session of the account ends.
Future<PendingSimpleLogin> mend(
SimpleLoginNewcomer newcomer, {
required String handle,
required String phrase,
}) async {
final challenge = await _gateway.beginMend(newcomer.attempt, handle);
final keys = await _crypto.openWithPhrase(
phrase,
challenge.recoveryKeys,
encryptionPublicKey: Uint8List.sublistView(
challenge.encryptionPublicKey,
),
signingPublicKey: Uint8List.sublistView(challenge.signingPublicKey),
);
final signature = await _crypto.signMendProof(
phrase,
challenge,
instanceUrl: _gateway.instanceUrl,
attempt: newcomer.attempt,
);
final account = await _gateway.mend(
newcomer.attempt,
protocol.KeysProof(
challenge: challenge.challenge,
signature: signature,
instanceUrl: _gateway.instanceUrl,
),
);
return PendingSimpleLogin(grant: account.grant, keys: keys);
}
/// Puts [pending] in place: its keys in the keyring, and its session in
/// place of the device's; or, where SimpleLogin was added to the device's
/// person's account, that person identified.
Future<void> enter(PendingSimpleLogin pending) async {
switch (pending.grant) {
case final grant?:
await _keys.keep(grant.authUserId, pending.keys);
await _auth.useSession(grant);
case null:
if (_auth.status case SignedIn(:final authUserId)) {
await _keys.keep(authUserId, pending.keys);
}
await _auth.markIdentified();
}
}
}

View file

@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' show UuidValue;
/// Where a SimpleLogin sign-in arrived once the browser came back (ADR
/// 0020, 5): someone new here, or an account. Its attempt's ID stands for
/// the sign-in a while longer: half an hour for someone new's sign-up, ten
/// minutes for an account's first keys or a lost password (D16, D20).
sealed class const SimpleLoginArrival() with Equatable;
/// Someone new here, whose SimpleLogin identity waits under [attempt] for
/// their sign-up, or for linking the account they have (D7).
final class const SimpleLoginNewcomer({
required final UuidValue attempt,
/// The handle suggested by the name SimpleLogin shared; null where none
/// could be made of it.
required final String? suggestedHandle,
}) extends SimpleLoginArrival {
@override
List<Object?> get props => [attempt, suggestedHandle];
}
/// An account: signed in, with [grant] not yet the device's, or, without
/// one, SimpleLogin added to the account of the device's person.
final class const SimpleLoginAccount({
required final UuidValue attempt,
required final SessionGrant? grant,
/// Whether it has its keys, which the password then opens; one without
/// (an account of before, adding SimpleLogin) makes them next.
required final bool hasKeys,
/// Its handle; null for one without, which chooses one with its keys.
required final String? handle,
}) extends SimpleLoginArrival {
@override
List<Object?> get props => [attempt, grant, hasKeys, handle];
}

View file

@ -0,0 +1,54 @@
/// Why signing in with SimpleLogin can't go on (ADR 0020). What the
/// account's keys refuse on the way (a wrong password, too many tries) is a
/// `PasswordFailed`, as for a password's sign-in.
enum SimpleLoginProblem() {
/// The instance doesn't offer SimpleLogin.
unavailable,
/// The person said no on SimpleLogin's page.
denied,
/// There's no browser to open SimpleLogin's page in.
noBrowser,
/// The sign-in took too long, or came back for another one: it begins
/// again.
startAgain,
/// SimpleLogin refused the sign-in, or what it sent proves nothing.
rejected,
/// The instance can't reach SimpleLogin.
providerUnavailable,
/// This SimpleLogin account is another account's here.
takenByAnotherPerson,
/// The person's account has another SimpleLogin account already.
otherIdentityLinked,
/// Too many sign-ins began from this network within an hour.
tooManyAttempts,
/// The handle is none a handle can be (its letters, its length).
handleInvalid,
/// The handle is kept back, as a word of the instance's own.
handleReserved,
/// Someone has the handle.
handleTaken,
/// Too many handles were tried within an hour.
tooManyHandleChecks,
/// No connection, the instance failed, or the browser's way back did.
failed,
}
/// Signing in with SimpleLogin failed, for [problem].
final class const SimpleLoginFailed(final SimpleLoginProblem problem)
implements Exception {
@override
String toString() => 'SimpleLoginFailed(${problem.name})';
}

View file

@ -0,0 +1,34 @@
import 'dart:async';
import 'package:halkyon_app/src/testing/fake_simplelogin_gateway.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
/// An [OidcBrowser] without a browser: SimpleLogin's page signs in as
/// [subject] and comes back at once with its code
/// ([FakeSimpleLoginGateway.codeFor]), or as [comesBack] says while set
/// (closed, refused, failed). The pages it opened are [opened]'s.
class FakeOidcBrowser() implements OidcBrowser {
/// Whom SimpleLogin's page signs in.
String subject = 'simplelogin-anna';
/// How the browser comes back in place of [subject]'s code, while set.
FutureOr<OidcCallback?> Function()? comesBack;
/// The sign-ins opened, with their way back, in order.
final opened = <({OidcStart start, OidcReturn back})>[];
/// How often the wait was cancelled.
int cancelled = 0;
@override
Future<OidcCallback?> open(OidcStart start, OidcReturn back) async {
opened.add((start: start, back: back));
if (comesBack case final other?) {
return await other();
}
return OidcCode(FakeSimpleLoginGateway.codeFor(subject));
}
@override
Future<void> cancel() async => cancelled++;
}

View file

@ -0,0 +1,363 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
import 'package:halkyon_app/src/simplelogin/data/simplelogin_gateway.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_app/src/testing/fake_account_crypto.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart' show OidcStart;
/// An account as [FakeSimpleLoginGateway] keeps it.
final class FakeSimpleLoginAccount({
/// Its handle; null for an account of before, until its keys come.
var String? handle,
/// Its keys; null for an account of before, until they come.
var protocol.KeysSetup? keys,
});
/// A [SimpleLoginGateway] without a server, as an instance's register
/// behaves in what an app sees (ADR 0020): SimpleLogin's people by the
/// subject a code names (`code for <subject>`, [codeFor]), accounts by the
/// person's ID ([accounts]), proofs checked by [FakeAccountCrypto]'s
/// fakes. The signed-in person is [_auth]'s. [failing] makes every call
/// fail.
class FakeSimpleLoginGateway(final AuthGateway _auth)
implements SimpleLoginGateway {
@override
String get instanceUrl => 'https://halkyon.example/';
/// Whether the instance offers SimpleLogin.
bool isOffered = true;
/// The accounts, by their person's ID.
final accounts = <String, FakeSimpleLoginAccount>{};
/// Whose each SimpleLogin subject is, by subject.
final linked = <String, String>{};
/// The calls made, by name, in order.
final calls = <String>[];
/// The ways back sign-ins began with, in order.
final begunBack = <protocol.SimpleLoginReturn>[];
/// Thrown by every call while set.
Exception? failing;
/// The handle suggested for someone new.
String? suggestedHandle = 'anna_mueller';
/// The code SimpleLogin hands back for [subject].
static String codeFor(String subject) => 'code for $subject';
final _begun = <protocol.UuidValue, ({bool link})>{};
final _waiting = <protocol.UuidValue, String>{};
final _signedIn = <protocol.UuidValue, String>{};
final _challenges = <protocol.UuidValue, String>{};
var _ids = 0;
var _sessions = 0;
/// Keys no phrase opens, as the register's decoy for a handle nobody has.
static final _decoy = UnlockedAccountKeys(
encryptionPublicKey: Uint8List(32),
encryptionSecretKey: Uint8List(32),
signingPublicKey: Uint8List(32),
signingSecretKey: Uint8List(64),
);
/// Adds an account for [person] with [handle] and [keys], which
/// SimpleLogin's [subject] signs in to.
void addAccount(
String person, {
required String subject,
String? handle,
protocol.KeysSetup? keys,
}) {
accounts[person] = FakeSimpleLoginAccount(handle: handle, keys: keys);
linked[subject] = person;
}
protocol.UuidValue _newId() => protocol.UuidValue.fromString(
'00000000-0000-4000-8000-${(++_ids).toString().padLeft(12, '0')}',
);
Future<void> _call(String name) async {
calls.add(name);
if (failing case final error?) {
throw error;
}
}
String? get _person => switch (_auth.status) {
SignedIn(:final authUserId) => authUserId,
SignedOut() => null,
};
SessionGrant _grantFor(String person) =>
SessionGrant(person, 'session-${++_sessions}');
/// Whose [grant] is, or the device's person's.
String _owner(SessionGrant? grant) =>
grant?.authUserId ??
_person ??
(throw const SimpleLoginFailed(SimpleLoginProblem.failed));
@override
Future<bool> offered() async {
await _call('offered');
return isOffered;
}
@override
Future<OidcStart> begin(
protocol.SimpleLoginReturn back, {
bool link = false,
}) async {
await _call('begin');
if (!isOffered) {
throw const SimpleLoginFailed(SimpleLoginProblem.unavailable);
}
begunBack.add(back);
final attempt = _newId();
_begun[attempt] = (link: link);
final state = 'test-${back.name}.$attempt';
return OidcStart(
attempt: attempt,
authorizationUrl: Uri.parse(
'https://app.simplelogin.io/oauth2/authorize?state=$state',
),
state: state,
);
}
@override
Future<SimpleLoginArrival> complete(
protocol.UuidValue attempt,
String code, {
bool link = false,
}) async {
await _call('complete');
final begun = _begun.remove(attempt);
if (begun == null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
if (begun.link != link) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
if (!code.startsWith('code for ')) {
throw const SimpleLoginFailed(SimpleLoginProblem.rejected);
}
final subject = code.substring('code for '.length);
final owner = linked[subject];
if (link) {
final person = _owner(null);
if (owner != null && owner != person) {
throw const SimpleLoginFailed(SimpleLoginProblem.takenByAnotherPerson);
}
if (owner == null && linked.containsValue(person)) {
throw const SimpleLoginFailed(SimpleLoginProblem.otherIdentityLinked);
}
linked[subject] = person;
final account = accounts.putIfAbsent(person, FakeSimpleLoginAccount.new);
_signedIn[attempt] = person;
return SimpleLoginAccount(
attempt: attempt,
grant: null,
hasKeys: account.keys != null,
handle: account.handle,
);
}
if (owner == null) {
_waiting[attempt] = subject;
return SimpleLoginNewcomer(
attempt: attempt,
suggestedHandle: suggestedHandle,
);
}
final account = accounts[owner]!;
_signedIn[attempt] = owner;
return SimpleLoginAccount(
attempt: attempt,
grant: _grantFor(owner),
hasKeys: account.keys != null,
handle: account.handle,
);
}
SimpleLoginProblem? _handleProblem(String handle) {
if (!RegExp(r'^[a-z0-9_]{3,30}$').hasMatch(handle)) {
return SimpleLoginProblem.handleInvalid;
}
if (handle == 'admin') {
return SimpleLoginProblem.handleReserved;
}
if (accounts.values.any((account) => account.handle == handle)) {
return SimpleLoginProblem.handleTaken;
}
return null;
}
@override
Future<SimpleLoginProblem?> checkHandle(
protocol.UuidValue attempt,
String handle,
) async {
await _call('checkHandle');
if (!_waiting.containsKey(attempt)) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
return _handleProblem(handle);
}
@override
Future<SessionGrant> signUp(
protocol.UuidValue attempt,
String handle,
protocol.KeysSetup setup,
) async {
await _call('signUp');
final subject = _waiting[attempt];
if (subject == null || _person != null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
if (_handleProblem(handle) case final problem?) {
throw SimpleLoginFailed(problem);
}
_waiting.remove(attempt);
final person = 'person-${accounts.length + 1}';
addAccount(person, subject: subject, handle: handle, keys: setup);
return _grantFor(person);
}
@override
Future<protocol.KeyProofChallenge> beginMend(
protocol.UuidValue attempt,
String handle,
) async {
await _call('beginMend');
if (!_waiting.containsKey(attempt) || _person != null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
final challenge = _newId();
final owner = accounts.entries
.where((entry) => entry.value.handle == handle)
.firstOrNull;
// A handle nobody has gets a decoy alike.
_challenges[challenge] = owner?.key ?? '';
final keys = owner?.value.keys;
return protocol.KeyProofChallenge(
challenge: challenge,
nonce: ByteData(32),
encryptionPublicKey: keys?.encryptionPublicKey ?? ByteData(32),
signingPublicKey: keys?.signingPublicKey ?? ByteData(32),
recoveryKeys: keys?.recoveryKeys ?? FakeAccountCrypto.wrap('', _decoy),
);
}
@override
Future<SimpleLoginAccount> mend(
protocol.UuidValue attempt,
protocol.KeysProof proof,
) async {
await _call('mend');
final subject = _waiting[attempt];
if (subject == null || _person != null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
final owner = _challenges.remove(proof.challenge);
final account = accounts[owner];
if (owner == null ||
account == null ||
!FakeAccountCrypto.proves(
proof.signature,
account.keys?.recoveryProofKey,
)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_waiting.remove(attempt);
linked
..removeWhere((_, person) => person == owner)
..[subject] = owner;
_signedIn[attempt] = owner;
return SimpleLoginAccount(
attempt: attempt,
grant: _grantFor(owner),
hasKeys: account.keys != null,
handle: account.handle,
);
}
@override
Future<protocol.KeysChallenge> beginUnlock({SessionGrant? as}) async {
await _call('beginUnlock');
final person = _owner(as);
final keys = accounts[person]?.keys;
if (keys == null) {
throw const PasswordFailed(PasswordProblem.noPassword);
}
final challenge = _newId();
_challenges[challenge] = person;
return protocol.KeysChallenge(
challenge: challenge,
nonce: ByteData(32),
salt: keys.salt,
costs: keys.costs,
);
}
@override
Future<protocol.DeviceKeys> unlock(
protocol.KeysProof proof, {
SessionGrant? as,
}) async {
await _call('unlock');
final person = _owner(as);
if (_challenges.remove(proof.challenge) != person) {
throw const PasswordFailed(PasswordProblem.challengeExpired);
}
final keys = accounts[person]!.keys!;
if (!FakeAccountCrypto.provesPassword(proof, keys.passwordProofKey)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
return protocol.DeviceKeys(
encryptionPublicKey: keys.encryptionPublicKey,
signingPublicKey: keys.signingPublicKey,
passwordKeys: keys.passwordKeys,
);
}
@override
Future<void> setKeys(
protocol.UuidValue attempt,
protocol.KeysSetup setup, {
String? handle,
SessionGrant? as,
}) async {
await _call('setKeys');
final person = _owner(as);
if (_signedIn[attempt] != person) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
final account = accounts[person]!;
if (account.keys != null) {
throw const PasswordFailed(PasswordProblem.passwordExists);
}
if ((account.handle == null) != (handle != null)) {
throw const SimpleLoginFailed(SimpleLoginProblem.handleInvalid);
}
if (handle != null) {
if (_handleProblem(handle) case final problem?) {
throw SimpleLoginFailed(problem);
}
account.handle = handle;
}
_signedIn.remove(attempt);
account.keys = setup;
}
}

View file

@ -7,8 +7,10 @@ export 'src/testing/fake_account_crypto.dart';
export 'src/testing/fake_auth_gateway.dart';
export 'src/testing/fake_content_cipher.dart';
export 'src/testing/fake_instance_checker.dart';
export 'src/testing/fake_oidc_browser.dart';
export 'src/testing/fake_password_gateway.dart';
export 'src/testing/fake_screen_guard.dart';
export 'src/testing/fake_simplelogin_gateway.dart';
export 'src/testing/memory_account_key_store.dart';
export 'src/testing/memory_file_saver.dart';
export 'src/testing/memory_instance_store.dart';

View file

@ -41,6 +41,9 @@ dependencies:
# hook compiles, so every build of an app needs a C compiler and make.
halkyon_keys:
path: ../halkyon_keys
# Read by a client of halkyon_base alone: SimpleLogin's refusals are its.
halkyon_oidc_client:
path: ../halkyon_oidc_client
halkyon_oidc_flutter:
path: ../halkyon_oidc_flutter
halkyon_ui:

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

View file

@ -23,7 +23,7 @@ typedef WebAuthenticate = Future<String> Function({
class OidcBrowser({
/// Waits on the loopback for [OidcReturn.loopback], in place of
/// flutter_web_auth_2's own; its page names the app.
required final LoopbackWebAuth loopback,
required final LoopbackWebAuth _loopback,
final WebAuthenticate _authenticate = FlutterWebAuth2.authenticate,
}) {
@ -48,7 +48,7 @@ class OidcBrowser({
),
// Straight to the loopback, not through the package's front, which
// would end the wait whenever the window regains focus.
final OidcLoopbackReturn loopbackReturn => loopback.authenticate(
final OidcLoopbackReturn loopbackReturn => _loopback.authenticate(
url: url,
callbackUrlScheme: loopbackReturn.address,
options: const {},
@ -72,5 +72,5 @@ class OidcBrowser({
/// Stops waiting on the loopback: the sign-in in progress returns nothing.
/// The system's browser sheet ends by itself when the person closes it.
Future<void> cancel() => loopback.cancel();
Future<void> cancel() => _loopback.cancel();
}