feat(base): SimpleLogin's sign-in comes back by the app's scheme or the loopback (ADR 0020, D9 corrected)

simpleLogin.begin takes the way back (SimpleLoginReturn: loopback,
scheme) rather than the platform, so macOS can come back through its
scheme as iOS and Android do; the markers become learn-loopback,
learn-scheme, todo-loopback and todo-scheme. Not breaking: the
endpoint has never been released.

D9 corrected by the developer after research: Android, iOS and macOS
come back through the app's scheme, Linux and Windows through the
loopback. Verified links protected nothing here: a code caught on its
way is worthless without the attempt's ID, and a hostile app beginning
an attempt of its own can ask for a way back that other platforms need
open. The ADR notes the residual risk and the check of prompt=consent
still to do. assetlinks.json stays served.

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 23:35:46 +02:00
commit 7ea9f16904
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 201 additions and 182 deletions

View file

@ -26,16 +26,17 @@
}
# 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.
# which sends it on to the app that began it, by the loopback or its scheme.
@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
# (tool/release/android-certificate.sha256), may open learn.halkyon.eu's links
# itself (App Links): built for ADR 0020's D9, whose correction brings the
# sign-in back through the app's scheme instead, and kept, as it costs
# nothing. 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 {

View file

@ -6,16 +6,16 @@
`/simplelogin-callback` on the register's web server (halkyon_base's
`addSimpleLoginCallbackRoute`, Caddy forwarding the path), ahead of the sign-in, so
development signs in through learn.halkyon.eu's page, which its SimpleLogin app
registers too. The state is `<app>-<platform>.<rest>`: the marker one of the module's
fixed table (Learn and Todo, the desktop's loopback on ports 43823 and 43824, or the
app's scheme), the rest 1 to 512 characters of base64url. A 303 carries the code or
registers too. The state is `<app>-<way back>.<rest>`: the marker one of the module's
fixed table (Learn and Todo, the loopback on ports 43823 and 43824, or the app's
scheme; named `<app>-<platform>` until D9's correction), the rest 1 to 512 characters
of base64url. A 303 carries the code or
SimpleLogin's error (`deny`) with the state and nothing else; whatever is malformed,
repeated or not a GET is refused with a page that repeats nothing. No caching, no
referrer, nothing logged. Android's `assetlinks.json` for Halkyon Learn signed with
the release key (D9) is answered by `deploy/Caddyfile` on learn.halkyon.eu alone,
held to `tool/release/android-certificate.sha256` by a test. The app's intent filter
comes with the sign-in; iOS's associated domains wait for an Apple team ID, so iPhones
come back through the forwarding page and the scheme until then.
the release key is answered by `deploy/Caddyfile` on learn.halkyon.eu alone, held to
`tool/release/android-certificate.sha256` by a test: built for D9's verified links,
which its correction dropped, and kept, as it costs nothing.
- **Built (23 Oct 2026), step 2:** the sign-in on the server's side (1 to 5), on
halkyon_oidc (ADR 0023) with its SimpleLogin preset: `simpleLogin.begin` and
`simpleLogin.complete` in halkyon_base, a member asking the register for both; the
@ -121,9 +121,20 @@ against app.simplelogin.io on 23 Oct:
GitHub ends, 22 Jan 2027, which was recommended): from then on every session of an
account without SimpleLogin ends, GitHub and email codes sign nobody in there, and the
instance offers SimpleLogin alone.
- **D9 Phones come back through verified links** (recommended): Android's App Links and
iOS's https callbacks with associated domains, for learn.halkyon.eu; the forwarding
page with the app's scheme for other instances, the loopback on desktops.
- **D9 Apps come back through their scheme or the loopback** (corrected 23 Oct, night,
by the developer, after research; decided first as verified links for phones on
learn.halkyon.eu, Android's App Links and iOS's https callbacks): Android, iOS and
macOS come back through the app's scheme, which the system's browser sheet catches
(Chrome's Auth Tab, since Chrome 132, even after the forwarding page's redirect;
`ASWebAuthenticationSession`), Linux and Windows through the loopback.
`simpleLogin.begin` takes the way back (`SimpleLoginReturn`: `loopback`, `scheme`),
not the platform. Verified links protected nothing here: a code caught on its way is
worthless without the attempt's ID (4), and a hostile app beginning an attempt of its
own can ask for the scheme or the loopback, which other platforms need open (an
Android app can listen on `127.0.0.1` too). They cost: debug builds and other
instances need the scheme anyway, Firefox has no Auth Tab, Samsung Internet stays on
the page, and flutter_web_auth_2 (v5.1.0) has open bugs with https callbacks (#183,
#215). The scheme is what AppAuth calls the most widely supported.
- **D10 Accounts without SimpleLogin are deleted 30 days after the cut-off**
(recommended, over keeping them unreachable): nobody can add SimpleLogin signed out;
the reminder names both days.
@ -200,12 +211,12 @@ against app.simplelogin.io on 23 Oct:
ID the app holds, a `nonce`, which app and platform, the caller if signed in; ten
minutes, taken once) and answers the authorization address. The app opens it in the
browser (`flutter_web_auth_2`, as GitHub's). SimpleLogin sends the browser to
`https://<register's host>/simplelogin-callback`, the one address registered. On a
phone with learn.halkyon.eu, the system hands that address straight to the app (D9).
Elsewhere the instance's page there forwards code and `state` to the app: the
desktop's loopback (`http://127.0.0.1:43823/simplelogin-callback`, each app its own
port) or the phone's scheme (`app.halkyon.learn://simplelogin-callback`). Its targets
are fixed per app and platform, chosen by a marker in `state`, so it sends nobody
`https://<register's host>/simplelogin-callback`, the one address registered. The
instance's page there forwards code and `state` to the app (D9): the loopback on
Linux and Windows (`http://127.0.0.1:43823/simplelogin-callback`, each app its own
port) or the app's scheme on Android, iOS and macOS
(`app.halkyon.learn://simplelogin-callback`). Its targets
are fixed per app and way back, chosen by a marker in `state`, so it sends nobody
elsewhere; SimpleLogin's errors (a denial) are forwarded alike. The app hands the code
with its attempt's ID to `simpleLogin.complete`.
4. **Checking.** The register exchanges the code and checks the ID token: RS256 against
@ -213,8 +224,8 @@ against app.simplelogin.io on 23 Oct:
`https://app.simplelogin.io`, the audience its client ID, not expired, and the `nonce`
the attempt's. So a code caught on its way to the app is worthless: it can only
complete the attempt it was asked for, whose ID never left the app. (A hostile app
starting an attempt of its own is what D9's verified links stop: SimpleLogin answers
without a consent page for an app already allowed.) It keeps the `sub` alone
on the device beginning an attempt of its own isn't stopped, verified links or not
(D9): SimpleLogin answers without a consent page for an app already allowed.) It keeps the `sub` alone
(`simplelogin_identity`: account ID, `sub`, each unique); the name serves only as the
handle's suggestion at sign-up (D5); address, name and avatar are never stored, and
SimpleLogin's userinfo is never asked.
@ -268,5 +279,9 @@ against app.simplelogin.io on 23 Oct:
- Everyone has a handle, which stays private until they go public.
- The keys' password is one more thing to keep, and the recovery phrase also mends a lost
SimpleLogin link.
- GitHub's sign-in on phones has the weakness D9 avoids (its callback is the scheme)
until it ends.
- A hostile app on a person's device can begin a sign-in of its own and, once the person
has allowed Halkyon at SimpleLogin, come back with a session without a consent page
(4, D9); what it gets is the account's server-side data, while private studysets,
tags and settings stay sealed under the keys (ADR 0019). Whether SimpleLogin honours
OpenID Connect's `prompt=consent`, which would show the consent page every time, is
still to be checked.

