halkyon_base: a Serverpod module for sign-in, accounts and instance basics

What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.

The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.

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-19 14:28:09 +02:00
commit af1de799f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
41 changed files with 17704 additions and 3 deletions

View file

@ -0,0 +1,18 @@
/// halkyon_base for a host's own code: its configuration
/// (`HalkyonBase.configure`), how people authenticate, the account's
/// deletion and the pruning, email sign-in's helpers, the instance's
/// basics, and the module's models.
library;
export 'src/auth/account.dart';
export 'src/auth/auth_config.dart';
export 'src/auth/code_mail.dart';
export 'src/auth/email.dart';
export 'src/auth/github.dart';
export 'src/auth/identified.dart';
export 'src/auth/scopes.dart';
export 'src/common/short_code.dart';
export 'src/generated/protocol.dart' hide Protocol;
export 'src/halkyon_base.dart';
export 'src/instance/data_location.dart';
export 'src/instance/source_code.dart';

View file

@ -0,0 +1,6 @@
/// What Serverpod's code generator needs to put the module into a host's
/// server; not for the host's own code, which imports `base.dart`.
library;
export 'src/generated/endpoints.dart';
export 'src/generated/protocol.dart';

View file

@ -0,0 +1,99 @@
import 'package:halkyon_base_server/src/auth/auth_config.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Deletes [authUserId] with everything they have, in one transaction, in
/// this order: their sessions end first, since a device with a valid session
/// would upload everything again; then the host deletes their data in its
/// own tables ([HalkyonBase.deletePersonData], in the order given); then
/// their address's hash and the person with their other identities. Once the
/// transaction holds, what the host's deletions left for afterwards runs.
Future<void> deletePerson(Session session, UuidValue authUserId) async {
final deletions = HalkyonBase.of(session.serverpod).deletePersonData;
final afterwards = await session.db.transaction((transaction) async {
// DELETE FROM "serverpod_auth_core_session" WHERE "authUserId" = $1
await ServerSideSession.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
final afterwards = <AfterDeletion>[];
for (final deletion in deletions) {
if (await deletion(session, authUserId, transaction) case final after?) {
afterwards.add(after);
}
}
// Their address's hash: the way they signed in.
// DELETE FROM "email_identity" WHERE "authUserId" = $1
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// Their GitHub id and anonymous account go with them (ON DELETE CASCADE).
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1
await AuthServices.instance.authUsers.delete(
session,
authUserId: authUserId,
transaction: transaction,
);
return afterwards;
});
for (final after in afterwards) {
await after();
}
}
/// 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(
'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) '
'ORDER BY a."createdAt" LIMIT @limit',
parameters: QueryParameters.named({'cutoff': cutoff, 'limit': limit}),
);
for (final row in abandoned) {
await deletePerson(session, UuidValue.withValidation(row.first.toString()));
}
return abandoned.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 notes
/// go too: none of its limits looks back further.
Future<int> forgetOldSignUpAttempts(
Session session, {
required DateTime now,
}) async {
final cutoff = now.toUtc().subtract(anonymousSignUpLimit.timeframe);
// DELETE FROM "serverpod_auth_idp_rate_limited_request_attempt"
// WHERE "attemptedAt" < $1
final forgotten = await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.attemptedAt < cutoff,
);
return forgotten.length;
}

View file

@ -0,0 +1,46 @@
import 'package:halkyon_base_server/src/auth/account.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// The signed-in person's account.
class AccountEndpoint extends Endpoint {
@override
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
/// ended.
Future<void> delete(Session session) async {
final person = session.authenticated!.authUserId;
await deletePerson(session, person);
await session.messages.authenticationRevoked(
person.uuid,
RevokedAuthenticationUser(),
);
}
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
Future<SignInWays> signInWays(Session session) async {
final person = session.authenticated!.authUserId;
return SignInWays(
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
// WHERE "authUserId" = $1
gitHub:
await GitHubAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
// SELECT count(*) FROM "email_identity" WHERE "authUserId" = $1
email:
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
);
}
}

View file

