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
11 changed files with 582 additions and 47 deletions

View file

@ -75,7 +75,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too. - **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address with a password) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address and password, one of each (`account.signInWays`, which also says whether they have a password and a confirmed authenticator); the Profile tab lists their ways in, adds GitHub where the instance adds it (never where passwords are offered), and holds halkyon_app's `AccountSecuritySection` (setting up a password, the authenticators, changing it). - **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address with a password) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address and password, one of each (`account.signInWays`, which also says whether they have a password and a confirmed authenticator); the Profile tab lists their ways in, adds GitHub where the instance adds it (never where passwords are offered), and holds halkyon_app's `AccountSecuritySection` (setting up a password, the authenticators, changing it).
- **A password and an authenticator** (ADR 0017, built 2026-10-20 for build 22; halkyon_base's and halkyon_app's READMEs): the only way an identified person signs in besides GitHub, which is deprecated. The address, a password that never leaves the device (SRP-6a, `passwordSignIn.begin` and `complete`, the verifier and the account's wrapped keys on the register) and a code from an authenticator app (TOTP, mandatory) or a recovery code. An address nobody has is answered as an account's would be (decoys from `signInDecoyKey`), and every wrong credential is refused alike (`wrongCredentials`). Lost authenticator: the password and a mailed code (or the recovery phrase) turn it off 72 hours later, a mail at the start, a sign-in with a code calling it off; lost password: the recovery phrase's signature and a code set a new one, keys kept, or without the phrase a mailed code resets the whole account (new keys) after the same 72 hours. A reset takes effect only once the owner had 72 hours outside hold-outs (codes waiting, the address's ceiling filled: each moves it on by the time it holds the owner out); one that took effect and wasn't carried out lapses a week later, and every reset 30 days after it was asked; the Profile tab's account section shows waiting resets, called off with the password and a code (ADR 0017's full reading). An account with a password and a confirmed authenticator no longer signs in by a mailed code or GitHub (`usePassword`); where passwords are offered, no new GitHub account is linked (`deprecated`), and from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in and, 30 days later (a date set wrong can be put right first), the register's pruning forgets every GitHub ID, deleting first the accounts that signed in with GitHub alone (no address), which the app warns of until then (D2; the full reading's M1). Password sign-in is on only where the passwords hold `totpKey`, `recoveryCodeKey` and `signInDecoyKey` (`TOTP_KEY`, `RECOVERY_CODE_KEY`, `SIGN_IN_DECOY_KEY` in `deploy/.env`) and email sign-in is set up (`passwordSignIn` in `instance.basics()`). On the device (halkyon_app): signing up shows the recovery phrase once and checks three of its words before anything reaches the instance; signing in checks SRP's M2 and opens the keys before the session is put in place; an authenticator still to set up is set up with the session held back; the unlocked keys stay in the system's keyring per instance and person, gone once another person signs in or nobody. A session alone never gets a code checked (ADR 0017's third reading, as Proton has it): every signed-in change of how the account is secured (adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password, deleting) takes the password and a code (`OwnerProof`), each sheet asking for both; a session's wrong proofs count for it alone and the tenth in a row ends it, never making the account's codes wait. Nor does a session alone set a first password or confirm a first authenticator (the fourth reading): an account with an address proves it by a code mailed there (`firstPassword`), one that signs in with GitHub alone by a GitHub sign-in of the last 10 minutes (the app offers a password right after one, before the person's app, and asks for GitHub anew when the instance says `signInAgain`), and the first authenticator takes the password. A change of password ends the account's other sessions and every reset all of them, the authenticators' included as the sign-in carries it out, on every service of the instance (`sessions_ended_notice`). ADR 0017's "The rules as they stand" lists who changes what with which proofs. - **A password and an authenticator** (ADR 0017, built 2026-10-20 for build 22; halkyon_base's and halkyon_app's READMEs): the only way an identified person signs in besides GitHub, which is deprecated. The address, a password that never leaves the device (SRP-6a, `passwordSignIn.begin` and `complete`, the verifier and the account's wrapped keys on the register) and a code from an authenticator app (TOTP, mandatory) or a recovery code. An address nobody has is answered as an account's would be (decoys from `signInDecoyKey`), and every wrong credential is refused alike (`wrongCredentials`). Lost authenticator: the password and a mailed code (or the recovery phrase) turn it off 72 hours later, a mail at the start, a sign-in with a code calling it off; lost password: the recovery phrase's signature and a code set a new one, keys kept, or without the phrase a mailed code resets the whole account (new keys) after the same 72 hours. A reset takes effect only once the owner had 72 hours outside hold-outs (codes waiting, the address's ceiling filled: each moves it on by the time it holds the owner out); one that took effect and wasn't carried out lapses a week later, and every reset 30 days after it was asked; the Profile tab's account section shows waiting resets, called off with the password and a code (ADR 0017's full reading). An account with a password and a confirmed authenticator no longer signs in by a mailed code or GitHub (`usePassword`); where passwords are offered, no new GitHub account is linked (`deprecated`), and from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in and, 30 days later (a date set wrong can be put right first), the register's pruning forgets every GitHub ID, deleting first the accounts that signed in with GitHub alone (no address), which the app warns of until then (D2; the full reading's M1). Password sign-in is on only where the passwords hold `totpKey`, `recoveryCodeKey` and `signInDecoyKey` (`TOTP_KEY`, `RECOVERY_CODE_KEY`, `SIGN_IN_DECOY_KEY` in `deploy/.env`) and email sign-in is set up (`passwordSignIn` in `instance.basics()`). On the device (halkyon_app): signing up shows the recovery phrase once and checks three of its words before anything reaches the instance; signing in checks SRP's M2 and opens the keys before the session is put in place; an authenticator still to set up is set up with the session held back; the unlocked keys stay in the system's keyring per instance and person, gone once another person signs in or nobody. A session alone never gets a code checked (ADR 0017's third reading, as Proton has it): every signed-in change of how the account is secured (adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password, deleting) takes the password and a code (`OwnerProof`), each sheet asking for both; a session's wrong proofs count for it alone and the tenth in a row ends it, never making the account's codes wait. Nor does a session alone set a first password or confirm a first authenticator (the fourth reading): an account with an address proves it by a code mailed there (`firstPassword`), one that signs in with GitHub alone by a GitHub sign-in of the last 10 minutes (the app offers a password right after one, before the person's app, and asks for GitHub anew when the instance says `signInAgain`), and the first authenticator takes the password. A change of password ends the account's other sessions and every reset all of them, the authenticators' included as the sign-in carries it out, on every service of the instance (`sessions_ended_notice`). ADR 0017's "The rules as they stand" lists who changes what with which proofs.
- **One account across the instance** (ADR 0016, decided 2026-10-19; halkyon_base's README): Learn's server holds the instance's register, so its tables keep every service's ways in, handles and profiles, and an account's ID is the `AuthUser` ID in every service. The instance's other services (Todo) are its members: they ask it over its web server (`/halkyon-base/register/…`, `addInstanceRoutes` in `server.dart`, never forwarded by Caddy) with the secret the services share (`instanceSecret`, `INSTANCE_SECRET` in `deploy/.env`), and a member's anonymous person adding a way in becomes an account here under the same ID. Deleting an account (here, or a member passing it on) keeps a notice per member in the same transaction (`account_deletion_notice`), told at once and hourly until each confirms (`tellMembersOfDeletions`). Learn's endpoints and code go through the module as on any register; nothing changes for Learn's apps. - **One account across the instance** (ADR 0016, decided 2026-10-19; halkyon_base's README): Learn's server holds the instance's register, so its tables keep every service's ways in, handles and profiles, and an account's ID is the `AuthUser` ID in every service. The instance's other services (Todo) are its members: they ask it over its web server (`/halkyon-base/register/…`, `addInstanceRoutes` in `server.dart`, never forwarded by Caddy) with the secret the services share (`instanceSecret`, `INSTANCE_SECRET` in `deploy/.env`), and a member's anonymous person adding a way in becomes an account here under the same ID. Deleting an account (here, or a member passing it on) keeps a notice per member in the same transaction (`account_deletion_notice`), told at once and hourly until each confirms (`tellMembersOfDeletions`). Learn's endpoints and code go through the module as on any register; nothing changes for Learn's apps. SimpleLogin's forwarding page is the register's too (ADR 0020, built 2026-10-23 ahead of the sign-in itself): `addSimpleLoginCallbackRoute` in `server.dart`, `/simplelogin-callback` on the web server, which Caddy forwards there, sending a sign-in on to the app by the marker its state begins with (halkyon_base's README); beside it `deploy/Caddyfile` answers Android's `/.well-known/assetlinks.json` for Halkyon Learn signed with the release key, on learn.halkyon.eu alone (`test/deploy/assetlinks_test.dart` holds it to `tool/release/android-certificate.sha256`).
- **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`. - **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason. - **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so. - **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.

View file

@ -15,9 +15,10 @@ apiServer:
# port through an SSH tunnel, never through Caddy. # port through an SSH tunnel, never through Caddy.
# The web server serves profile pages (ADR 0011), the dictionaries devices # The web server serves profile pages (ADR 0011), the dictionaries devices
# download (ADR 0012) and the calls between the instance's services (ADR 0016). # download (ADR 0012), SimpleLogin's forwarding page (ADR 0020) and the calls
# Caddy forwards the paths of the first two alone to it (deploy/Caddyfile), # between the instance's services (ADR 0016). Caddy forwards the paths of the
# never the third; everything else stays on the API server. # first three alone to it (deploy/Caddyfile), never the last; everything else
# stays on the API server.
webServer: webServer:
port: 8082 port: 8082
publicHost: learn.halkyon.eu publicHost: learn.halkyon.eu

View file

@ -34,6 +34,9 @@ Future<void> run(List<String> args) async {
// The calls of the instance's other services, on the web server // The calls of the instance's other services, on the web server
// (ADR 0016); Caddy never forwards them. // (ADR 0016); Caddy never forwards them.
addInstanceRoutes(pod); addInstanceRoutes(pod);
// Where SimpleLogin sends a sign-in back, which forwards it to the app
// (ADR 0020): the register's page, as its SimpleLogin app is.
addSimpleLoginCallbackRoute(pod);
// Profile pages, `/@handle`, which profile links link back to (ADR 0011), // Profile pages, `/@handle`, which profile links link back to (ADR 0011),
// halkyon_base's since profiles are the instance's register's (ADR 0016). // halkyon_base's since profiles are the instance's register's (ADR 0016).
addProfilePageRoute(pod); addProfilePageRoute(pod);

View file

@ -1,5 +1,6 @@
# Caddy forwards to the API server, and profile pages (ADR 0011) and the dictionaries # Caddy forwards to the API server, and profile pages (ADR 0011), the dictionaries
# devices download (ADR 0012) to the web server (8082), which serves nothing else. Never the calls between the instance's # devices download (ADR 0012) and SimpleLogin's forwarding page (ADR 0020) to the web
# server (8082), which serves nothing else. Never the calls between the instance's
# services (`/halkyon-base/…`, ADR 0016): they reach each other on its own network. Insights (8081) doesn't run in production; # services (`/halkyon-base/…`, ADR 0016): they reach each other on its own network. Insights (8081) doesn't run in production;
# if it is ever re-enabled, reach it through an SSH tunnel, never through this file. # if it is ever re-enabled, reach it through an SSH tunnel, never through this file.
# #
@ -24,6 +25,28 @@
respond 404 respond 404
} }
# SimpleLogin's forwarding page (ADR 0020): where SimpleLogin sends a sign-in back,
# which sends it on to the app that began it, on the desktop or a phone.
@simplelogin path /simplelogin-callback
handle @simplelogin {
reverse_proxy server:8082
}
# Android's statement that Halkyon Learn, signed with the project's key
# (tool/release/android-certificate.sha256), opens learn.halkyon.eu's links
# itself (App Links, ADR 0020's D9), so SimpleLogin's way back reaches the app
# without the forwarding page. On learn.halkyon.eu alone, the one instance the
# project's builds name: another operator's domain is no link of the project's
# app. Written here rather than by the server, which knows no app's key.
@assetlinks {
host learn.halkyon.eu
path /.well-known/assetlinks.json
}
handle @assetlinks {
header Content-Type application/json
respond `[{"relation":["delegate_permission/common.handle_all_urls"],"target":{"namespace":"android_app","package_name":"app.halkyon.learn","sha256_cert_fingerprints":["27:E2:43:FC:BD:C8:26:FF:98:AB:7D:BB:10:43:6C:98:C3:BE:A2:4B:2A:01:5E:94:E4:CF:C7:D1:FC:2A:00:6C"]}}]` 200
}
# The dictionaries devices download to look Japanese up by themselves, named by # The dictionaries devices download to look Japanese up by themselves, named by
# their hashes; already compressed. # their hashes; already compressed.
handle /device-dictionaries/* { handle /device-dictionaries/* {

View file

@ -43,6 +43,15 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host), `https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host),
served by `addProfilePageRoute`, in English or German, its foot what the host's served by `addProfilePageRoute`, in English or German, its foot what the host's
`profilePageNote` says. `profilePageNote` says.
- SimpleLogin's forwarding page (Halkyon Learn's ADR 0020; `addSimpleLoginCallbackRoute`,
on the register): `/simplelogin-callback`, the one address the instance's SimpleLogin
app registers, as SimpleLogin takes only https addresses, each registered exactly. It
answers a 303 to the app that began the sign-in, with SimpleLogin's `code` (or its
`error`) and the `state`, and nothing else: the state is `<app>-<platform>.<rest>`, the
marker one of `simpleLoginReturns` (the desktop's loopback, `127.0.0.1` on a port of each
app's own, or the phone's scheme, the app's ID), the rest 1 to 512 characters of
base64url; anything else gets a page saying the sign-in can't go on, repeating nothing
it was given. It keeps and logs nothing.
- **Per-network limits** (new anonymous accounts, codes by mail, password sign-ins, - **Per-network limits** (new anonymous accounts, codes by mail, password sign-ins,
proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the
connection's, or, for a connection from a proxy the operator trusts connection's, or, for a connection from a proxy the operator trusts
@ -399,8 +408,9 @@ Built on the three parts above, on the register (members forward every step thro
``` ```
Serve the calls between the instance's services on its web server: Serve the calls between the instance's services on its web server:
`addInstanceRoutes(pod)`; and the profile pages: `addProfilePageRoute(pod)`, after any `addInstanceRoutes(pod)`; on the register, SimpleLogin's forwarding page:
literal routes of its own. `addSimpleLoginCallbackRoute(pod)`; and the profile pages: `addProfilePageRoute(pod)`,
after any literal routes of its own.
Then schedule the pruning in a future call of the host's own: daily Then schedule the pruning in a future call of the host's own: daily
`pruneAbandonedAnonymousPeople`, `forgetGitHubAccounts` (the register's), `pruneAbandonedAnonymousPeople`, `forgetGitHubAccounts` (the register's),

View file

@ -3,7 +3,8 @@
/// deletion and the pruning, email sign-in's helpers, the instance's /// deletion and the pruning, email sign-in's helpers, the instance's
/// basics, people's public profiles and their pages, the instance's register /// 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 /// (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; library;
export 'src/auth/account.dart'; 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/people.dart' show issueSession;
export 'src/register/sessions_ended.dart' export 'src/register/sessions_ended.dart'
show AccountSessionsEnded, deliverSessionNotices; show AccountSessionsEnded, deliverSessionNotices;
export 'src/simplelogin/simplelogin_callback.dart'
show
addSimpleLoginCallbackRoute,
simpleLoginCallbackPath,
simpleLoginReturns;
export 'src/srp/srp_group.dart'; export 'src/srp/srp_group.dart';
export 'src/srp/srp_math.dart' show numberOf, padded; export 'src/srp/srp_math.dart' show numberOf, padded;
export 'src/srp/srp_server.dart' export 'src/srp/srp_server.dart'

View 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}';

View file

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; 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/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart'; import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.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, required String host,
String? acceptLanguage, String? acceptLanguage,
}) async { }) async {
final german = _prefersGerman(acceptLanguage); final german = prefersGerman(acceptLanguage);
final handle = segment.startsWith('@') ? segment.substring(1) : null; final handle = segment.startsWith('@') ? segment.substring(1) : null;
final profile = handle == null || !isHandle(handle) final profile = handle == null || !isHandle(handle)
? null ? 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 _text = HtmlEscape(HtmlEscapeMode.element);
const _attribute = HtmlEscape(HtmlEscapeMode.attribute); const _attribute = HtmlEscape(HtmlEscapeMode.attribute);
@ -157,7 +138,7 @@ String _page(
page.writeln('<link rel="me" href="${_attribute.convert(link)}">'); page.writeln('<link rel="me" href="${_attribute.convert(link)}">');
} }
page page
..writeln('<style>$_style</style>') ..writeln('<style>$pageStyle</style>')
..writeln('</head>') ..writeln('</head>')
..writeln('<body>') ..writeln('<body>')
..writeln('<main>'); ..writeln('<main>');
@ -211,21 +192,6 @@ String _notFound({required bool german}) =>
'<!doctype html>\n<html lang="${german ? 'de' : 'en'}">\n<head>\n' '<!doctype html>\n<html lang="${german ? 'de' : 'en'}">\n<head>\n'
'<meta charset="utf-8">\n<meta name="robots" content="noindex">\n' '<meta charset="utf-8">\n<meta name="robots" content="noindex">\n'
'<title>${german ? 'Nicht gefunden' : 'Not found'}</title>\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>${german ? 'Dieses Profil gibt es nicht.' : "This profile doesn't exist."}'
'</h1>\n</main>\n</body>\n</html>\n'; '</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}';

View file

@ -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'],
}),
);
}
}

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"'),
);
});
});
}

