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:
parent
80fa7b9628
commit
af1de799f4
41 changed files with 17704 additions and 3 deletions
18
packages/halkyon_base_server/lib/base.dart
Normal file
18
packages/halkyon_base_server/lib/base.dart
Normal 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';
|
||||
|
|
@ -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';
|
||||
99
packages/halkyon_base_server/lib/src/auth/account.dart
Normal file
99
packages/halkyon_base_server/lib/src/auth/account.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
39
packages/halkyon_base_server/lib/src/auth/auth_config.dart
Normal file
39
packages/halkyon_base_server/lib/src/auth/auth_config.dart
Normal 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(),
|
||||
];
|
||||
170
packages/halkyon_base_server/lib/src/auth/code_mail.dart
Normal file
170
packages/halkyon_base_server/lib/src/auth/code_mail.dart
Normal 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);
|
||||
477
packages/halkyon_base_server/lib/src/auth/email.dart
Normal file
477
packages/halkyon_base_server/lib/src/auth/email.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
139
packages/halkyon_base_server/lib/src/auth/github.dart
Normal file
139
packages/halkyon_base_server/lib/src/auth/github.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Reference in a new issue