refactor(base): SimpleLogin's forwarding page on the OpenID Connect module (ADR 0023)
halkyon_base depends on halkyon_oidc_server, and its forwarding page is now the module's (`addOidcCallbackForwarding`) with Halkyon's table of apps and its page in English or German; its tests keep what is Halkyon's own, the rest is tested in the module. Every host now includes the module: migrations `…-oidc-module` in halkyon_base, Learn and Todo create its two tables (no data touched) and record its version; Learn's devices get them empty, as they have halkyon_base's server-only tables. halkyon_base_client depends on halkyon_oidc_client, whose models its generated code uses, and Learn's server image copies both packages into its narrowed workspace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f549fa59df
commit
620ad270db
1 changed files with 43572 additions and 408 deletions
|
|
@ -1,9 +1,14 @@
|
|||
import 'package:halkyon_base_server/src/simplelogin/simplelogin_callback.dart';
|
||||
import 'package:halkyon_oidc_server/oidc.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// Halkyon's use of halkyon_oidc's forwarding page (ADR 0020, 3; ADR 0023, 7):
|
||||
// its apps and its page. What the page takes and refuses is the module's,
|
||||
// tested there (halkyon_oidc_server's callback_forwarding_test.dart).
|
||||
|
||||
/// A GET of the page with [parameters] as the query, encoded as a browser
|
||||
/// sends them.
|
||||
SimpleLoginCallbackRequest _get(
|
||||
OidcCallbackRequest _get(
|
||||
Map<String, String> parameters, {
|
||||
String? acceptLanguage,
|
||||
}) => (
|
||||
|
|
@ -12,237 +17,68 @@ SimpleLoginCallbackRequest _get(
|
|||
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("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", () {
|
||||
test('sends each app its sign-in, with its code and state', () {
|
||||
for (final MapEntry(key: marker, value: target)
|
||||
in simpleLoginReturns.entries) {
|
||||
final state = '$marker.Az09_-attempt';
|
||||
final answer = simpleLoginCallback(
|
||||
_get({'error': 'deny', 'state': 'learn-phone.abc'}),
|
||||
_get({'code': 'qwertyuiop', 'state': state}),
|
||||
);
|
||||
expect(answer.status, 303);
|
||||
expect(answer.status, 303, reason: marker);
|
||||
expect(
|
||||
answer.location,
|
||||
Uri.parse(
|
||||
'app.halkyon.learn://simplelogin-callback',
|
||||
).replace(
|
||||
queryParameters: {'error': 'deny', 'state': 'learn-phone.abc'},
|
||||
Uri.parse(target).replace(
|
||||
queryParameters: {'code': 'qwertyuiop', 'state': state},
|
||||
),
|
||||
reason: marker,
|
||||
);
|
||||
});
|
||||
|
||||
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('refuses a marker of no Halkyon app', () {
|
||||
for (final state in [
|
||||
'evil-desktop.abc',
|
||||
'Learn-desktop.abc',
|
||||
'learn.abc',
|
||||
]) {
|
||||
final answer = simpleLoginCallback(_get({'code': 'abc', 'state': state}));
|
||||
expect(answer.status, 400, reason: state);
|
||||
expect(answer.location, isNull, reason: 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("refuses with Halkyon's page, saying nothing of what it was given", () {
|
||||
final answer = simpleLoginCallback(
|
||||
_get({'code': 'zzsecretcodezz', 'state': 'zzsecretstatezz'}),
|
||||
);
|
||||
expect(answer.html, contains("This sign-in can't go on."));
|
||||
expect(answer.html, contains('noindex'));
|
||||
expect(answer.html, isNot(contains('zzsecret')));
|
||||
});
|
||||
|
||||
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"'),
|
||||
);
|
||||
});
|
||||
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"'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue