feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
All checks were successful
CI / check (push) Successful in 30m13s
CI / deploy (push) Has been skipped

The server's half of D25: every account has a way in, and the anonymous
accounts left from before go with the daily pruning.

- The module signs nobody in anonymously: `anonymousIdp` and
  `gitHubUpgrade` are gone, and so is linking GitHub at the register.
- A password is set only by someone whose address is theirs (a
  `firstPassword` code) or who signs in with GitHub alone (a fresh
  sign-in and a `signUp` code); an anonymous account is refused
  `wrongCredentials` before any code is used.
- SimpleLogin refuses to link onto a caller without `identified`
  (`wrongCaller`), the member's endpoint too, so nothing turns an
  anonymous account into one with a way in.
- `pruneAnonymousPeople` deletes every anonymous account left, however
  new, never one identified. Learn and Todo schedule it as
  `prune-accounts` and cancel the old `prune-abandoned-anonymous-people`
  entry once.
- Learn: someone without `identified` keeps no files;
  `FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds
  up to 22 start with an anonymous account.
- Learn's sync tests make their person and session by SQL in the
  development database, as Serverpod stores sessions.
- Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended.

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-24 16:07:27 +02:00
commit 87671208df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
37 changed files with 837 additions and 1353 deletions

View file

@ -14,14 +14,12 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| Endpoint | Methods | Who may call |
|---|---|---|
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code | signed in |
| `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 |
| `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; signed in, SimpleLogin added to the caller's account, who becomes `identified`; for an account, whether it has its keys and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `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; signed in (`identified` only, `wrongCaller` otherwise), SimpleLogin added to the caller's account; for an account, whether it has its keys and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `keys` | for an account that signs in with SimpleLogin (Halkyon Learn's ADR 0020, D19 and D20): `begin`, `unlock`: the caller's keys for their device, once the password is proven with its own proof key; `set`: the first keys (and handle) of an account SimpleLogin was added to, with a SimpleLogin sign-in of the last ten minutes; `changePassword`, `beginPhrase` and `resetPassword` (a lost password, with the recovery phrase), `setRecoveryKeys`: each with the password's proof (the phrase's) and such a sign-in (`KeysOwnerProof`), a new password ending every other session; the tenth wrong proof in a row ends the session, as ADR 0017's do | signed in |
| `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` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |
@ -93,22 +91,23 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`simpleLogin.begin` takes the way back as a `SimpleLoginReturn`), the rest 1 to 512
characters of base64url; anything else gets a page saying the sign-in can't go on,
repeating nothing it was given, in English or German. It keeps and logs nothing.
- **Per-network limits** (new anonymous accounts, codes by mail, password sign-ins,
proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the
- **No anonymous accounts** (Halkyon Learn's ADR 0020, D25): every account has a way
in. An anonymous account left from before signs into nothing new and keeps no
password or SimpleLogin; the app deletes its own as it starts (`account.delete`), and
the pruning deletes the rest (`pruneAnonymousPeople`), whatever their age.
- **Per-network limits** (codes by mail, password sign-ins, SimpleLogin's sign-ins,
handle checks, proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the
connection's, or, for a connection from a proxy the operator trusts
(`TRUSTED_PROXIES`), the last address of `X-Forwarded-For`, the one the proxy saw
itself. Never the `Forwarded` header, which Caddy passes on as the visitor wrote it,
so never Serverpod's `remoteInfo`. An IPv6 visitor counts by their /64
(`networkKeyOf`), which one line or server holds whole. Serverpod's own limit on
anonymous accounts is off
(it counts by the connection's address, behind a proxy the proxy's for everyone); the
endpoint counts them itself.
(`networkKeyOf`), which one line or server holds whole.
- Refusals are `PasswordRefusedException`, `GitHubSignInRefusedException`,
`EmailSignInRefusedException` and `ProfileRefusedException`, each with a reason;
`ProfileNotFoundException` where there is no profile.
- For the host's own code, `package:halkyon_base_server/base.dart`: the configuration,
`tokenManagerBuilders` and `identityProviderBuilders`, the `identified` scope and
`grantIdentified`, `deletePerson`, the pruning (`pruneAbandonedAnonymousPeople`,
`grantIdentified`, `deletePerson`, the pruning (`pruneAnonymousPeople`,
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`, `forgetUnconfirmedAuthenticators`,
`forgetGitHubAccounts`, `forgetLapsedResets`), short codes, `sourceCodeUrl`,
`dataLocation`, `backupLocation`, people's profiles as the host's code reads them
@ -125,9 +124,9 @@ One operator's services share one account per person: the same address, GitHub a
and handle reach the same person in each, under one account ID (each service's
`AuthUser` has it), and deleting the account deletes it everywhere. One service holds
the register, its database keeping the ways in (`email_identity`, GitHub's IDs), the
handles and profiles; the others are its members and ask it. Anonymous people stay each
service's own until they add a way in, which makes them an account of the register under
the same ID. Sessions stay each service's own: every app signs in itself.
handles and profiles; the others are its members and ask it. An anonymous account left
from before stays its service's own. Sessions stay each service's own: every app signs
in itself.
- **Modes** (`AccountsMode`, `HalkyonBase.accounts`; a host reads it with
`AccountsMode.fromEnvironment()`): `HoldsRegister` by default, also for an instance of
@ -151,19 +150,19 @@ the same ID. Sessions stay each service's own: every app signs in itself.
asks the register to mail a code (the device's network passed on, as the register
counts codes per network) and to check it, or to say whose GitHub ID it is, and opens a
session of its own for that account (`issueSession`, which makes them a person there
under the account's ID the first time). Linking a way in makes the person identified on
under the account's ID the first time). Adding a way in makes the person identified on
the register and then on the member; a switch token signs in as the account the
address is already. Every refusal is the register's, as on the register itself.
- **Deleting:** a deletion on a member asks the register first whose account it is
(`knownAccounts`), and is refused while it can't be reached, deleting nothing, a
person still anonymous on the member too: the register may have linked them while
their becoming identified there failed (the app then gives an anonymous account up
all the same, leaving it to the pruning). An account of the register's goes there,
their becoming identified there failed (the app then deletes an anonymous account
left from before all the same, leaving it to the pruning). An account of the register's goes there,
which deletes the account and
keeps a notice for each member in the same transaction (`account_deletion_notice`),
told at once and again hourly (`deliverDeletionNotices`, tried least first) until each
confirms; notices to a member no longer named are dropped. An anonymous person goes
from their service alone. Each member also deletes, daily, the identified people the
confirms; notices to a member no longer named are dropped. An anonymous account goes
from its service alone. Each member also deletes, daily, the identified people the
register no longer knows (`forgetPeopleTheRegisterForgot`): it asks about everyone
before deleting anyone, deletes nobody while the register knows none of them (a
register misnamed or emptied), and fails, deleting nobody, while it can't reach it.
@ -238,12 +237,12 @@ Built on the three parts above, on the register (members forward every step thro
3–16 passes, 64 MiB–1 GiB), verifier (1 < v < N, as long as N), public keys (32 bytes
each) and both wrappings (24-byte nonce, 112 bytes). It needs an address, and a
session alone sets none (ADR 0017's fourth reading): an account with an address proves
it anew by a code mailed to it for this (`firstPassword`); an anonymous person by a
mailed code (`signUp`) for the address they add, which becomes theirs as email sign-in
makes it (anonymous → `identified`, same ID); an account that signs in with GitHub
alone does that within 10 minutes of a GitHub sign-in on the session asking
it anew by a code mailed to it for this (`firstPassword`); an account that signs in
with GitHub alone does that within 10 minutes of a GitHub sign-in on the session asking
(`gitHubSignInFreshFor`, the session row's method and creation time, a member telling
the register; else `signInAgain`, before any code is used). It signs in once an
the register; else `signInAgain`, before any code is used), the address it adds proved
by a mailed code (`signUp`). Anyone else, an anonymous account left from before among
them, sets none (`wrongCredentials`, before any code is used). It signs in once an
authenticator is confirmed; until then a new setup replaces it, never once one was,
even while a reset leaves the account without one (a new password comes through the
recovery phrase or the account's reset alone).
@ -390,17 +389,14 @@ Built on the three parts above, on the register (members forward every step thro
(`takesEffectAt`).
- **Older ways:** an account that signs in with a password (it had a confirmed
authenticator, which a reset may have taken since: `passwordOnly` in its ways in) no
longer signs in with GitHub (`usePassword`); where passwords are offered, no new GitHub
account is linked (`deprecated`; one someone else has is still `takenByAnotherPerson`,
so an anonymous person's app switches to it); from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in
longer signs in with GitHub (`usePassword`); no account gets GitHub any more (D25),
so it signs in only those who have it; from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in
(`ended`), the basics stop naming the day (`gitHubSignInEnds`, which apps show until
then) and, 30 days later (`gitHubAccountsKeptAfterEnd`, so a date set wrong can be put
right first), `forgetGitHubAccounts` deletes every GitHub ID on the register, first
deleting the accounts that sign in with GitHub alone (no address, never a password),
each on its own (one failing is tried again the next day; one deleted whose host step
after failed counts as deleted), the members told once after all of them. `password.set` adds an
address without GitHub only for someone anonymous where the session lives
(`anonymousHere`, which a member tells the register).
after failed counts as deleted), the members told once after all of them.
## Taking it into a host
@ -455,7 +451,7 @@ Built on the three parts above, on the register (members forward every step thro
after any literal routes of its own.
Then schedule the pruning in a future call of the host's own: daily
`pruneAbandonedAnonymousPeople`, `forgetGitHubAccounts` (the register's),
`pruneAnonymousPeople`, `forgetGitHubAccounts` (the register's),
`forgetLapsedResets` and `forgetPeopleTheRegisterForgot` (a member's), hourly
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`,
`forgetUnconfirmedAuthenticators`, `forgetUnfinishedSimpleLogins` (the

View file

@ -1,4 +1,3 @@
import 'package:halkyon_base_server/src/auth/auth_config.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
@ -128,7 +127,8 @@ Future<void> deletePerson(
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// Their GitHub id and anonymous account go with them (ON DELETE CASCADE).
// Their GitHub ID and SimpleLogin identity go with them, and an
// anonymous account left from before (ON DELETE CASCADE).
// Gone already on a member the register told first (ADR 0016).
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1
if (person != null) {
@ -148,50 +148,48 @@ Future<void> deletePerson(
}
}
/// Deletes the anonymous people nobody can return to, at most [limit] per
/// call, and returns how many: created before and unused since 90 days
/// before [now]. Their sessions have expired by then (the inactivity
/// timeout, `auth_config.dart`), and an anonymous account can't sign in
/// again. Identified people are never pruned. Each goes through
/// [deletePerson], so the host's data of theirs goes too.
Future<int> pruneAbandonedAnonymousPeople(
Session session, {
required DateTime now,
int limit = 500,
}) async {
// The columns hold UTC as timestamp without time zone.
final cutoff = now
.toUtc()
.subtract(const Duration(days: 90))
.toIso8601String()
.replaceFirst('Z', '');
final abandoned = await session.db.unsafeQuery(
/// Deletes the anonymous accounts left from before every account had a way
/// in (ADR 0020, D25), at most [limit] per call, and returns how many: none
/// is made any more, an app deletes its own as it starts, and none can sign
/// in again once its session is gone, so the rest are a device's that never
/// started a newer app. Whatever their age: the app no longer serves them
/// (`minimumAppBuild`). Someone `identified` with an anonymous account's
/// row left is never pruned. Each goes through [deletePerson], so the
/// host's data of theirs goes too.
Future<int> pruneAnonymousPeople(Session session, {int limit = 500}) async {
// "scopeNames" holds the person's scopes as a JSON array of names.
final anonymous = await session.db.unsafeQuery(
'SELECT a."authUserId" FROM "serverpod_auth_idp_anonymous_account" a '
'WHERE a."createdAt" < @cutoff::timestamp '
'AND NOT EXISTS (SELECT 1 FROM "serverpod_auth_core_session" s '
'WHERE s."authUserId" = a."authUserId" '
'AND s."lastUsedAt" >= @cutoff::timestamp) '
'JOIN "serverpod_auth_core_user" u ON u."id" = a."authUserId" '
'WHERE NOT EXISTS (SELECT 1 FROM json_array_elements_text(u."scopeNames") '
'AS scope WHERE scope = @identified) '
'ORDER BY a."createdAt" LIMIT @limit',
parameters: QueryParameters.named({'cutoff': cutoff, 'limit': limit}),
parameters: QueryParameters.named({
'identified': identified.name,
'limit': limit,
}),
);
for (final row in abandoned) {
for (final row in anonymous) {
await deletePerson(session, UuidValue.withValidation(row.first.toString()));
}
return abandoned.length;
return anonymous.length;
}
/// Deletes Serverpod's notes of sign-up attempts that are older than the
/// rate limit looks back ([anonymousSignUpLimit]) at [now], and returns how
/// many: each holds the IP address it came from, which the instance keeps no
/// longer than the limit needs it. Serverpod itself deletes them only for its
/// own email sign-in, which Halkyon servers don't use. Email sign-in's and
/// password sign-in's notes go too: none of their limits looks back
/// further.
/// How far back the limits counting in Serverpod's notes of attempts look
/// (`RateLimitedRequestAttempt`): email sign-in's, password sign-in's,
/// SimpleLogin's and the handle checks' all count an hour.
const _attemptsLookBack = Duration(hours: 1);
/// Deletes Serverpod's notes of attempts that are older than the limits
/// look back ([_attemptsLookBack]) at [now], and returns how many: each
/// holds the IP address it came from, which the instance keeps no longer
/// than the limit needs it. Serverpod itself deletes them only for its own
/// email sign-in, which Halkyon servers don't use.
Future<int> forgetOldSignUpAttempts(
Session session, {
required DateTime now,
}) async {
final cutoff = now.toUtc().subtract(anonymousSignUpLimit.timeframe);
final cutoff = now.toUtc().subtract(_attemptsLookBack);
// DELETE FROM "serverpod_auth_idp_rate_limited_request_attempt"
// WHERE "attemptedAt" < $1
final forgotten = await RateLimitedRequestAttempt.db.deleteWhere(

View file

@ -15,8 +15,8 @@ class AccountEndpoint extends Endpoint {
bool get requireLogin => true;
/// Deletes the caller with everything they have (see [deletePerson]). The
/// app also calls it when an anonymous account is given up (decided
/// 2026-09-30). Their other devices learn at once that their sessions
/// app also calls it for an anonymous account left from before, as it
/// starts (ADR 0020, D25). Their other devices learn at once that their sessions
/// ended. An account deletes everywhere (ADR 0016): on a member, the
/// register deletes it and tells every member, this one deleting its own
/// data at once; an anonymous person is this service's alone. An account
@ -83,7 +83,7 @@ class AccountEndpoint extends Endpoint {
/// ([RegisterUnavailableException]), a person still anonymous here
/// neither (the short reading's L2): the register may have linked them
/// while their becoming identified here failed. An anonymous account
/// given up meanwhile stays to the pruning (90 days unused).
/// given up meanwhile stays to the nightly pruning.
static Future<bool> _registersAccount(
Session session,
UuidValue person,
@ -91,8 +91,8 @@ class AccountEndpoint extends Endpoint {
session.serverpod,
).knownAccounts(session, {person})).contains(person);
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
/// How the caller signs in besides their sessions ([SignInWays]): with
/// SimpleLogin, a password, an email address or GitHub.
Future<SignInWays> signInWays(Session session) => AccountRegister.of(
session.serverpod,
).signInWaysOf(session, session.authenticated!.authUserId);

View file

@ -1,35 +0,0 @@
import 'package:halkyon_base_server/src/auth/auth_config.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
/// Signs people in anonymously; the app calls it on first launch.
class AnonymousIdpEndpoint extends AnonymousIdpBaseEndpoint {
/// Creates a new anonymous account and returns its session: at most 30 an
/// hour from one network (`anonymousSignUpLimit`), counted by the
/// visitor's address (`networkOf`) rather than as Serverpod's own limit
/// counts them, by the connection's, which behind a proxy is the proxy's
/// for everyone.
@override
Future<AuthSuccess> login(Session session, {String? token}) async {
if (!await _accountsPerNetwork.tryRecordAttempt(
session,
key: networkOf(session),
)) {
throw AnonymousAccountBlockedException(
reason: AnonymousAccountBlockedExceptionReason.tooManyAttempts,
);
}
return await super.login(session, token: token);
}
}
final _accountsPerNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'anonymous',
source: 'accounts_per_network',
maxAttempts: anonymousSignUpLimit.maxAttempts,
timeframe: anonymousSignUpLimit.timeframe,
),
);

View file

@ -2,39 +2,22 @@ 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';
import 'package:serverpod_auth_idp_server/providers/github.dart';
// How people authenticate. The server and its tests use this same
// configuration, so the tests check what production runs.
/// Server-side sessions: revocable immediately, scopes live on the session
/// row. No absolute lifetime (an anonymous session cannot be restored). The
/// inactivity timeout matches the pruning policy for abandoned accounts.
/// row. No absolute lifetime, only 90 days unused.
final List<TokenManagerBuilder> tokenManagerBuilders = [
ServerSideSessionsConfigFromPasswords(
defaultSessionInactivityTimeout: const Duration(days: 90),
),
];
/// How many new anonymous accounts one network (the visitor's IP address,
/// `networkOf`) may make, and in what time: counted by the anonymous
/// endpoint itself (`AnonymousIdpEndpoint`), as Serverpod's own limit counts
/// by the connection's address, behind a proxy the proxy's. Each attempt is
/// noted with that address, which the instance forgets once the time is up
/// (`forgetOldSignUpAttempts`).
const anonymousSignUpLimit = RateLimit(
maxAttempts: 30,
timeframe: Duration(hours: 1),
);
/// The ways people sign in on [pod].
///
/// Anonymous accounts are the trial mode for solo use. At most 30 new ones per
/// IP address and hour (Serverpod's default is 100): a school class behind one
/// address can still start together, while one abuser gets at most 720 a day.
/// [anonymousSignUpLimit] counts them; Serverpod's own limit is off, as it
/// would count every visitor behind a proxy as one.
/// The ways people sign in on [pod], besides a password and codes by mail,
/// which are the module's own (`passwords.dart`, `email.dart`). No anonymous
/// accounts (ADR 0020, D25): every account has a way in.
///
/// GitHub only where the operator registered a GitHub OAuth app (see
/// [gitHubSignInAvailable]); its helpers serve our own slim login
@ -44,7 +27,6 @@ const anonymousSignUpLimit = RateLimit(
/// 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

@ -2,13 +2,9 @@ import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/account.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
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';
@ -198,82 +194,11 @@ Future<String> verifiedGitHubUserId(
return details.userIdentifier;
}
/// Links the GitHub account [gitHubUserId] to the person [authUserId], in one
/// transaction: they become `identified` (on their existing sessions too) and
/// lose their anonymous identity, since they now sign in with GitHub. The
/// person stays the same, so everything they have stays where it is; a
/// member's person becomes an account of the register under their ID
/// (ADR 0016).
///
/// 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
/// 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
/// who have one.
Future<void> linkGitHubAccount(
Session session, {
required UuidValue authUserId,
required String gitHubUserId,
}) => session.db.transaction((transaction) async {
// SELECT * FROM "serverpod_auth_idp_github_account"
// WHERE "userIdentifier" = $1 OR "authUserId" = $2
final accounts = await GitHubAccount.db.find(
session,
where: (t) =>
t.userIdentifier.equals(gitHubUserId) | t.authUserId.equals(authUserId),
transaction: transaction,
);
if (accounts.any(
(account) =>
account.userIdentifier == gitHubUserId &&
account.authUserId == authUserId,
)) {
return;
}
if (accounts.any((account) => account.userIdentifier == gitHubUserId)) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.takenByAnotherPerson,
);
}
if (passwordSignInAvailable(session.serverpod) ||
simpleLoginAvailable(session.serverpod)) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.deprecated,
);
}
if (accounts.isNotEmpty) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.otherGitHubAccountLinked,
);
}
await ensurePerson(session, authUserId, transaction: transaction);
// INSERT INTO "serverpod_auth_idp_github_account"
// ("userIdentifier", "authUserId", "created") VALUES ($1, $2, $3)
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: gitHubUserId, authUserId: authUserId),
transaction: transaction,
);
await grantIdentified(session, authUserId, transaction: transaction);
// DELETE FROM "serverpod_auth_idp_anonymous_account" WHERE "authUserId" = $1
await AnonymousAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
});
/// The person who has the GitHub account [gitHubUserId]; null for nobody.
/// A session for them is the caller's to issue (`issueSession`), when it
/// switches to them on another device.
///
/// Nobody is created here: people start anonymous and link GitHub later
/// ([linkGitHubAccount]).
/// Nobody is created here: no account gets GitHub any more (ADR 0017, D2).
Future<UuidValue?> accountWithGitHub(
Session session, {
required String gitHubUserId,

View file

@ -1,38 +0,0 @@
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Links a GitHub account to the signed-in person: the lossless upgrade from
/// an anonymous account. The person and everything they have stay
/// where they are; only the way they sign in changes. The instance's
/// register keeps the GitHub ID (ADR 0016).
class GitHubUpgradeEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Links the GitHub account that [code] proves to the caller; refusals as in
/// [linkGitHubAccount].
Future<void> upgrade(
Session session, {
required String code,
required String codeVerifier,
required String redirectUri,
}) async {
final gitHubUserId = await verifiedGitHubUserId(
session,
code: code,
codeVerifier: codeVerifier,
redirectUri: redirectUri,
);
final person = session.authenticated!.authUserId;
await AccountRegister.of(
session.serverpod,
).linkGitHub(session, person: person, gitHubUserId: gitHubUserId);
if (isMember(session.serverpod)) {
await becomeIdentified(session, person);
}
}
}

View file

@ -1,7 +1,8 @@
import 'package:serverpod/serverpod.dart';
/// People with an identity, such as GitHub, rather than only an anonymous
/// account. Granted when they link one (see `grantIdentified`).
/// People with a way in, every account since anonymous ones went (ADR 0020,
/// D25), unlike an anonymous account left from before. Granted as a way in
/// becomes theirs (see `grantIdentified`).
///
/// Endpoints that involve other people or several devices require it through
/// `requiredScopes`, never through checks of their own.

View file

@ -1,4 +1,4 @@
### The ways a person signs in, besides an anonymous account's session.
### The ways a person signs in, besides their sessions.
class: SignInWays
fields:
gitHub: bool

View file

@ -4,8 +4,8 @@ import 'dart:typed_data';
import 'package:serverpod/serverpod.dart';
// The network a request came from, which the per-network rate limits count
// by (new anonymous accounts, codes by mail, password sign-ins and proofs of
// the recovery phrase). Behind a reverse proxy every connection comes from
// by (codes by mail, password sign-ins, SimpleLogin's sign-ins, handle
// checks and proofs of the recovery phrase). Behind a reverse proxy every connection comes from
// the proxy, which would make each of those limits the whole instance's.
// So for a connection from a proxy the operator trusts (`TRUSTED_PROXIES`;
// deploy/compose.yaml names Caddy), the visitor is the last address of

View file

@ -12,7 +12,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// The ways a person signs in, besides an anonymous account's session.
/// The ways a person signs in, besides their sessions.
abstract class SignInWays
implements _is.SerializableModel, _is.ProtocolSerialization {
SignInWays._({

View file

@ -40,10 +40,8 @@ import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import '../auth/account_endpoint.dart' as _ii6ycf6v;
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
import '../auth/email_code_endpoint.dart' as _in3cimlf;
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
import '../keys/keys_endpoint.dart' as _icowoccn;
import '../password/account_recovery_endpoint.dart' as _ivnbjhab;
@ -63,12 +61,6 @@ class Endpoints extends _is.EndpointDispatch {
'account',
'halkyon_base',
),
'anonymousIdp': _in0zita6.AnonymousIdpEndpoint()
..initialize(
server,
'anonymousIdp',
'halkyon_base',
),
'emailCode': _in3cimlf.EmailCodeEndpoint()
..initialize(
server,
@ -81,12 +73,6 @@ class Endpoints extends _is.EndpointDispatch {
'gitHubSignIn',
'halkyon_base',
),
'gitHubUpgrade': _ivnugbf2.GitHubUpgradeEndpoint()
..initialize(
server,
'gitHubUpgrade',
'halkyon_base',
),
'instance': _i3g8bfc2.InstanceEndpoint()
..initialize(
server,
@ -183,32 +169,6 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['anonymousIdp'] = _is.EndpointConnector(
name: 'anonymousIdp',
endpoint: endpoints['anonymousIdp']!,
methodConnectors: {
'login': _is.MethodConnector(
name: 'login',
params: {
'token': _is.ParameterDescription(
name: 'token',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['anonymousIdp'] as _in0zita6.AnonymousIdpEndpoint)
.login(
session,
token: params['token'],
),
),
},
);
connectors['emailCode'] = _is.EndpointConnector(
name: 'emailCode',
endpoint: endpoints['emailCode']!,
@ -284,45 +244,6 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['gitHubUpgrade'] = _is.EndpointConnector(
name: 'gitHubUpgrade',
endpoint: endpoints['gitHubUpgrade']!,
methodConnectors: {
'upgrade': _is.MethodConnector(
name: 'upgrade',
params: {
'code': _is.ParameterDescription(
name: 'code',
type: _is.getType<String>(),
nullable: false,
),
'codeVerifier': _is.ParameterDescription(
name: 'codeVerifier',
type: _is.getType<String>(),
nullable: false,
),
'redirectUri': _is.ParameterDescription(
name: 'redirectUri',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['gitHubUpgrade']
as _ivnugbf2.GitHubUpgradeEndpoint)
.upgrade(
session,
code: params['code'],
codeVerifier: params['codeVerifier'],
redirectUri: params['redirectUri'],
),
),
},
);
connectors['instance'] = _is.EndpointConnector(
name: 'instance',
endpoint: endpoints['instance']!,

View file

@ -1,14 +1,10 @@
account:
- delete:
- signInWays:
anonymousIdp:
- login:
emailCode:
- sendCode:
gitHubSignIn:
- signIn:
gitHubUpgrade:
- upgrade:
instance:
- basics:
keys:

View file

@ -43,7 +43,7 @@ Future<DeviceKeys> unlockKeys(
}
/// Gives [person], an account without keys that SimpleLogin was added to
/// (one of before: anonymous, or signing in with GitHub or a code), the
/// (one of before that signs in with GitHub or a code), the
/// keys [setup] describes, and [handle] where it has none (ADR 0020, 6 and
/// D5), with their SimpleLogin sign-in under [attempt] of the last ten
/// minutes, taken (D20). Nothing else proves the owner: the account has no

View file

@ -12,9 +12,8 @@ import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Giving the signed-in person a password (ADR 0017): the way an anonymous
/// person becomes identified, keeping everything they have, and the way
/// someone who signs in with a code by mail or GitHub moves to a password.
/// Giving the signed-in person a password (ADR 0017): the way someone who
/// signs in with a code by mail or GitHub moves to a password.
/// The instance's register keeps it (ADR 0016).
class PasswordEndpoint extends Endpoint {
@override
@ -42,14 +41,6 @@ class PasswordEndpoint extends Endpoint {
request: request,
code: code,
freshGitHubSignIn: await _signedInWithGitHubJustNow(session),
// SELECT count(*) FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
anonymousHere:
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
keepSession: switch (session.authenticated!.authId) {
final id when Uuid.isValidUUID(fromString: id) =>
UuidValue.withValidation(id),

View file

@ -412,9 +412,8 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
/// Gives [person] a password ([setup], ADR 0017). A password needs an
/// address, for resets: the person's own, or the one [code] proves for
/// [request] (mailed with `sendSignInCode`), which becomes theirs as email
/// sign-in makes it (they become `identified` and stop being anonymous,
/// keeping their ID and everything they have) in the same transaction. The
/// [request] (mailed with `sendSignInCode`), which becomes theirs in the
/// same transaction. The
/// account signs in with the password only once an authenticator is
/// confirmed; until then a new setup replaces it, but never once one was
/// ([hadConfirmedAuthenticator]: after a reset, a new password comes through
@ -426,12 +425,11 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
/// ([EmailCodePurpose.firstPassword]); one that signs in with GitHub alone
/// by a GitHub sign-in of the last [gitHubSignInFreshFor]
/// ([freshGitHubSignIn], which the server holding the session tells), with
/// the address it adds proved as an anonymous person proves theirs
/// ([EmailCodePurpose.signUp]). Only someone anonymous where the session
/// lives ([anonymousHere], which that server tells) adds an address so
/// without either: anyone else has no way in left to prove (a GitHub ID
/// forgotten at its end, the full reading's M1), and a session of theirs
/// alone would make the account a thief's.
/// the address it adds proved as a sign-up proves one
/// ([EmailCodePurpose.signUp]). Anyone else has no way in left to prove (a
/// GitHub ID forgotten at its end, the full reading's M1, or an anonymous
/// account left from before, ADR 0020, D25), and a session of theirs alone
/// would make the account a thief's.
///
/// Refused ([PasswordRefusedException]) where the instance has no password
/// sign-in, for a setup out of range, without an address, for an address
@ -439,8 +437,8 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
/// confirmed authenticator ([PasswordRefusal.passwordExists]), without the
/// code its own address takes ([PasswordRefusal.wrongCredentials]), and
/// for GitHub without a recent sign-in ([PasswordRefusal.signInAgain],
/// before any code is used), and for someone neither anonymous nor with a
/// way in ([PasswordRefusal.wrongCredentials], before any code is used); a
/// before any code is used), and for someone without a way in
/// ([PasswordRefusal.wrongCredentials], before any code is used); a
/// code's refusals as `proveAddressCode`'s, and
/// another address than the account's as email sign-in refuses it
/// ([EmailSignInRefusedException]). Nothing changes then.
@ -451,7 +449,6 @@ Future<void> setPassword(
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
bool anonymousHere = false,
UuidValue? keepSession,
}) async {
requirePasswordKeys(session.serverpod);
@ -491,7 +488,7 @@ Future<void> setPassword(
if (gitHub && !freshGitHubSignIn) {
throw refusedFor(PasswordRefusal.signInAgain);
}
if (!gitHub && !anonymousHere) {
if (!gitHub) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
proven = request != null && code != null
@ -543,13 +540,6 @@ Future<void> setPassword(
transaction: transaction,
);
await grantIdentified(session, person, transaction: transaction);
// DELETE FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
await AnonymousAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
}
} else {
// No address proved: none at all, or one that came meanwhile, whose
@ -683,16 +673,9 @@ Future<UuidValue> signUpWithCode(
0) {
throw refusedFor(PasswordRefusal.passwordExists);
}
// An address's person is identified already, and no longer
// anonymous; as email sign-in left them.
// An address's person is identified already, as email sign-in
// left them.
await grantIdentified(session, person, transaction: transaction);
// DELETE FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
await AnonymousAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
}
// INSERT INTO "account_password" (…) VALUES (…)
await AccountPassword.db.insertRow(

View file

@ -126,14 +126,6 @@ abstract interface class AccountRegister {
required String network,
});
/// Links the GitHub account [gitHubUserId] to [person] (an account of the
/// register, under their ID). Refusals as `linkGitHubAccount`'s.
Future<void> linkGitHub(
Session session, {
required UuidValue person,
required String gitHubUserId,
});
/// The account that signs in with the GitHub account [gitHubUserId]; null
/// for none.
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId);
@ -298,9 +290,7 @@ abstract interface class AccountRegister {
/// Gives [person] the password [setup] describes, with the address [code]
/// proves for [request] (their own where they have one), and for one who
/// signs in with GitHub alone a GitHub sign-in of theirs made recently
/// ([freshGitHubSignIn], of the session asking); someone with neither
/// only while anonymous where the session lives ([anonymousHere]).
/// Replacing a setup never finished ends every other session of the
/// ([freshGitHubSignIn], of the session asking). Replacing a setup never finished ends every other session of the
/// account but [keepSession]. Refusals as `setPassword`'s.
Future<void> setPassword(
Session session, {
@ -309,7 +299,6 @@ abstract interface class AccountRegister {
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
bool anonymousHere = false,
UuidValue? keepSession,
});

View file

@ -182,13 +182,6 @@ Future<Map<String, Object?>?> _answer(
network: arguments.string('network'),
);
return {'request': request.uuid};
case RegisterOperation.linkGitHub:
await register.linkGitHub(
session,
person: arguments.id('person'),
gitHubUserId: arguments.string('gitHubUserId'),
);
return const {};
case RegisterOperation.accountWithGitHub:
final account = await register.accountWithGitHub(
session,
@ -347,11 +340,8 @@ Future<Map<String, Object?>?> _answer(
setup: arguments.model('setup', PasswordSetup.fromJson),
request: arguments.optionalId('request'),
code: arguments.optionalString('code'),
// The member tells it of its own session (the fourth reading) and
// person (the full reading's M1): the register keeps no anonymous
// person of a member's.
// The member tells it of its own session (the fourth reading).
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
anonymousHere: arguments.flag('anonymousHere'),
keepSession: arguments.optionalId('keepSession'),
);
return const {};

View file

@ -366,17 +366,6 @@ final class LocalRegister implements AccountRegister {
network: network,
);
@override
Future<void> linkGitHub(
Session session, {
required UuidValue person,
required String gitHubUserId,
}) => github.linkGitHubAccount(
session,
authUserId: person,
gitHubUserId: gitHubUserId,
);
@override
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
github.accountWithGitHub(session, gitHubUserId: gitHubUserId);
@ -615,7 +604,6 @@ final class LocalRegister implements AccountRegister {
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
bool anonymousHere = false,
UuidValue? keepSession,
}) => passwords.setPassword(
session,
@ -624,7 +612,6 @@ final class LocalRegister implements AccountRegister {
request: request,
code: code,
freshGitHubSignIn: freshGitHubSignIn,
anonymousHere: anonymousHere,
keepSession: keepSession,
);

View file

@ -7,8 +7,8 @@ import 'package:serverpod_auth_idp_server/core.dart';
/// Makes [account] a person of this server's database, under that ID, unless
/// it is one already, with [scopes] (ADR 0016): on the register, a member's
/// anonymous person becoming identified keeps their ID; on a member, the
/// register's account signs in here. Serverpod makes people with an ID of its
/// person adding a way in keeps their ID; on a member, the register's
/// account signs in here. Serverpod makes people with an ID of its
/// own choosing, so the row is written here with the one given.
Future<void> ensurePerson(
Session session,
@ -104,16 +104,6 @@ Future<AuthSuccess> issueSessionAfter(
/// What [person] becomes here once a way in is theirs, as linking makes them
/// on the register in the same transaction: `identified` (on their sessions
/// too), and no longer anonymous. A member does it after the register linked
/// the way in.
/// too). A member does it after the register linked the way in.
Future<void> becomeIdentified(Session session, UuidValue person) =>
session.db.transaction((transaction) async {
await grantIdentified(session, person, transaction: transaction);
// DELETE FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
await AnonymousAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
});
grantIdentified(session, person);

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