feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)

The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.

The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.

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-23 21:03:15 +02:00
commit 42d06f7ee8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 3017 additions and 44 deletions

View file

@ -18,6 +18,7 @@ abstract class SignInWays
SignInWays._({
required this.gitHub,
required this.email,
bool? simpleLogin,
bool? password,
bool? authenticator,
bool? passwordOnly,
@ -25,7 +26,8 @@ abstract class SignInWays
this.sessionEndedForProofsAt,
this.authenticatorResetAt,
this.accountResetAt,
}) : password = password ?? false,
}) : simpleLogin = simpleLogin ?? false,
password = password ?? false,
authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false,
secondFactorWorks = secondFactorWorks ?? false;
@ -33,6 +35,7 @@ abstract class SignInWays
factory SignInWays({
required bool gitHub,
required bool email,
bool? simpleLogin,
bool? password,
bool? authenticator,
bool? passwordOnly,
@ -46,6 +49,9 @@ abstract class SignInWays
return SignInWays(
gitHub: _isc.BoolJsonExtension.fromJson(jsonSerialization['gitHub']),
email: _isc.BoolJsonExtension.fromJson(jsonSerialization['email']),
simpleLogin: jsonSerialization['simpleLogin'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['simpleLogin']),
password: jsonSerialization['password'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['password']),
@ -83,6 +89,10 @@ abstract class SignInWays
bool email;
/// SimpleLogin (ADR 0020): the instance's register keeps its ID for the
/// instance's SimpleLogin app.
bool simpleLogin;
/// A password (ADR 0017), which an address needs for resets.
bool password;
@ -118,6 +128,7 @@ abstract class SignInWays
SignInWays copyWith({
bool? gitHub,
bool? email,
bool? simpleLogin,
bool? password,
bool? authenticator,
bool? passwordOnly,
@ -132,6 +143,7 @@ abstract class SignInWays
'__className__': 'halkyon_base.SignInWays',
'gitHub': gitHub,
'email': email,
'simpleLogin': simpleLogin,
'password': password,
'authenticator': authenticator,
'passwordOnly': passwordOnly,
@ -150,6 +162,7 @@ abstract class SignInWays
'__className__': 'halkyon_base.SignInWays',
'gitHub': gitHub,
'email': email,
'simpleLogin': simpleLogin,
'password': password,
'authenticator': authenticator,
'passwordOnly': passwordOnly,
@ -174,6 +187,7 @@ class _SignInWaysImpl extends SignInWays {
_SignInWaysImpl({
required bool gitHub,
required bool email,
bool? simpleLogin,
bool? password,
bool? authenticator,
bool? passwordOnly,
@ -184,6 +198,7 @@ class _SignInWaysImpl extends SignInWays {
}) : super._(
gitHub: gitHub,
email: email,
simpleLogin: simpleLogin,
password: password,
authenticator: authenticator,
passwordOnly: passwordOnly,
@ -200,6 +215,7 @@ class _SignInWaysImpl extends SignInWays {
SignInWays copyWith({
bool? gitHub,
bool? email,
bool? simpleLogin,
bool? password,
bool? authenticator,
bool? passwordOnly,
@ -211,6 +227,7 @@ class _SignInWaysImpl extends SignInWays {
return SignInWays(
gitHub: gitHub ?? this.gitHub,
email: email ?? this.email,
simpleLogin: simpleLogin ?? this.simpleLogin,
password: password ?? this.password,
authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly,

View file

@ -44,6 +44,11 @@ import 'package:halkyon_base_client/src/protocol/profile/profile.dart'
as _i8qdmv6n;
import 'package:halkyon_base_client/src/protocol/profile/profile_refusal.dart'
as _ih722tf9;
import 'package:halkyon_base_client/src/protocol/simplelogin/simplelogin_completed.dart'
as _ianfx7qj;
import 'package:halkyon_base_client/src/protocol/simplelogin/simplelogin_platform.dart'
as _i32mw11n;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
@ -205,8 +210,10 @@ class EndpointInstance extends _isc.EndpointRef {
/// operator's OAuth app and until it ends (ADR 0017), email sign-in only
/// where the register can mail codes (this server, or on a member the
/// register, ADR 0016), password sign-in where the register holds its
/// keys, when GitHub sign-in ends, where its source is, the oldest app
/// build it works with, and where it keeps people's data and its backups.
/// keys, SimpleLogin where the register has the instance's SimpleLogin app
/// (ADR 0020), each as its operator offers it, when GitHub sign-in ends,
/// where its source is, the oldest app build it works with, and where it
/// keeps people's data and its backups.
_ida.Future<_isnyur2i.InstanceBasics> basics() =>
caller.callServerEndpoint<_isnyur2i.InstanceBasics>(
'halkyon_base.instance',
@ -729,6 +736,48 @@ class EndpointProfiles extends _isc.EndpointRef {
);
}
/// Signing in with SimpleLogin (ADR 0020, 3 to 5), signed out or, to add it
/// to one's account, signed in. The instance's register keeps the attempts
/// and checks SimpleLogin's codes (ADR 0016); a session is this server's.
/// Refusals are `OidcRefusedException`s.
/// {@category Endpoint}
class EndpointSimpleLogin extends _isc.EndpointRef {
EndpointSimpleLogin(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'halkyon_base.simpleLogin';
/// Begins a sign-in from this server's app on [platform]: the attempt,
/// whose ID the device keeps to itself, and SimpleLogin's address to open
/// in the browser, which comes back through the forwarding page with a
/// code for [complete]. Refusals as `beginSimpleLogin`'s.
_ida.Future<_ibkpwzgl.OidcStart> begin({
required _i32mw11n.SimpleLoginPlatform platform,
}) => caller.callServerEndpoint<_ibkpwzgl.OidcStart>(
'halkyon_base.simpleLogin',
'begin',
{'platform': platform},
);
/// Ends [attempt] with SimpleLogin's [code], as the caller began it:
/// signed out, a session for the account with that identity, or for
/// someone new the handle suggested (their account comes next); signed
/// in, SimpleLogin added to the caller's account. Serverpod issues no
/// session for another person inside a signed-in call, and nobody is
/// switched to another account. Refusals as `completeSimpleLogin`'s.
_ida.Future<_ianfx7qj.SimpleLoginCompleted> complete({
required _isc.UuidValue attempt,
required String code,
}) => caller.callServerEndpoint<_ianfx7qj.SimpleLoginCompleted>(
'halkyon_base.simpleLogin',
'complete',
{
'attempt': attempt,
'code': code,
},
);
}
class Caller extends _isc.ModuleEndpointCaller {
Caller(_isc.ServerpodClientShared client) : super(client) {
account = EndpointAccount(this);
@ -743,6 +792,7 @@ class Caller extends _isc.ModuleEndpointCaller {
passwordSignIn = EndpointPasswordSignIn(this);
profile = EndpointProfile(this);
profiles = EndpointProfiles(this);
simpleLogin = EndpointSimpleLogin(this);
}
late final EndpointAccount account;
@ -769,6 +819,8 @@ class Caller extends _isc.ModuleEndpointCaller {
late final EndpointProfiles profiles;
late final EndpointSimpleLogin simpleLogin;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {
'halkyon_base.account': account,
@ -783,5 +835,6 @@ class Caller extends _isc.ModuleEndpointCaller {
'halkyon_base.passwordSignIn': passwordSignIn,
'halkyon_base.profile': profile,
'halkyon_base.profiles': profiles,
'halkyon_base.simpleLogin': simpleLogin,
};
}

View file

@ -27,8 +27,10 @@ abstract class InstanceBasics
this.dataLocation,
this.backupLocation,
bool? passwordSignIn,
bool? simpleLogin,
this.gitHubSignInEnds,
}) : passwordSignIn = passwordSignIn ?? false;
}) : passwordSignIn = passwordSignIn ?? false,
simpleLogin = simpleLogin ?? false;
factory InstanceBasics({
required String app,
@ -39,6 +41,7 @@ abstract class InstanceBasics
String? dataLocation,
String? backupLocation,
bool? passwordSignIn,
bool? simpleLogin,
DateTime? gitHubSignInEnds,
}) = _InstanceBasicsImpl;
@ -58,6 +61,9 @@ abstract class InstanceBasics
: _isc.BoolJsonExtension.fromJson(
jsonSerialization['passwordSignIn'],
),
simpleLogin: jsonSerialization['simpleLogin'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['simpleLogin']),
gitHubSignInEnds: jsonSerialization['gitHubSignInEnds'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
@ -100,6 +106,10 @@ abstract class InstanceBasics
/// operator set the keys it needs.
bool passwordSignIn;
/// Whether people sign in with SimpleLogin here (ADR 0020): the
/// instance's register has a SimpleLogin app, and its operator offers it.
bool simpleLogin;
/// When GitHub sign-in ends here (ADR 0017: it is deprecated), as its
/// operator set it (GITHUB_SIGN_IN_ENDS), in UTC; null while no end is set,
/// or once it has ended. People who sign in with GitHub add an address, a
@ -118,6 +128,7 @@ abstract class InstanceBasics
String? dataLocation,
String? backupLocation,
bool? passwordSignIn,
bool? simpleLogin,
DateTime? gitHubSignInEnds,
});
@override
@ -132,6 +143,7 @@ abstract class InstanceBasics
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
'passwordSignIn': passwordSignIn,
'simpleLogin': simpleLogin,
if (gitHubSignInEnds != null)
'gitHubSignInEnds': gitHubSignInEnds?.toJson(),
};
@ -149,6 +161,7 @@ abstract class InstanceBasics
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
'passwordSignIn': passwordSignIn,
'simpleLogin': simpleLogin,
if (gitHubSignInEnds != null)
'gitHubSignInEnds': gitHubSignInEnds?.toJson(),
};
@ -172,6 +185,7 @@ class _InstanceBasicsImpl extends InstanceBasics {
String? dataLocation,
String? backupLocation,
bool? passwordSignIn,
bool? simpleLogin,
DateTime? gitHubSignInEnds,
}) : super._(
app: app,
@ -182,6 +196,7 @@ class _InstanceBasicsImpl extends InstanceBasics {
dataLocation: dataLocation,
backupLocation: backupLocation,
passwordSignIn: passwordSignIn,
simpleLogin: simpleLogin,
gitHubSignInEnds: gitHubSignInEnds,
);
@ -198,6 +213,7 @@ class _InstanceBasicsImpl extends InstanceBasics {
Object? dataLocation = _Undefined,
Object? backupLocation = _Undefined,
bool? passwordSignIn,
bool? simpleLogin,
Object? gitHubSignInEnds = _Undefined,
}) {
return InstanceBasics(
@ -213,6 +229,7 @@ class _InstanceBasicsImpl extends InstanceBasics {
? backupLocation
: this.backupLocation,
passwordSignIn: passwordSignIn ?? this.passwordSignIn,
simpleLogin: simpleLogin ?? this.simpleLogin,
gitHubSignInEnds: gitHubSignInEnds is DateTime?
? gitHubSignInEnds
: this.gitHubSignInEnds,

View file

@ -47,6 +47,9 @@ import 'profile/profile.dart' as _ia5j8fch;
import 'profile/profile_not_found_exception.dart' as _iim3hfcf;
import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'simplelogin/simplelogin_completed.dart' as _i51ry5s2;
import 'simplelogin/simplelogin_outcome.dart' as _i5ar1hzl;
import 'simplelogin/simplelogin_platform.dart' as _ivny5pkt;
export 'auth/email_code_purpose.dart';
export 'auth/email_sign_in_refusal.dart';
export 'auth/email_sign_in_refused_exception.dart';
@ -75,6 +78,9 @@ export 'profile/profile.dart';
export 'profile/profile_not_found_exception.dart';
export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart';
export 'simplelogin/simplelogin_completed.dart';
export 'simplelogin/simplelogin_outcome.dart';
export 'simplelogin/simplelogin_platform.dart';
export 'client.dart';
class Protocol extends _isc.SerializationManager {
@ -206,6 +212,15 @@ class Protocol extends _isc.SerializationManager {
if (t == _idvc5e7p.ProfileRefusedException) {
return _idvc5e7p.ProfileRefusedException.fromJson(data) as T;
}
if (t == _i51ry5s2.SimpleLoginCompleted) {
return _i51ry5s2.SimpleLoginCompleted.fromJson(data) as T;
}
if (t == _i5ar1hzl.SimpleLoginOutcome) {
return _i5ar1hzl.SimpleLoginOutcome.fromJson(data) as T;
}
if (t == _ivny5pkt.SimpleLoginPlatform) {
return _ivny5pkt.SimpleLoginPlatform.fromJson(data) as T;
}
if (t == _isc.getType<_ifpka2on.EmailCodePurpose?>()) {
return (data != null ? _ifpka2on.EmailCodePurpose.fromJson(data) : null)
as T;
@ -328,6 +343,22 @@ class Protocol extends _isc.SerializationManager {
: null)
as T;
}
if (t == _isc.getType<_i51ry5s2.SimpleLoginCompleted?>()) {
return (data != null
? _i51ry5s2.SimpleLoginCompleted.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i5ar1hzl.SimpleLoginOutcome?>()) {
return (data != null ? _i5ar1hzl.SimpleLoginOutcome.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_ivny5pkt.SimpleLoginPlatform?>()) {
return (data != null
? _ivny5pkt.SimpleLoginPlatform.fromJson(data)
: null)
as T;
}
if (t == Set<String>) {
return (data as List).map((e) => deserialize<String>(e)).toSet() as T;
}
@ -391,6 +422,9 @@ class Protocol extends _isc.SerializationManager {
_iim3hfcf.ProfileNotFoundException => 'ProfileNotFoundException',
_i6j6efd1.ProfileRefusal => 'ProfileRefusal',
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_i51ry5s2.SimpleLoginCompleted => 'SimpleLoginCompleted',
_i5ar1hzl.SimpleLoginOutcome => 'SimpleLoginOutcome',
_ivny5pkt.SimpleLoginPlatform => 'SimpleLoginPlatform',
_ => null,
};
}
@ -464,6 +498,12 @@ class Protocol extends _isc.SerializationManager {
return 'ProfileRefusal';
case _idvc5e7p.ProfileRefusedException():
return 'ProfileRefusedException';
case _i51ry5s2.SimpleLoginCompleted():
return 'SimpleLoginCompleted';
case _i5ar1hzl.SimpleLoginOutcome():
return 'SimpleLoginOutcome';
case _ivny5pkt.SimpleLoginPlatform():
return 'SimpleLoginPlatform';
}
return null;
}
@ -558,6 +598,15 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'ProfileRefusedException') {
return deserialize<_idvc5e7p.ProfileRefusedException>(data['data']);
}
if (dataClassName == 'SimpleLoginCompleted') {
return deserialize<_i51ry5s2.SimpleLoginCompleted>(data['data']);
}
if (dataClassName == 'SimpleLoginOutcome') {
return deserialize<_i5ar1hzl.SimpleLoginOutcome>(data['data']);
}
if (dataClassName == 'SimpleLoginPlatform') {
return deserialize<_ivny5pkt.SimpleLoginPlatform>(data['data']);
}
return super.deserializeByClassName(data);
}

View file

@ -0,0 +1,125 @@
/* 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:halkyon_base_client/src/protocol/protocol.dart' as _iwgbpyo5;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../simplelogin/simplelogin_outcome.dart' as _i2lb2chw;
/// A SimpleLogin sign-in ended (ADR 0020, 5 and 6), as the app gets it: the
/// session where it signed in; the handle suggested from the name SimpleLogin
/// shared for someone new here, which nothing keeps (null where none could
/// be made of it).
abstract class SimpleLoginCompleted
implements _isc.SerializableModel, _isc.ProtocolSerialization {
SimpleLoginCompleted._({
required this.outcome,
this.session,
this.suggestedHandle,
});
factory SimpleLoginCompleted({
required _i2lb2chw.SimpleLoginOutcome outcome,
_iacc.AuthSuccess? session,
String? suggestedHandle,
}) = _SimpleLoginCompletedImpl;
factory SimpleLoginCompleted.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return SimpleLoginCompleted(
outcome: _i2lb2chw.SimpleLoginOutcome.fromJson(
(jsonSerialization['outcome'] as String),
),
session: jsonSerialization['session'] == null
? null
: _iwgbpyo5.Protocol().deserialize<_iacc.AuthSuccess>(
jsonSerialization['session'],
),
suggestedHandle: jsonSerialization['suggestedHandle'] as String?,
);
}
_i2lb2chw.SimpleLoginOutcome outcome;
_iacc.AuthSuccess? session;
String? suggestedHandle;
/// Returns a shallow copy of this [SimpleLoginCompleted]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SimpleLoginCompleted copyWith({
_i2lb2chw.SimpleLoginOutcome? outcome,
_iacc.AuthSuccess? session,
String? suggestedHandle,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SimpleLoginCompleted',
'outcome': outcome.toJson(),
if (session != null) 'session': session?.toJson(),
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.SimpleLoginCompleted',
'outcome': outcome.toJson(),
if (session != null) 'session': session?.toJson(),
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
_SimpleLoginCompletedImpl({
required _i2lb2chw.SimpleLoginOutcome outcome,
_iacc.AuthSuccess? session,
String? suggestedHandle,
}) : super._(
outcome: outcome,
session: session,
suggestedHandle: suggestedHandle,
);
/// Returns a shallow copy of this [SimpleLoginCompleted]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SimpleLoginCompleted copyWith({
_i2lb2chw.SimpleLoginOutcome? outcome,
Object? session = _Undefined,
Object? suggestedHandle = _Undefined,
}) {
return SimpleLoginCompleted(
outcome: outcome ?? this.outcome,
session: session is _iacc.AuthSuccess?
? session
: this.session?.copyWith(),
suggestedHandle: suggestedHandle is String?
? suggestedHandle
: this.suggestedHandle,
);
}
}

View file

@ -0,0 +1,44 @@
/* 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_client/serverpod_client.dart' as _isc;
/// How a SimpleLogin sign-in ended (ADR 0020, 5 and 6): signed in to the
/// account with that identity; the identity added to the account of who
/// began it signed in; or someone new here, signed out, whose identity
/// waits for them to make an account or to mend the link to theirs.
enum SimpleLoginOutcome implements _isc.SerializableModel {
signedIn,
linked,
newcomer;
static SimpleLoginOutcome fromJson(String name) {
switch (name) {
case 'signedIn':
return SimpleLoginOutcome.signedIn;
case 'linked':
return SimpleLoginOutcome.linked;
case 'newcomer':
return SimpleLoginOutcome.newcomer;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SimpleLoginOutcome"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,40 @@
/* 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_client/serverpod_client.dart' as _isc;
/// Where an app begins a SimpleLogin sign-in (ADR 0020, 3), which says where
/// the forwarding page sends it back: the desktop's loopback, or the phone's
/// scheme (or, with learn.halkyon.eu's App Links, the phone's app itself).
enum SimpleLoginPlatform implements _isc.SerializableModel {
desktop,
phone;
static SimpleLoginPlatform fromJson(String name) {
switch (name) {
case 'desktop':
return SimpleLoginPlatform.desktop;
case 'phone':
return SimpleLoginPlatform.phone;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SimpleLoginPlatform"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}