feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023)

halkyon_oidc_server and its generated client halkyon_oidc_client: any
provider following OpenID Connect's code flow, beside Serverpod's own
providers, with a preset for SimpleLogin (no PKCE, no `typ`). Nothing
of Halkyon's is in it; BSD-3-Clause, `publish_to: none` for now.

- Attempts in `halkyon_oidc_attempt` (ADR 0020's D13): the ID
  PostgreSQL's gen_random_uuid(), a state and a nonce of 32 random
  bytes, PKCE's verifier where used, the caller if signed in; ten
  minutes, taken once. A host may begin the state with a marker.
- The code exchanged by the module itself, so a provider out of reach
  (a timeout, a 5xx) is told apart from a refused code; the ID token
  checked with dart_jsonwebtoken's algorithms (ADR 0020's D12): the
  provider's algorithms alone, the key its `kid` names, of the right
  type; issuer, audience and `azp`, expiry, time issued, `nbf`, a
  minute's leeway; the attempt's nonce; a `sub`.
- Keys kept an hour, fetched again for an unknown `kid` at most once
  a minute; discovery checked against the issuer; every request with
  a timeout.
- Identities in `halkyon_oidc_identity`: the provider's key and the
  `sub`, one per person and provider, deleted with the person.
- The stock endpoint (`OidcIdpBaseEndpoint`), and the parts for a host
  with rules of its own: begin, verify, keep and takeKept, link,
  accountOf, unlink; merging people; a limit per network.
- The forwarding page, moved here from halkyon_base in the next commit.

The package's main library is the code generator's, as halkyon_base's;
a host imports `package:halkyon_oidc_server/oidc.dart`. ADR 0023's
point 5 no longer names OAuth2PkceUtil, which the module doesn't use.

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 20:28:37 +02:00
commit f549fa59df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 15870 additions and 2 deletions

View file

@ -0,0 +1,3 @@
export 'src/protocol/protocol.dart';
export 'package:serverpod_client/serverpod_client.dart';

View file