View file

@ -0,0 +1,34 @@
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
/// The certificate's SHA-256 as `tool/release/android-certificate.sha256`
/// pins it, the release tool's way: the first line that isn't a comment.
String _pinned() =>
File('tool/release/android-certificate.sha256')
.readAsLinesSync()
.map((line) => line.trim())
.firstWhere((line) => line.isNotEmpty && !line.startsWith('#'));
void main() {
test("learn.halkyon.eu's assetlinks.json names Halkyon Learn signed with "
'the key releases are signed with, and nothing else (ADR 0020, D9)', () {
final caddyfile = File('deploy/Caddyfile').readAsStringSync();
final body = RegExp(r'handle @assetlinks \{[^}]*respond `([^`]*)` 200')
.firstMatch(caddyfile)?[1];
expect(body, isNotNull, reason: 'the Caddyfile answers assetlinks.json');
expect(jsonDecode(body!), [
{
'relation': ['delegate_permission/common.handle_all_urls'],
'target': {
'namespace': 'android_app',
'package_name': 'app.halkyon.learn',
'sha256_cert_fingerprints': [_pinned()],
},
},
]);
// Colon-separated upper-case hex, as Android's verifier compares it.
expect(_pinned(), matches(RegExp(r'^([0-9A-F]{2}:){31}[0-9A-F]{2}$')));
});
}