feat(base): SimpleLogin's forwarding page and Learn's App Links statement (ADR 0020, step 1)
SimpleLogin takes only https redirect addresses, each registered exactly, so the instance's SimpleLogin app registers one page on the register's web server, /simplelogin-callback, which sends the browser on to the app that began the sign-in: a 303 to the desktop's loopback (127.0.0.1, a port per app) or the phone's scheme, chosen by the marker the state begins with (`<app>-<platform>.<rest>`, the rest 1 to 512 characters of base64url), from a fixed table, so it sends nobody anywhere else. It forwards the code or SimpleLogin's error (`deny`) with the state and nothing else, refuses anything malformed, repeated or not a GET with a page that repeats nothing it was given, and keeps and logs nothing (no-store, no referrer). It is built ahead of the sign-in itself so development can sign in through learn.halkyon.eu's page, which the development app registers too. Android's assetlinks.json for Halkyon Learn signed with the release key is answered by the Caddyfile on learn.halkyon.eu alone: the one domain the project's builds name, and the server knows no app's key. A root test holds its fingerprint to tool/release/android-certificate.sha256. The app's intent filter comes with the sign-in; iOS's association waits for an Apple team ID. The profile page's language choice and look move to common/web_page.dart, shared by both pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ca24eb9cf9
commit
8d6785f5c6
4 changed files with 582 additions and 47 deletions
|
|
@ -3,7 +3,8 @@
|
|||
/// deletion and the pruning, email sign-in's helpers, the instance's
|
||||
/// basics, people's public profiles and their pages, the instance's register
|
||||
/// (ADR 0016), signing in with a password and TOTP and getting back into an
|
||||
/// account (ADR 0017), and the module's models.
|
||||
/// account (ADR 0017), SimpleLogin's forwarding page (Halkyon Learn's ADR
|
||||
/// 0020), and the module's models.
|
||||
library;
|
||||
|
||||
export 'src/auth/account.dart';
|
||||
|
|
@ -73,6 +74,11 @@ export 'src/register/member_pruning.dart';
|
|||
export 'src/register/people.dart' show issueSession;
|
||||
export 'src/register/sessions_ended.dart'
|
||||
show AccountSessionsEnded, deliverSessionNotices;
|
||||
export 'src/simplelogin/simplelogin_callback.dart'
|
||||
show
|
||||
addSimpleLoginCallbackRoute,
|
||||
simpleLoginCallbackPath,
|
||||
simpleLoginReturns;
|
||||
export 'src/srp/srp_group.dart';
|
||||
export 'src/srp/srp_math.dart' show numberOf, padded;
|
||||
export 'src/srp/srp_server.dart'
|
||||
|
|
|
|||
38
packages/halkyon_base_server/lib/src/common/web_page.dart
Normal file
38
packages/halkyon_base_server/lib/src/common/web_page.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// What the module's pages on the web share (profile pages, SimpleLogin's
|
||||
// forwarding page): the language a visitor reads, and the look.
|
||||
|
||||
/// Whether German comes before English in [acceptLanguage], an
|
||||
/// `Accept-Language` header; English otherwise.
|
||||
bool prefersGerman(String? acceptLanguage) {
|
||||
final languages = [
|
||||
for (final part in (acceptLanguage ?? '').split(','))
|
||||
if (part.trim().isNotEmpty)
|
||||
(
|
||||
language: part.split(';').first.trim().split('-').first.toLowerCase(),
|
||||
weight: switch (RegExp(r';\s*q=([0-9.]+)').firstMatch(part)) {
|
||||
final match? => double.tryParse(match[1]!) ?? 0,
|
||||
null => 1.0,
|
||||
},
|
||||
),
|
||||
]..sort((a, b) => b.weight.compareTo(a.weight));
|
||||
return languages
|
||||
.where((each) => each.language == 'de' || each.language == 'en')
|
||||
.firstOrNull
|
||||
?.language ==
|
||||
'de';
|
||||
}
|
||||
|
||||
/// The pages' look: the brand's cobalt, light and dark as the system is.
|
||||
const pageStyle =
|
||||
':root{color-scheme:light dark;--ink:#1b1b1f;--muted:#5d5e66;'
|
||||
'--paper:#fbf8fd;--accent:#273eb2}'
|
||||
'@media (prefers-color-scheme:dark){:root{--ink:#e4e1e6;--muted:#c6c5d0;'
|
||||
'--paper:#131316;--accent:#b9c3ff}}'
|
||||
'body{margin:0;background:var(--paper);color:var(--ink);'
|
||||
'font:16px/1.5 system-ui,sans-serif}'
|
||||
'main{max-width:36rem;margin:0 auto;padding:3rem 1rem}'
|
||||
'.avatar{border-radius:50%}h1{margin:.5rem 0 0;font-size:1.75rem}'
|
||||
'.address,.app{color:var(--muted)}.address{margin:0 0 1rem}'
|
||||
'.links{padding:0;list-style:none}.links li{margin:.25rem 0}'
|
||||
'a{color:var(--accent)}.verified{color:var(--muted);font-size:.875rem}'
|
||||
'.app{margin-top:2rem;font-size:.875rem}';
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_base_server/src/common/web_page.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
||||
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
||||
|
|
@ -25,7 +26,7 @@ Future<({int status, String html, String? location})> profilePage(
|
|||
required String host,
|
||||
String? acceptLanguage,
|
||||
}) async {
|
||||
final german = _prefersGerman(acceptLanguage);
|
||||
final german = prefersGerman(acceptLanguage);
|
||||
final handle = segment.startsWith('@') ? segment.substring(1) : null;
|
||||
final profile = handle == null || !isHandle(handle)
|
||||
? null
|
||||
|
|
@ -107,26 +108,6 @@ class _ProfilePageRoute() extends Route {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether German comes before English in [acceptLanguage].
|
||||
bool _prefersGerman(String? acceptLanguage) {
|
||||
final languages = [
|
||||
for (final part in (acceptLanguage ?? '').split(','))
|
||||
if (part.trim().isNotEmpty)
|
||||
(
|
||||
language: part.split(';').first.trim().split('-').first.toLowerCase(),
|
||||
weight: switch (RegExp(r';\s*q=([0-9.]+)').firstMatch(part)) {
|
||||
final match? => double.tryParse(match[1]!) ?? 0,
|
||||
null => 1.0,
|
||||
},
|
||||
),
|
||||
]..sort((a, b) => b.weight.compareTo(a.weight));
|
||||
return languages
|
||||
.where((each) => each.language == 'de' || each.language == 'en')
|
||||
.firstOrNull
|
||||
?.language ==
|
||||
'de';
|
||||
}
|
||||
|
||||
const _text = HtmlEscape(HtmlEscapeMode.element);
|
||||
const _attribute = HtmlEscape(HtmlEscapeMode.attribute);
|
||||
|
||||
|
|
@ -157,7 +138,7 @@ String _page(
|
|||
page.writeln('<link rel="me" href="${_attribute.convert(link)}">');
|
||||
}
|
||||
page
|
||||
..writeln('<style>$_style</style>')
|
||||
..writeln('<style>$pageStyle</style>')
|
||||
..writeln('</head>')
|
||||
..writeln('<body>')
|
||||
..writeln('<main>');
|
||||
|
|
@ -211,21 +192,6 @@ String _notFound({required bool german}) =>
|
|||
'<!doctype html>\n<html lang="${german ? 'de' : 'en'}">\n<head>\n'
|
||||
'<meta charset="utf-8">\n<meta name="robots" content="noindex">\n'
|
||||
'<title>${german ? 'Nicht gefunden' : 'Not found'}</title>\n'
|
||||
'<style>$_style</style>\n</head>\n<body>\n<main>\n'
|
||||
'<style>$pageStyle</style>\n</head>\n<body>\n<main>\n'
|
||||
'<h1>${german ? 'Dieses Profil gibt es nicht.' : "This profile doesn't exist."}'
|
||||
'</h1>\n</main>\n</body>\n</html>\n';
|
||||
|
||||
/// The page's look: the brand's cobalt, light and dark as the system is.
|
||||
const _style =
|
||||
':root{color-scheme:light dark;--ink:#1b1b1f;--muted:#5d5e66;'
|
||||
'--paper:#fbf8fd;--accent:#273eb2}'
|
||||
'@media (prefers-color-scheme:dark){:root{--ink:#e4e1e6;--muted:#c6c5d0;'
|
||||
'--paper:#131316;--accent:#b9c3ff}}'
|
||||
'body{margin:0;background:var(--paper);color:var(--ink);'
|
||||
'font:16px/1.5 system-ui,sans-serif}'
|
||||
'main{max-width:36rem;margin:0 auto;padding:3rem 1rem}'
|
||||
'.avatar{border-radius:50%}h1{margin:.5rem 0 0;font-size:1.75rem}'
|
||||
'.address,.app{color:var(--muted)}.address{margin:0 0 1rem}'
|
||||
'.links{padding:0;list-style:none}.links li{margin:.25rem 0}'
|
||||
'a{color:var(--accent)}.verified{color:var(--muted);font-size:.875rem}'
|
||||
'.app{margin-top:2rem;font-size:.875rem}';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_base_server/src/common/web_page.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).
|
||||
|
||||
/// 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`).
|
||||
const simpleLoginReturns = {
|
||||
'learn-desktop': 'http://127.0.0.1:43823/simplelogin-callback',
|
||||
'learn-phone': 'app.halkyon.learn://simplelogin-callback',
|
||||
'todo-desktop': 'http://127.0.0.1:43824/simplelogin-callback',
|
||||
'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',
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
/// 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'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue