halkyon/packages/halkyon_app/lib/src/testing/fake_simplelogin_gateway.dart

363 lines
11 KiB
Dart
Raw Normal View History

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