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
12 changed files with 15870 additions and 2 deletions

13
packages/halkyon_oidc_client/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# Files and directories created by pub
.dart_tool/
.packages
# Omit committing pubspec.lock for library packages:
# https://dart.dev/guides/libraries/private-files#pubspeclock
pubspec.lock
# Conventional directory for build outputs
build/
# Directory created by dartdoc
doc/api/

View file

@ -0,0 +1,3 @@
## 0.1.0
* The client of halkyon_oidc 0.1.0, generated by Serverpod.

View file

@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, the Project Halkyon authors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,9 @@
# halkyon_oidc_client
The client of [`halkyon_oidc`](../halkyon_oidc_server/README.md), generated by
Serverpod: the models an app gets when it signs in with OpenID Connect (`OidcStart`,
`OidcRefusedException` with its `OidcRefusal`), and the stock endpoint a host extends.
Never edited by hand; run `serverpod --no-analytics generate` in `halkyon_oidc_server`
instead.
Under the BSD 3-Clause licence, Serverpod's own.

View file

@ -0,0 +1,16 @@
# Defines a default set of lint rules enforced for
# projects at Google. For details and rationale,
# see https://github.com/dart-lang/pedantic#enabled-lints.
# For lint rules and documentation, see http://dart-lang.github.io/linter/lints.
# Uncomment to specify additional rules.
# linter:
# rules:
# - camel_case_types
analyzer:
exclude:
- lib/src/protocol/**
formatter:
trailing_commas: preserve

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}');
}
}

View file

@ -0,0 +1,16 @@
name: halkyon_oidc_client
description: >-
The client side of halkyon_oidc, generated by Serverpod: what an app calls
to sign in with an OpenID Connect provider.
version: 0.1.0
# In the Halkyon repository until it runs on learn.halkyon.eu (ADR 0023, D3).
publish_to: none
environment:
sdk: ^3.13.0
resolution: workspace
dependencies:
serverpod_auth_idp_client: 4.0.4
serverpod_client: 4.0.4