@ -0,0 +1,5 @@
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
/// Signs people in anonymously; the app calls it on first launch. Creating an
/// account is rate-limited per IP address (see `auth_config.dart`).
class AnonymousIdpEndpoint extends AnonymousIdpBaseEndpoint {}

View file

@ -0,0 +1,39 @@
import 'package:halkyon_base_server/src/auth/github.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.
final List<TokenManagerBuilder> tokenManagerBuilders = [
ServerSideSessionsConfigFromPasswords(
defaultSessionInactivityTimeout: const Duration(days: 90),
),
];
/// How many new anonymous accounts one IP address may make, and in what
/// time. Serverpod notes each attempt with the address it came from, 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.
///
/// 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.
List<IdentityProviderBuilder> identityProviderBuilders(Serverpod pod) => [
const AnonymousIdpConfig(perIpAddressRateLimit: anonymousSignUpLimit),
if (gitHubSignInAvailable(pod)) GitHubIdpConfigFromPasswords(),
];

View file

@ -0,0 +1,170 @@
import 'package:mailer/mailer.dart' as mailer;
import 'package:mailer/smtp_server.dart';
import 'package:serverpod/serverpod.dart';
// The mail that carries an email sign-in code (Halkyon Learn's ADR 0010):
// plain text, the code in its subject so it shows in the notification and
// the system can offer it, no links, no tracking. Sent over SMTP, through any
// service the operator chooses (Proton's, for one).
/// Mails sign-in codes.
abstract interface class CodeMailer {
/// Mails [code] to [to], worded in [language] (`en` or `de`).
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
});
}
/// Writes codes into the server's log, for development, where nobody has a
/// mail service. Never elsewhere: the log would then hold every address
/// asked for.
final class LogCodeMailer implements CodeMailer {
const LogCodeMailer();
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async => session.log('Email sign-in code for $to: $code');
}
/// A code's mail, worded in [language] (`en`, else German for `de`): its
/// subject and its body. [app] is the app's name, which the code is typed
/// into, and [instance] the instance's address, which the mail names so
/// that people know where they are signing in.
({String subject, String body}) codeMail({
required String code,
required String language,
required String app,
required String instance,
}) => switch (language) {
'de' => (
// A compound with the name, hyphenated through: Halkyon-Learn-Code.
subject: '$code ist dein ${app.replaceAll(' ', '-')}-Code',
body:
'$code\n'
'\n'
'Gib diesen Code in $app ein, um dich auf $instance '
'anzumelden. Er gilt 15 Minuten.\n'
'\n'
'Wenn du ihn nicht angefordert hast, ignoriere diese Mail: Ohne den '
'Code kann sich niemand mit deiner Adresse anmelden.\n'
'\n'
'Diese Instanz behält nur einen Fingerabdruck deiner Adresse und '
'schreibt ihr nur, wenn du einen Code anforderst.\n',
),
_ => (
subject: '$code is your $app code',
body:
'$code\n'
'\n'
'Type this code in $app to sign in on $instance. It is valid '
'for 15 minutes.\n'
'\n'
"If you didn't ask for it, ignore this mail: without the code, nobody "
'can sign in with your address.\n'
'\n'
'This instance keeps only a fingerprint of your address and writes to '
'it only when you ask for a code.\n',
),
};
/// Mails codes through an SMTP service, over TLS only: directly on port
/// 465, upgraded with STARTTLS elsewhere (Proton's: `smtp.protonmail.ch`,
/// 587). It logs nothing of a mail, neither its address nor its code.
final class SmtpCodeMailer implements CodeMailer {
SmtpCodeMailer({
required this._server,
required this._from,
required this._app,
required this._instance,
});
final SmtpServer _server;
final mailer.Address _from;
final String _app;
final String _instance;
/// The mailer [pod]'s passwords set up: `emailSmtpHost`,
/// `emailSmtpPort` (587 unless set), `emailSmtpUser`,
/// `emailSmtpPassword` and `emailFrom`, the address mails come from (for
/// Proton the address the token belongs to), the [app]'s name as their
/// sender; null unless all but the port are set. Empty values count as
/// unset: Compose passes unset variables on as empty strings.
static SmtpCodeMailer? fromPasswords(
Serverpod pod, {
required String app,
}) {
String? password(String name) => switch (pod.getPassword(name)) {
final value? when value.trim().isNotEmpty => value.trim(),
_ => null,
};
final host = password('emailSmtpHost');
final user = password('emailSmtpUser');
final secret = password('emailSmtpPassword');
final from = password('emailFrom');
if (host == null || user == null || secret == null || from == null) {
return null;
}
final port = int.tryParse(password('emailSmtpPort') ?? '') ?? 587;
return SmtpCodeMailer(
server: SmtpServer(
host,
port: port,
ssl: port == 465,
username: user,
password: secret,
),
from: mailer.Address(from, app),
app: app,
instance: pod.config.apiServer.publicHost,
);
}
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) => mail(to: to, code: code, language: language);
/// Mails [code] to [to], worded in [language]; throws what the service
/// answered when it took no mail.
Future<void> mail({
required String to,
required String code,
required String language,
}) async {
final mail = codeMail(
code: code,
language: language,
app: _app,
instance: _instance,
);
await mailer.send(
mailer.Message()
..from = _from
..recipients.add(to)
..subject = mail.subject
..text = mail.body,
_server,
timeout: const Duration(seconds: 20),
);
}
}
/// How codes are mailed on an instance in [runMode]: through [smtp] where
/// its operator set one up, into the log in development otherwise, and not
/// at all elsewhere (no email sign-in).
CodeMailer? codeMailerFor({
required String runMode,
required CodeMailer? smtp,
}) =>
smtp ??
(runMode == ServerpodRunMode.development ? const LogCodeMailer() : null);