View file

@ -58,8 +58,8 @@ import 'package:halkyon_base_client/src/protocol/profile/profile_refusal.dart'
as _ih722tf9;
import 'package:halkyon_base_client/src/protocol/simplelogin/simplelogin_completed.dart'
as _ianfx7qj;
import 'package:halkyon_base_client/src/protocol/simplelogin/simplelogin_platform.dart'
as _i32mw11n;
import 'package:halkyon_base_client/src/protocol/simplelogin/simplelogin_return.dart'
as _ihc3njmr;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -901,16 +901,17 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
@override
String get name => 'halkyon_base.simpleLogin';
/// Begins a sign-in from this server's app on [platform]: the attempt,
/// whose ID the device keeps to itself, and SimpleLogin's address to open
/// in the browser, which comes back through the forwarding page with a
/// code for [complete]. Refusals as `beginSimpleLogin`'s.
/// Begins a sign-in from this server's app, coming [back] by the loopback
/// or the app's scheme: the attempt, whose ID the device keeps to itself,
/// and SimpleLogin's address to open in the browser, which comes back
/// through the forwarding page with a code for [complete]. Refusals as
/// `beginSimpleLogin`'s.
_ida.Future<_ibkpwzgl.OidcStart> begin({
required _i32mw11n.SimpleLoginPlatform platform,
required _ihc3njmr.SimpleLoginReturn back,
}) => caller.callServerEndpoint<_ibkpwzgl.OidcStart>(
'halkyon_base.simpleLogin',
'begin',
{'platform': platform},
{'back': back},
);
/// Ends [attempt] with SimpleLogin's [code], as the caller began it:

View file

@ -55,7 +55,7 @@ import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'simplelogin/simplelogin_completed.dart' as _i51ry5s2;
import 'simplelogin/simplelogin_outcome.dart' as _i5ar1hzl;
import 'simplelogin/simplelogin_platform.dart' as _ivny5pkt;
import 'simplelogin/simplelogin_return.dart' as _i8dcjd27;
export 'auth/email_code_purpose.dart';
export 'auth/email_sign_in_refusal.dart';
export 'auth/email_sign_in_refused_exception.dart';
@ -92,7 +92,7 @@ export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart';
export 'simplelogin/simplelogin_completed.dart';
export 'simplelogin/simplelogin_outcome.dart';
export 'simplelogin/simplelogin_platform.dart';
export 'simplelogin/simplelogin_return.dart';
export 'client.dart';
class Protocol extends _isc.SerializationManager {
@ -248,8 +248,8 @@ class Protocol extends _isc.SerializationManager {
if (t == _i5ar1hzl.SimpleLoginOutcome) {
return _i5ar1hzl.SimpleLoginOutcome.fromJson(data) as T;
}
if (t == _ivny5pkt.SimpleLoginPlatform) {
return _ivny5pkt.SimpleLoginPlatform.fromJson(data) as T;
if (t == _i8dcjd27.SimpleLoginReturn) {
return _i8dcjd27.SimpleLoginReturn.fromJson(data) as T;
}
if (t == _isc.getType<_ifpka2on.EmailCodePurpose?>()) {
return (data != null ? _ifpka2on.EmailCodePurpose.fromJson(data) : null)
@ -403,10 +403,8 @@ class Protocol extends _isc.SerializationManager {
return (data != null ? _i5ar1hzl.SimpleLoginOutcome.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_ivny5pkt.SimpleLoginPlatform?>()) {
return (data != null
? _ivny5pkt.SimpleLoginPlatform.fromJson(data)
: null)
if (t == _isc.getType<_i8dcjd27.SimpleLoginReturn?>()) {
return (data != null ? _i8dcjd27.SimpleLoginReturn.fromJson(data) : null)
as T;
}
if (t == Set<String>) {
@ -480,7 +478,7 @@ class Protocol extends _isc.SerializationManager {
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_i51ry5s2.SimpleLoginCompleted => 'SimpleLoginCompleted',
_i5ar1hzl.SimpleLoginOutcome => 'SimpleLoginOutcome',
_ivny5pkt.SimpleLoginPlatform => 'SimpleLoginPlatform',
_i8dcjd27.SimpleLoginReturn => 'SimpleLoginReturn',
_ => null,
};
}
@ -570,8 +568,8 @@ class Protocol extends _isc.SerializationManager {
return 'SimpleLoginCompleted';
case _i5ar1hzl.SimpleLoginOutcome():
return 'SimpleLoginOutcome';
case _ivny5pkt.SimpleLoginPlatform():
return 'SimpleLoginPlatform';
case _i8dcjd27.SimpleLoginReturn():
return 'SimpleLoginReturn';
}
return null;
}
@ -690,8 +688,8 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'SimpleLoginOutcome') {
return deserialize<_i5ar1hzl.SimpleLoginOutcome>(data['data']);
}
if (dataClassName == 'SimpleLoginPlatform') {
return deserialize<_ivny5pkt.SimpleLoginPlatform>(data['data']);
if (dataClassName == 'SimpleLoginReturn') {
return deserialize<_i8dcjd27.SimpleLoginReturn>(data['data']);
}
return super.deserializeByClassName(data);
}

View file

@ -0,0 +1,40 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// How an app comes back from a SimpleLogin sign-in (ADR 0020, 3 and D9),
/// which says where the forwarding page sends it: the loopback (Linux,
/// Windows) or the app's scheme (Android, iOS, macOS).
enum SimpleLoginReturn implements _isc.SerializableModel {
loopback,
scheme;
static SimpleLoginReturn fromJson(String name) {
switch (name) {
case 'loopback':
return SimpleLoginReturn.loopback;
case 'scheme':
return SimpleLoginReturn.scheme;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SimpleLoginReturn"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -87,11 +87,12 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`/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, in English or German. It keeps and logs nothing.
`error`) and the `state`, and nothing else: the state is `<app>-<way back>.<rest>`, the
marker one of `simpleLoginReturns` (the loopback on Linux and Windows, `127.0.0.1` on a
port of each app's own, or the app's scheme, its ID, on Android, iOS and macOS;
`simpleLogin.begin` takes the way back as a `SimpleLoginReturn`), 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, in English or German. It keeps and logs nothing.
- **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
connection's, or, for a connection from a proxy the operator trusts

View file

@ -31,8 +31,8 @@ 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_base_server/src/generated/simplelogin/simplelogin_platform.dart'
as _ixrtiuh0;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_return.dart'
as _igmh0yku;
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'
@ -1283,9 +1283,9 @@ class Endpoints extends _is.EndpointDispatch {
'begin': _is.MethodConnector(
name: 'begin',
params: {
'platform': _is.ParameterDescription(
name: 'platform',
type: _is.getType<_ixrtiuh0.SimpleLoginPlatform>(),
'back': _is.ParameterDescription(
name: 'back',
type: _is.getType<_igmh0yku.SimpleLoginReturn>(),
nullable: false,
),
},
@ -1297,7 +1297,7 @@ class Endpoints extends _is.EndpointDispatch {
(endpoints['simpleLogin'] as _ia8c9alv.SimpleLoginEndpoint)
.begin(
session,
platform: params['platform'],
back: params['back'],
),
),
'complete': _is.MethodConnector(

View file

@ -69,8 +69,8 @@ import 'register/account_deletion_notice.dart' as _i2lyzpm9;
import 'register/sessions_ended_notice.dart' as _io5c3tod;
import 'simplelogin/simplelogin_completed.dart' as _i51ry5s2;
import 'simplelogin/simplelogin_outcome.dart' as _i5ar1hzl;
import 'simplelogin/simplelogin_platform.dart' as _ivny5pkt;
import 'simplelogin/simplelogin_proof.dart' as _iauqtzog;
import 'simplelogin/simplelogin_return.dart' as _i8dcjd27;
export 'auth/email_code_purpose.dart';
export 'auth/email_code_request.dart';
export 'auth/email_identity.dart';
@ -120,8 +120,8 @@ export 'register/account_deletion_notice.dart';
export 'register/sessions_ended_notice.dart';
export 'simplelogin/simplelogin_completed.dart';
export 'simplelogin/simplelogin_outcome.dart';
export 'simplelogin/simplelogin_platform.dart';
export 'simplelogin/simplelogin_proof.dart';
export 'simplelogin/simplelogin_return.dart';
class Protocol extends _is.DatabaseSerializationManager {
Protocol._();
@ -1219,12 +1219,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i5ar1hzl.SimpleLoginOutcome) {
return _i5ar1hzl.SimpleLoginOutcome.fromJson(data) as T;
}
if (t == _ivny5pkt.SimpleLoginPlatform) {
return _ivny5pkt.SimpleLoginPlatform.fromJson(data) as T;
}
if (t == _iauqtzog.SimpleLoginProof) {
return _iauqtzog.SimpleLoginProof.fromJson(data) as T;
}
if (t == _i8dcjd27.SimpleLoginReturn) {
return _i8dcjd27.SimpleLoginReturn.fromJson(data) as T;
}
if (t == _is.getType<_ifpka2on.EmailCodePurpose?>()) {
return (data != null ? _ifpka2on.EmailCodePurpose.fromJson(data) : null)
as T;
@ -1435,16 +1435,14 @@ class Protocol extends _is.DatabaseSerializationManager {
return (data != null ? _i5ar1hzl.SimpleLoginOutcome.fromJson(data) : null)
as T;
}
if (t == _is.getType<_ivny5pkt.SimpleLoginPlatform?>()) {
return (data != null
? _ivny5pkt.SimpleLoginPlatform.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_iauqtzog.SimpleLoginProof?>()) {
return (data != null ? _iauqtzog.SimpleLoginProof.fromJson(data) : null)
as T;
}
if (t == _is.getType<_i8dcjd27.SimpleLoginReturn?>()) {
return (data != null ? _i8dcjd27.SimpleLoginReturn.fromJson(data) : null)
as T;
}
if (t == Set<String>) {
return (data as List).map((e) => deserialize<String>(e)).toSet() as T;
}
@ -1532,8 +1530,8 @@ class Protocol extends _is.DatabaseSerializationManager {
_io5c3tod.SessionsEndedNotice => 'SessionsEndedNotice',
_i51ry5s2.SimpleLoginCompleted => 'SimpleLoginCompleted',
_i5ar1hzl.SimpleLoginOutcome => 'SimpleLoginOutcome',
_ivny5pkt.SimpleLoginPlatform => 'SimpleLoginPlatform',
_iauqtzog.SimpleLoginProof => 'SimpleLoginProof',
_i8dcjd27.SimpleLoginReturn => 'SimpleLoginReturn',
_ => null,
};
}
@ -1649,10 +1647,10 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'SimpleLoginCompleted';
case _i5ar1hzl.SimpleLoginOutcome():
return 'SimpleLoginOutcome';
case _ivny5pkt.SimpleLoginPlatform():
return 'SimpleLoginPlatform';
case _iauqtzog.SimpleLoginProof():
return 'SimpleLoginProof';
case _i8dcjd27.SimpleLoginReturn():
return 'SimpleLoginReturn';
}
className = _isp.Protocol().getClassNameForObject(data);
if (className != null) {
@ -1814,12 +1812,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'SimpleLoginOutcome') {
return deserialize<_i5ar1hzl.SimpleLoginOutcome>(data['data']);
}
if (dataClassName == 'SimpleLoginPlatform') {
return deserialize<_ivny5pkt.SimpleLoginPlatform>(data['data']);
}
if (dataClassName == 'SimpleLoginProof') {
return deserialize<_iauqtzog.SimpleLoginProof>(data['data']);
}
if (dataClassName == 'SimpleLoginReturn') {
return deserialize<_i8dcjd27.SimpleLoginReturn>(data['data']);
}
if (dataClassName.startsWith('serverpod.')) {
data['className'] = dataClassName.substring(10);
return _isp.Protocol().deserializeByClassName(data);

View file

@ -0,0 +1,40 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// How an app comes back from a SimpleLogin sign-in (ADR 0020, 3 and D9),
/// which says where the forwarding page sends it: the loopback (Linux,
/// Windows) or the app's scheme (Android, iOS, macOS).
enum SimpleLoginReturn implements _is.SerializableModel {
loopback,
scheme;
static SimpleLoginReturn fromJson(String name) {
switch (name) {
case 'loopback':
return SimpleLoginReturn.loopback;
case 'scheme':
return SimpleLoginReturn.scheme;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SimpleLoginReturn"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -32,8 +32,8 @@ OidcIdp _simpleLoginOn(Session session) {
return AuthServices.instance.oidcIdp;
}
/// Begins a SimpleLogin sign-in for the app and platform [marker] names
/// (`learn-desktop`, one of [simpleLoginReturns]), the state beginning with
/// Begins a SimpleLogin sign-in for the app and way back [marker] names
/// (`learn-loopback`, one of [simpleLoginReturns]), the state beginning with
/// it for the forwarding page; [caller] began it signed in, to add
/// SimpleLogin to their account, a member's person becoming a person here
/// under their ID, as linking would make them (ADR 0016). At most 30 an hour

View file

@ -8,8 +8,8 @@ import 'package:serverpod/serverpod.dart';
// (`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
// began it, the loopback or the app'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
@ -17,16 +17,14 @@ import 'package:serverpod/serverpod.dart';
const simpleLoginCallbackPath = '/simplelogin-callback';
/// Where each Halkyon app takes a sign-in back, by the marker its state
/// 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`).
/// begins with (`<app>-<way back>`): the loopback on Linux and Windows, each
/// app on a port of its own (RFC 8252, 7.3), and the app's scheme, its ID, on
/// Android, iOS and macOS, where the system's browser sheet catches it.
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',
'learn-loopback': 'http://127.0.0.1:43823/simplelogin-callback',
'learn-scheme': 'app.halkyon.learn://simplelogin-callback',
'todo-loopback': 'http://127.0.0.1:43824/simplelogin-callback',
'todo-scheme': 'app.halkyon.todo://simplelogin-callback',
};
final _returns = {

View file

@ -161,11 +161,11 @@ OidcIdpConfig? simpleLoginIdpConfig(Serverpod pod) {
);
}
/// The marker a sign-in's state begins with for [app] on [platform]
/// (`learn-desktop`), by which the forwarding page sends it back
/// The marker a sign-in's state begins with for [app] coming [back]
/// (`learn-loopback`), by which the forwarding page sends it back
/// ([simpleLoginReturns]).
String simpleLoginMarker(String app, SimpleLoginPlatform platform) =>
'$app-${platform.name}';
String simpleLoginMarker(String app, SimpleLoginReturn back) =>
'$app-${back.name}';
/// Puts [app] in place of [pod]'s SimpleLogin app, null meaning none
/// whatever the passwords hold, asking SimpleLogin through [client]: tests

View file

@ -15,18 +15,19 @@ import 'package:serverpod_auth_idp_server/core.dart';
/// and checks SimpleLogin's codes (ADR 0016); a session is this server's.
/// Refusals are `OidcRefusedException`s.
class SimpleLoginEndpoint extends Endpoint {
/// Begins a sign-in from this server's app on [platform]: the attempt,
/// whose ID the device keeps to itself, and SimpleLogin's address to open
/// in the browser, which comes back through the forwarding page with a
/// code for [complete]. Refusals as `beginSimpleLogin`'s.
/// Begins a sign-in from this server's app, coming [back] by the loopback
/// or the app's scheme: the attempt, whose ID the device keeps to itself,
/// and SimpleLogin's address to open in the browser, which comes back
/// through the forwarding page with a code for [complete]. Refusals as
/// `beginSimpleLogin`'s.
Future<OidcStart> begin(
Session session, {
required SimpleLoginPlatform platform,
required SimpleLoginReturn back,
}) {
final pod = session.serverpod;
return AccountRegister.of(pod).beginSimpleLogin(
session,
marker: simpleLoginMarker(HalkyonBase.of(pod).app, platform),
marker: simpleLoginMarker(HalkyonBase.of(pod).app, back),
caller: session.authenticated?.authUserId,
network: networkOf(session),
);

View file

@ -1,8 +0,0 @@
### Where an app begins a SimpleLogin sign-in (ADR 0020, 3), which says where
### the forwarding page sends it back: the desktop's loopback, or the phone's
### scheme (or, with learn.halkyon.eu's App Links, the phone's app itself).
enum: SimpleLoginPlatform
serialized: byName
values:
- desktop
- phone

View file

@ -0,0 +1,8 @@
### How an app comes back from a SimpleLogin sign-in (ADR 0020, 3 and D9),
### which says where the forwarding page sends it: the loopback (Linux,
### Windows) or the app's scheme (Android, iOS, macOS).
enum: SimpleLoginReturn
serialized: byName
values:
- loopback
- scheme

View file

@ -131,7 +131,7 @@ void main() {
) async {
final start = await endpoints.simpleLogin.begin(
as,
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
);
await endpoints.simpleLogin.complete(
as,

View file

@ -1183,30 +1183,30 @@ void main() {
});
group('SimpleLogin, by asking the register (ADR 0020)', () {
test('begins for this app and platform, signed out, the device '
test('begins for this app and way back, signed out, the device '
'network passed on', () async {
final start = await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.phone,
back: SimpleLoginReturn.scheme,
);
expect(register.asked, ['beginSimpleLogin']);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-phone',
marker: '$testApp-scheme',
caller: null,
));
expect(register.networks, hasLength(1));
expect(start.state, startsWith('$testApp-phone.'));
expect(start.state, startsWith('$testApp-scheme.'));
});
test('begins signed in for the caller', () async {
await endpoints.simpleLogin.begin(
asAnna(),
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-desktop',
marker: '$testApp-loopback',
caller: anna,
));
});

View file

@ -565,7 +565,7 @@ void main() {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
@ -577,7 +577,7 @@ void main() {
),
);
expect(start.state, startsWith('todo-desktop.'));
expect(start.state, startsWith('todo-loopback.'));
expect(proof.outcome, SimpleLoginOutcome.signedIn);
expect(proof.account, ben);
});
@ -588,7 +588,7 @@ void main() {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-phone',
marker: 'todo-scheme',
caller: annaOnTodo,
network: '203.0.113.0/24',
);
@ -617,7 +617,7 @@ void main() {
test('passes on the handle suggested to someone new', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
@ -639,7 +639,7 @@ void main() {
'crossing whole', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
final code = simpleLogin.authorize(
@ -677,7 +677,7 @@ void main() {
final device = await KeysDevice.make();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
@ -737,7 +737,7 @@ void main() {
final device = await KeysDevice.make();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
@ -810,7 +810,7 @@ void main() {
) async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
caller: person,
network: '203.0.113.0/24',
);
@ -885,7 +885,7 @@ void main() {
final next = await KeysDevice.make(seed: 7);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
@ -988,7 +988,7 @@ void main() {
final device = await KeysDevice.make();
final first = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
@ -1008,7 +1008,7 @@ void main() {
);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
@ -1073,7 +1073,7 @@ void main() {
await expectLater(
remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
marker: 'todo-loopback',
network: '203.0.113.0/24',
),
refused(OidcRefusal.unknownProvider),

View file

@ -101,7 +101,7 @@ void main() {
}) async {
final start = await endpoints.simpleLogin.begin(
as,
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
);
return (
attempt: start.attempt,
@ -138,7 +138,7 @@ void main() {
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
),
refused(OidcRefusal.unknownProvider),
);
@ -159,7 +159,7 @@ void main() {
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
),
refused(OidcRefusal.unknownProvider),
);
@ -196,17 +196,17 @@ void main() {
group('beginning', () {
test('sends the person to SimpleLogin, the state marked with the app '
'and its platform for the forwarding page', () async {
final desktop = await endpoints.simpleLogin.begin(
'and its way back for the forwarding page', () async {
final loopback = await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
);
final phone = await endpoints.simpleLogin.begin(
final scheme = await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.phone,
back: SimpleLoginReturn.scheme,
);
final url = desktop.authorizationUrl;
final url = loopback.authorizationUrl;
expect(
'${url.origin}${url.path}',
'https://app.simplelogin.io/oauth2/authorize',
@ -216,10 +216,13 @@ void main() {
url.queryParameters['redirect_uri'],
'https://learn.test/simplelogin-callback',
);
expect(desktop.state, startsWith('learn-desktop.'));
expect(phone.state, startsWith('learn-phone.'));
expect(loopback.state, startsWith('learn-loopback.'));
expect(scheme.state, startsWith('learn-scheme.'));
expect(
(await OidcAttempt.db.findById(session, desktop.attempt))!.callerId,
(await OidcAttempt.db.findById(
session,
loopback.attempt,
))!.callerId,
isNull,
);
});
@ -227,7 +230,7 @@ void main() {
test('signed in, notes who began it', () async {
final start = await endpoints.simpleLogin.begin(
asAnna(),
platform: SimpleLoginPlatform.phone,
back: SimpleLoginReturn.scheme,
);
expect(
@ -245,7 +248,7 @@ void main() {
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
),
throwsA(isA<StateError>()),
);
@ -255,14 +258,14 @@ void main() {
for (var i = 0; i < 30; i++) {
await endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
);
}
await expectLater(
endpoints.simpleLogin.begin(
notSignedIn(),
platform: SimpleLoginPlatform.desktop,
back: SimpleLoginReturn.loopback,
),
refused(OidcRefusal.tooManyAttempts),
);

View file

@ -60,8 +60,8 @@ import 'package:halkyon_base_server/src/generated/profile/profile_refusal.dart'
as _idszjcpz;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_completed.dart'
as _i8g1l2iv;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_platform.dart'
as _ixrtiuh0;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_return.dart'
as _igmh0yku;
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'
@ -1866,7 +1866,7 @@ class _SimpleLoginEndpoint {
_ida.Future<_ipoqfiqr.OidcStart> begin(
_ist.TestSessionBuilder sessionBuilder, {
required _ixrtiuh0.SimpleLoginPlatform platform,
required _igmh0yku.SimpleLoginReturn back,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1879,7 +1879,7 @@ class _SimpleLoginEndpoint {
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'simpleLogin',
methodName: 'begin',
parameters: _ist.testObjectToJson({'platform': platform}),
parameters: _ist.testObjectToJson({'back': back}),
serializationManager: _serializationManager,
);
var _localReturnValue =

Some files were not shown because too many files have changed in this diff Show more