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
7 changed files with 201 additions and 182 deletions

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 =

View file

@ -18,13 +18,13 @@ OidcCallbackRequest _get(
);
void main() {
test("names Halkyon's apps on the desktop's loopback and the phones' "
'schemes alone', () {
test("names Halkyon's apps on the loopback (Linux, Windows) and their "
'schemes (Android, iOS, macOS) 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',
'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',
});
});
@ -48,8 +48,8 @@ void main() {
test('refuses a marker of no Halkyon app', () {
for (final state in [
'evil-desktop.abc',
'Learn-desktop.abc',
'evil-loopback.abc',
'Learn-loopback.abc',
'learn.abc',
]) {
final answer = simpleLoginCallback(_get({'code': 'abc', 'state': state}));

View file

@ -102,18 +102,21 @@ void main() {
expect(simpleLoginAppFrom((name) => null, webServer: null), isNull);
});
test('marks a sign-in with the app and the platform, which the forwarding '
test('marks a sign-in with the app and its way back, which the forwarding '
'page knows', () {
expect(
simpleLoginMarker('learn', SimpleLoginPlatform.desktop),
'learn-desktop',
simpleLoginMarker('learn', SimpleLoginReturn.loopback),
'learn-loopback',
);
expect(
simpleLoginMarker('todo', SimpleLoginReturn.scheme),
'todo-scheme',
);
expect(simpleLoginMarker('todo', SimpleLoginPlatform.phone), 'todo-phone');
for (final app in ['learn', 'todo']) {
for (final platform in SimpleLoginPlatform.values) {
for (final back in SimpleLoginReturn.values) {
expect(
simpleLoginReturns,
contains(simpleLoginMarker(app, platform)),
contains(simpleLoginMarker(app, back)),
);
}
}