feat(app): adding SimpleLogin to an account (ADR 0020, D21, D23)
halkyon_app reads where the device's person stands with SimpleLogin (nothing to show, linked, or missing) and shows it in SimpleLoginSection, which each app places above the password part: Add SimpleLogin, once added one more way in. GitHub is added to nobody where SimpleLogin is offered, and Learn's Profile offers an anonymous person SimpleLogin alone there (D23). No cut-off day, reminder or banner (D24): with the developer the only person on learn.halkyon.eu, the cut-off is the operator taking GitHub's OAuth app out of the deploy's settings once that account has SimpleLogin. ADR 0020 records it and drops step 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3a93dc4fd8
commit
fad12b4411
10 changed files with 829 additions and 77 deletions
|
|
@ -73,7 +73,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
## Rules the code relies on
|
||||
|
||||
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
|
||||
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address with a password) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address and password, one of each (`account.signInWays`, which also says whether they have a password and a confirmed authenticator); the Profile tab lists their ways in, adds GitHub where the instance adds it (never where passwords are offered), and holds halkyon_app's `AccountSecuritySection` (setting up a password, the authenticators, changing it).
|
||||
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address with a password) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address and password, one of each (`account.signInWays`, which also says whether they have a password and a confirmed authenticator); the Profile tab lists their ways in, adds GitHub where the instance adds it (never where passwords or SimpleLogin are offered), and holds halkyon_app's `SimpleLoginSection` (adding SimpleLogin, ADR 0020's D21; an anonymous person's only way to keep the account where the instance offers it, D23) and `AccountSecuritySection` (setting up a password, the authenticators, changing it).
|
||||
- **A password and an authenticator** (ADR 0017, built 2026-10-20 for build 22; halkyon_base's and halkyon_app's READMEs): the only way an identified person signs in besides GitHub, which is deprecated. The address, a password that never leaves the device (SRP-6a, `passwordSignIn.begin` and `complete`, the verifier and the account's wrapped keys on the register) and a code from an authenticator app (TOTP, mandatory) or a recovery code. An address nobody has is answered as an account's would be (decoys from `signInDecoyKey`), and every wrong credential is refused alike (`wrongCredentials`). Lost authenticator: the password and a mailed code (or the recovery phrase) turn it off 72 hours later, a mail at the start, a sign-in with a code calling it off; lost password: the recovery phrase's signature and a code set a new one, keys kept, or without the phrase a mailed code resets the whole account (new keys) after the same 72 hours. A reset takes effect only once the owner had 72 hours outside hold-outs (codes waiting, the address's ceiling filled: each moves it on by the time it holds the owner out); one that took effect and wasn't carried out lapses a week later, and every reset 30 days after it was asked; the Profile tab's account section shows waiting resets, called off with the password and a code (ADR 0017's full reading). An account with a password and a confirmed authenticator no longer signs in by a mailed code or GitHub (`usePassword`); where passwords are offered, no new GitHub account is linked (`deprecated`), and from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in and, 30 days later (a date set wrong can be put right first), the register's pruning forgets every GitHub ID, deleting first the accounts that signed in with GitHub alone (no address), which the app warns of until then (D2; the full reading's M1). Password sign-in is on only where the passwords hold `totpKey`, `recoveryCodeKey` and `signInDecoyKey` (`TOTP_KEY`, `RECOVERY_CODE_KEY`, `SIGN_IN_DECOY_KEY` in `deploy/.env`) and email sign-in is set up (`passwordSignIn` in `instance.basics()`). On the device (halkyon_app): signing up shows the recovery phrase once and checks three of its words before anything reaches the instance; signing in checks SRP's M2 and opens the keys before the session is put in place; an authenticator still to set up is set up with the session held back; the unlocked keys stay in the system's keyring per instance and person, gone once another person signs in or nobody. A session alone never gets a code checked (ADR 0017's third reading, as Proton has it): every signed-in change of how the account is secured (adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password, deleting) takes the password and a code (`OwnerProof`), each sheet asking for both; a session's wrong proofs count for it alone and the tenth in a row ends it, never making the account's codes wait. Nor does a session alone set a first password or confirm a first authenticator (the fourth reading): an account with an address proves it by a code mailed there (`firstPassword`), one that signs in with GitHub alone by a GitHub sign-in of the last 10 minutes (the app offers a password right after one, before the person's app, and asks for GitHub anew when the instance says `signInAgain`), and the first authenticator takes the password. A change of password ends the account's other sessions and every reset all of them, the authenticators' included as the sign-in carries it out, on every service of the instance (`sessions_ended_notice`). ADR 0017's "The rules as they stand" lists who changes what with which proofs.
|
||||
- **One account across the instance** (ADR 0016, decided 2026-10-19; halkyon_base's README): Learn's server holds the instance's register, so its tables keep every service's ways in, handles and profiles, and an account's ID is the `AuthUser` ID in every service. The instance's other services (Todo) are its members: they ask it over its web server (`/halkyon-base/register/…`, `addInstanceRoutes` in `server.dart`, never forwarded by Caddy) with the secret the services share (`instanceSecret`, `INSTANCE_SECRET` in `deploy/.env`), and a member's anonymous person adding a way in becomes an account here under the same ID. Deleting an account (here, or a member passing it on) keeps a notice per member in the same transaction (`account_deletion_notice`), told at once and hourly until each confirms (`tellMembersOfDeletions`). Learn's endpoints and code go through the module as on any register; nothing changes for Learn's apps. The sign-in with SimpleLogin is the register's (ADR 0020, step 2 built 2026-10-23: halkyon_base's `simpleLogin` endpoint on halkyon_oidc, the instance's SimpleLogin app as `SIMPLELOGIN_CLIENT_ID` and `SIMPLELOGIN_CLIENT_SECRET` in `deploy/.env`, the ways offered as `SIGN_IN_WAYS`), and so is its forwarding page, built ahead of it: `addSimpleLoginCallbackRoute` in `server.dart`, `/simplelogin-callback` on the web server, which Caddy forwards there, sending a sign-in on to the app by the marker its state begins with (halkyon_base's README); beside it `deploy/Caddyfile` answers Android's `/.well-known/assetlinks.json` for Halkyon Learn signed with the release key, on learn.halkyon.eu alone (`test/deploy/assetlinks_test.dart` holds it to `tool/release/android-certificate.sha256`).
|
||||
- **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`.
|
||||
|
|
|
|||
|
|
@ -470,6 +470,7 @@
|
|||
"profileSettingsTooltip": "Einstellungen",
|
||||
"profileIdentified": "Dein Fortschritt wird in deinem Konto gespeichert.",
|
||||
"profileAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
|
||||
"profileAnonymousSimpleLogin": "Dein Fortschritt wird auf diesem Gerät gespeichert. Füge SimpleLogin hinzu, um ihn zu behalten.",
|
||||
"profileSignInWithGitHub": "Mit GitHub anmelden",
|
||||
"profileSignInFailed": "Anmeldung mit GitHub fehlgeschlagen.",
|
||||
"profileContinueInBrowser": "Fahre in deinem Browser fort.",
|
||||
|
|
|
|||
|
|
@ -535,6 +535,7 @@
|
|||
"profileSettingsTooltip": "Settings",
|
||||
"profileIdentified": "Your progress is kept with your account.",
|
||||
"profileAnonymous": "Progress is saved on this device. Sign in to keep it.",
|
||||
"profileAnonymousSimpleLogin": "Progress is saved on this device. Add SimpleLogin to keep it.",
|
||||
"profileSignInWithGitHub": "Sign in with GitHub",
|
||||
"profileSignInFailed": "Couldn't sign in with GitHub.",
|
||||
"profileContinueInBrowser": "Continue in your browser.",
|
||||
|
|
|
|||
|
|
@ -2830,6 +2830,12 @@ abstract class AppLocalizations {
|
|||
/// **'Progress is saved on this device. Sign in to keep it.'**
|
||||
String get profileAnonymous;
|
||||
|
||||
/// No description provided for @profileAnonymousSimpleLogin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Progress is saved on this device. Add SimpleLogin to keep it.'**
|
||||
String get profileAnonymousSimpleLogin;
|
||||
|
||||
/// No description provided for @profileSignInWithGitHub.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1719,6 +1719,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get profileAnonymous =>
|
||||
'Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.';
|
||||
|
||||
@override
|
||||
String get profileAnonymousSimpleLogin =>
|
||||
'Dein Fortschritt wird auf diesem Gerät gespeichert. Füge SimpleLogin hinzu, um ihn zu behalten.';
|
||||
|
||||
@override
|
||||
String get profileSignInWithGitHub => 'Mit GitHub anmelden';
|
||||
|
||||
|
|
|
|||
|
|
@ -1703,6 +1703,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get profileAnonymous =>
|
||||
'Progress is saved on this device. Sign in to keep it.';
|
||||
|
||||
@override
|
||||
String get profileAnonymousSimpleLogin =>
|
||||
'Progress is saved on this device. Add SimpleLogin to keep it.';
|
||||
|
||||
@override
|
||||
String get profileSignInWithGitHub => 'Sign in with GitHub';
|
||||
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ class Dependencies({
|
|||
onDispose: () async {
|
||||
await deviceDictionaries.dispose();
|
||||
await passwords.dispose();
|
||||
await simpleLogin.dispose();
|
||||
await authGateway.dispose();
|
||||
client.close();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -155,34 +155,40 @@ class const _Account() extends StatelessWidget {
|
|||
),
|
||||
children: [
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.settingsAccount,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isIdentified
|
||||
? l10n.profileIdentified
|
||||
: l10n.profileAnonymous,
|
||||
),
|
||||
if (isIdentified) const _SignInWays(),
|
||||
if (!isIdentified) ...[
|
||||
const _GitHubSignIn(),
|
||||
const SizedBox(height: 16),
|
||||
// A password keeps the account too (ADR 0017).
|
||||
const AccountSecuritySection(),
|
||||
const SizedBox(height: 16),
|
||||
// Where the person stands with SimpleLogin (ADR 0020, D21,
|
||||
// D23), read once for the card's parts that show it.
|
||||
child: SimpleLoginStandingScope(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.publicProfileAnonymousHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
l10n.settingsAccount,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_AccountLine(identified: isIdentified),
|
||||
if (isIdentified) const _SignInWays(),
|
||||
if (!isIdentified) ...[
|
||||
const _GitHubSignIn(),
|
||||
// SimpleLogin keeps the account, where the instance
|
||||
// offers it.
|
||||
const SimpleLoginSection(gapAbove: 16),
|
||||
const SizedBox(height: 16),
|
||||
// A password keeps the account too (ADR 0017).
|
||||
const AccountSecuritySection(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.publicProfileAnonymousHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isIdentified) ...[
|
||||
|
|
@ -201,6 +207,26 @@ class const _Account() extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the account card says first: that the account keeps the progress,
|
||||
/// or that only this device does, with what keeps it (SimpleLogin, where
|
||||
/// the instance offers it).
|
||||
class const _AccountLine({required final bool identified})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (identified) {
|
||||
return Text(l10n.profileIdentified);
|
||||
}
|
||||
final simpleLogin = context.watch<SimpleLoginStandingCubit>().state;
|
||||
return Text(
|
||||
simpleLogin is SimpleLoginMissing
|
||||
? l10n.profileAnonymousSimpleLogin
|
||||
: l10n.profileAnonymous,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _identifiedIn(SessionState state) => switch (state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
|
|
@ -274,6 +300,8 @@ class const _SignInWays() extends StatelessWidget {
|
|||
l10n.profileWayEmail,
|
||||
linkedAs: ways.emailAddress ?? l10n.profileWayLinked,
|
||||
),
|
||||
// SimpleLogin (ADR 0020, D21): once added, one more way in here.
|
||||
const SimpleLoginSection(gapAbove: 16),
|
||||
const SizedBox(height: 16),
|
||||
// The password and authenticators, and setting them up (ADR 0017),
|
||||
// which brings the address.
|
||||
|
|
@ -286,31 +314,38 @@ class const _SignInWays() extends StatelessWidget {
|
|||
/// The way to keep an anonymous account: signing in with GitHub, where the
|
||||
/// instance offers it. Where people sign in with a password it reaches only
|
||||
/// a GitHub account someone has (ADR 0017), and the password beneath keeps
|
||||
/// the account instead.
|
||||
/// the account instead. Not where the instance offers SimpleLogin, which
|
||||
/// alone keeps it there (ADR 0020, D23), nor until the instance has said.
|
||||
class const _GitHubSignIn() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<GitHubSignInBloc, GitHubSignInState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
GitHubSignInStateChecking() ||
|
||||
GitHubSignInStateUnavailable() ||
|
||||
GitHubSignInStateLinked() => const SizedBox.shrink(),
|
||||
GitHubSignInStateReady(:final failed, :final problem) => _button(
|
||||
context,
|
||||
enabled: true,
|
||||
failed: failed,
|
||||
problem: problem,
|
||||
),
|
||||
// Only for someone identified, who sees their ways in instead.
|
||||
GitHubSignInStateTaken() => _button(context, enabled: true),
|
||||
GitHubSignInStateBusy(cancellable: true) => const Padding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: _GitHubWaiting(),
|
||||
),
|
||||
GitHubSignInStateBusy() ||
|
||||
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
|
||||
},
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
final simpleLogin = context.watch<SimpleLoginStandingCubit>().state;
|
||||
if (simpleLogin is SimpleLoginChecking ||
|
||||
simpleLogin is SimpleLoginMissing) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return BlocBuilder<GitHubSignInBloc, GitHubSignInState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
GitHubSignInStateChecking() ||
|
||||
GitHubSignInStateUnavailable() ||
|
||||
GitHubSignInStateLinked() => const SizedBox.shrink(),
|
||||
GitHubSignInStateReady(:final failed, :final problem) => _button(
|
||||
context,
|
||||
enabled: true,
|
||||
failed: failed,
|
||||
problem: problem,
|
||||
),
|
||||
// Only for someone identified, who sees their ways in instead.
|
||||
GitHubSignInStateTaken() => _button(context, enabled: true),
|
||||
GitHubSignInStateBusy(cancellable: true) => const Padding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: _GitHubWaiting(),
|
||||
),
|
||||
GitHubSignInStateBusy() ||
|
||||
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _button(
|
||||
BuildContext context, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
// Where the instance offers SimpleLogin (ADR 0020, D21, D23): it alone keeps
|
||||
// an anonymous account.
|
||||
void main() {
|
||||
late FakeAuthGateway auth;
|
||||
late FakeSimpleLoginGateway simpleLogin;
|
||||
|
||||
setUp(() {
|
||||
auth = FakeAuthGateway();
|
||||
simpleLogin = FakeSimpleLoginGateway(auth);
|
||||
});
|
||||
|
||||
Future<void> start(WidgetTester tester) => pumpApp(
|
||||
tester,
|
||||
testDependencies(authGateway: auth, simpleLoginGateway: simpleLogin),
|
||||
);
|
||||
|
||||
testWidgets('an anonymous person is offered SimpleLogin alone, not GitHub '
|
||||
'(D23)', (tester) async {
|
||||
await start(tester);
|
||||
await openTab(tester, 'Profile');
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Progress is saved on this device. Add SimpleLogin to keep it.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Sign in with GitHub'), findsNothing);
|
||||
expect(find.text('Add SimpleLogin'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('without SimpleLogin, GitHub keeps the account as before', (
|
||||
tester,
|
||||
) async {
|
||||
simpleLogin.isOffered = false;
|
||||
await start(tester);
|
||||
await openTab(tester, 'Profile');
|
||||
|
||||
expect(
|
||||
find.text('Progress is saved on this device. Sign in to keep it.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Sign in with GitHub'), findsOneWidget);
|
||||
expect(find.textContaining('SimpleLogin'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
@ -175,6 +175,8 @@ Dependencies testDependencies({
|
|||
MediaPlayers? mediaPlayers,
|
||||
PasswordGateway? passwordGateway,
|
||||
AccountKeyStore? accountKeys,
|
||||
// On the same `authGateway`; none offers SimpleLogin unless given.
|
||||
FakeSimpleLoginGateway? simpleLoginGateway,
|
||||
}) {
|
||||
final device = deviceDictionaries ?? noDeviceDictionaries();
|
||||
final auth = authGateway ?? FakeAuthGateway();
|
||||
|
|
@ -192,10 +194,10 @@ Dependencies testDependencies({
|
|||
auth,
|
||||
addresses,
|
||||
),
|
||||
// Offered by no instance in Learn's tests: halkyon_app's tests test its
|
||||
// sheet.
|
||||
// Offered by no instance in Learn's tests but where one is given:
|
||||
// halkyon_app's tests test its sheet.
|
||||
simpleLogin: SimpleLoginRepository(
|
||||
FakeSimpleLoginGateway(auth)..isOffered = false,
|
||||
simpleLoginGateway ?? (FakeSimpleLoginGateway(auth)..isOffered = false),
|
||||
FakeOidcBrowser(),
|
||||
crypto,
|
||||
keys,
|
||||
|
|
|
|||
Loading…
Reference in a new issue