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
31 changed files with 3017 additions and 44 deletions

View file

@ -3,8 +3,8 @@
/// deletion and the pruning, email sign-in's helpers, the instance's
/// basics, people's public profiles and their pages, the instance's register
/// (ADR 0016), signing in with a password and TOTP and getting back into an
/// account (ADR 0017), SimpleLogin's forwarding page (Halkyon Learn's ADR
/// 0020), and the module's models.
/// account (ADR 0017), signing in with SimpleLogin and its forwarding page
/// (ADR 0020), the ways in an operator offers, and the module's models.
library;
export 'src/auth/account.dart';
@ -14,6 +14,7 @@ export 'src/auth/email.dart';
export 'src/auth/github.dart';
export 'src/auth/github_sign_in_endpoint.dart' show gitHubAccountToSignIn;
export 'src/auth/identified.dart';
export 'src/auth/offered_ways.dart';
export 'src/auth/scopes.dart';
export 'src/common/network.dart';
export 'src/common/short_code.dart';
@ -74,11 +75,15 @@ export 'src/register/member_pruning.dart';
export 'src/register/people.dart' show issueSession;
export 'src/register/sessions_ended.dart'
show AccountSessionsEnded, deliverSessionNotices;
export 'src/simplelogin/handle_suggestion.dart'
show handleFromName, handlesToTry, suggestable;
export 'src/simplelogin/simplelogin.dart' show forgetUnfinishedSimpleLogins;
export 'src/simplelogin/simplelogin_callback.dart'
show
addSimpleLoginCallbackRoute,
simpleLoginCallbackPath,
simpleLoginReturns;
export 'src/simplelogin/simplelogin_config.dart';
export 'src/srp/srp_group.dart';
export 'src/srp/srp_math.dart' show numberOf, padded;
export 'src/srp/srp_server.dart'

View file