@ -0,0 +1,62 @@
/* 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 'dart:async' as _ida;
import 'package:halkyon_oidc_client/src/protocol/oidc_start.dart' as _innqwlkm;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
as _iaic;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// The stock endpoint of OpenID Connect sign-in (ADR 0023, 6), as
/// Serverpod's own providers have theirs: a host extends it in its own
/// server (`class OidcIdpEndpoint extends OidcIdpBaseEndpoint {}`), which
/// puts it into the host's client.
///
/// Its per-network limit counts by the request's `remoteInfo`, the connection's
/// address, or the `Forwarded` or `X-Forwarded-For` header's first address
/// where one came, whoever wrote it: behind a proxy that passes either on as
/// the visitor sent it, a host overrides [begin] and counts by the address
/// its proxy saw.
/// {@category Endpoint}
abstract class EndpointOidcIdpBase extends _iaic.EndpointIdpBase {
EndpointOidcIdpBase(_isc.EndpointCaller caller) : super(caller);
/// Begins a sign-in with [provider] (its key): signed in, to add the
/// identity to the caller ([link]); signed out, to sign in ([login]).
_ida.Future<_innqwlkm.OidcStart> begin({required String provider});
/// Signs in with the identity [code] proves for [attempt], begun signed
/// out: a session for its person, made for one nobody has.
_ida.Future<_iacc.AuthSuccess> login({
required _isc.UuidValue attempt,
required String code,
});
/// Links the identity [code] proves for [attempt], begun by the caller
/// signed in, to them.
_ida.Future<void> link({
required _isc.UuidValue attempt,
required String code,
});
@override
_ida.Future<bool> hasAccount();
}
class Caller extends _isc.ModuleEndpointCaller {
Caller(_isc.ServerpodClientShared client) : super(client) {}
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {};
}

View file

@ -0,0 +1,75 @@
/* 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;
/// Why a sign-in with an OpenID Connect provider was refused.
enum OidcRefusal implements _isc.SerializableModel {
/// The host names no provider by this key.
unknownProvider,
/// No attempt has this ID: none began, it was ended already, or its time
/// ran out.
unknownAttempt,
/// The attempt was begun by someone else, or signed in where this call is
/// signed out, or the other way round.
wrongCaller,
/// The provider refused the code, or its answer didn't prove anyone: a
/// token not signed by its key, for another client or attempt, expired.
rejected,
/// The provider couldn't be reached, or answered nothing readable; trying
/// again later may work.
providerUnavailable,
/// The identity is another person's.
takenByAnotherPerson,
/// The person has another identity at this provider.
otherIdentityLinked,
/// Too many sign-ins were begun from this network; later.
tooManyAttempts;
static OidcRefusal fromJson(String name) {
switch (name) {
case 'unknownProvider':
return OidcRefusal.unknownProvider;
case 'unknownAttempt':
return OidcRefusal.unknownAttempt;
case 'wrongCaller':
return OidcRefusal.wrongCaller;
case 'rejected':
return OidcRefusal.rejected;
case 'providerUnavailable':
return OidcRefusal.providerUnavailable;
case 'takenByAnotherPerson':
return OidcRefusal.takenByAnotherPerson;
case 'otherIdentityLinked':
return OidcRefusal.otherIdentityLinked;
case 'tooManyAttempts':
return OidcRefusal.tooManyAttempts;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "OidcRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,77 @@
/* 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;
import 'oidc_refusal.dart' as _i8gvcysd;
/// A sign-in with an OpenID Connect provider was refused; nothing changed.
abstract class OidcRefusedException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
OidcRefusedException._({required this.reason});
factory OidcRefusedException({required _i8gvcysd.OidcRefusal reason}) =
_OidcRefusedExceptionImpl;
factory OidcRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return OidcRefusedException(
reason: _i8gvcysd.OidcRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
);
}
/// Why it was refused.
_i8gvcysd.OidcRefusal reason;
/// Returns a shallow copy of this [OidcRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
OidcRefusedException copyWith({_i8gvcysd.OidcRefusal? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_oidc.OidcRefusedException',
'reason': reason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_oidc.OidcRefusedException',
'reason': reason.toJson(),
};
}
@override
String toString() {
return 'OidcRefusedException(reason: $reason)';
}
}
class _OidcRefusedExceptionImpl extends OidcRefusedException {
_OidcRefusedExceptionImpl({required _i8gvcysd.OidcRefusal reason})
: super._(reason: reason);
/// Returns a shallow copy of this [OidcRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
OidcRefusedException copyWith({_i8gvcysd.OidcRefusal? reason}) {
return OidcRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -0,0 +1,114 @@
/* 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;
/// What a device needs to send a person to their provider: the attempt it
/// ends the sign-in with, which only the device holds, and where to go.
abstract class OidcStart
implements _isc.SerializableModel, _isc.ProtocolSerialization {
OidcStart._({
required this.attempt,
required this.authorizationUrl,
required this.state,
});
factory OidcStart({
required _isc.UuidValue attempt,
required Uri authorizationUrl,
required String state,
}) = _OidcStartImpl;
factory OidcStart.fromJson(Map<String, dynamic> jsonSerialization) {
return OidcStart(
attempt: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['attempt'],
),
authorizationUrl: _isc.UriJsonExtension.fromJson(
jsonSerialization['authorizationUrl'],
),
state: jsonSerialization['state'] as String,
);
}
/// The attempt's ID, which ends it (`verify`); never shown, never sent
/// anywhere but to this server.
_isc.UuidValue attempt;
/// The provider's authorization address, with everything it needs.
Uri authorizationUrl;
/// The state the provider sends back with the code, which the device
/// compares with what comes back before it goes on.
String state;
/// Returns a shallow copy of this [OidcStart]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
OidcStart copyWith({
_isc.UuidValue? attempt,
Uri? authorizationUrl,
String? state,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_oidc.OidcStart',
'attempt': attempt.toJson(),
'authorizationUrl': authorizationUrl.toJson(),
'state': state,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_oidc.OidcStart',
'attempt': attempt.toJson(),
'authorizationUrl': authorizationUrl.toJson(),
'state': state,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _OidcStartImpl extends OidcStart {
_OidcStartImpl({
required _isc.UuidValue attempt,
required Uri authorizationUrl,
required String state,
}) : super._(
attempt: attempt,
authorizationUrl: authorizationUrl,
state: state,
);
/// Returns a shallow copy of this [OidcStart]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
OidcStart copyWith({
_isc.UuidValue? attempt,
Uri? authorizationUrl,
String? state,
}) {
return OidcStart(
attempt: attempt ?? this.attempt,
authorizationUrl: authorizationUrl ?? this.authorizationUrl,
state: state ?? this.state,
);
}
}

View file

@ -0,0 +1,231 @@
/* 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: dead_code, unnecessary_type_check
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
as _iaic;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'oidc_refusal.dart' as _i8gvcysd;
import 'oidc_refused_exception.dart' as _i70wwg4b;
import 'oidc_start.dart' as _i4fk0f61;
export 'oidc_refusal.dart';
export 'oidc_refused_exception.dart';
export 'oidc_start.dart';
export 'client.dart';
class Protocol extends _isc.SerializationManager {
Protocol._();
factory Protocol() => _instance;
static final Protocol _instance = Protocol._();
final Set<_isc.SerializationManager> _hostProtocols = {};
void registerHostProtocol(
String projectName,
_isc.SerializationManager protocol,
) {
_hostProtocols.add(protocol);
}
static String? getClassNameFromObjectJson(dynamic data) {
if (data is! Map) return null;
final className = data['__className__'] as String?;
if (className == null) return null;
if (!className.startsWith('halkyon_oidc.')) return className;
return className.substring(13);
}
@override
T deserialize<T>(
dynamic data, [
Type? t,
]) {
t ??= T;
final dataClassName = getClassNameFromObjectJson(data);
if (dataClassName != null && dataClassName != getClassNameForType(t)) {
try {
return deserializeByClassName({
'className': dataClassName,
'data': data,
});
} on _isc.DeserializationClassNameNotFoundException catch (_) {
// If the className is not recognized (e.g., older client receiving
// data with a new subtype), fall back to deserializing without the
// className, using the expected type T.
}
}
if (t == _i8gvcysd.OidcRefusal) {
return _i8gvcysd.OidcRefusal.fromJson(data) as T;
}
if (t == _i70wwg4b.OidcRefusedException) {
return _i70wwg4b.OidcRefusedException.fromJson(data) as T;
}
if (t == _i4fk0f61.OidcStart) {
return _i4fk0f61.OidcStart.fromJson(data) as T;
}
if (t == _isc.getType<_i8gvcysd.OidcRefusal?>()) {
return (data != null ? _i8gvcysd.OidcRefusal.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i70wwg4b.OidcRefusedException?>()) {
return (data != null
? _i70wwg4b.OidcRefusedException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i4fk0f61.OidcStart?>()) {
return (data != null ? _i4fk0f61.OidcStart.fromJson(data) : null) as T;
}
try {
return _iaic.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
try {
return _iacc.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
return super.deserialize<T>(data, t);
}
static String? getClassNameForType(Type type) {
return switch (type) {
_i8gvcysd.OidcRefusal => 'OidcRefusal',
_i70wwg4b.OidcRefusedException => 'OidcRefusedException',
_i4fk0f61.OidcStart => 'OidcStart',
_ => null,
};
}
@override
String? getClassNameForObject(Object? data) {
String? className = super.getClassNameForObject(data);
if (className != null) return className;
if (data is Map<String, dynamic> && data['__className__'] is String) {
return (data['__className__'] as String).replaceFirst(
'halkyon_oidc.',
'',
);
}
switch (data) {
case _i8gvcysd.OidcRefusal():
return 'OidcRefusal';
case _i70wwg4b.OidcRefusedException():
return 'OidcRefusedException';
case _i4fk0f61.OidcStart():
return 'OidcStart';
}
return null;
}
@override
dynamic deserializeByClassName(Map<String, dynamic> data) {
var dataClassName = data['className'];
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'OidcRefusal') {
return deserialize<_i8gvcysd.OidcRefusal>(data['data']);
}
if (dataClassName == 'OidcRefusedException') {
return deserialize<_i70wwg4b.OidcRefusedException>(data['data']);
}
if (dataClassName == 'OidcStart') {
return deserialize<_i4fk0f61.OidcStart>(data['data']);
}
return super.deserializeByClassName(data);
}
@override
Object? dynamicFieldToJson(
Object? object, {
bool forProtocol = false,
}) {
if ((object is List || object is Set || object is Map) ||
getClassNameForObject(object) != null) {
return super.dynamicFieldToJson(object, forProtocol: forProtocol);
}
for (final protocol in _hostProtocols) {
final className = protocol.getClassNameForObject(object);
if (className == null) continue;
final host = protocol.getModuleName();
final wrapped = {
'className': className.contains('.') ? className : '$host.$className',
'data': object,
};
return forProtocol
? _isc.SerializationManager.toEncodableForProtocol(wrapped)
: _isc.SerializationManager.toEncodable(wrapped);
}
return super.dynamicFieldToJson(object, forProtocol: forProtocol);
}
@override
dynamic deserializeDynamicFieldValue(Object? value) {
if (value == null) return null;
if (value is! Map<String, dynamic> || value['className'] is! String) {
throw FormatException(
'Dynamic fields are encoded as a Map with className and data, but got '
'${value.runtimeType} instead.',
);
}
final className = value['className'] as String;
for (final protocol in _hostProtocols) {
final host = protocol.getModuleName();
final hostPrefix = '$host.';
if (className.startsWith(hostPrefix)) {
final strippedClassName = className.substring(hostPrefix.length);
if (strippedClassName.contains('.')) {
throw FormatException(
'Dynamic field className must not use multiple prefixes: $className',
);
}
final hostData = Map<String, dynamic>.from(value);
hostData['className'] = strippedClassName;
return protocol.deserializeByClassName(hostData);
}
}
if (className.contains('.')) {
for (final protocol in _hostProtocols) {
try {
return protocol.deserializeByClassName(value);
} on _isc.DeserializationClassNameNotFoundException catch (_) {}
}
}
return deserializeByClassName(value);
}
@override
String getModuleName() => 'halkyon_oidc';
/// Maps any `Record`s known to this [Protocol] to their JSON representation
///
/// Throws in case the record type is not known.
///
/// This method will return `null` (only) for `null` inputs.
Map<String, dynamic>? mapRecordToJson(Record? record) {
if (record == null) {
return null;
}
try {
return _iaic.Protocol().mapRecordToJson(record);
} catch (_) {}
try {
return _iacc.Protocol().mapRecordToJson(record);
} catch (_) {}
throw Exception('Unsupported record type ${record.runtimeType}');
}
}