View file

@ -0,0 +1,477 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/src/auth/code_mail.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/common/short_code.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// Signing in with an email address, by a code mailed to it each time
// (Halkyon Learn's ADR 0010). The instance keeps only a keyed hash of the
// address: it can find the person and tell whether an address is free from
// what someone types, and it mails only an address someone has just typed.
// So the login is our own, on Serverpod's helpers (its code challenges and
// rate limiter): Serverpod's email sign-in keeps the address itself, in three
// tables, and passwords.
/// Serverpod's name for email sign-in on session rows.
const emailMethod = 'email';
/// How long a code mailed is valid.
const emailCodeLifetime = Duration(minutes: 15);
/// How this instance mails sign-in codes ([codeMailerFor]): through the
/// SMTP service its operator set up, as the app the host names
/// ([HalkyonBase.appName]), into the log in development without one, and
/// elsewhere not at all. Tests put their own [mailer].
final class EmailCodes {
EmailCodes._(this.mailer);
factory EmailCodes.of(Serverpod pod) => _ofPod[pod] ??= EmailCodes._(
codeMailerFor(
runMode: pod.runMode,
smtp: SmtpCodeMailer.fromPasswords(
pod,
app: HalkyonBase.of(pod).appName,
),
),
);
static final _ofPod = Expando<EmailCodes>('Email codes');
/// What mails the codes; none means no email sign-in here.
CodeMailer? mailer;
}
/// The instance's pepper for addresses and codes (`emailSignInPepper` in
/// the passwords); null without one. It must never change once people sign
/// in with addresses: their hashes would no longer be found.
String? _pepperOf(Serverpod pod) => switch (pod.getPassword(
'emailSignInPepper',
)) {
final pepper? when pepper.isNotEmpty => pepper,
_ => null,
};
/// Whether people can sign in with an email address here: the instance has
/// its pepper and a way to mail codes ([EmailCodes]).
bool emailSignInAvailable(Serverpod pod) =>
_pepperOf(pod) != null && EmailCodes.of(pod).mailer != null;
/// [typed] as the address it means, trimmed and in lower case; null when it
/// is no address.
String? normalizedAddress(String typed) {
final address = typed.trim().toLowerCase();
return address.length <= 254 && _address.hasMatch(address) ? address : null;
}
/// Something, an @, and a domain with a dot: what mail services take, as
/// near as matters; the code mailed proves the rest.
final _address = RegExp(r'^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$');
/// What the instance keeps of the [address] (as [normalizedAddress] gives
/// it): HMAC-SHA256 keyed with its pepper, in hex. Without the pepper, a
/// database holding it gives no address away.
String addressHash(Serverpod pod, String address) {
final pepper =
_pepperOf(pod) ?? (throw StateError('No emailSignInPepper is set.'));
return Hmac(
sha256,
utf8.encode(pepper),
).convert(utf8.encode('email-address:$address')).toString();
}
/// How many codes one address gets in an hour.
final _codesPerAddress = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',
source: 'codes_per_address',
maxAttempts: 3,
timeframe: const Duration(hours: 1),
),
);
/// How many codes one network (IP address) asks for in an hour.
final _codesPerNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',
source: 'codes_per_network',
maxAttempts: 10,
timeframe: const Duration(hours: 1),
),
);
/// Mails a new code to [address] and returns the request it answers, in the
/// same way whether anybody has the address or not. An earlier code for the
/// address stops being valid. Refused for no address, too many codes, or a
/// mail that couldn't be sent, leaving no request behind
/// ([EmailSignInRefusedException]).
Future<UuidValue> sendSignInCode(
Session session, {
required String address,
required String language,
}) async {
final pod = session.serverpod;
final mailer = EmailCodes.of(pod).mailer;
if (mailer == null || !emailSignInAvailable(pod)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
}
final to = normalizedAddress(address);
if (to == null) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.notAnAddress);
}
final hash = addressHash(pod, to);
// The address first: a code it can't have takes nothing of the network's.
if (!await _codesPerAddress.tryRecordAttempt(session, key: hash) ||
!await _codesPerNetwork.tryRecordAttempt(
session,
// As Serverpod's own rate limits take it (no request in tests).
key: '${session.request?.connectionInfo.remote.address}',
)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.tooManyCodes);
}
final code = newShortCode();
return await session.db.transaction((transaction) async {
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id" IN
// (SELECT "challengeId" FROM "email_code_request"
// WHERE "addressHash" = $1); its request goes with it (ON DELETE
// CASCADE)
await _forget(
session,
await EmailCodeRequest.db.find(
session,
where: (t) => t.addressHash.equals(hash),
transaction: transaction,
),
transaction: transaction,
);
final challenge = await _challengesOf(pod).createChallenge(
session,
verificationCode: normalizeShortCode(code),
transaction: transaction,
);
// INSERT INTO "email_code_request" ("addressHash", "challengeId",
// "createdAt") VALUES ($1, $2, $3)
final request = await EmailCodeRequest.db.insertRow(
session,
EmailCodeRequest(
addressHash: hash,
challengeId: challenge.id!,
createdAt: clock.now(),
),
transaction: transaction,
);
try {
await mailer.send(
session,
to: to,
code: code,
language: language == 'de' ? 'de' : 'en',
);
} on Exception catch (error) {
// Its type only: what the mail service answered may name the address.
session.log(
'Mailing a sign-in code failed: ${error.runtimeType}',
level: LogLevel.warning,
);
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.mailFailed,
);
}
return request.id!;
});
}
/// Accepts [code] for [request] from the person [authUserId]: an address
/// nobody has becomes theirs, in one transaction (they become `identified`,
/// on their sessions too, and stop being anonymous, keeping everything they
/// have); their own changes nothing; another person's changes nothing here
/// and gives a token that signs in as that person ([signInWithEmail]).
///
/// Refused for a wrong code, too many tries, a code no longer valid, or a
/// second address for someone who has one ([EmailSignInRefusedException]);
/// nothing changes then.
Future<EmailCodeAccepted> acceptSignInCode(
Session session, {
required UuidValue authUserId,
required UuidValue request,
required String code,
}) async {
final pod = session.serverpod;
if (!emailSignInAvailable(pod)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
}
return await session.db.transaction((transaction) async {
final switchToken = await _refusingAsEmail(
() => _challengesOf(pod).verifyChallenge(
session,
requestId: request,
verificationCode: normalizeShortCode(code),
transaction: transaction,
),
);
final accepted = (await _requestById(
session,
request,
transaction: transaction,
))!;
// SELECT * FROM "email_identity"
// WHERE "addressHash" = $1 OR "authUserId" = $2
final identities = await EmailIdentity.db.find(
session,
where: (t) =>
t.addressHash.equals(accepted.addressHash) |
t.authUserId.equals(authUserId),
transaction: transaction,
);
final owner = identities
.where((identity) => identity.addressHash == accepted.addressHash)
.firstOrNull;
if (owner != null && owner.authUserId != authUserId) {
// Kept until the token is used, or its time is up.
return EmailCodeAccepted(switchToken: switchToken);
}
if (owner == null) {
if (identities.isNotEmpty) {
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.otherAddressLinked,
);
}
// INSERT INTO "email_identity" ("authUserId", "addressHash",
// "createdAt") VALUES ($1, $2, $3)
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: authUserId,
addressHash: accepted.addressHash,
createdAt: clock.now(),
),
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,
);
}
await _forget(session, [accepted], transaction: transaction);
return EmailCodeAccepted();
});
}
/// A new session for the person whose address a code proved, from the token
/// [acceptSignInCode] gave; once only. Called without a session: Serverpod
/// issues no session for another person inside a signed-in call. Refused
/// once the token was used or its time is up, or for a token it never gave
/// ([EmailSignInRefusal.codeExpired]).
Future<AuthSuccess> signInWithEmail(
Session session, {
required String switchToken,
}) async {
final pod = session.serverpod;
if (!emailSignInAvailable(pod)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
}
return await session.db.transaction((transaction) async {
final request = await _refusingAsEmail(
() => _challengesOf(pod).completeChallenge(
session,
completionToken: switchToken,
transaction: transaction,
),
);
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.addressHash.equals(request.addressHash),
transaction: transaction,
);
await _forget(session, [request], transaction: transaction);
if (identity == null) {
// The person deleted their account since.
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.codeExpired,
);
}
final person = await AuthServices.instance.authUsers.get(
session,
authUserId: identity.authUserId,
transaction: transaction,
);
return await AuthServices.instance.tokenManager.issueToken(
session,
authUserId: identity.authUserId,
method: emailMethod,
scopes: person.scopeNames.map(Scope.new).toSet(),
transaction: transaction,
);
});
}
/// Deletes the code requests past their time at [now], and returns how many:
/// hourly, with the rate limit's notes (`PruningFutureCall`).
Future<int> forgetExpiredEmailCodes(
Session session, {
required DateTime now,
}) async {
// SELECT * FROM "email_code_request" WHERE "createdAt" < $1
final expired = await EmailCodeRequest.db.find(
session,
where: (t) => t.createdAt < now.subtract(emailCodeLifetime),
);
await _forget(session, expired);
return expired.length;
}
/// Deletes [requests] with their challenges; deleting a challenge deletes
/// its request (`ON DELETE CASCADE`).
Future<void> _forget(
Session session,
List<EmailCodeRequest> requests, {
Transaction? transaction,
}) async {
final challenges = {
for (final request in requests) ...[
request.challengeId,
?request.switchChallengeId,
],
};
if (challenges.isNotEmpty) {
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id" IN (…)
await SecretChallenge.db.deleteWhere(
session,
where: (t) => t.id.inSet(challenges),
transaction: transaction,
);
}
}
/// What [action] gives; a challenge's refusal as email sign-in's.
Future<T> _refusingAsEmail<T>(Future<T> Function() action) async {
try {
return await action();
} on SecretChallengeException catch (refusal) {
throw EmailSignInRefusedException(
reason: switch (refusal) {
ChallengeInvalidVerificationCodeException() =>
EmailSignInRefusal.wrongCode,
ChallengeRateLimitExceededException() =>
EmailSignInRefusal.tooManyTries,
ChallengeRequestNotFoundException() ||
ChallengeAlreadyUsedException() ||
ChallengeExpiredException() ||
ChallengeNotVerifiedException() ||
ChallengeInvalidCompletionTokenException() =>
EmailSignInRefusal.codeExpired,
},
);
}
}
/// The request [id] with its challenges; null when it's gone.
Future<EmailCodeRequest?> _requestById(
Session session,
UuidValue id, {
required Transaction? transaction,
}) =>
// SELECT * FROM "email_code_request" WHERE "id" = $1, with its
// challenges
EmailCodeRequest.db.findById(
session,
id,
transaction: transaction,
include: EmailCodeRequest.include(
challenge: SecretChallenge.include(),
switchChallenge: SecretChallenge.include(),
),
);
bool _isExpired(EmailCodeRequest request) =>
request.createdAt.add(emailCodeLifetime).isBefore(clock.now());
/// The code challenges of [pod]'s email sign-in, with its pepper.
SecretChallengeUtil<EmailCodeRequest> _challengesOf(Serverpod pod) =>
_challenges[pod] ??= _newChallenges(_pepperOf(pod)!);
final _challenges = Expando<SecretChallengeUtil<EmailCodeRequest>>(
'Email code challenges',
);
SecretChallengeUtil<EmailCodeRequest> _newChallenges(String pepper) {
Future<void> forgetOne(Session session, EmailCodeRequest request) =>
// Outside the caller's transaction, so it isn't rolled back.
_forget(session, [request]);
return SecretChallengeUtil(
// Codes are short: Argon2id at its usual cost.
hashUtil: Argon2HashUtil(hashPepper: pepper, hashSaltLength: 16),
// Switch tokens are long and random.
completionTokenHash: Argon2HashUtil.forRandomSecrets(
hashPepper: pepper,
hashSaltLength: 16,
),
verificationConfig: SecretChallengeVerificationConfig(
// Three tries for each code; then it goes, unless it was accepted.
rateLimiter: DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',
source: 'code_tries',
maxAttempts: 3,
onRateLimitExceeded: (session, key) async {
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id"
// = (the request's) AND the request has no switch challenge
final request = await _requestById(
session,
UuidValue.withValidation(key),
transaction: null,
);
if (request != null && request.switchChallengeId == null) {
await forgetOne(session, request);
}
},
),
),
getRequest: _requestById,
isAlreadyUsed: (request) => request.switchChallengeId != null,
getChallenge: (request) => request.challenge!,
isExpired: _isExpired,
onExpired: forgetOne,
linkCompletionToken: (session, request, challenge, {transaction}) async {
// UPDATE "email_code_request" SET "switchChallengeId" = $1
// WHERE "id" = $2 AND "switchChallengeId" IS NULL
final linked = await EmailCodeRequest.db.updateWhere(
session,
columnValues: (t) => [t.switchChallengeId(challenge.id)],
where: (t) =>
t.id.equals(request.id) & t.switchChallengeId.equals(null),
transaction: transaction,
);
if (linked.isEmpty) {
throw ChallengeAlreadyUsedException();
}
},
),
completionConfig: SecretChallengeCompletionConfig(
// Switch tokens are long and random, but tries are counted all the
// same.
rateLimiter: DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',
source: 'switch_tries',
maxAttempts: 3,
),
),
getRequest: _requestById,
getCompletionChallenge: (request) => request.switchChallenge,
isExpired: _isExpired,
onExpired: forgetOne,
),
);
}

View file

@ -0,0 +1,8 @@
### A code accepted: the address is now the signed-in person's (or already
### was), or it is another person's and [switchToken] signs in as them.
class: EmailCodeAccepted
fields:
### Null when the address is the signed-in person's; else what signs in as
### the person who has it, once, within the code's 15 minutes
### (`EmailSignInEndpoint.signIn`).
switchToken: String?

View file

@ -0,0 +1,35 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Signing in with an email address, by a code mailed to it:
/// the way to keep an anonymous account, as linking GitHub is, and a second
/// way in for someone who signs in with GitHub. Signed in only, as every
/// device is from its first start.
class EmailCodeEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Mails a code to [address], worded in [language] (`en`, `de`), and
/// returns the request it answers; refusals as in [sendSignInCode].
Future<UuidValue> sendCode(
Session session, {
required String address,
required String language,
}) => sendSignInCode(session, address: address, language: language);
/// Accepts [code] for [request]: the address becomes the caller's unless
/// it is another person's, whose token then signs in as them
/// (`EmailSignInEndpoint.signIn`); refusals as in [acceptSignInCode].
Future<EmailCodeAccepted> verifyCode(
Session session, {
required UuidValue request,
required String code,
}) => acceptSignInCode(
session,
authUserId: session.authenticated!.authUserId,
request: request,
code: code,
);
}

