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
14 changed files with 43572 additions and 408 deletions
|
|
@ -13,6 +13,7 @@
|
|||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:halkyon_base_client/src/protocol/password/authenticator.dart'
|
||||
as _ivfmflch;
|
||||
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
|
||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||
as _iacc;
|
||||
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
||||
|
|
@ -348,6 +349,9 @@ class Protocol extends _isc.SerializationManager {
|
|||
.toList()
|
||||
as T;
|
||||
}
|
||||
try {
|
||||
return _ibkpwzgl.Protocol().deserialize<T>(data, t);
|
||||
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
||||
try {
|
||||
return _iaic.Protocol().deserialize<T>(data, t);
|
||||
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -628,6 +632,9 @@ class Protocol extends _isc.SerializationManager {
|
|||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return _ibkpwzgl.Protocol().mapRecordToJson(record);
|
||||
} catch (_) {}
|
||||
try {
|
||||
return _iaic.Protocol().mapRecordToJson(record);
|
||||
} catch (_) {}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ resolution: workspace
|
|||
dependencies:
|
||||
# SRP-6a's hashes (lib/src/srp/, hand-written beside the generated code).
|
||||
crypto: ^3.0.7
|
||||
# The client side of the OpenID Connect module the server signs in with
|
||||
# (ADR 0023), whose models the generated code uses.
|
||||
halkyon_oidc_client:
|
||||
path: ../halkyon_oidc_client
|
||||
meta: ^1.16.0
|
||||
serverpod_auth_idp_client: 4.0.4
|
||||
serverpod_client: 4.0.4
|
||||
|
|
|
|||
|
|
@ -44,14 +44,15 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
served by `addProfilePageRoute`, in English or German, its foot what the host's
|
||||
`profilePageNote` says.
|
||||
- SimpleLogin's forwarding page (Halkyon Learn's ADR 0020; `addSimpleLoginCallbackRoute`,
|
||||
on the register): `/simplelogin-callback`, the one address the instance's SimpleLogin
|
||||
on the register, on halkyon_oidc's `addOidcCallbackForwarding`, ADR 0023):
|
||||
`/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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ 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_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'
|
||||
as _iacs;
|
||||
|
|
@ -1090,6 +1091,8 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
),
|
||||
},
|
||||
);
|
||||
modules['halkyon_oidc'] = _ipoqfiqr.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:halkyon_base_server/src/generated/password/authenticator.dart'
|
||||
as _ip34xal6;
|
||||
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
|
||||
import 'package:serverpod/protocol.dart' as _isp;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
|
|
@ -1004,6 +1005,7 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
],
|
||||
managed: true,
|
||||
),
|
||||
..._ipoqfiqr.Protocol.targetTableDefinitions,
|
||||
..._iais.Protocol.targetTableDefinitions,
|
||||
..._iacs.Protocol.targetTableDefinitions,
|
||||
];
|
||||
|
|
@ -1368,6 +1370,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
.toList()
|
||||
as T;
|
||||
}
|
||||
try {
|
||||
return _ipoqfiqr.Protocol().deserialize<T>(data, t);
|
||||
} on _is.DeserializationTypeNotFoundException catch (_) {}
|
||||
try {
|
||||
return _iais.Protocol().deserialize<T>(data, t);
|
||||
} on _is.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -1727,6 +1732,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
|
||||
@override
|
||||
_is.Table? getTableForType(Type t) {
|
||||
{
|
||||
var table = _ipoqfiqr.Protocol().getTableForType(t);
|
||||
if (table != null) {
|
||||
return table;
|
||||
}
|
||||
}
|
||||
{
|
||||
var table = _iais.Protocol().getTableForType(t);
|
||||
if (table != null) {
|
||||
|
|
@ -1790,6 +1801,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return _ipoqfiqr.Protocol().mapRecordToJson(record);
|
||||
} catch (_) {}
|
||||
try {
|
||||
return _iais.Protocol().mapRecordToJson(record);
|
||||
} catch (_) {}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_base_server/src/common/web_page.dart';
|
||||
import 'package:halkyon_oidc_server/oidc.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).
|
||||
// only https addresses, each registered exactly. halkyon_oidc's page
|
||||
// (`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
|
||||
// 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`).
|
||||
/// 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`).
|
||||
const simpleLoginReturns = {
|
||||
'learn-desktop': 'http://127.0.0.1:43823/simplelogin-callback',
|
||||
'learn-phone': 'app.halkyon.learn://simplelogin-callback',
|
||||
|
|
@ -30,177 +29,43 @@ const simpleLoginReturns = {
|
|||
'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',
|
||||
final _returns = {
|
||||
for (final MapEntry(:key, :value) in simpleLoginReturns.entries)
|
||||
key: Uri.parse(value),
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
/// The page's answer to [request] (see `oidcCallbackForwarding`), with
|
||||
/// [simpleLoginReturns] and Halkyon's page for a refusal.
|
||||
OidcCallbackAnswer simpleLoginCallback(OidcCallbackRequest request) =>
|
||||
oidcCallbackForwarding(
|
||||
request,
|
||||
returns: _returns,
|
||||
refusalPage: _refusedPage,
|
||||
);
|
||||
|
||||
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';
|
||||
String _refusedPage(String? acceptLanguage) {
|
||||
final german = prefersGerman(acceptLanguage);
|
||||
return '<!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'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
void addSimpleLoginCallbackRoute(Serverpod pod) => addOidcCallbackForwarding(
|
||||
pod,
|
||||
path: simpleLoginCallbackPath,
|
||||
returns: _returns,
|
||||
refusalPage: _refusedPage,
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,229 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "createTable",
|
||||
"createTable": {
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "halkyon_oidc_attempt",
|
||||
"dartName": "OidcAttempt",
|
||||
"module": "halkyon_oidc",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "provider",
|
||||
"fieldName": "provider",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "state",
|
||||
"fieldName": "state",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "codeVerifier",
|
||||
"fieldName": "codeVerifier",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "callerId",
|
||||
"fieldName": "callerId",
|
||||
"columnType": 7,
|
||||
"isNullable": true,
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "subject",
|
||||
"fieldName": "subject",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "expiresAt",
|
||||
"fieldName": "expiresAt",
|
||||
"columnType": 4,
|
||||
"isNullable": false,
|
||||
"dartType": "DateTime"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "halkyon_oidc_attempt_fk_0",
|
||||
"columns": [
|
||||
"callerId"
|
||||
],
|
||||
"referenceTable": "serverpod_auth_core_user",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "halkyon_oidc_attempt_expires_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "expiresAt"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "createTable",
|
||||
"createTable": {
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "halkyon_oidc_identity",
|
||||
"dartName": "OidcIdentity",
|
||||
"module": "halkyon_oidc",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "authUserId",
|
||||
"fieldName": "authUserId",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"dartType": "UuidValue"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "provider",
|
||||
"fieldName": "provider",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "subject",
|
||||
"fieldName": "subject",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "createdAt",
|
||||
"fieldName": "createdAt",
|
||||
"columnType": 4,
|
||||
"isNullable": false,
|
||||
"dartType": "DateTime"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "halkyon_oidc_identity_fk_0",
|
||||
"columns": [
|
||||
"authUserId"
|
||||
],
|
||||
"referenceTable": "serverpod_auth_core_user",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "halkyon_oidc_identity_subject_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "provider"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "subject"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": true,
|
||||
"isPrimary": false
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "halkyon_oidc_identity_person_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "authUserId"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "provider"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": true,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
BEGIN;
|
||||
|
||||
--
|
||||
-- Function: gen_random_uuid_v7()
|
||||
-- Source: https://gist.github.com/kjmph/5bd772b2c2df145aa645b837da7eca74
|
||||
-- License: MIT (copyright notice included on the generator source code).
|
||||
--
|
||||
create or replace function gen_random_uuid_v7()
|
||||
returns uuid
|
||||
as $$
|
||||
begin
|
||||
-- use random v4 uuid as starting point (which has the same variant we need)
|
||||
-- then overlay timestamp
|
||||
-- then set version 7 by flipping the 2 and 1 bit in the version 4 string
|
||||
return encode(
|
||||
set_bit(
|
||||
set_bit(
|
||||
overlay(uuid_send(gen_random_uuid())
|
||||
placing substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3)
|
||||
from 1 for 6
|
||||
),
|
||||
52, 1
|
||||
),
|
||||
53, 1
|
||||
),
|
||||
'hex')::uuid;
|
||||
end
|
||||
$$
|
||||
language plpgsql
|
||||
volatile;
|
||||
|
||||
--
|
||||
-- ACTION CREATE TABLE
|
||||
--
|
||||
CREATE TABLE "halkyon_oidc_attempt" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"provider" text NOT NULL,
|
||||
"state" text NOT NULL,
|
||||
"nonce" text NOT NULL,
|
||||
"codeVerifier" text,
|
||||
"callerId" uuid,
|
||||
"subject" text,
|
||||
"expiresAt" timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX "halkyon_oidc_attempt_expires_idx" ON "halkyon_oidc_attempt" USING btree ("expiresAt");
|
||||
|
||||
--
|
||||
-- ACTION CREATE TABLE
|
||||
--
|
||||
CREATE TABLE "halkyon_oidc_identity" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid_v7(),
|
||||
"authUserId" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"createdAt" timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE UNIQUE INDEX "halkyon_oidc_identity_subject_idx" ON "halkyon_oidc_identity" USING btree ("provider", "subject");
|
||||
CREATE UNIQUE INDEX "halkyon_oidc_identity_person_idx" ON "halkyon_oidc_identity" USING btree ("authUserId", "provider");
|
||||
|
||||
--
|
||||
-- ACTION CREATE FOREIGN KEY
|
||||
--
|
||||
ALTER TABLE ONLY "halkyon_oidc_attempt"
|
||||
ADD CONSTRAINT "halkyon_oidc_attempt_fk_0"
|
||||
FOREIGN KEY("callerId")
|
||||
REFERENCES "serverpod_auth_core_user"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE NO ACTION;
|
||||
|
||||
--
|
||||
-- ACTION CREATE FOREIGN KEY
|
||||
--
|
||||
ALTER TABLE ONLY "halkyon_oidc_identity"
|
||||
ADD CONSTRAINT "halkyon_oidc_identity_fk_0"
|
||||
FOREIGN KEY("authUserId")
|
||||
REFERENCES "serverpod_auth_core_user"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE NO ACTION;
|
||||
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR halkyon_base
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('halkyon_base', '20261023181809816-oidc-module', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20261023181809816-oidc-module', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR halkyon_oidc
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('halkyon_oidc', '20261023180644988', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20261023180644988', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod', '20260824182259319', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_auth_idp
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_auth_idp', '20260924105404509', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_auth_core
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_auth_core', '20260924105232991', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
|
||||
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -16,3 +16,4 @@
|
|||
20261021100319502-session-ended-warning
|
||||
20261021110159448-second-factor-paths
|
||||
20261021134802955-reset-hold-outs
|
||||
20261023181809816-oidc-module
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ dependencies:
|
|||
# 0017): what a server needs of both, without libsodium. Pinned exactly, as
|
||||
# what keeps secrets must never move by accident.
|
||||
cryptography: 2.9.0
|
||||
# Signing in with SimpleLogin (ADR 0020), on the OpenID Connect module
|
||||
# (ADR 0023).
|
||||
halkyon_oidc_server:
|
||||
path: ../halkyon_oidc_server
|
||||
# Reads the pages profile links point to, for their rel="me" back-links.
|
||||
html: ^0.15.6
|
||||
# Asks the instance's register, and tells its members (ADR 0016).
|
||||
|
|
|
|||
|
|
@ -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