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

View file

@ -1,8 +1,8 @@
# halkyon_base
What every Halkyon server has, as a Serverpod module (`halkyon_base_server`, with its
generated client `halkyon_base_client`): signing in (with a password and TOTP, ADR 0017,
and the older ways), getting back into an account, the account's ways in and its
generated client `halkyon_base_client`): signing in (with SimpleLogin, Halkyon Learn's
ADR 0020, with a password and TOTP, ADR 0017, and the older ways), getting back into an account, the account's ways in and its
deletion, the pruning of what nobody can return to, what an instance tells its apps
before anyone signs in, and people's public profiles with their pages on the web, which
the instance's register keeps (ADR 0016). It comes from Halkyon Learn's server, whose
@ -19,11 +19,12 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code; a setup never finished, a code by mail); a session. `signUp`: an account at the address a mailed code proves, with a password; a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified`; refused where passwords are offered (deprecated) | signed in |
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name (their account comes with step 3); signed in, SimpleLogin added to the caller's account, who becomes `identified` | anyone |
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified`; refused where passwords or SimpleLogin are offered (deprecated) | signed in |
| `gitHubSignIn` | `signIn`: a session for whoever has that GitHub account, unless they sign in with a password; none once `GITHUB_SIGN_IN_ENDS` has come | without a session |
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `account` | `delete`, `signInWays` | signed in |
| `instance` | `basics`: the app it serves, GitHub's client ID if any and when GitHub sign-in ends, whether codes by mail and password sign-in are on, the source link, the oldest app build, where data and backups are | anyone |
| `instance` | `basics`: the app it serves, GitHub's client ID if any and when GitHub sign-in ends, whether codes by mail, password sign-in and SimpleLogin are on, the source link, the oldest app build, where data and backups are | anyone |
| `profile` | `mine`, `save`, `checkHandle`, `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile, whatever the handle's case | signed in |
@ -43,6 +44,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host),
served by `addProfilePageRoute`, in English or German, its foot what the host's
`profilePageNote` says.
- SimpleLogin (Halkyon Learn's ADR 0020) is halkyon_oidc's sign-in (ADR 0023) with its
SimpleLogin preset, on the register alone, which holds the instance's SimpleLogin app
and exchanges every code; a member asks it to begin and to complete, and issues its own
session. The attempt (ten minutes, taken once) holds a nonce and the caller, its state
the app's and platform's marker; the ID token is checked by halkyon_oidc (RS256, the
issuer, the audience, the expiry, the nonce). Only SimpleLogin's ID is kept
(`halkyon_oidc_identity`, the provider `simplelogin`): one per account, each an
account's. Someone new's identity waits 30 minutes under the attempt's ID, with a
handle suggested from the name SimpleLogin shared (`handleFromName`: `anna_mueller`,
a number added where taken), which nothing keeps.
- SimpleLogin's forwarding page (Halkyon Learn's ADR 0020; `addSimpleLoginCallbackRoute`,
on the register, on halkyon_oidc's `addOidcCallbackForwarding`, ADR 0023):
`/simplelogin-callback`, the one address the instance's SimpleLogin
@ -417,8 +428,8 @@ Built on the three parts above, on the register (members forward every step thro
`pruneAbandonedAnonymousPeople`, `forgetGitHubAccounts` (the register's),
`forgetLapsedResets` and `forgetPeopleTheRegisterForgot` (a member's), hourly
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`,
`forgetUnconfirmedAuthenticators`, `deliverDeletionNotices` and
`deliverSessionNotices` (the register's). Each marked as one mode's does nothing in
`forgetUnconfirmedAuthenticators`, `forgetUnfinishedSimpleLogins` (the
register's), `deliverDeletionNotices` and `deliverSessionNotices` (the register's). Each marked as one mode's does nothing in
the other.
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
@ -438,13 +449,19 @@ The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClie
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; for passwords
`totpKey`, `recoveryCodeKey` and `signInDecoyKey` (each at least 32 characters, or
`addInstanceRoutes` stops the server; all three and email sign-in, or no password
sign-in); between the instance's services `instanceSecret`. Never change either pepper
sign-in); for SimpleLogin, on the register, `simpleLoginClientId` and
`simpleLoginClientSecret` (the instance's SimpleLogin app, both or none); between the
instance's services `instanceSecret`. Never change either pepper
once people sign in: every session or address hash would be lost; changing `totpKey`
voids every authenticator, `recoveryCodeKey` every recovery code. A member needs no email settings of its
own (the register mails the codes), but GitHub's (its own OAuth app's, whose callback is
its app's). The environment: `ACCOUNT_REGISTER_URL` and `INSTANCE_MEMBERS` (above);
`ACCOUNT_DOMAIN`, the domain handles are on, which can never change once anyone follows
one; `GITHUB_SIGN_IN_ENDS`, the day GitHub stops signing anyone in (`2027-01-22`);
one; `SIMPLELOGIN_CALLBACK_URL`, the https address the SimpleLogin app registered, where
it isn't the register's own page (`/simplelogin-callback` on its web server's public
address; development names learn.halkyon.eu's); `SIGN_IN_WAYS`, the ways in the
register offers of those set up, `simplelogin`, `password` or both (unset: every one), a
name it doesn't know stopping the server (`addInstanceRoutes`); `GITHUB_SIGN_IN_ENDS`, the day GitHub stops signing anyone in (`2027-01-22`);
`SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166-2,
`US-CA`, `DE-BY`), the backups also `none`; where the disks are, not which law governs;

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,

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