View file

@ -0,0 +1,18 @@
### A code mailed to an address, waiting to be typed (Halkyon Learn's ADR
### 0010): the address's hash and the code's challenge (Serverpod's
### SecretChallenge, which keeps the code as an Argon2id hash). Once the code
### is typed and the address turns out to be another person's, the challenge
### of the token that switches to them; otherwise the request goes once used.
### Requests last 15 minutes, and those past their time go hourly.
class: EmailCodeRequest
serverOnly: true
table: email_code_request
fields:
id: UuidValue?, defaultPersist=random_v7
addressHash: String
challenge: module:serverpod_auth_idp:SecretChallenge?, relation(onDelete=Cascade)
switchChallenge: module:serverpod_auth_idp:SecretChallenge?, relation(optional, onDelete=Cascade)
createdAt: DateTime
indexes:
email_code_request_address_idx:
fields: addressHash

View file

@ -0,0 +1,22 @@
### A person's email address as a way to sign in (Halkyon Learn's ADR
### 0010): only a keyed hash of it, never the address. The instance mails a
### code only to an address someone has just typed, and can't write to
### anyone otherwise.
class: EmailIdentity
serverOnly: true
table: email_identity
fields:
### The person: the AuthUser's id.
authUserId: UuidValue
### HMAC-SHA256 of the address, lower-cased, keyed with the instance's
### emailSignInPepper, in hex (`addressHash` in email.dart).
addressHash: String
createdAt: DateTime
indexes:
### One address per person, and each address one person's.
email_identity_person_idx:
fields: authUserId
unique: true
email_identity_address_idx:
fields: addressHash
unique: true