@ -1,4 +1,5 @@
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_config.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
@ -38,7 +39,12 @@ const anonymousSignUpLimit = RateLimit(
/// GitHub only where the operator registered a GitHub OAuth app (see
/// [gitHubSignInAvailable]); its helpers serve our own slim login
/// (`github.dart`), and Serverpod's stock GitHub login endpoint isn't exposed.
///
/// SimpleLogin (ADR 0020) on the register, where it has the instance's
/// SimpleLogin app ([simpleLoginIdpConfig]): halkyon_oidc's sign-in, whose
/// parts `simplelogin.dart` uses; its stock endpoint isn't exposed either.
List<IdentityProviderBuilder> identityProviderBuilders(Serverpod pod) => [
const AnonymousIdpConfig(perIpAddressRateLimit: null),
if (gitHubSignInAvailable(pod)) GitHubIdpConfigFromPasswords(),
?simpleLoginIdpConfig(pod),
];

View file

@ -8,6 +8,7 @@ import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_config.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_auth_idp_server/providers/github.dart';
@ -207,7 +208,8 @@ Future<String> verifiedGitHubUserId(
/// Linking the account the person already has changes nothing. Refused, with
/// nothing changed, when the account belongs to someone else or the person
/// has another GitHub account, and wherever people sign in with a password
/// (ADR 0017: GitHub is deprecated, [GitHubSignInRefusal.deprecated])
/// or SimpleLogin (ADR 0017, ADR 0020: GitHub is deprecated,
/// [GitHubSignInRefusal.deprecated])
/// ([GitHubSignInRefusedException]). An account someone else has is said to
/// be so there too, as nothing would be linked: an anonymous person's app
/// then switches to it (`gitHubSignIn.signIn`), which still signs in those
@ -237,7 +239,8 @@ Future<void> linkGitHubAccount(
reason: GitHubSignInRefusal.takenByAnotherPerson,
);
}
if (passwordSignInAvailable(session.serverpod)) {
if (passwordSignInAvailable(session.serverpod) ||
simpleLoginAvailable(session.serverpod)) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.deprecated,
);

View file

@ -0,0 +1,59 @@
import 'dart:io';
import 'package:serverpod/serverpod.dart';
/// A way in an operator may offer on their instance (ADR 0020, 1), by its
/// name in `SIGN_IN_WAYS`.
enum OfferedWay {
simpleLogin('simplelogin'),
password('password');
OfferedWay(this.setting);
/// Its name in `SIGN_IN_WAYS`.
final String setting;
}
/// The ways in [pod]'s operator offers (`SIGN_IN_WAYS`); null for every way
/// whose keys are there. The register's alone counts: a member asks the
/// register what it offers. Throws a [StateError] for a way it doesn't know
/// ([offeredWaysFrom]), which hosts check as they start
/// (`addInstanceRoutes`).
Set<OfferedWay>? offeredWaysOf(Serverpod pod) => switch (_waysOf[pod]) {
(:final ways) => ways,
null => offeredWaysFrom(Platform.environment['SIGN_IN_WAYS']),
};
/// The ways [value] (`SIGN_IN_WAYS`) names, by commas or spaces, whatever
/// their case: `simplelogin`, `password` or both; null for none named
/// (unset or empty, as Compose passes an unset variable), which offers every
/// way whose keys are there. Throws a [StateError] for any other name.
Set<OfferedWay>? offeredWaysFrom(String? value) {
final names = [
for (final name in (value ?? '').split(RegExp(r'[\s,]+')))
if (name.isNotEmpty) name.toLowerCase(),
];
if (names.isEmpty) {
return null;
}
final byName = {for (final way in OfferedWay.values) way.setting: way};
return {
for (final name in names)
byName[name] ??
(throw StateError(
'SIGN_IN_WAYS names "$name": the ways are '
'${OfferedWay.values.map((way) => way.setting).join(' and ')}.',
)),
};
}
/// Whether [pod]'s operator offers [way], its keys there or not.
bool offersWay(Serverpod pod, OfferedWay way) =>
offeredWaysOf(pod)?.contains(way) ?? true;
/// Puts [ways] in place of the operator's `SIGN_IN_WAYS` on [pod], null
/// meaning every way whatever the environment says: tests do.
void useOfferedWays(Serverpod pod, Set<OfferedWay>? ways) =>
_waysOf[pod] = (ways: ways);
final _waysOf = Expando<({Set<OfferedWay>? ways})>('Offered ways');

View file

@ -3,6 +3,9 @@ class: SignInWays
fields:
gitHub: bool
email: bool
### SimpleLogin (ADR 0020): the instance's register keeps its ID for the
### instance's SimpleLogin app.
simpleLogin: bool, default=false
### A password (ADR 0017), which an address needs for resets.
password: bool, default=false
### A confirmed authenticator: the password signs in only with one.

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: _is.BoolJsonExtension.fromJson(jsonSerialization['gitHub']),
email: _is.BoolJsonExtension.fromJson(jsonSerialization['email']),
simpleLogin: jsonSerialization['simpleLogin'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['simpleLogin']),
password: jsonSerialization['password'] == null
? null
: _is.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

@ -23,6 +23,8 @@ import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
as _ikhyee47;
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
as _iji0wmu8;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_platform.dart'
as _ixrtiuh0;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
@ -40,6 +42,7 @@ import '../password/authenticator_endpoint.dart' as _i3xqawxj;
import '../password/password_endpoint.dart' as _ig7fyg1b;
import '../password/password_sign_in_endpoint.dart' as _ik13m562;
import '../profile/profile_endpoint.dart' as _i6ky944g;
import '../simplelogin/simplelogin_endpoint.dart' as _ia8c9alv;
class Endpoints extends _is.EndpointDispatch {
@override
@ -117,6 +120,12 @@ class Endpoints extends _is.EndpointDispatch {
'profiles',
'halkyon_base',
),
'simpleLogin': _ia8c9alv.SimpleLoginEndpoint()
..initialize(
server,
'simpleLogin',
'halkyon_base',
),
};
connectors['account'] = _is.EndpointConnector(
name: 'account',
@ -1091,6 +1100,58 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['simpleLogin'] = _is.EndpointConnector(
name: 'simpleLogin',
endpoint: endpoints['simpleLogin']!,
methodConnectors: {
'begin': _is.MethodConnector(
name: 'begin',
params: {
'platform': _is.ParameterDescription(
name: 'platform',
type: _is.getType<_ixrtiuh0.SimpleLoginPlatform>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['simpleLogin'] as _ia8c9alv.SimpleLoginEndpoint)
.begin(
session,
platform: params['platform'],
),
),
'complete': _is.MethodConnector(
name: 'complete',
params: {
'attempt': _is.ParameterDescription(
name: 'attempt',
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['simpleLogin'] as _ia8c9alv.SimpleLoginEndpoint)
.complete(
session,
attempt: params['attempt'],
code: params['code'],
),
),
},
);
modules['halkyon_oidc'] = _ipoqfiqr.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()

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;
@ -56,6 +59,9 @@ abstract class InstanceBasics
passwordSignIn: jsonSerialization['passwordSignIn'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['passwordSignIn']),
simpleLogin: jsonSerialization['simpleLogin'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['simpleLogin']),
gitHubSignInEnds: jsonSerialization['gitHubSignInEnds'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
@ -98,6 +104,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
@ -116,6 +126,7 @@ abstract class InstanceBasics
String? dataLocation,
String? backupLocation,
bool? passwordSignIn,
bool? simpleLogin,
DateTime? gitHubSignInEnds,
});
@override
@ -130,6 +141,7 @@ abstract class InstanceBasics
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
'passwordSignIn': passwordSignIn,
'simpleLogin': simpleLogin,
if (gitHubSignInEnds != null)
'gitHubSignInEnds': gitHubSignInEnds?.toJson(),
};
@ -147,6 +159,7 @@ abstract class InstanceBasics
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
'passwordSignIn': passwordSignIn,
'simpleLogin': simpleLogin,
if (gitHubSignInEnds != null)
'gitHubSignInEnds': gitHubSignInEnds?.toJson(),
};
@ -170,6 +183,7 @@ class _InstanceBasicsImpl extends InstanceBasics {
String? dataLocation,
String? backupLocation,
bool? passwordSignIn,
bool? simpleLogin,
DateTime? gitHubSignInEnds,
}) : super._(
app: app,
@ -180,6 +194,7 @@ class _InstanceBasicsImpl extends InstanceBasics {
dataLocation: dataLocation,
backupLocation: backupLocation,
passwordSignIn: passwordSignIn,
simpleLogin: simpleLogin,
gitHubSignInEnds: gitHubSignInEnds,
);
@ -196,6 +211,7 @@ class _InstanceBasicsImpl extends InstanceBasics {
Object? dataLocation = _Undefined,
Object? backupLocation = _Undefined,
bool? passwordSignIn,
bool? simpleLogin,
Object? gitHubSignInEnds = _Undefined,
}) {
return InstanceBasics(
@ -211,6 +227,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

@ -61,6 +61,10 @@ 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;
import 'simplelogin/simplelogin_completed.dart' as _i51ry5s2;
import 'simplelogin/simplelogin_outcome.dart' as _i5ar1hzl;
import 'simplelogin/simplelogin_platform.dart' as _ivny5pkt;
import 'simplelogin/simplelogin_proof.dart' as _iauqtzog;
export 'auth/email_code_purpose.dart';
export 'auth/email_code_request.dart';
export 'auth/email_identity.dart';
@ -102,6 +106,10 @@ export 'profile/profile_refused_exception.dart';
export 'profile/public_profile.dart';
export 'register/account_deletion_notice.dart';
export 'register/sessions_ended_notice.dart';
export 'simplelogin/simplelogin_completed.dart';
export 'simplelogin/simplelogin_outcome.dart';
export 'simplelogin/simplelogin_platform.dart';
export 'simplelogin/simplelogin_proof.dart';
class Protocol extends _is.DatabaseSerializationManager {
Protocol._();
@ -1169,6 +1177,18 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _io5c3tod.SessionsEndedNotice) {
return _io5c3tod.SessionsEndedNotice.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 == _iauqtzog.SimpleLoginProof) {
return _iauqtzog.SimpleLoginProof.fromJson(data) as T;
}
if (t == _is.getType<_ifpka2on.EmailCodePurpose?>()) {
return (data != null ? _ifpka2on.EmailCodePurpose.fromJson(data) : null)
as T;
@ -1349,6 +1369,26 @@ class Protocol extends _is.DatabaseSerializationManager {
: null)
as T;
}
if (t == _is.getType<_i51ry5s2.SimpleLoginCompleted?>()) {
return (data != null
? _i51ry5s2.SimpleLoginCompleted.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_i5ar1hzl.SimpleLoginOutcome?>()) {
return (data != null ? _i5ar1hzl.SimpleLoginOutcome.fromJson(data) : null)
as T;
}
if (t == _is.getType<_ivny5pkt.SimpleLoginPlatform?>()) {
return (data != null
? _ivny5pkt.SimpleLoginPlatform.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_iauqtzog.SimpleLoginProof?>()) {
return (data != null ? _iauqtzog.SimpleLoginProof.fromJson(data) : null)
as T;
}
if (t == Set<String>) {
return (data as List).map((e) => deserialize<String>(e)).toSet() as T;
}
@ -1428,6 +1468,10 @@ class Protocol extends _is.DatabaseSerializationManager {
_ioppe2ep.PublicProfile => 'PublicProfile',
_i2lyzpm9.AccountDeletionNotice => 'AccountDeletionNotice',
_io5c3tod.SessionsEndedNotice => 'SessionsEndedNotice',
_i51ry5s2.SimpleLoginCompleted => 'SimpleLoginCompleted',
_i5ar1hzl.SimpleLoginOutcome => 'SimpleLoginOutcome',
_ivny5pkt.SimpleLoginPlatform => 'SimpleLoginPlatform',
_iauqtzog.SimpleLoginProof => 'SimpleLoginProof',
_ => null,
};
}
@ -1527,6 +1571,14 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'AccountDeletionNotice';
case _io5c3tod.SessionsEndedNotice():
return 'SessionsEndedNotice';
case _i51ry5s2.SimpleLoginCompleted():
return 'SimpleLoginCompleted';
case _i5ar1hzl.SimpleLoginOutcome():
return 'SimpleLoginOutcome';
case _ivny5pkt.SimpleLoginPlatform():
return 'SimpleLoginPlatform';
case _iauqtzog.SimpleLoginProof():
return 'SimpleLoginProof';
}
className = _isp.Protocol().getClassNameForObject(data);
if (className != null) {
@ -1664,6 +1716,18 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'SessionsEndedNotice') {
return deserialize<_io5c3tod.SessionsEndedNotice>(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']);
}
if (dataClassName == 'SimpleLoginProof') {
return deserialize<_iauqtzog.SimpleLoginProof>(data['data']);
}
if (dataClassName.startsWith('serverpod.')) {
data['className'] = dataClassName.substring(10);
return _isp.Protocol().deserializeByClassName(data);

View file

@ -44,3 +44,6 @@ profile:
- remove:
profiles:
- byHandle:
simpleLogin:
- begin:
- complete:

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_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
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 _is.SerializableModel, _is.ProtocolSerialization {
SimpleLoginCompleted._({
required this.outcome,
this.session,
this.suggestedHandle,
});
factory SimpleLoginCompleted({
required _i2lb2chw.SimpleLoginOutcome outcome,
_iacs.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
: _ibewtqgw.Protocol().deserialize<_iacs.AuthSuccess>(
jsonSerialization['session'],
),
suggestedHandle: jsonSerialization['suggestedHandle'] as String?,
);
}
_i2lb2chw.SimpleLoginOutcome outcome;
_iacs.AuthSuccess? session;
String? suggestedHandle;
/// Returns a shallow copy of this [SimpleLoginCompleted]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SimpleLoginCompleted copyWith({
_i2lb2chw.SimpleLoginOutcome? outcome,
_iacs.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 _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
_SimpleLoginCompletedImpl({
required _i2lb2chw.SimpleLoginOutcome outcome,
_iacs.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.
@_is.useResult
@override
SimpleLoginCompleted copyWith({
_i2lb2chw.SimpleLoginOutcome? outcome,
Object? session = _Undefined,
Object? suggestedHandle = _Undefined,
}) {
return SimpleLoginCompleted(
outcome: outcome ?? this.outcome,
session: session is _iacs.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/serverpod.dart' as _is;
/// 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 _is.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/serverpod.dart' as _is;
/// 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 _is.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;
}

View file

@ -0,0 +1,111 @@
/* 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;
import '../simplelogin/simplelogin_outcome.dart' as _i2lb2chw;
/// What the register gives a member (or itself) once a SimpleLogin sign-in's
/// ID token checked out (ADR 0020, 4): how it ended, the account signed in
/// to (the member issues its own session for it), and for someone new the
/// handle suggested from their name.
abstract class SimpleLoginProof
implements _is.SerializableModel, _is.ProtocolSerialization {
SimpleLoginProof._({
required this.outcome,
this.account,
this.suggestedHandle,
});
factory SimpleLoginProof({
required _i2lb2chw.SimpleLoginOutcome outcome,
_is.UuidValue? account,
String? suggestedHandle,
}) = _SimpleLoginProofImpl;
factory SimpleLoginProof.fromJson(Map<String, dynamic> jsonSerialization) {
return SimpleLoginProof(
outcome: _i2lb2chw.SimpleLoginOutcome.fromJson(
(jsonSerialization['outcome'] as String),
),
account: jsonSerialization['account'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['account']),
suggestedHandle: jsonSerialization['suggestedHandle'] as String?,
);
}
_i2lb2chw.SimpleLoginOutcome outcome;
_is.UuidValue? account;
String? suggestedHandle;
/// Returns a shallow copy of this [SimpleLoginProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SimpleLoginProof copyWith({
_i2lb2chw.SimpleLoginOutcome? outcome,
_is.UuidValue? account,
String? suggestedHandle,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SimpleLoginProof',
'outcome': outcome.toJson(),
if (account != null) 'account': account?.toJson(),
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SimpleLoginProofImpl extends SimpleLoginProof {
_SimpleLoginProofImpl({
required _i2lb2chw.SimpleLoginOutcome outcome,
_is.UuidValue? account,
String? suggestedHandle,
}) : super._(
outcome: outcome,
account: account,
suggestedHandle: suggestedHandle,
);
/// Returns a shallow copy of this [SimpleLoginProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
SimpleLoginProof copyWith({
_i2lb2chw.SimpleLoginOutcome? outcome,
Object? account = _Undefined,
Object? suggestedHandle = _Undefined,
}) {
return SimpleLoginProof(
outcome: outcome ?? this.outcome,
account: account is _is.UuidValue? ? account : this.account,
suggestedHandle: suggestedHandle is String?
? suggestedHandle
: this.suggestedHandle,
);
}
}

View file

@ -30,6 +30,9 @@ fields:
### Whether people sign in with a password and TOTP here (ADR 0017): the
### operator set the keys it needs.
passwordSignIn: bool, default=false
### Whether people sign in with SimpleLogin here (ADR 0020): the
### instance's register has a SimpleLogin app, and its operator offers it.
simpleLogin: bool, default=false
### 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

View file

@ -8,6 +8,7 @@ import 'package:halkyon_base_server/src/instance/source_code.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_config.dart';
import 'package:serverpod/serverpod.dart';
/// What every Halkyon instance tells its apps. Public: an app asks before
@ -17,8 +18,10 @@ class InstanceEndpoint extends Endpoint {
/// 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.
Future<InstanceBasics> basics(Session session) async {
final pod = session.serverpod;
final base = HalkyonBase.of(pod);
@ -37,6 +40,11 @@ class InstanceEndpoint extends Endpoint {
return register.passwordSignInOffered(session);
})
: passwordSignInAvailable(pod),
simpleLogin: isMember(pod)
? await _registerOffers(session, (register) {
return register.simpleLoginOffered(session);
})
: simpleLoginAvailable(pod),
sourceCodeUrl: sourceCodeUrl(pod),
minimumAppBuild: base.minimumAppBuild,
dataLocation: dataLocation(),

View file

@ -1,4 +1,5 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/auth/offered_ways.dart';
import 'package:serverpod/serverpod.dart';
/// The shortest key of the instance taken for password sign-in: shorter, it
@ -67,9 +68,12 @@ PasswordKeys? passwordKeysFrom(String? Function(String name) password) {
/// Whether people sign in with a password and TOTP on [pod]: it holds the
/// register's keys, and email sign-in's pepper and mailer, since the
/// addresses' hashes are made with the one and codes and notices of resets
/// go out through the other ([emailSignInAvailable]).
/// go out through the other ([emailSignInAvailable]), and its operator
/// offers it (`SIGN_IN_WAYS`, ADR 0020).
bool passwordSignInAvailable(Serverpod pod) =>
passwordKeysOf(pod) != null && emailSignInAvailable(pod);
passwordKeysOf(pod) != null &&
emailSignInAvailable(pod) &&
offersWay(pod, OfferedWay.password);
/// Puts [keys] in place of [pod]'s passwords' keys, null meaning none
/// whatever the passwords hold: tests do, so a test run's passwords (CI's

View file

@ -6,6 +6,7 @@ import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:halkyon_base_server/src/register/local_register.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:serverpod/serverpod.dart';
/// The instance's register of accounts (ADR 0016): what one person is across
@ -132,6 +133,31 @@ abstract interface class AccountRegister {
/// for none.
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId);
/// Whether people sign in with SimpleLogin (ADR 0020): the register has
/// the instance's SimpleLogin app, and its operator offers it.
Future<bool> simpleLoginOffered(Session session);
/// Begins a SimpleLogin sign-in for the app and platform [marker] names,
/// by [caller] if signed in, the sign-ins begun from [network] counted.
/// Refusals as `beginSimpleLogin`'s.
Future<OidcStart> beginSimpleLogin(
Session session, {
required String marker,
UuidValue? caller,
required String network,
});
/// Ends the SimpleLogin sign-in [attempt] with SimpleLogin's [code], as
/// [caller] (null signed out) began it: how it ended, the account signed
/// in to, which the member issues its own session for. Refusals as
/// `completeSimpleLogin`'s.
Future<SimpleLoginProof> completeSimpleLogin(
Session session, {
required UuidValue attempt,
required String code,
UuidValue? caller,
});
/// How [person] signs in: with GitHub, an email address, a password and
/// whether an authenticator is confirmed; and their warning that a session
/// was ended for its wrong proofs, if any.

View file

@ -193,6 +193,24 @@ Future<Map<String, Object?>?> _answer(
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.signInWaysOf:
final ways = await register.signInWaysOf(
session,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/auth/offered_ways.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
@ -9,6 +10,7 @@ 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/instance_api.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_config.dart';
import 'package:serverpod/serverpod.dart';
/// Serves the calls between the instance's services (ADR 0016) on [pod]'s
@ -17,8 +19,9 @@ import 'package:serverpod/serverpod.dart';
/// ([accountGonePath], [sessionsEndedPath]); each answers only in its mode, and only with the
/// instance's secret. Caddy never forwards them: the services reach each
/// other on the instance's own network. Throws as [checkInstanceSecret],
/// [checkPasswordKeys], [gitHubSignInEnds] and [trustedProxies] do, so a
/// server set up so stops as it starts.
/// [checkPasswordKeys], [gitHubSignInEnds], [offeredWaysOf],
/// [simpleLoginAppOf] and [trustedProxies] do, so a server set up so stops
/// as it starts.
void addInstanceRoutes(Serverpod pod) {
checkInstanceSecret(pod);
// Here too, as every host calls this as it starts: a key for password
@ -26,6 +29,10 @@ void addInstanceRoutes(Serverpod pod) {
// stops the server rather than serving with it (ADR 0017).
checkPasswordKeys(pod);
gitHubSignInEnds(pod);
// So too ways in that the module doesn't know (SIGN_IN_WAYS), and a
// SimpleLogin app with no callback SimpleLogin takes (ADR 0020).
offeredWaysOf(pod);
simpleLoginAppOf(pod);
// And proxies that are no addresses: counted by the proxy's address, every
// visitor would share each per-network limit.
trustedProxies();

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