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:
Tizian 「ティツィアーン」 2026-10-23 15:27:56 +02:00
commit 8d6785f5c6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,248 @@
import 'package:halkyon_base_server/src/simplelogin/simplelogin_callback.dart';
import 'package:test/test.dart';
/// A GET of the page with [parameters] as the query, encoded as a browser
/// sends them.
SimpleLoginCallbackRequest _get(
Map<String, String> parameters, {
String? acceptLanguage,
}) => (
method: 'GET',
query: Uri(queryParameters: parameters).query,
acceptLanguage: acceptLanguage,
);
/// A GET of the page with [query] exactly as written.
SimpleLoginCallbackRequest _raw(String query) =>
(method: 'GET', query: query, acceptLanguage: null);
void expectRefused(SimpleLoginCallbackAnswer answer, {int status = 400}) {
expect(answer.status, status);
expect(answer.location, isNull);
expect(answer.html, contains('noindex'));
expect(answer.html, isNot(contains('<script')));
}
void main() {
group('a sign-in coming back from SimpleLogin', () {
test('goes to exactly the address its app and platform have, with its '
'code and state, whichever of them it is', () {
for (final MapEntry(key: marker, value: target)
in simpleLoginReturns.entries) {
final state = '$marker.Az09_-attempt';
final answer = simpleLoginCallback(
_get({'code': 'qwertyuiop', 'state': state}),
);
final own = Uri.parse(target);
expect(answer.status, 303, reason: marker);
final location = answer.location!;
expect(location.scheme, own.scheme, reason: marker);
expect(location.host, own.host, reason: marker);
expect(location.port, own.port, reason: marker);
expect(location.path, own.path, reason: marker);
expect(location.userInfo, isEmpty, reason: marker);
expect(location.fragment, isEmpty, reason: marker);
expect(location.queryParametersAll, {
'code': ['qwertyuiop'],
'state': [state],
}, reason: marker);
}
});
test("names Halkyon's apps on the desktop's loopback and the phones' "
'schemes alone', () {
expect(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',
});
});
test("takes SimpleLogin's refusal back as well, its error and state", () {
final answer = simpleLoginCallback(
_get({'error': 'deny', 'state': 'learn-phone.abc'}),
);
expect(answer.status, 303);
expect(
answer.location,
Uri.parse(
'app.halkyon.learn://simplelogin-callback',
).replace(
queryParameters: {'error': 'deny', 'state': 'learn-phone.abc'},
),
);
});
test('takes what SimpleLogin writes as it writes it, and leaves out '
'what the app needs not know', () {
// SimpleLogin writes code, state and scope with Python's quote().
final answer = simpleLoginCallback(
_raw(
'state=learn-desktop.r4nd0m-_x&scope=openid%20email&code=abcdefghij',
),
);
expect(answer.status, 303);
expect(answer.location!.queryParametersAll, {
'code': ['abcdefghij'],
'state': ['learn-desktop.r4nd0m-_x'],
});
});
test('is kept by nobody on the way', () {
final answer = simpleLoginCallback(
_get({'code': 'abc', 'state': 'learn-desktop.abc'}),
);
expect(answer.headers, {
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
});
});
});
group('the page refuses, sending nobody anywhere,', () {
test('a state naming no app it knows', () {
for (final state in [
'abc', // no marker
'evil-desktop.abc', // a marker of no app
'Learn-desktop.abc', // not as written
'learn-desktop', // no rest
'learn-desktop.', // an empty rest
'.abc',
'learn-desktop..abc',
]) {
expectRefused(
simpleLoginCallback(_get({'code': 'abc', 'state': state})),
);
}
});
test('a state holding anything but base64url, so nothing in it can '
'reach the address', () {
for (final state in [
'learn-desktop.x@evil.example',
'learn-desktop.x/../../evil',
'//evil.example/learn-desktop.abc',
'learn-desktop.abc\r\nSet-Cookie: a=b',
'learn-desktop.abc#fragment',
'learn-desktop.abc?code=other',
'learn-desktop.abc&code=other',
'learn-desktop.a b',
'learn-desktop.ab.c',
'learn-desktop.${'a' * 513}',
]) {
expectRefused(
simpleLoginCallback(_get({'code': 'abc', 'state': state})),
);
}
});
test('a missing state', () {
expectRefused(simpleLoginCallback(_get({'code': 'abc'})));
expectRefused(simpleLoginCallback(_raw('')));
});
test('neither a code nor an error, or both', () {
expectRefused(simpleLoginCallback(_get({'state': 'learn-desktop.abc'})));
expectRefused(
simpleLoginCallback(
_get({
'code': 'abc',
'error': 'deny',
'state': 'learn-desktop.abc',
}),
),
);
});
test('a code or an error that is no such thing', () {
for (final code in [
'',
'a b',
'//evil.example',
'<script>',
'abc\r\nLocation: x',
'a' * 513,
]) {
expectRefused(
simpleLoginCallback(
_get({'code': code, 'state': 'learn-desktop.abc'}),
),
);
}
for (final error in ['', 'access denied', '"deny"', 'a' * 65]) {
expectRefused(
simpleLoginCallback(
_get({'error': error, 'state': 'learn-desktop.abc'}),
),
);
}
});
test('a code, an error or a state given twice', () {
for (final query in [
'code=a&code=b&state=learn-desktop.abc',
'error=deny&error=deny&state=learn-desktop.abc',
'code=a&state=learn-desktop.abc&state=todo-phone.abc',
]) {
expectRefused(simpleLoginCallback(_raw(query)));
}
});
test("a query it can't read, or one longer than any sign-in sends", () {
expectRefused(
simpleLoginCallback(_raw('code=%zz&state=learn-desktop.abc')),
);
expectRefused(
simpleLoginCallback(
_raw('code=abc&state=learn-desktop.abc&x=${'a' * 4096}'),
),
);
});
test('every method but GET', () {
for (final method in ['POST', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']) {
final answer = simpleLoginCallback((
method: method,
query: 'code=abc&state=learn-desktop.abc',
acceptLanguage: null,
));
expectRefused(answer, status: 405);
expect(answer.headers['allow'], 'GET', reason: method);
}
});
test('keeping its page from being kept, indexed or framed, and saying '
'nothing of what it was given', () {
final answer = simpleLoginCallback(
_get({'code': 'zzsecretcodezz', 'state': 'zzsecretstatezz'}),
);
expectRefused(answer);
expect(answer.html, isNot(contains('zzsecret')));
expect(answer.headers, {
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'content-security-policy':
"default-src 'none'; style-src 'unsafe-inline'; "
"frame-ancestors 'none'",
});
});
test('in German where the browser asks for it first', () {
expect(
simpleLoginCallback(
_get({'code': 'abc'}, acceptLanguage: 'de-DE,de;q=0.9,en;q=0.8'),
).html,
contains('lang="de"'),
);
expect(
simpleLoginCallback(
_get({'code': 'abc'}, acceptLanguage: 'fr,en;q=0.5'),
).html,
contains('lang="en"'),
);
});
});
}