View file

@ -0,0 +1,15 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Signs in the person whose address a mailed code proved, when the device
/// switches to them. Called without a session: Serverpod issues no session
/// for another person inside a signed-in call.
class EmailSignInEndpoint extends Endpoint {
/// A session for the person [switchToken] names; refusals as in
/// [signInWithEmail].
Future<AuthSuccess> signIn(
Session session, {
required String switchToken,
}) => signInWithEmail(session, switchToken: switchToken);
}

View file

@ -0,0 +1,24 @@
### Why mailing a code, accepting it, or signing in with an email address
### was refused.
enum: EmailSignInRefusal
serialized: byName
values:
### This instance has no email sign-in (no emailSignInPepper in its
### passwords, or no way to send mail).
- unavailable
### What was typed isn't an email address.
- notAnAddress
### Too many codes asked for, for this address or from this network, in
### the last hour.
- tooManyCodes
### The code is wrong.
- wrongCode
### The code was typed wrong too often; a new one is needed.
- tooManyTries
### The code is no longer valid: past its time, used, or replaced by a
### newer one.
- codeExpired
### The person already signs in with another address.
- otherAddressLinked
### The mail couldn't be sent; nothing was kept of the request.
- mailFailed

View file

@ -0,0 +1,6 @@
### Mailing a code, accepting it, or signing in with an email address was
### refused; nothing changed.
exception: EmailSignInRefusedException
fields:
### Why it was refused.
reason: EmailSignInRefusal

View file

@ -0,0 +1,139 @@
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_auth_idp_server/providers/github.dart';
// Signing in with GitHub keeps only the numeric GitHub ID (decided 2026-09-30).
// So the login is our own, on Serverpod's GitHub helpers: Serverpod's stock
// login also stores the name and the email in a profile and downloads the
// avatar to the server.
/// Serverpod's name for GitHub sign-in on session rows (`GitHubIdp.method`).
const gitHubMethod = 'github';
/// Whether this instance's operator registered a GitHub OAuth app: its client
/// ID and secret are in the passwords (`githubClientId`,
/// `githubClientSecret`). Without them, GitHub sign-in is off and the app
/// hides it. Empty values count as missing: Compose passes unset variables on
/// as empty strings.
bool gitHubSignInAvailable(Serverpod pod) =>
(pod.getPassword('githubClientId') ?? '').isNotEmpty &&
(pod.getPassword('githubClientSecret') ?? '').isNotEmpty;
/// The numeric GitHub ID that [code], from the app's GitHub sign-in, proves.
///
/// The code is exchanged here, with the client secret, which never leaves the
/// server. GitHub's answer also carries the name and possibly an email
/// address; neither is kept, and the access token is used once and dropped.
Future<String> verifiedGitHubUserId(
Session session, {
required String code,
required String codeVerifier,
required String redirectUri,
}) async {
if (!gitHubSignInAvailable(session.serverpod)) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.unavailable,
);
}
final utils = AuthServices.instance.githubIdp.utils;
final accessToken = await utils.exchangeCodeForToken(
session,
code: code,
codeVerifier: codeVerifier,
redirectUri: redirectUri,
);
final details = await utils.fetchAccountDetails(
session,
accessToken: accessToken,
);
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.
///
/// 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 ([GitHubSignInRefusedException]).
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 (accounts.isNotEmpty) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.otherGitHubAccountLinked,
);
}
// 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,
);
});
/// A new session for the person who has the GitHub account [gitHubUserId],
/// with their scopes; used to switch to that person on another device.
///
/// Nobody is created here: people start anonymous and link GitHub later
/// ([linkGitHubAccount]). An unknown account is refused
/// ([GitHubSignInRefusal.unknownAccount]).
Future<AuthSuccess> signInWithGitHubAccount(
Session session, {
required String gitHubUserId,
}) async {
// SELECT * FROM "serverpod_auth_idp_github_account" WHERE "userIdentifier" = $1
final account = await GitHubAccount.db.findFirstRow(
session,
where: (t) => t.userIdentifier.equals(gitHubUserId),
);
if (account == null) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.unknownAccount,
);
}
final person = await AuthServices.instance.authUsers.get(
session,
authUserId: account.authUserId,
);
return await AuthServices.instance.tokenManager.issueToken(
session,
authUserId: account.authUserId,
method: gitHubMethod,
scopes: person.scopeNames.map(Scope.new).toSet(),
);
}

