feat(auth)!: codes by mail no longer sign in

ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.

The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.

BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.

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-20 19:31:49 +02:00
commit ae57cbe864
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 429 additions and 3835 deletions

View file

@ -3,23 +3,21 @@ import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/src/auth/code_mail.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/common/short_code.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/password/passwords.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// Signing in with an email address, by a code mailed to it each time
// (Halkyon Learn's ADR 0010). The instance keeps only a keyed hash of the
// address: it can find the person and tell whether an address is free from
// what someone types, and it mails only an address someone has just typed.
// So the login is our own, on Serverpod's helpers (its code challenges and
// rate limiter): Serverpod's email sign-in keeps the address itself, in three
// tables, and passwords.
// An email address, and the codes mailed to it (Halkyon Learn's ADR 0010).
// The instance keeps only a keyed hash of the address: it can find the
// person and tell whether an address is free from what someone types, and
// it mails only an address someone has just typed. So this is our own, on
// Serverpod's helpers (its code challenges and rate limiter): Serverpod's
// email sign-in keeps the address itself, in three tables. Since ADR 0017 a
// code signs nobody in by itself: it proves the address for a password's
// setup, a sign-up, a setup never finished and the resets.
/// Serverpod's name for email sign-in on session rows.
const emailMethod = 'email';
@ -193,105 +191,12 @@ Future<UuidValue> sendSignInCode(
});
}
/// Accepts [code] for [request] from the person [authUserId]: an address
/// nobody has becomes theirs, in one transaction (they become `identified`,
/// on their sessions too, and stop being anonymous, keeping everything they
/// have; a member's person becomes an account of the register under their
/// ID, ADR 0016); their own changes nothing; another person's changes nothing
/// here and gives a token that signs in as that person
/// ([completeEmailSwitch]), unless that person signs in with a password
/// ([signsInWithPassword], ADR 0017).
///
/// Refused for a wrong code, too many tries, a code no longer valid, a
/// second address for someone who has one, or another person's address who
/// signs in with a password ([EmailSignInRefusal.usePassword])
/// ([EmailSignInRefusedException]); nothing changes then.
Future<EmailCodeAccepted> acceptSignInCode(
Session session, {
required UuidValue authUserId,
required UuidValue request,
required String code,
}) async {
final pod = session.serverpod;
if (!emailSignInAvailable(pod)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
}
return await session.db.transaction((transaction) async {
final switchToken = await _refusingAsEmail(
() => _challengesOf(pod).verifyChallenge(
session,
requestId: request,
verificationCode: normalizeShortCode(code),
transaction: transaction,
),
);
final accepted = (await _requestById(
session,
request,
transaction: transaction,
))!;
// SELECT * FROM "email_identity"
// WHERE "addressHash" = $1 OR "authUserId" = $2
final identities = await EmailIdentity.db.find(
session,
where: (t) =>
t.addressHash.equals(accepted.addressHash) |
t.authUserId.equals(authUserId),
transaction: transaction,
);
final owner = identities
.where((identity) => identity.addressHash == accepted.addressHash)
.firstOrNull;
if (owner != null && owner.authUserId != authUserId) {
if (await signsInWithPassword(
session,
owner.authUserId,
transaction: transaction,
)) {
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.usePassword,
);
}
// Kept until the token is used, or its time is up.
return EmailCodeAccepted(switchToken: switchToken);
}
if (owner == null) {
if (identities.isNotEmpty) {
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.otherAddressLinked,
);
}
await ensurePerson(session, authUserId, transaction: transaction);
// INSERT INTO "email_identity" ("authUserId", "addressHash",
// "createdAt") VALUES ($1, $2, $3)
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: authUserId,
addressHash: accepted.addressHash,
createdAt: clock.now(),
),
transaction: transaction,
);
await grantIdentified(session, authUserId, transaction: transaction);
// DELETE FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
await AnonymousAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
}
await _forget(session, [accepted], transaction: transaction);
return EmailCodeAccepted();
});
}
/// The keyed hash of the address [code] proves for [request], once: the
/// request goes as it is accepted. For what an address proves without
/// becoming anyone's way in: resets of an account (ADR 0017), whose register
/// compares the hash with the account's. Refusals as [acceptSignInCode]'s
/// ([EmailSignInRefusedException]).
/// request goes as it is accepted. A code by mail signs nobody in (ADR
/// 0017): it proves an address for a password's setup, a sign-up, a setup
/// never finished and the resets, whose register compares the hash with
/// the account's. Refused for a wrong code, too many tries, or a code no
/// longer valid ([EmailSignInRefusedException]).
Future<String> proveAddressCode(
Session session, {
required UuidValue request,
@ -320,52 +225,6 @@ Future<String> proveAddressCode(
});
}
/// The person whose address a code proved, from the token
/// [acceptSignInCode] gave; once only. A session for them is the caller's to
/// issue (`issueSession`): this server's own, or a member's (ADR 0016).
/// Refused once the token was used or its time is up, or for a token it
/// never gave ([EmailSignInRefusal.codeExpired]), and for a person who signs
/// in with a password since ([EmailSignInRefusal.usePassword]).
Future<UuidValue> completeEmailSwitch(
Session session, {
required String switchToken,
}) async {
final pod = session.serverpod;
if (!emailSignInAvailable(pod)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
}
return await session.db.transaction((transaction) async {
final request = await _refusingAsEmail(
() => _challengesOf(pod).completeChallenge(
session,
completionToken: switchToken,
transaction: transaction,
),
);
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.addressHash.equals(request.addressHash),
transaction: transaction,
);
await _forget(session, [request], transaction: transaction);
if (identity == null) {
// The person deleted their account since.
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.codeExpired,
);
}
if (await signsInWithPassword(
session,
identity.authUserId,
transaction: transaction,
)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.usePassword);
}
return identity.authUserId;
});
}
/// Deletes the code requests past their time at [now], and returns how many:
/// hourly, with the rate limit's notes (`PruningFutureCall`).
Future<int> forgetExpiredEmailCodes(
@ -462,7 +321,10 @@ SecretChallengeUtil<EmailCodeRequest> _newChallenges(String pepper) {
return SecretChallengeUtil(
// Codes are short: Argon2id at its usual cost.
hashUtil: Argon2HashUtil(hashPepper: pepper, hashSaltLength: 16),
// Switch tokens are long and random.
// Serverpod's challenges hand out a completion token for every code
// accepted; nothing takes it since codes sign nobody in (ADR 0017), and
// it goes with its request (`switchChallengeId`, kept as the column the
// util links, and as older servers read it during a deploy).
completionTokenHash: Argon2HashUtil.forRandomSecrets(
hashPepper: pepper,
hashSaltLength: 16,
@ -509,8 +371,7 @@ SecretChallengeUtil<EmailCodeRequest> _newChallenges(String pepper) {
},
),
completionConfig: SecretChallengeCompletionConfig(
// Switch tokens are long and random, but tries are counted all the
// same.
// Never completed any more (above); counted all the same.
rateLimiter: DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',

View file

@ -1,8 +0,0 @@
### A code accepted: the address is now the signed-in person's (or already
### was), or it is another person's and [switchToken] signs in as them.
class: EmailCodeAccepted
fields:
### Null when the address is the signed-in person's; else what signs in as
### the person who has it, once, within the code's 15 minutes
### (`EmailSignInEndpoint.signIn`).
switchToken: String?

View file

@ -1,22 +1,15 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Signing in with an email address, by a code mailed to it:
/// the way to keep an anonymous account, as linking GitHub is, and a second
/// way in for someone who signs in with GitHub. Signed in only, as every
/// device is from its first start. The instance's register mails the codes
/// and keeps the addresses' hashes (ADR 0016), this server's own database
/// when it holds the register.
/// Codes by mail (ADR 0017): what proves an address for a password's setup,
/// a sign-up, a setup never finished and the resets. A code signs nobody in
/// by itself. Without a session, as signing up and getting back in have
/// none. The instance's register mails the codes and keeps the addresses'
/// hashes (ADR 0016), this server's own database when it holds the
/// register.
class EmailCodeEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Mails a code to [address], worded in [language] (`en`, `de`), and
/// returns the request it answers; refusals as in [sendSignInCode].
Future<UuidValue> sendCode(
@ -29,22 +22,4 @@ class EmailCodeEndpoint extends Endpoint {
language: language,
network: networkOf(session),
);
/// Accepts [code] for [request]: the address becomes the caller's unless
/// it is another person's, whose token then signs in as them
/// (`EmailSignInEndpoint.signIn`); refusals as in [acceptSignInCode].
Future<EmailCodeAccepted> verifyCode(
Session session, {
required UuidValue request,
required String code,
}) async {
final person = session.authenticated!.authUserId;
final accepted = await AccountRegister.of(
session.serverpod,
).verifySignInCode(session, person: person, request: request, code: code);
if (accepted.switchToken == null && isMember(session.serverpod)) {
await becomeIdentified(session, person);
}
return accepted;
}
}

View file

@ -1,22 +0,0 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Signs in the person whose address a mailed code proved, when the device
/// switches to them. Called without a session: Serverpod issues no session
/// for another person inside a signed-in call.
class EmailSignInEndpoint extends Endpoint {
/// A session for the person [switchToken] names; refusals as in
/// [completeEmailSwitch].
Future<AuthSuccess> signIn(
Session session, {
required String switchToken,
}) async {
final account = await AccountRegister.of(
session.serverpod,
).completeEmailSwitch(session, switchToken: switchToken);
return await issueSession(session, account, method: emailMethod);
}
}

View file

@ -23,5 +23,6 @@ values:
### The mail couldn't be sent; nothing was kept of the request.
- mailFailed
### The account signs in with its password and an authenticator (ADR
### 0017): a code by mail no longer signs into it.
### 0017): a code by mail no longer signs into it. No longer refused for
### since codes sign nobody in; kept, as older apps know it by name.
- usePassword

View file

@ -1,75 +0,0 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// A code accepted: the address is now the signed-in person's (or already
/// was), or it is another person's and [switchToken] signs in as them.
abstract class EmailCodeAccepted
implements _is.SerializableModel, _is.ProtocolSerialization {
EmailCodeAccepted._({this.switchToken});
factory EmailCodeAccepted({String? switchToken}) = _EmailCodeAcceptedImpl;
factory EmailCodeAccepted.fromJson(Map<String, dynamic> jsonSerialization) {
return EmailCodeAccepted(
switchToken: jsonSerialization['switchToken'] as String?,
);
}
/// Null when the address is the signed-in person's; else what signs in as
/// the person who has it, once, within the code's 15 minutes
/// (`EmailSignInEndpoint.signIn`).
String? switchToken;
/// Returns a shallow copy of this [EmailCodeAccepted]
/// with some or all fields replaced by the given arguments.
@_is.useResult
EmailCodeAccepted copyWith({String? switchToken});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.EmailCodeAccepted',
if (switchToken != null) 'switchToken': switchToken,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.EmailCodeAccepted',
if (switchToken != null) 'switchToken': switchToken,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _EmailCodeAcceptedImpl extends EmailCodeAccepted {
_EmailCodeAcceptedImpl({String? switchToken})
: super._(switchToken: switchToken);
/// Returns a shallow copy of this [EmailCodeAccepted]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
EmailCodeAccepted copyWith({Object? switchToken = _Undefined}) {
return EmailCodeAccepted(
switchToken: switchToken is String? ? switchToken : this.switchToken,
);
}
}

View file

@ -43,7 +43,8 @@ enum EmailSignInRefusal implements _is.SerializableModel {
mailFailed,
/// The account signs in with its password and an authenticator (ADR
/// 0017): a code by mail no longer signs into it.
/// 0017): a code by mail no longer signs into it. No longer refused for
/// since codes sign nobody in; kept, as older apps know it by name.
usePassword;
static EmailSignInRefusal fromJson(String name) {

View file

@ -25,7 +25,6 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
import '../auth/account_endpoint.dart' as _ii6ycf6v;
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
import '../auth/email_code_endpoint.dart' as _in3cimlf;
import '../auth/email_sign_in_endpoint.dart' as _irqdog5c;
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
@ -57,12 +56,6 @@ class Endpoints extends _is.EndpointDispatch {
'emailCode',
'halkyon_base',
),
'emailSignIn': _irqdog5c.EmailSignInEndpoint()
..initialize(
server,
'emailSignIn',
'halkyon_base',
),
'gitHubSignIn': _iasw79m5.GitHubSignInEndpoint()
..initialize(
server,
@ -199,57 +192,6 @@ class Endpoints extends _is.EndpointDispatch {
language: params['language'],
),
),
'verifyCode': _is.MethodConnector(
name: 'verifyCode',
params: {
'request': _is.ParameterDescription(
name: 'request',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'code': _is.ParameterDescription(
name: 'code',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['emailCode'] as _in3cimlf.EmailCodeEndpoint)
.verifyCode(
session,
request: params['request'],
code: params['code'],
),
),
},
);
connectors['emailSignIn'] = _is.EndpointConnector(
name: 'emailSignIn',
endpoint: endpoints['emailSignIn']!,
methodConnectors: {
'signIn': _is.MethodConnector(
name: 'signIn',
params: {
'switchToken': _is.ParameterDescription(
name: 'switchToken',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['emailSignIn'] as _irqdog5c.EmailSignInEndpoint)
.signIn(
session,
switchToken: params['switchToken'],
),
),
},
);
connectors['gitHubSignIn'] = _is.EndpointConnector(
@ -349,33 +291,6 @@ class Endpoints extends _is.EndpointDispatch {
name: 'accountRecovery',
endpoint: endpoints['accountRecovery']!,
methodConnectors: {
'sendCode': _is.MethodConnector(
name: 'sendCode',
params: {
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['accountRecovery']
as _ivnbjhab.AccountRecoveryEndpoint)
.sendCode(
session,
address: params['address'],
language: params['language'],
),
),
'resetAuthenticator': _is.MethodConnector(
name: 'resetAuthenticator',
params: {

View file

@ -19,7 +19,6 @@ import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'auth/email_code_accepted.dart' as _iys46ui1;
import 'auth/email_code_request.dart' as _iqxum1j8;
import 'auth/email_identity.dart' as _id1dpd4g;
import 'auth/email_sign_in_refusal.dart' as _ieyvcag1;
@ -55,7 +54,6 @@ import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'profile/public_profile.dart' as _ioppe2ep;
import 'register/account_deletion_notice.dart' as _i2lyzpm9;
import 'register/sessions_ended_notice.dart' as _io5c3tod;
export 'auth/email_code_accepted.dart';
export 'auth/email_code_request.dart';
export 'auth/email_identity.dart';
export 'auth/email_sign_in_refusal.dart';
@ -881,9 +879,6 @@ class Protocol extends _is.DatabaseSerializationManager {
}
}
if (t == _iys46ui1.EmailCodeAccepted) {
return _iys46ui1.EmailCodeAccepted.fromJson(data) as T;
}
if (t == _iqxum1j8.EmailCodeRequest) {
return _iqxum1j8.EmailCodeRequest.fromJson(data) as T;
}
@ -989,10 +984,6 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _io5c3tod.SessionsEndedNotice) {
return _io5c3tod.SessionsEndedNotice.fromJson(data) as T;
}
if (t == _is.getType<_iys46ui1.EmailCodeAccepted?>()) {
return (data != null ? _iys46ui1.EmailCodeAccepted.fromJson(data) : null)
as T;
}
if (t == _is.getType<_iqxum1j8.EmailCodeRequest?>()) {
return (data != null ? _iqxum1j8.EmailCodeRequest.fromJson(data) : null)
as T;
@ -1181,7 +1172,6 @@ class Protocol extends _is.DatabaseSerializationManager {
static String? getClassNameForType(Type type) {
return switch (type) {
_iys46ui1.EmailCodeAccepted => 'EmailCodeAccepted',
_iqxum1j8.EmailCodeRequest => 'EmailCodeRequest',
_id1dpd4g.EmailIdentity => 'EmailIdentity',
_ieyvcag1.EmailSignInRefusal => 'EmailSignInRefusal',
@ -1234,8 +1224,6 @@ class Protocol extends _is.DatabaseSerializationManager {
}
switch (data) {
case _iys46ui1.EmailCodeAccepted():
return 'EmailCodeAccepted';
case _iqxum1j8.EmailCodeRequest():
return 'EmailCodeRequest';
case _id1dpd4g.EmailIdentity():
@ -1320,9 +1308,6 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'EmailCodeAccepted') {
return deserialize<_iys46ui1.EmailCodeAccepted>(data['data']);
}
if (dataClassName == 'EmailCodeRequest') {
return deserialize<_iqxum1j8.EmailCodeRequest>(data['data']);
}

View file

@ -5,9 +5,6 @@ anonymousIdp:
- login:
emailCode:
- sendCode:
- verifyCode:
emailSignIn:
- signIn:
gitHubSignIn:
- signIn:
gitHubUpgrade:
@ -15,7 +12,6 @@ gitHubUpgrade:
instance:
- basics:
accountRecovery:
- sendCode:
- resetAuthenticator:
- beginKeyProof:
- resetPassword:

View file

@ -13,24 +13,10 @@ import 'package:serverpod_auth_idp_server/core.dart';
/// without a session, as the person can't sign in. The instance's register
/// checks every proof (ADR 0016); a session is this server's.
class AccountRecoveryEndpoint extends Endpoint {
/// Mails a code to [address], worded in [language] (`en`, `de`), as email
/// sign-in mails it, and returns the request it answers: the proof of the
/// address that resets take. Refusals as `sendSignInCode`'s.
Future<UuidValue> sendCode(
Session session, {
required String address,
required String language,
}) => AccountRegister.of(session.serverpod).sendSignInCode(
session,
address: address,
language: language,
network: networkOf(session),
);
/// Asks for the reset of the authenticators of the account at [address]:
/// the password's proof for [challenge] (`passwordSignIn.begin`), and a
/// [code] mailed for [request] or the recovery phrase's [signature] for
/// [keyChallenge] ([beginKeyProof]). It takes effect 72 hours later
/// [keyChallenge] ([beginKeyProof]); codes by mail come from `emailCode.sendCode`. It takes effect 72 hours later
/// unless the owner signs in with a code meanwhile; a mail in [language]
/// says so. Refusals as `resetAuthenticator`'s.
Future<ResetPending> resetAuthenticator(

View file

@ -119,24 +119,6 @@ abstract interface class AccountRegister {
required String network,
});
/// Accepts [code] for [request] from [person]: the address becomes theirs
/// (an account of the register, under their ID) unless it is another
/// person's, whose switch token it then gives. Refusals as
/// `acceptSignInCode`'s.
Future<EmailCodeAccepted> verifySignInCode(
Session session, {
required UuidValue person,
required UuidValue request,
required String code,
});
/// The account a switch token names, once only. Refusals as
/// `completeEmailSwitch`'s.
Future<UuidValue> completeEmailSwitch(
Session session, {
required String switchToken,
});
/// Links the GitHub account [gitHubUserId] to [person] (an account of the
/// register, under their ID). Refusals as `linkGitHubAccount`'s.
Future<void> linkGitHub(

View file

@ -178,20 +178,6 @@ Future<Map<String, Object?>?> _answer(
network: arguments.string('network'),
);
return {'request': request.uuid};
case RegisterOperation.verifySignInCode:
final accepted = await register.verifySignInCode(
session,
person: arguments.id('person'),
request: arguments.id('request'),
code: arguments.string('code'),
);
return {'accepted': accepted.toJson()};
case RegisterOperation.completeEmailSwitch:
final account = await register.completeEmailSwitch(
session,
switchToken: arguments.string('switchToken'),
);
return {'account': account.uuid};
case RegisterOperation.linkGitHub:
await register.linkGitHub(
session,

View file

@ -343,25 +343,6 @@ final class LocalRegister implements AccountRegister {
network: network,
);
@override
Future<EmailCodeAccepted> verifySignInCode(
Session session, {
required UuidValue person,
required UuidValue request,
required String code,
}) => email.acceptSignInCode(
session,
authUserId: person,
request: request,
code: code,
);
@override
Future<UuidValue> completeEmailSwitch(
Session session, {
required String switchToken,
}) => email.completeEmailSwitch(session, switchToken: switchToken);
@override
Future<void> linkGitHub(
Session session, {

View file

@ -26,8 +26,6 @@ abstract final class RegisterOperation {
static const fixHandle = 'fix-handle';
static const emailSignInOffered = 'email-sign-in-offered';
static const sendSignInCode = 'send-sign-in-code';
static const verifySignInCode = 'verify-sign-in-code';
static const completeEmailSwitch = 'complete-email-switch';
static const linkGitHub = 'link-github';
static const accountWithGitHub = 'account-with-github';
static const signInWaysOf = 'sign-in-ways-of';

View file

@ -243,33 +243,6 @@ final class RemoteRegister implements AccountRegister {
}))['request'],
)!;
@override
Future<EmailCodeAccepted> verifySignInCode(
Session session, {
required UuidValue person,
required UuidValue request,
required String code,
}) async => switch ((await _ask(RegisterOperation.verifySignInCode, {
'person': person.uuid,
'request': request.uuid,
'code': code,
}))['accepted']) {
final Map<String, Object?> accepted => EmailCodeAccepted.fromJson(accepted),
_ => throw RegisterUnavailableException(
'${RegisterOperation.verifySignInCode}: no answer',
),
};
@override
Future<UuidValue> completeEmailSwitch(
Session session, {
required String switchToken,
}) async => _id(
(await _ask(RegisterOperation.completeEmailSwitch, {
'switchToken': switchToken,
}))['account'],
)!;
@override
Future<void> linkGitHub(
Session session, {

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
@ -60,7 +59,7 @@ final class _Refusing implements CodeMailer {
void main() {
withServerpod(
'Email sign-in',
'Codes by mail',
// Mailing and accepting codes count attempts in transactions of their
// own (Serverpod's rate limiter), which the test's database rollback
// can't wrap. So these tests delete what they make instead.
@ -75,11 +74,9 @@ void main() {
/// Identified, signing in with ben@example.org.
late UuidValue ben;
late UuidValue bensLaptop;
/// Identified, signing in with GitHub.
late UuidValue carla;
late UuidValue carlasPhone;
setUp(() async {
useBase(sessionBuilder);
@ -106,7 +103,7 @@ void main() {
createdAt: clock.now(),
),
);
bensLaptop = await signInNewDevice(
await signInNewDevice(
session,
ben,
scopes: {identified},
@ -119,7 +116,7 @@ void main() {
session,
GitHubAccount(userIdentifier: '1001', authUserId: carla),
);
carlasPhone = await signInNewDevice(
await signInNewDevice(
session,
carla,
scopes: {identified},
@ -142,50 +139,31 @@ void main() {
),
);
TestSessionBuilder asAnna() => signedIn(anna, annasPhone);
TestSessionBuilder asBen() =>
signedIn(ben, bensLaptop, isIdentified: true);
TestSessionBuilder asCarla() =>
signedIn(carla, carlasPhone, isIdentified: true);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
/// Mails a code to [address] for [as] and types it.
Future<EmailCodeAccepted> signInWith(
TestSessionBuilder as,
String address,
) async {
final request = await endpoints.emailCode.sendCode(
as,
address: address,
language: 'en',
);
return await endpoints.emailCode.verifyCode(
as,
request: request,
code: mailbox.codeFor(address.trim().toLowerCase()),
);
}
group('refuses', () {
test('mailing and accepting codes when not signed in', () async {
await expectLater(
endpoints.emailCode.sendCode(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
),
throwsA(isA<ServerpodUnauthenticatedException>()),
test('nothing that turns a code into a session: a code signs nobody '
"in, and makes no address anyone's", () async {
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
);
await expectLater(
endpoints.emailCode.verifyCode(
notSignedIn(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
expect(
await proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
addressHash(session.serverpod, 'anna@example.org'),
);
expect(mailbox.sent, isEmpty);
expect(await _addressesOf(session, anna), isEmpty);
expect(await ServerSideSession.db.count(session), 3);
expect(await EmailCodeRequest.db.count(session), 0);
});
test(
@ -247,8 +225,8 @@ void main() {
);
for (var i = 0; i < 3; i++) {
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: request,
code: 'AAAA-AAAA',
),
@ -256,8 +234,8 @@ void main() {
);
}
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
),
@ -277,8 +255,8 @@ void main() {
await withClock(
Clock.fixed(clock.now().add(const Duration(minutes: 16))),
() => expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: late,
code: lateCode,
),
@ -298,8 +276,8 @@ void main() {
language: 'en',
);
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: replaced,
code: replacedCode,
),
@ -307,14 +285,14 @@ void main() {
);
final newerCode = mailbox.codeFor('anna@example.org');
await endpoints.emailCode.verifyCode(
asAnna(),
await proveAddressCode(
session,
request: newer,
code: newerCode,
);
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
proveAddressCode(
session,
request: newer,
code: newerCode,
),
@ -322,19 +300,6 @@ void main() {
);
});
test(
'a second address for a person who has one; nothing changes',
() async {
await expectLater(
signInWith(asBen(), 'ben@elsewhere.org'),
throwsA(_refusal(EmailSignInRefusal.otherAddressLinked)),
);
expect(await _addressesOf(session, ben), [
addressHash(session.serverpod, 'ben@example.org'),
]);
},
);
test('a fourth code for one address within an hour, and an '
'eleventh from one network', () async {
for (var i = 0; i < 3; i++) {
@ -370,21 +335,6 @@ void main() {
expect(mailbox.sent, hasLength(10));
});
test('a switch token used twice, or made up', () async {
final token = (await signInWith(
asAnna(),
'ben@example.org',
)).switchToken!;
await endpoints.emailSignIn.signIn(notSignedIn(), switchToken: token);
for (final used in [token, 'made-up']) {
await expectLater(
endpoints.emailSignIn.signIn(notSignedIn(), switchToken: used),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
}
});
test('saying how someone signs in when not signed in', () async {
await expectLater(
endpoints.account.signInWays(notSignedIn()),
@ -417,106 +367,6 @@ void main() {
);
});
test("a code for an address nobody has makes it the person's: they "
'become identified, on every session, and stop being anonymous; '
'the instance keeps only a keyed hash of it', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: ' Anna@Example.org',
language: 'en',
);
final code = mailbox.codeFor('anna@example.org');
final accepted = await endpoints.emailCode.verifyCode(
asAnna(),
request: request,
// As typed off the mail: lower case, a space for the dash.
code: code.toLowerCase().replaceAll('-', ' '),
);
expect(accepted.switchToken, isNull);
final hash = addressHash(session.serverpod, 'anna@example.org');
expect(hash, matches(RegExp(r'^[0-9a-f]{64}$')));
expect(await _addressesOf(session, anna), [hash]);
final person = await AuthServices.instance.authUsers.get(
session,
authUserId: anna,
);
expect(person.scopeNames, contains(identified.name));
expect(
(await ServerSideSession.db.findById(
session,
annasPhone,
))?.scopeNames,
contains(identified.name),
);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(await EmailCodeRequest.db.count(session), 0);
final kept = jsonEncode([
...await EmailIdentity.db.find(session),
...await EmailCodeRequest.db.find(session),
...await RateLimitedRequestAttempt.db.find(session),
]);
expect(kept.toLowerCase(), isNot(contains('anna')));
expect(kept, isNot(contains(code)));
});
test('a code for the address the person signs in with changes '
'nothing', () async {
final accepted = await signInWith(asBen(), 'ben@example.org');
expect(accepted.switchToken, isNull);
expect(await EmailIdentity.db.count(session), 1);
});
test("a code for another person's address changes nothing here, and "
'gives a token that signs in as them, without a session', () async {
final accepted = await signInWith(asAnna(), 'ben@example.org');
expect(accepted.switchToken, isNotNull);
expect(await _addressesOf(session, anna), isEmpty);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
final signedIn = await endpoints.emailSignIn.signIn(
notSignedIn(),
switchToken: accepted.switchToken!,
);
expect(signedIn.authUserId, ben);
expect(signedIn.scopeNames, contains(identified.name));
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben) & t.method.equals('email'),
);
expect(sessions, hasLength(1));
expect(await EmailCodeRequest.db.count(session), 0);
});
test('a person who signs in with GitHub adds an address', () async {
Future<(bool, bool)> waysOf(TestSessionBuilder person) async {
final ways = await endpoints.account.signInWays(person);
return (ways.gitHub, ways.email);
}
expect(await waysOf(asCarla()), (true, false));
final accepted = await signInWith(asCarla(), 'carla@example.org');
expect(accepted.switchToken, isNull);
expect(await waysOf(asCarla()), (true, true));
expect(await waysOf(asAnna()), (false, false));
});
test('tells the app that this instance offers it', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,

View file

@ -72,7 +72,7 @@ void main() {
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
final request = await endpoints.accountRecovery.sendCode(
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: address,
language: 'en',

View file

@ -75,7 +75,7 @@ void main() {
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
final request = await endpoints.accountRecovery.sendCode(
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: address,
language: 'en',
@ -674,37 +674,13 @@ void main() {
);
});
test('refuses email codes and GitHub for an account that signs in '
'with a password and an authenticator, not before', () async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
authId: bensLaptop.uuid,
),
);
test('refuses GitHub for an account that signs in with a password '
'and an authenticator, not before', () async {
await annaSetsPassword();
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: anna),
);
Future<EmailCodeAccepted> switchByCode() async {
final request = await endpoints.emailCode.sendCode(
asBen,
address: 'anna@example.org',
language: 'en',
);
return await endpoints.emailCode.verifyCode(
asBen,
request: request,
code: mailbox.codeFor('anna@example.org'),
);
}
final early = await switchByCode();
expect(early.switchToken, isNotNull);
expect(await signsInWithPassword(session, anna), isFalse);
expect(await gitHubAccountToSignIn(session, '1001'), anna);
@ -722,17 +698,6 @@ void main() {
});
expect(await signsInWithPassword(session, anna), isTrue);
await expectLater(
endpoints.emailSignIn.signIn(
notSignedIn(),
switchToken: early.switchToken!,
),
refusedAsEmail(EmailSignInRefusal.usePassword),
);
await expectLater(
switchByCode(),
refusedAsEmail(EmailSignInRefusal.usePassword),
);
await expectLater(
gitHubAccountToSignIn(session, '1001'),
throwsA(
@ -1262,26 +1227,6 @@ void main() {
);
// Nor does a code by mail or GitHub sign in in its place.
expect(await signsInWithPassword(session, anna), isTrue);
final ben = await newAnonymousPerson(session);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
),
);
final request = await endpoints.emailCode.sendCode(
asBen,
address: 'anna@example.org',
language: 'en',
);
await expectLater(
endpoints.emailCode.verifyCode(
asBen,
request: request,
code: mailbox.codeFor('anna@example.org'),
),
refusedAsEmail(EmailSignInRefusal.usePassword),
);
await expectLater(
gitHubAccountToSignIn(session, '1001'),
throwsA(

View file

@ -61,7 +61,7 @@ void main() {
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
final request = await endpoints.accountRecovery.sendCode(
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: address,
language: 'en',

Some files were not shown because too many files have changed in this diff Show more