refactor(base): SimpleLogin's forwarding page on the OpenID Connect module (ADR 0023)

halkyon_base depends on halkyon_oidc_server, and its forwarding page
is now the module's (`addOidcCallbackForwarding`) with Halkyon's table
of apps and its page in English or German; its tests keep what is
Halkyon's own, the rest is tested in the module.

Every host now includes the module: migrations `…-oidc-module` in
halkyon_base, Learn and Todo create its two tables (no data touched)
and record its version; Learn's devices get them empty, as they have
halkyon_base's server-only tables. halkyon_base_client depends on
halkyon_oidc_client, whose models its generated code uses, and Learn's
server image copies both packages into its narrowed workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 20:28:37 +02:00
commit 620ad270db
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 43572 additions and 408 deletions

View file

@ -23,6 +23,7 @@ import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
as _ikhyee47;
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
as _iji0wmu8;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
@ -1090,6 +1091,8 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
modules['halkyon_oidc'] = _ipoqfiqr.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_core'] = _iacs.Endpoints()

View file

@ -13,6 +13,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_base_server/src/generated/password/authenticator.dart'
as _ip34xal6;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:serverpod/protocol.dart' as _isp;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
@ -1004,6 +1005,7 @@ class Protocol extends _is.DatabaseSerializationManager {
],
managed: true,
),
..._ipoqfiqr.Protocol.targetTableDefinitions,
..._iais.Protocol.targetTableDefinitions,
..._iacs.Protocol.targetTableDefinitions,
];
@ -1368,6 +1370,9 @@ class Protocol extends _is.DatabaseSerializationManager {
.toList()
as T;
}
try {
return _ipoqfiqr.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
try {
return _iais.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
@ -1727,6 +1732,12 @@ class Protocol extends _is.DatabaseSerializationManager {
@override
_is.Table? getTableForType(Type t) {
{
var table = _ipoqfiqr.Protocol().getTableForType(t);
if (table != null) {
return table;
}
}
{
var table = _iais.Protocol().getTableForType(t);
if (table != null) {
@ -1790,6 +1801,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (record == null) {
return null;
}
try {
return _ipoqfiqr.Protocol().mapRecordToJson(record);
} catch (_) {}
try {
return _iais.Protocol().mapRecordToJson(record);
} catch (_) {}

View file

@ -1,28 +1,27 @@
import 'dart:io';
import 'package:halkyon_base_server/src/common/web_page.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:serverpod/serverpod.dart';
// SimpleLogin's forwarding page (Halkyon Learn's ADR 0020, 3 and D9): the one
// address the instance's SimpleLogin app registers, since SimpleLogin takes
// only https addresses, each registered exactly. The browser comes back here
// with SimpleLogin's code (or its refusal) and the state the sign-in began
// with, and the page sends both on to the app that began it: the desktop's
// loopback or the phone's scheme. A state starts with a marker naming the
// app and platform, so the page sends nobody anywhere but to an address of
// its own table. It keeps and logs nothing (the web server's session names
// the path alone): a code caught on its way is worthless without the attempt
// the app holds (ADR 0020, 4).
// only https addresses, each registered exactly. halkyon_oidc's page
// (`oidcCallbackForwarding`, ADR 0023, 7) with Halkyon's apps and look: the
// browser comes back here with SimpleLogin's code (or its refusal) and the
// state the sign-in began with, and the page sends both on to the app that
// began it, the desktop's loopback or the phone's scheme, by the marker its
// state begins with. It keeps and logs nothing: a code caught on its way is
// worthless without the attempt the app holds (ADR 0020, 4).
/// Where SimpleLogin sends the browser back to, on the register's web
/// server.
const simpleLoginCallbackPath = '/simplelogin-callback';
/// Where each Halkyon app takes a sign-in back, by the marker its state
/// begins with: the desktop's loopback, each app on a port of its own (RFC
/// 8252, 7.3), and the phone's scheme, the app's ID. A phone with Halkyon
/// Learn and learn.halkyon.eu never reaches the page: Android hands the
/// page's address to the app itself (App Links, `assetlinks.json`).
/// begins with (`<app>-<platform>`): the desktop's loopback, each app on a
/// port of its own (RFC 8252, 7.3), and the phone's scheme, the app's ID. A
/// phone with Halkyon Learn and learn.halkyon.eu never reaches the page:
/// Android hands the page's address to the app itself (App Links,
/// `assetlinks.json`).
const simpleLoginReturns = {
'learn-desktop': 'http://127.0.0.1:43823/simplelogin-callback',
'learn-phone': 'app.halkyon.learn://simplelogin-callback',
@ -30,177 +29,43 @@ const simpleLoginReturns = {
'todo-phone': 'app.halkyon.todo://simplelogin-callback',
};
/// A state as the page takes it: a marker of [simpleLoginReturns], a dot,
/// and the rest the sign-in began with, 1 to 512 characters of base64url
/// (`learn-desktop.q3J…`). Nothing else, so nothing in it can reach the
/// address it goes to but as the value of `state`.
final _state = RegExp(r'^([a-z]+-[a-z]+)\.([A-Za-z0-9_-]{1,512})$');
/// A code as SimpleLogin makes them (ten lower-case letters), or longer
/// ones of RFC 3986's unreserved characters, up to 512.
final _code = RegExp(r'^[A-Za-z0-9._~-]{1,512}$');
/// An error as OAuth names them (RFC 6749, 4.1.2.1; SimpleLogin's own is
/// `deny`).
final _error = RegExp(r'^[A-Za-z0-9._-]{1,64}$');
/// The longest query the page reads: a sign-in's is far shorter.
const _maxQuery = 4096;
/// A request for the page as it reads one: plain values, so tests can make
/// one. The route turns Relic's requests into these.
typedef SimpleLoginCallbackRequest = ({
/// The method, upper case.
String method,
/// The query as the browser sent it, still encoded.
String query,
/// The `Accept-Language` header, if any.
String? acceptLanguage,
});
/// What the page answers: its status, where it sends the browser (`location`,
/// with an empty `html`) or the page saying why not, and its headers, by
/// lower-case name.
typedef SimpleLoginCallbackAnswer = ({
int status,
Uri? location,
String html,
Map<String, String> headers,
});
/// The page's answer to [request]: a 303 to the app's own address with the
/// code or the error, and the state, where the state names an app and
/// exactly one of them came, each once and well formed; a page saying the
/// sign-in can't go on otherwise, which repeats nothing it was given.
SimpleLoginCallbackAnswer simpleLoginCallback(
SimpleLoginCallbackRequest request,
) {
final german = prefersGerman(request.acceptLanguage);
if (request.method != 'GET') {
return _refused(405, german: german, allow: 'GET');
}
if (request.query.length > _maxQuery) {
return _refused(400, german: german);
}
final Map<String, List<String>> query;
try {
query = Uri(query: request.query).queryParametersAll;
} on Object {
// Encoding Uri can't read: FormatException or ArgumentError.
return _refused(400, german: german);
}
String? only(String name) => switch (query[name]) {
[final value] => value,
_ => null,
};
bool twice(String name) => (query[name]?.length ?? 0) > 1;
if (twice('state') || twice('code') || twice('error')) {
return _refused(400, german: german);
}
final state = only('state');
final target = switch (_state.firstMatch(state ?? '')) {
final match? => simpleLoginReturns[match[1]],
null => null,
};
final code = only('code');
final error = only('error');
if (target == null ||
state == null ||
(code == null) == (error == null) ||
(code != null && !_code.hasMatch(code)) ||
(error != null && !_error.hasMatch(error))) {
return _refused(400, german: german);
}
return (
status: 303,
location: Uri.parse(target).replace(
queryParameters: {
'code': ?code,
'error': ?error,
'state': state,
},
),
html: '',
headers: _kept,
);
}
/// What every answer says to whoever passes it on: keep nothing, send no
/// referrer with the code in it, guess no type.
const _kept = {
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
final _returns = {
for (final MapEntry(:key, :value) in simpleLoginReturns.entries)
key: Uri.parse(value),
};
SimpleLoginCallbackAnswer _refused(
int status, {
required bool german,
String? allow,
}) => (
status: status,
location: null,
html: _refusedPage(german: german),
headers: {
..._kept,
// Nothing runs, nothing is fetched, and no other page frames it.
'content-security-policy':
"default-src 'none'; style-src 'unsafe-inline'; "
"frame-ancestors 'none'",
'allow': ?allow,
},
);
/// The page's answer to [request] (see `oidcCallbackForwarding`), with
/// [simpleLoginReturns] and Halkyon's page for a refusal.
OidcCallbackAnswer simpleLoginCallback(OidcCallbackRequest request) =>
oidcCallbackForwarding(
request,
returns: _returns,
refusalPage: _refusedPage,
);
String _refusedPage({required bool german}) =>
'<!doctype html>\n<html lang="${german ? 'de' : 'en'}">\n<head>\n'
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
'<meta name="robots" content="noindex">\n'
'<title>${german ? 'Anmeldung nicht abgeschlossen' : 'Sign-in not completed'}'
'</title>\n<style>$pageStyle</style>\n</head>\n<body>\n<main>\n'
'<h1>${german ? 'Diese Anmeldung kann nicht weitergehen.' : "This sign-in can't go on."}'
'</h1>\n<p>${german ? 'Halkyon kann sie nicht zu einer seiner Apps '
'zurückbringen. Geh zurück zur App und melde dich noch einmal '
'an.' : "Halkyon can't take it back to one of its apps. Go back "
'to the app and sign in again.'}</p>\n'
'</main>\n</body>\n</html>\n';
String _refusedPage(String? acceptLanguage) {
final german = prefersGerman(acceptLanguage);
return '<!doctype html>\n<html lang="${german ? 'de' : 'en'}">\n<head>\n'
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
'<meta name="robots" content="noindex">\n'
'<title>${german ? 'Anmeldung nicht abgeschlossen' : 'Sign-in not completed'}'
'</title>\n<style>$pageStyle</style>\n</head>\n<body>\n<main>\n'
'<h1>${german ? 'Diese Anmeldung kann nicht weitergehen.' : "This sign-in can't go on."}'
'</h1>\n<p>${german ? 'Halkyon kann sie nicht zu einer seiner Apps '
'zurückbringen. Geh zurück zur App und melde dich noch einmal '
'an.' : "Halkyon can't take it back to one of its apps. Go back "
'to the app and sign in again.'}</p>\n'
'</main>\n</body>\n</html>\n';
}
/// Serves SimpleLogin's forwarding page on [pod]'s web server, at
/// [simpleLoginCallbackPath]: on the instance's register, whose SimpleLogin
/// app registers its address (ADR 0016). Caddy forwards the path to it (the
/// host's `deploy/Caddyfile`).
void addSimpleLoginCallbackRoute(Serverpod pod) {
if (pod.config.webServer == null) {
return;
}
pod.webServer.addRoute(_SimpleLoginCallbackRoute(), simpleLoginCallbackPath);
}
/// Every method, so the page answers them itself rather than another route.
class _SimpleLoginCallbackRoute() extends Route {
this : super(methods: Method.values.toSet());
@override
Future<Result> handleCall(Session session, Request request) async {
final answer = simpleLoginCallback((
method: request.method.value,
query: request.url.query,
acceptLanguage: request.headers[HttpHeaders.acceptLanguageHeader]?.join(
',',
),
));
return Response(
answer.status,
body: answer.location == null
? Body.fromString(answer.html, mimeType: MimeType.html)
: Body.empty(),
headers: Headers.fromMap({
for (final MapEntry(:key, :value) in answer.headers.entries)
key: [value],
if (answer.location case final location?) 'location': ['$location'],
}),
);
}
}
void addSimpleLoginCallbackRoute(Serverpod pod) => addOidcCallbackForwarding(
pod,
path: simpleLoginCallbackPath,
returns: _returns,
refusalPage: _refusedPage,
);