View file

@ -0,0 +1,25 @@
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Signs in the person who already has a GitHub account, e.g. when they switch
/// to it on another device. Called without a session: Serverpod issues no
/// session for another person inside a signed-in call.
class GitHubSignInEndpoint extends Endpoint {
/// A session for the person with the GitHub account that [code] proves;
/// refusals as in [signInWithGitHubAccount].
Future<AuthSuccess> signIn(
Session session, {
required String code,
required String codeVerifier,
required String redirectUri,
}) async {
final gitHubUserId = await verifiedGitHubUserId(
session,
code: code,
codeVerifier: codeVerifier,
redirectUri: redirectUri,
);
return await signInWithGitHubAccount(session, gitHubUserId: gitHubUserId);
}
}

View file

@ -0,0 +1,13 @@
### Why signing in with GitHub, or linking a GitHub account, was refused.
enum: GitHubSignInRefusal
serialized: byName
values:
### This instance has no GitHub OAuth app (no githubClientId and
### githubClientSecret in its passwords).
- unavailable
### The GitHub account already belongs to another person.
- takenByAnotherPerson
### The person already has a different GitHub account.
- otherGitHubAccountLinked
### No person has this GitHub account; only linking one creates it.
- unknownAccount

View file

@ -0,0 +1,6 @@
### Signing in with GitHub, or linking a GitHub account, was refused; nothing
### changed.
exception: GitHubSignInRefusedException
fields:
### Why it was refused.
reason: GitHubSignInRefusal

View file

@ -0,0 +1,32 @@
import 'package:halkyon_base_server/src/auth/github.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.
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,
);
await linkGitHubAccount(
session,
authUserId: session.authenticated!.authUserId,
gitHubUserId: gitHubUserId,
);
}
}

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