import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:clock/clock.dart'; import 'package:halkyon_base_server/src/auth/account.dart'; import 'package:halkyon_base_server/src/generated/protocol.dart'; import 'package:halkyon_base_server/src/halkyon_base.dart'; import 'package:halkyon_base_server/src/register/accounts_mode.dart'; import 'package:halkyon_base_server/src/register/deletion_notices.dart'; import 'package:halkyon_base_server/src/register/local_register.dart'; import 'package:halkyon_base_server/src/register/register_protocol.dart'; import 'package:halkyon_base_server/src/register/sessions_ended.dart'; import 'package:serverpod/serverpod.dart'; import 'package:serverpod_auth_idp_server/core.dart'; // The calls between an instance's services (ADR 0016), read into plain values // so tests drive them without a web server; `instance_routes.dart` puts them // on the web server. Every call carries the instance's shared secret: without // it, or on a server without one, nothing is answered. /// What a call between the instance's services is answered: an HTTP status /// and a JSON body. typedef InstanceAnswer = ({int status, Map body}); const _unauthorized = (status: 401, body: {'error': 'secret'}); const _notHere = (status: 404, body: {'error': 'not here'}); const _unreadable = (status: 400, body: {'error': 'request'}); /// Whether [authorization] (a request's header) carries the instance's /// secret, which [pod] must have. bool _carriesSecret(Serverpod pod, String? authorization) { final secret = instanceSecretOf(pod); if (secret == null || authorization == null) { return false; } const scheme = 'Bearer '; return authorization.startsWith(scheme) && isInstanceSecret(authorization.substring(scheme.length), secret); } /// [body] as a JSON object; null for anything else. Map? _arguments(String body) { try { return switch (jsonDecode(body)) { final Map arguments => arguments, _ => null, }; } on FormatException { return null; } } /// The register answering a member's [operation] (`register_protocol.dart`), /// asked with [body] (JSON) and [authorization] (the request's header). On /// a server that is a member itself, nothing is answered. Future answerMember( Session session, String operation, String body, { required String? authorization, }) async { final pod = session.serverpod; if (!_carriesSecret(pod, authorization)) { return _unauthorized; } if (HalkyonBase.maybeOf(pod)?.accounts is MemberOfRegister) { return _notHere; } final arguments = _arguments(body); if (arguments == null) { return _unreadable; } try { final answer = await _answer(session, operation, _Arguments(arguments)); return answer == null ? _notHere : (status: 200, body: answer); } on FormatException { return _unreadable; } on ProfileNotFoundException { return (status: 404, body: {'notFound': 'profile'}); } on Exception catch (error) { if (refusalJson(error) case final refusal?) { return (status: 409, body: {'refusal': refusal}); } rethrow; } } /// [operation]'s answer from this server's own register; null for an /// operation it doesn't know. Future?> _answer( Session session, String operation, _Arguments arguments, ) async { const register = LocalRegister(); switch (operation) { case RegisterOperation.profileOf: final profile = await register.profileOf(session, arguments.id('person')); return {'profile': profile?.toJson()}; case RegisterOperation.profilesOf: final profiles = await register.profilesOf( session, arguments.ids('people'), ); return { 'profiles': { for (final MapEntry(:key, :value) in profiles.entries) key.uuid: value.toJson(), }, }; case RegisterOperation.profileByHandle: final profile = await register.profileByHandle( session, arguments.string('handle'), ); return {'profile': profile?.toJson()}; case RegisterOperation.personWithHandle: final person = await register.personWithHandle( session, arguments.string('handle'), ); return {'person': person?.uuid}; case RegisterOperation.saveProfile: final profile = await register.saveProfile( session, arguments.id('person'), handle: arguments.string('handle'), displayName: arguments.string('displayName'), bio: arguments.string('bio'), links: arguments.strings('links'), ); return {'profile': profile.toJson()}; case RegisterOperation.checkHandle: final refusal = await register.checkHandle( session, arguments.id('person'), arguments.string('handle'), ); return {'refusal': refusal?.name}; case RegisterOperation.checkLinks: final profile = await register.checkLinks( session, arguments.id('person'), ); return {'profile': profile.toJson()}; case RegisterOperation.setAvatar: final Uint8List png; try { png = base64Decode(arguments.string('png')); } on FormatException { throw ProfileRefusedException(reason: ProfileRefusal.avatarInvalid); } final profile = await register.setAvatar( session, arguments.id('person'), ByteData.sublistView(png), ); return {'profile': profile.toJson()}; case RegisterOperation.removeAvatar: final profile = await register.removeAvatar( session, arguments.id('person'), ); return {'profile': profile.toJson()}; case RegisterOperation.removeProfile: await register.removeProfile(session, arguments.id('person')); return const {}; case RegisterOperation.fixHandle: await register.fixHandle(session, arguments.id('person')); return const {}; case RegisterOperation.emailSignInOffered: return {'offered': await register.emailSignInOffered(session)}; case RegisterOperation.sendSignInCode: final request = await register.sendSignInCode( session, address: arguments.string('address'), language: arguments.string('language'), purpose: EmailCodePurpose.fromJson(arguments.string('purpose')), network: arguments.string('network'), ); return {'request': request.uuid}; case RegisterOperation.linkGitHub: await register.linkGitHub( session, person: arguments.id('person'), gitHubUserId: arguments.string('gitHubUserId'), ); return const {}; case RegisterOperation.accountWithGitHub: final account = await register.accountWithGitHub( session, arguments.string('gitHubUserId'), ); return {'account': account?.uuid}; case RegisterOperation.simpleLoginOffered: return {'offered': await register.simpleLoginOffered(session)}; case RegisterOperation.beginSimpleLogin: final start = await register.beginSimpleLogin( session, marker: arguments.string('marker'), caller: arguments.optionalId('caller'), network: arguments.string('network'), ); return {'start': start.toJson()}; case RegisterOperation.completeSimpleLogin: final proof = await register.completeSimpleLogin( session, attempt: arguments.id('attempt'), code: arguments.string('code'), caller: arguments.optionalId('caller'), ); return {'proof': proof.toJson()}; case RegisterOperation.checkSimpleLoginHandle: final refusal = await register.checkSimpleLoginHandle( session, attempt: arguments.id('attempt'), handle: arguments.string('handle'), network: arguments.string('network'), ); return {'refusal': refusal?.name}; case RegisterOperation.signUpWithSimpleLogin: final account = await register.signUpWithSimpleLogin( session, attempt: arguments.id('attempt'), handle: arguments.string('handle'), setup: arguments.model('setup', KeysSetup.fromJson), ); return {'account': account.uuid}; case RegisterOperation.beginSimpleLoginMend: final challenge = await register.beginSimpleLoginMend( session, attempt: arguments.id('attempt'), handle: arguments.string('handle'), network: arguments.string('network'), ); return {'challenge': challenge.toJson()}; case RegisterOperation.mendSimpleLogin: final proof = await register.mendSimpleLogin( session, attempt: arguments.id('attempt'), proof: arguments.model('proof', KeysProof.fromJson), ); return {'proof': proof.toJson()}; case RegisterOperation.beginPasswordKeyProof: final challenge = await register.beginPasswordKeyProof( session, arguments.id('person'), ); return {'challenge': challenge.toJson()}; case RegisterOperation.unlockKeys: final keys = await register.unlockKeys( session, arguments.id('person'), arguments.model('proof', KeysProof.fromJson), ); return {'keys': keys.toJson()}; case RegisterOperation.setFirstKeys: await register.setFirstKeys( session, person: arguments.id('person'), attempt: arguments.id('attempt'), setup: arguments.model('setup', KeysSetup.fromJson), handle: arguments.optionalString('handle'), ); return const {}; case RegisterOperation.beginPhraseKeyProof: final challenge = await register.beginPhraseKeyProof( session, arguments.id('person'), ); return {'challenge': challenge.toJson()}; case RegisterOperation.changeKeysPassword: await register.changeKeysPassword( session, person: arguments.id('person'), proof: arguments.model('proof', KeysOwnerProof.fromJson), password: arguments.model('password', KeysPassword.fromJson), keepSession: arguments.optionalId('keepSession'), ); return const {}; case RegisterOperation.resetKeysPassword: await register.resetKeysPassword( session, person: arguments.id('person'), proof: arguments.model('proof', KeysOwnerProof.fromJson), password: arguments.model('password', KeysPassword.fromJson), keepSession: arguments.optionalId('keepSession'), ); return const {}; case RegisterOperation.setKeysRecovery: await register.setKeysRecovery( session, person: arguments.id('person'), proof: arguments.model('proof', KeysOwnerProof.fromJson), recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson), recoveryProofKey: arguments.bytes('recoveryProofKey'), ); return const {}; case RegisterOperation.signInWaysOf: final ways = await register.signInWaysOf( session, arguments.id('person'), ); return {'ways': ways.toJson()}; case RegisterOperation.noteSessionEndedForProofs: await register.noteSessionEndedForProofs(session, arguments.id('person')); return const {}; case RegisterOperation.dismissSessionEndedWarning: await register.dismissSessionEndedWarning( session, arguments.id('person'), // When the member's session asking was made (the full reading's L1). sessionMadeAt: arguments.optionalTime('sessionMadeAt'), ); return const {}; case RegisterOperation.deleteAccount: await register.deleteAccount( session, arguments.id('person'), proof: arguments.has('proof') ? arguments.model('proof', DeletionProof.fromJson) : null, keysProof: arguments.has('keysProof') ? arguments.model('keysProof', KeysOwnerProof.fromJson) : null, ); return const {}; case RegisterOperation.knownAccounts: final known = await register.knownAccounts( session, arguments.ids('people'), ); return { 'known': [for (final person in known) person.uuid], }; case RegisterOperation.passwordSignInOffered: return {'offered': await register.passwordSignInOffered(session)}; case RegisterOperation.setPassword: await register.setPassword( session, person: arguments.id('person'), setup: arguments.model('setup', PasswordSetup.fromJson), request: arguments.optionalId('request'), code: arguments.optionalString('code'), // The member tells it of its own session (the fourth reading) and // person (the full reading's M1): the register keeps no anonymous // person of a member's. freshGitHubSignIn: arguments.flag('freshGitHubSignIn'), anonymousHere: arguments.flag('anonymousHere'), keepSession: arguments.optionalId('keepSession'), ); return const {}; case RegisterOperation.beginPasswordSignIn: final challenge = await register.beginPasswordSignIn( session, address: arguments.string('address'), network: arguments.string('network'), ); return {'challenge': challenge.toJson()}; case RegisterOperation.completePasswordSignIn: final proof = await register.completePasswordSignIn( session, challenge: arguments.id('challenge'), clientValue: arguments.bytes('clientValue'), clientProof: arguments.bytes('clientProof'), totpCode: arguments.optionalString('totpCode'), recoveryCode: arguments.optionalString('recoveryCode'), request: arguments.optionalId('request'), code: arguments.optionalString('code'), instanceUrl: arguments.string('instanceUrl'), address: arguments.string('address'), language: arguments.string('language'), ); return {'proof': proof.toJson()}; case RegisterOperation.signUpWithCode: final account = await register.signUpWithCode( session, request: arguments.id('request'), code: arguments.string('code'), setup: arguments.model('setup', PasswordSetup.fromJson), ); return {'account': account.uuid}; case RegisterOperation.enrolAuthenticator: final enrolment = await register.enrolAuthenticator( session, person: arguments.id('person'), name: arguments.string('name'), label: arguments.string('label'), ); return {'enrolment': enrolment.toJson()}; case RegisterOperation.confirmAuthenticator: final codes = await register.confirmAuthenticator( session, person: arguments.id('person'), authenticator: arguments.id('authenticator'), code: arguments.string('code'), proof: arguments.has('proof') ? arguments.model('proof', OwnerProof.fromJson) : null, ); return {'recoveryCodes': codes}; case RegisterOperation.authenticatorsOf: final authenticators = await register.authenticatorsOf( session, arguments.id('person'), ); return { 'authenticators': [ for (final authenticator in authenticators) authenticator.toJson(), ], }; case RegisterOperation.removeAuthenticator: await register.removeAuthenticator( session, person: arguments.id('person'), authenticator: arguments.id('authenticator'), proof: arguments.has('proof') ? arguments.model('proof', OwnerProof.fromJson) : null, ); return const {}; case RegisterOperation.newRecoveryCodes: final codes = await register.newRecoveryCodes( session, person: arguments.id('person'), proof: arguments.model('proof', OwnerProof.fromJson), ); return {'recoveryCodes': codes}; case RegisterOperation.resetAuthenticator: final pending = await register.resetAuthenticator( session, challenge: arguments.id('challenge'), clientValue: arguments.bytes('clientValue'), clientProof: arguments.bytes('clientProof'), address: arguments.string('address'), language: arguments.string('language'), request: arguments.optionalId('request'), code: arguments.optionalString('code'), keyChallenge: arguments.optionalId('keyChallenge'), signature: arguments.optionalBytes('signature'), instanceUrl: arguments.string('instanceUrl'), ); return {'pending': pending.toJson()}; case RegisterOperation.beginKeyProof: final challenge = await register.beginKeyProof( session, address: arguments.string('address'), network: arguments.string('network'), ); return {'challenge': challenge.toJson()}; case RegisterOperation.resetPasswordWithPhrase: final ended = await register.resetPasswordWithPhrase( session, challenge: arguments.id('challenge'), signature: arguments.bytes('signature'), password: arguments.model('password', NewPassword.fromJson), totpCode: arguments.optionalString('totpCode'), recoveryCode: arguments.optionalString('recoveryCode'), instanceUrl: arguments.string('instanceUrl'), address: arguments.string('address'), language: arguments.string('language'), ); return _sessionsEnded(ended); case RegisterOperation.startAccountReset: final pending = await register.startAccountReset( session, address: arguments.string('address'), language: arguments.string('language'), request: arguments.id('request'), code: arguments.string('code'), ); return {'pending': pending.toJson()}; case RegisterOperation.completeAccountReset: final ended = await register.completeAccountReset( session, address: arguments.string('address'), request: arguments.id('request'), code: arguments.string('code'), setup: arguments.model('setup', PasswordSetup.fromJson), ); return _sessionsEnded(ended); case RegisterOperation.beginPasswordChange: final challenge = await register.beginPasswordChange( session, person: arguments.id('person'), ); return {'challenge': challenge.toJson()}; case RegisterOperation.changePassword: await register.changePassword( session, person: arguments.id('person'), proof: arguments.model('proof', OwnerProof.fromJson), password: arguments.model('password', NewPassword.fromJson), keepSession: arguments.optionalId('keepSession'), ); return const {}; case RegisterOperation.callOffResets: await register.callOffResets( session, person: arguments.id('person'), proof: arguments.model('proof', OwnerProof.fromJson), ); return const {}; case RegisterOperation.setRecoveryKeys: await register.setRecoveryKeys( session, person: arguments.id('person'), proof: arguments.model('proof', OwnerProof.fromJson), recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson), recoveryProofKey: arguments.bytes('recoveryProofKey'), ); return const {}; } return null; } /// [ended] as the register answers it. Map _sessionsEnded(AccountSessionsEnded ended) => { 'account': ended.account.uuid, 'endedBefore': ended.endedBefore.toUtc().toIso8601String(), }; /// A member answering the register that [body]'s account is gone /// ([accountGonePath]): that person's data here is deleted, here alone (the /// register deleted the account), and the register told so. On the register /// itself, nothing is answered. Future answerRegister( Session session, String body, { required String? authorization, }) async { final pod = session.serverpod; if (!_carriesSecret(pod, authorization)) { return _unauthorized; } if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) { return _notHere; } final arguments = _arguments(body); if (arguments == null) { return _unreadable; } final UuidValue account; try { account = _Arguments(arguments).id('account'); } on FormatException { return _unreadable; } // SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1 if (await AuthUser.db.findById(session, account) != null) { await deletePerson(session, account); await session.messages.authenticationRevoked( account.uuid, RevokedAuthenticationUser(), ); } return (status: 200, body: const {}); } /// A member answering the register that [body]'s account's sessions ended /// ([sessionsEndedPath], ADR 0017): those here created before the moment it /// names end, but the one it keeps and the one made here for that moment /// (`sessionMadeAfter`), and the register is told so. On the register /// itself, nothing is answered. Future answerSessionsEnded( Session session, String body, { required String? authorization, }) async { final pod = session.serverpod; if (!_carriesSecret(pod, authorization)) { return _unauthorized; } if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) { return _notHere; } final arguments = _arguments(body); if (arguments == null) { return _unreadable; } final UuidValue account; final DateTime endedBefore; final DateTime? sentAt; final UuidValue? keepSession; try { final read = _Arguments(arguments); account = read.id('account'); endedBefore = DateTime.parse(read.string('endedBefore')).toUtc(); sentAt = read.optionalTime('sentAt'); keepSession = read.optionalId('keepSession'); } on FormatException { return _unreadable; } await endSessionsHere( session, account, endedBefore: _byThisClock(endedBefore, sentAt: sentAt), keepSessions: { ?keepSession, ?sessionMadeAfter(account, endedBefore), }, ); return (status: 200, body: const {}); } /// The register's moment [endedBefore] by this server's clock: as long /// before now as it was before the register sent it ([sentAt], by the /// register's clock), so the clocks' offset drops out (the reading of the /// fixes' C1; one way's travel makes it a moment later, ending a session /// made in that instant too). A moment sent before it was taken (a clock set /// back) is taken as now; without [sentAt] (an older register), as sent. DateTime _byThisClock(DateTime endedBefore, {required DateTime? sentAt}) { if (sentAt == null) { return endedBefore; } final age = sentAt.difference(endedBefore); return clock.now().toUtc().subtract(age.isNegative ? Duration.zero : age); } /// A request's arguments, each read as what it must be; a [FormatException] /// for anything else. extension type const _Arguments(Map _map) { String string(String name) => switch (_map[name]) { final String value => value, _ => throw FormatException('$name is no string'), }; List strings(String name) => switch (_map[name]) { final List values when values.every((value) => value is String) => values.cast(), _ => throw FormatException('$name is no list of strings'), }; String? optionalString(String name) => switch (_map[name]) { null => null, final String value => value, _ => throw FormatException('$name is no string'), }; UuidValue id(String name) => UuidValue.withValidation(string(name)); UuidValue? optionalId(String name) => switch (optionalString(name)) { final value? => UuidValue.withValidation(value), null => null, }; ByteData bytes(String name) => ByteData.sublistView( base64Decode(string(name)), ); ByteData? optionalBytes(String name) => optionalString(name) == null ? null : bytes(name); /// The moment [name] (ISO 8601) in UTC; null where it is missing. DateTime? optionalTime(String name) => switch (optionalString(name)) { final value? => DateTime.parse(value).toUtc(), null => null, }; /// Whether the argument [name] holds anything (not null). bool has(String name) => _map[name] != null; /// The flag [name]: false where it is missing. bool flag(String name) => switch (_map[name]) { null => false, final bool value => value, _ => throw FormatException('$name is no flag'), }; /// The model [read] makes of the object [name]; a [FormatException] for /// what isn't one. T model(String name, T Function(Map json) read) { final json = _map[name]; if (json is! Map) { throw FormatException('$name is no object'); } try { return read(json); } on FormatException { rethrow; } on Object catch (error) { // A field missing or of another type throws a TypeError: the request // is unreadable all the same. throw FormatException('$name: ${error.runtimeType}'); } } Set ids(String name) => { for (final value in strings(name)) UuidValue.withValidation(value), }; }