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

View file

@ -0,0 +1,6 @@
/// What Serverpod's code generator needs to put the module into a host's
/// server; not for the host's own code, which imports `oidc.dart`.
library;
export 'src/generated/endpoints.dart';
export 'src/generated/protocol.dart';

View file

@ -0,0 +1,13 @@
/// Signing in with OpenID Connect for Serverpod (ADR 0023): any provider
/// that follows the code flow, SimpleLogin a preset; Serverpod's contract for
/// providers of one's own, its stock endpoint, and the parts for a host with
/// rules of its own.
library;
export 'src/callback_forwarding.dart';
export 'src/endpoints/oidc_idp_base_endpoint.dart';
export 'src/generated/protocol.dart' hide Protocol;
export 'src/id_token.dart' show IdTokenRejected, checkIdToken, idTokenLeeway;
export 'src/oidc_idp.dart';
export 'src/oidc_provider.dart';
export 'src/provider_keys.dart' show ProviderKeys;

View file

@ -0,0 +1,198 @@
import 'dart:io';
import 'package:serverpod/serverpod.dart';
// A forwarding page for a provider's callbacks (ADR 0023, 7): for a provider
// that takes https callbacks alone, each registered exactly (SimpleLogin), so
// a desktop app's loopback or a phone's scheme can't be one. The host
// registers this page's address; the browser comes back here with the
// provider's code (or its error) and the state the sign-in began with, and
// the page sends both on to the app that began it. The state starts with a
// marker the host chose (`OidcIdp.begin`'s `statePrefix`), which names an
// address of the host's own table, so the page sends nobody anywhere else.
// 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.
/// A state as the page takes it: a marker (letters, digits, `-` and `_`, up
/// to 64), 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-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{1,512})$');
/// A code of RFC 3986's unreserved characters, up to 512 (SimpleLogin's are
/// ten lower-case letters).
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 OidcCallbackRequest = ({
/// 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 OidcCallbackAnswer = ({
int status,
Uri? location,
String html,
Map<String, String> headers,
});
/// The page a refused callback shows, for a visitor whose browser asks for
/// [acceptLanguage]; it can say nothing of what came, as it is given none
/// of it.
typedef OidcRefusalPage = String Function(String? acceptLanguage);
/// The page's answer to [request]: a 303 to the address [returns] has for
/// the state's marker, with the code or the error, and the state, where
/// exactly one of them came, each once and well formed; [refusalPage]
/// otherwise, a 400 (a 405 for any method but GET).
OidcCallbackAnswer oidcCallbackForwarding(
OidcCallbackRequest request, {
required Map<String, Uri> returns,
OidcRefusalPage refusalPage = defaultOidcRefusalPage,
}) {
OidcCallbackAnswer refused(int status, {String? allow}) => (
status: status,
location: null,
html: refusalPage(request.acceptLanguage),
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,
},
);
if (request.method != 'GET') {
return refused(405, allow: 'GET');
}
if (request.query.length > _maxQuery) {
return refused(400);
}
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);
}
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);
}
final state = only('state');
final target = switch (_state.firstMatch(state ?? '')) {
final match? => returns[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);
}
return (
status: 303,
location: 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',
};
/// The module's own refusal page, in English, unstyled; a host gives its own
/// for its look and languages.
String defaultOidcRefusalPage(String? acceptLanguage) =>
'<!doctype html>\n<html lang="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>Sign-in not completed</title>\n</head>\n<body>\n'
"<h1>This sign-in can't go on.</h1>\n"
'<p>Go back to the app and sign in again.</p>\n</body>\n</html>\n';
/// Serves the forwarding page on [pod]'s web server at [path], sending each
/// sign-in to the address [returns] has for its state's marker (see
/// [oidcCallbackForwarding]); nothing where the pod has no web server.
void addOidcCallbackForwarding(
Serverpod pod, {
required String path,
required Map<String, Uri> returns,
OidcRefusalPage refusalPage = defaultOidcRefusalPage,
}) {
if (pod.config.webServer == null) {
return;
}
pod.webServer.addRoute(
_CallbackForwardingRoute(returns, refusalPage),
path,
);
}
/// Every method, so the page answers them itself rather than another route.
class _CallbackForwardingRoute extends Route {
_CallbackForwardingRoute(this._returns, this._refusalPage)
: super(methods: Method.values.toSet());
final Map<String, Uri> _returns;
final OidcRefusalPage _refusalPage;
@override
Future<Result> handleCall(Session session, Request request) async {
final answer = oidcCallbackForwarding(
(
method: request.method.value,
query: request.url.query,
acceptLanguage: request.headers[HttpHeaders.acceptLanguageHeader]?.join(
',',
),
),
returns: _returns,
refusalPage: _refusalPage,
);
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'],
}),
);
}
}

View file

@ -0,0 +1,62 @@
import 'package:halkyon_oidc_server/src/generated/protocol.dart';
import 'package:halkyon_oidc_server/src/oidc_idp.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// 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.
abstract class OidcIdpBaseEndpoint extends IdpBaseEndpoint {
/// The OpenID Connect sign-in the host set up.
OidcIdp get oidcIdp => AuthServices.instance.oidcIdp;
/// Begins a sign-in with [provider] (its key): signed in, to add the
/// identity to the caller ([link]); signed out, to sign in ([login]).
Future<OidcStart> begin(Session session, {required String provider}) =>
oidcIdp.begin(
session,
provider: provider,
caller: session.authenticated?.authUserId,
network: session.request?.remoteInfo,
);
/// Signs in with the identity [code] proves for [attempt], begun signed
/// out: a session for its person, made for one nobody has.
Future<AuthSuccess> login(
Session session, {
required UuidValue attempt,
required String code,
}) => oidcIdp.login(session, attempt: attempt, code: code);
/// Links the identity [code] proves for [attempt], begun by the caller
/// signed in, to them.
Future<void> link(
Session session, {
required UuidValue attempt,
required String code,
}) async {
final caller =
session.authenticated?.authUserId ??
(throw OidcRefusedException(reason: OidcRefusal.wrongCaller));
await oidcIdp.linkToCaller(
session,
attempt: attempt,
code: code,
caller: caller,
);
}
@override
Future<bool> hasAccount(Session session) async {
final caller = session.authenticated?.authUserId;
return caller != null &&
await oidcIdp.hasIdentity(session, authUserId: caller);
}
}

View file

@ -0,0 +1,27 @@
/* 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/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
class Endpoints extends _is.EndpointDispatch {
@override
void initializeEndpoints(_is.Server server) {
modules['serverpod_auth_idp'] = _iais.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_core'] = _iacs.Endpoints()
..initializeEndpoints(server);
}
}

View file

@ -0,0 +1,852 @@
/* 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_null_comparison
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_oidc_server/src/generated/protocol.dart' as _i7me1mu7;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
/// A sign-in a device began with a provider (`OidcIdp.begin`), until it is
/// ended (`OidcIdp.verify`), once, within ten minutes (ADR 0023, 4); or the
/// identity it proved, kept a while for the host to finish with
/// (`OidcIdp.takeKept`).
abstract class OidcAttempt
implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization {
OidcAttempt._({
this.id,
required this.provider,
required this.state,
required this.nonce,
this.codeVerifier,
this.callerId,
this.caller,
this.subject,
required this.expiresAt,
});
factory OidcAttempt({
_is.UuidValue? id,
required String provider,
required String state,
required String nonce,
String? codeVerifier,
_is.UuidValue? callerId,
_iacs.AuthUser? caller,
String? subject,
required DateTime expiresAt,
}) = _OidcAttemptImpl;
factory OidcAttempt.fromJson(Map<String, dynamic> jsonSerialization) {
return OidcAttempt(
id: jsonSerialization['id'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
provider: jsonSerialization['provider'] as String,
state: jsonSerialization['state'] as String,
nonce: jsonSerialization['nonce'] as String,
codeVerifier: jsonSerialization['codeVerifier'] as String?,
callerId: jsonSerialization['callerId'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['callerId']),
caller: jsonSerialization['caller'] == null
? null
: _i7me1mu7.Protocol().deserialize<_iacs.AuthUser>(
jsonSerialization['caller'],
),
subject: jsonSerialization['subject'] as String?,
expiresAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['expiresAt'],
),
);
}
static final t = OidcAttemptTable();
static const db = OidcAttemptRepository._();
@override
_is.UuidValue? id;
/// The provider, by its key (`OidcProvider.key`).
String provider;
/// The state sent with the person, which comes back with the code.
String state;
/// The nonce the provider's ID token must carry.
String nonce;
/// PKCE's code verifier; null for a provider without PKCE.
String? codeVerifier;
_is.UuidValue? callerId;
/// Who began it signed in, to have the identity linked to; null for a
/// sign-in.
_iacs.AuthUser? caller;
/// The ID the provider proved, for an attempt kept after it was verified;
/// null before.
String? subject;
/// Until when it can be ended, or taken once verified.
DateTime expiresAt;
@override
_is.Table<_is.UuidValue?> get table => t;
/// Returns a shallow copy of this [OidcAttempt]
/// with some or all fields replaced by the given arguments.
@_is.useResult
OidcAttempt copyWith({
_is.UuidValue? id,
String? provider,
String? state,
String? nonce,
String? codeVerifier,
_is.UuidValue? callerId,
_iacs.AuthUser? caller,
String? subject,
DateTime? expiresAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_oidc.OidcAttempt',
if (id != null) 'id': id?.toJson(),
'provider': provider,
'state': state,
'nonce': nonce,
if (codeVerifier != null) 'codeVerifier': codeVerifier,
if (callerId != null) 'callerId': callerId?.toJson(),
if (caller != null) 'caller': caller?.toJson(),
if (subject != null) 'subject': subject,
'expiresAt': expiresAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static OidcAttemptInclude include({_iacs.AuthUserInclude? caller}) {
return OidcAttemptInclude._(caller: caller);
}
static OidcAttemptIncludeList includeList({
_is.WhereExpressionBuilder<OidcAttemptTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<OidcAttemptTable>? orderBy,
_is.OrderByListBuilder<OidcAttemptTable>? orderByList,
OidcAttemptInclude? include,
}) {
return OidcAttemptIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(OidcAttempt.t),
orderByList: orderByList?.call(OidcAttempt.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _OidcAttemptImpl extends OidcAttempt {
_OidcAttemptImpl({
_is.UuidValue? id,
required String provider,
required String state,
required String nonce,
String? codeVerifier,
_is.UuidValue? callerId,
_iacs.AuthUser? caller,
String? subject,
required DateTime expiresAt,
}) : super._(
id: id,
provider: provider,
state: state,
nonce: nonce,
codeVerifier: codeVerifier,
callerId: callerId,
caller: caller,
subject: subject,
expiresAt: expiresAt,
);
/// Returns a shallow copy of this [OidcAttempt]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
OidcAttempt copyWith({
Object? id = _Undefined,
String? provider,
String? state,
String? nonce,
Object? codeVerifier = _Undefined,
Object? callerId = _Undefined,
Object? caller = _Undefined,
Object? subject = _Undefined,
DateTime? expiresAt,
}) {
return OidcAttempt(
id: id is _is.UuidValue? ? id : this.id,
provider: provider ?? this.provider,
state: state ?? this.state,
nonce: nonce ?? this.nonce,
codeVerifier: codeVerifier is String? ? codeVerifier : this.codeVerifier,
callerId: callerId is _is.UuidValue? ? callerId : this.callerId,
caller: caller is _iacs.AuthUser? ? caller : this.caller?.copyWith(),
subject: subject is String? ? subject : this.subject,
expiresAt: expiresAt ?? this.expiresAt,
);
}
}
class OidcAttemptUpdateTable extends _is.UpdateTable<OidcAttemptTable> {
OidcAttemptUpdateTable(super.table);
_is.ColumnValue<String, String> provider(String value) => _is.ColumnValue(
table.provider,
value,
);
_is.ColumnValue<String, String> state(String value) => _is.ColumnValue(
table.state,
value,
);
_is.ColumnValue<String, String> nonce(String value) => _is.ColumnValue(
table.nonce,
value,
);
_is.ColumnValue<String, String> codeVerifier(String? value) =>
_is.ColumnValue(
table.codeVerifier,
value,
);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> callerId(
_is.UuidValue? value,
) => _is.ColumnValue(
table.callerId,
value,
);
_is.ColumnValue<String, String> subject(String? value) => _is.ColumnValue(
table.subject,
value,
);
_is.ColumnValue<DateTime, DateTime> expiresAt(DateTime value) =>
_is.ColumnValue(
table.expiresAt,
value,
);
}
class OidcAttemptTable extends _is.Table<_is.UuidValue?> {
OidcAttemptTable({super.tableRelation})
: super(tableName: 'halkyon_oidc_attempt') {
updateTable = OidcAttemptUpdateTable(this);
provider = _is.ColumnString(
'provider',
this,
);
state = _is.ColumnString(
'state',
this,
);
nonce = _is.ColumnString(
'nonce',
this,
);
codeVerifier = _is.ColumnString(
'codeVerifier',
this,
);
callerId = _is.ColumnUuid(
'callerId',
this,
);
subject = _is.ColumnString(
'subject',
this,
);
expiresAt = _is.ColumnDateTime(
'expiresAt',
this,
);
}
late final OidcAttemptUpdateTable updateTable;
/// The provider, by its key (`OidcProvider.key`).
late final _is.ColumnString provider;
/// The state sent with the person, which comes back with the code.
late final _is.ColumnString state;
/// The nonce the provider's ID token must carry.
late final _is.ColumnString nonce;
/// PKCE's code verifier; null for a provider without PKCE.
late final _is.ColumnString codeVerifier;
late final _is.ColumnUuid callerId;
/// Who began it signed in, to have the identity linked to; null for a
/// sign-in.
_iacs.AuthUserTable? _caller;
/// The ID the provider proved, for an attempt kept after it was verified;
/// null before.
late final _is.ColumnString subject;
/// Until when it can be ended, or taken once verified.
late final _is.ColumnDateTime expiresAt;
_iacs.AuthUserTable get caller {
if (_caller != null) return _caller!;
_caller = _is.createRelationTable(
relationFieldName: 'caller',
field: OidcAttempt.t.callerId,
foreignField: _iacs.AuthUser.t.id,
tableRelation: tableRelation,
createTable: (foreignTableRelation) =>
_iacs.AuthUserTable(tableRelation: foreignTableRelation),
);
return _caller!;
}
@override
List<_is.Column> get columns => [
id,
provider,
state,
nonce,
codeVerifier,
callerId,
subject,
expiresAt,
];
@override
_is.Table? getRelationTable(String relationField) {
if (relationField == 'caller') {
return caller;
}
return null;
}
}
class OidcAttemptInclude extends _is.IncludeObject {
OidcAttemptInclude._({_iacs.AuthUserInclude? caller}) {
_caller = caller;
}
_iacs.AuthUserInclude? _caller;
@override
Map<String, _is.Include?> get includes => {'caller': _caller};
@override
_is.Table<_is.UuidValue?> get table => OidcAttempt.t;
}
class OidcAttemptIncludeList extends _is.IncludeList {
OidcAttemptIncludeList._({
_is.WhereExpressionBuilder<OidcAttemptTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(OidcAttempt.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<_is.UuidValue?> get table => OidcAttempt.t;
}
class OidcAttemptRepository {
const OidcAttemptRepository._();
final attachRow = const OidcAttemptAttachRowRepository._();
final detachRow = const OidcAttemptDetachRowRepository._();
/// Returns a list of [OidcAttempt]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<OidcAttempt>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<OidcAttemptTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<OidcAttemptTable>? orderBy,
_is.OrderByListBuilder<OidcAttemptTable>? orderByList,
_is.Transaction? transaction,
OidcAttemptInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<OidcAttempt>(
where: where?.call(OidcAttempt.t),
orderBy: orderBy?.call(OidcAttempt.t),
orderByList: orderByList?.call(OidcAttempt.t),
limit: limit,
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [OidcAttempt] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<OidcAttempt?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<OidcAttemptTable>? where,
int? offset,
_is.OrderByBuilder<OidcAttemptTable>? orderBy,
_is.OrderByListBuilder<OidcAttemptTable>? orderByList,
_is.Transaction? transaction,
OidcAttemptInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<OidcAttempt>(
where: where?.call(OidcAttempt.t),
orderBy: orderBy?.call(OidcAttempt.t),
orderByList: orderByList?.call(OidcAttempt.t),
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [OidcAttempt] by its [id] or null if no such row exists.
Future<OidcAttempt?> findById(
_is.DatabaseSession session,
_is.UuidValue id, {
_is.Transaction? transaction,
OidcAttemptInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<OidcAttempt>(
id,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [OidcAttempt]s in the list and returns the inserted rows.
///
/// The returned [OidcAttempt]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcAttempt>> insert(
_is.DatabaseSession session,
List<OidcAttempt> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<OidcAttempt>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [OidcAttempt] and returns the inserted row.
///
/// The returned [OidcAttempt] will have its `id` field set.
Future<OidcAttempt> insertRow(
_is.DatabaseSession session,
OidcAttempt row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<OidcAttempt>(
row,
transaction: transaction,
);
}
/// Upserts all [OidcAttempt]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [OidcAttempt]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcAttempt>> upsert(
_is.DatabaseSession session,
List<OidcAttempt> rows, {
required _is.ColumnSelections<OidcAttemptTable> conflictColumns,
_is.ColumnSelections<OidcAttemptTable>? updateColumns,
_is.WhereExpressionBuilder<OidcAttemptTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<OidcAttempt>(
rows,
conflictColumns: conflictColumns(OidcAttempt.t),
updateColumns: updateColumns?.call(OidcAttempt.t),
updateWhere: updateWhere?.call(OidcAttempt.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [OidcAttempt] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [OidcAttempt] will have its `id` field set.
Future<OidcAttempt?> upsertRow(
_is.DatabaseSession session,
OidcAttempt row, {
required _is.ColumnSelections<OidcAttemptTable> conflictColumns,
_is.ColumnSelections<OidcAttemptTable>? updateColumns,
_is.WhereExpressionBuilder<OidcAttemptTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<OidcAttempt>(
row,
conflictColumns: conflictColumns(OidcAttempt.t),
updateColumns: updateColumns?.call(OidcAttempt.t),
updateWhere: updateWhere?.call(OidcAttempt.t),
transaction: transaction,
);
}
/// Updates all [OidcAttempt]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcAttempt>> update(
_is.DatabaseSession session,
List<OidcAttempt> rows, {
_is.ColumnSelections<OidcAttemptTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<OidcAttempt>(
rows,
columns: columns?.call(OidcAttempt.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [OidcAttempt]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<OidcAttempt> updateRow(
_is.DatabaseSession session,
OidcAttempt row, {
_is.ColumnSelections<OidcAttemptTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<OidcAttempt>(
row,
columns: columns?.call(OidcAttempt.t),
transaction: transaction,
);
}
/// Updates a single [OidcAttempt] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<OidcAttempt?> updateById(
_is.DatabaseSession session,
_is.UuidValue id, {
required _is.ColumnValueListBuilder<OidcAttemptUpdateTable> columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<OidcAttempt>(
id,
columnValues: columnValues(OidcAttempt.t.updateTable),
transaction: transaction,
);
}
/// Updates all [OidcAttempt]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcAttempt>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<OidcAttemptUpdateTable> columnValues,
required _is.WhereExpressionBuilder<OidcAttemptTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<OidcAttemptTable>? orderBy,
_is.OrderByListBuilder<OidcAttemptTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<OidcAttempt>(
columnValues: columnValues(OidcAttempt.t.updateTable),
where: where(OidcAttempt.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(OidcAttempt.t),
orderByList: orderByList?.call(OidcAttempt.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [OidcAttempt]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcAttempt>> delete(
_is.DatabaseSession session,
List<OidcAttempt> rows, {
_is.OrderByBuilder<OidcAttemptTable>? orderBy,
_is.OrderByListBuilder<OidcAttemptTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<OidcAttempt>(
rows,
orderBy: orderBy?.call(OidcAttempt.t),
orderByList: orderByList?.call(OidcAttempt.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [OidcAttempt].
Future<OidcAttempt> deleteRow(
_is.DatabaseSession session,
OidcAttempt row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<OidcAttempt>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcAttempt>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<OidcAttemptTable> where,
_is.OrderByBuilder<OidcAttemptTable>? orderBy,
_is.OrderByListBuilder<OidcAttemptTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<OidcAttempt>(
where: where(OidcAttempt.t),
orderBy: orderBy?.call(OidcAttempt.t),
orderByList: orderByList?.call(OidcAttempt.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<OidcAttemptTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<OidcAttempt>(
where: where?.call(OidcAttempt.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [OidcAttempt] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<OidcAttemptTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<OidcAttempt>(
where: where(OidcAttempt.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}
class OidcAttemptAttachRowRepository {
const OidcAttemptAttachRowRepository._();
/// Creates a relation between the given [OidcAttempt] and [AuthUser]
/// by setting the [OidcAttempt]'s foreign key `callerId` to refer to the [AuthUser].
Future<void> caller(
_is.DatabaseSession session,
OidcAttempt oidcAttempt,
_iacs.AuthUser caller, {
_is.Transaction? transaction,
}) async {
if (oidcAttempt.id == null) {
throw ArgumentError.notNull('oidcAttempt.id');
}
if (caller.id == null) {
throw ArgumentError.notNull('caller.id');
}
var $oidcAttempt = oidcAttempt.copyWith(callerId: caller.id);
await session.db.updateRow<OidcAttempt>(
$oidcAttempt,
columns: [OidcAttempt.t.callerId],
transaction: transaction,
);
}
}
class OidcAttemptDetachRowRepository {
const OidcAttemptDetachRowRepository._();
/// Detaches the relation between this [OidcAttempt] and the [AuthUser] set in `caller`
/// by setting the [OidcAttempt]'s foreign key `callerId` to `null`.
///
/// This removes the association between the two models without deleting
/// the related record.
Future<void> caller(
_is.DatabaseSession session,
OidcAttempt oidcAttempt, {
_is.Transaction? transaction,
}) async {
if (oidcAttempt.id == null) {
throw ArgumentError.notNull('oidcAttempt.id');
}
var $oidcAttempt = oidcAttempt.copyWith(callerId: null);
await session.db.updateRow<OidcAttempt>(
$oidcAttempt,
columns: [OidcAttempt.t.callerId],
transaction: transaction,
);
}
}

View file

@ -0,0 +1,742 @@
/* 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_null_comparison
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_oidc_server/src/generated/protocol.dart' as _i7me1mu7;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
/// A person's identity at an OpenID Connect provider: the provider's ID of
/// them (`sub`) and nothing else of what it says (ADR 0023, 3).
abstract class OidcIdentity
implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization {
OidcIdentity._({
this.id,
required this.authUserId,
this.authUser,
required this.provider,
required this.subject,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
factory OidcIdentity({
_is.UuidValue? id,
required _is.UuidValue authUserId,
_iacs.AuthUser? authUser,
required String provider,
required String subject,
DateTime? createdAt,
}) = _OidcIdentityImpl;
factory OidcIdentity.fromJson(Map<String, dynamic> jsonSerialization) {
return OidcIdentity(
id: jsonSerialization['id'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
authUserId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
authUser: jsonSerialization['authUser'] == null
? null
: _i7me1mu7.Protocol().deserialize<_iacs.AuthUser>(
jsonSerialization['authUser'],
),
provider: jsonSerialization['provider'] as String,
subject: jsonSerialization['subject'] as String,
createdAt: jsonSerialization['createdAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['createdAt']),
);
}
static final t = OidcIdentityTable();
static const db = OidcIdentityRepository._();
@override
_is.UuidValue? id;
_is.UuidValue authUserId;
/// The person, who goes with their identities.
_iacs.AuthUser? authUser;
/// The provider, by the key the host named it with (`OidcProvider.key`).
String provider;
/// The provider's ID of the person, its ID token's `sub`, as it came.
String subject;
DateTime createdAt;
@override
_is.Table<_is.UuidValue?> get table => t;
/// Returns a shallow copy of this [OidcIdentity]
/// with some or all fields replaced by the given arguments.
@_is.useResult
OidcIdentity copyWith({
_is.UuidValue? id,
_is.UuidValue? authUserId,
_iacs.AuthUser? authUser,
String? provider,
String? subject,
DateTime? createdAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_oidc.OidcIdentity',
if (id != null) 'id': id?.toJson(),
'authUserId': authUserId.toJson(),
if (authUser != null) 'authUser': authUser?.toJson(),
'provider': provider,
'subject': subject,
'createdAt': createdAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static OidcIdentityInclude include({_iacs.AuthUserInclude? authUser}) {
return OidcIdentityInclude._(authUser: authUser);
}
static OidcIdentityIncludeList includeList({
_is.WhereExpressionBuilder<OidcIdentityTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<OidcIdentityTable>? orderBy,
_is.OrderByListBuilder<OidcIdentityTable>? orderByList,
OidcIdentityInclude? include,
}) {
return OidcIdentityIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(OidcIdentity.t),
orderByList: orderByList?.call(OidcIdentity.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _OidcIdentityImpl extends OidcIdentity {
_OidcIdentityImpl({
_is.UuidValue? id,
required _is.UuidValue authUserId,
_iacs.AuthUser? authUser,
required String provider,
required String subject,
DateTime? createdAt,
}) : super._(
id: id,
authUserId: authUserId,
authUser: authUser,
provider: provider,
subject: subject,
createdAt: createdAt,
);
/// Returns a shallow copy of this [OidcIdentity]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
OidcIdentity copyWith({
Object? id = _Undefined,
_is.UuidValue? authUserId,
Object? authUser = _Undefined,
String? provider,
String? subject,
DateTime? createdAt,
}) {
return OidcIdentity(
id: id is _is.UuidValue? ? id : this.id,
authUserId: authUserId ?? this.authUserId,
authUser: authUser is _iacs.AuthUser?
? authUser
: this.authUser?.copyWith(),
provider: provider ?? this.provider,
subject: subject ?? this.subject,
createdAt: createdAt ?? this.createdAt,
);
}
}
class OidcIdentityUpdateTable extends _is.UpdateTable<OidcIdentityTable> {
OidcIdentityUpdateTable(super.table);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> authUserId(
_is.UuidValue value,
) => _is.ColumnValue(
table.authUserId,
value,
);
_is.ColumnValue<String, String> provider(String value) => _is.ColumnValue(
table.provider,
value,
);
_is.ColumnValue<String, String> subject(String value) => _is.ColumnValue(
table.subject,
value,
);
_is.ColumnValue<DateTime, DateTime> createdAt(DateTime value) =>
_is.ColumnValue(
table.createdAt,
value,
);
}
class OidcIdentityTable extends _is.Table<_is.UuidValue?> {
OidcIdentityTable({super.tableRelation})
: super(tableName: 'halkyon_oidc_identity') {
updateTable = OidcIdentityUpdateTable(this);
authUserId = _is.ColumnUuid(
'authUserId',
this,
);
provider = _is.ColumnString(
'provider',
this,
);
subject = _is.ColumnString(
'subject',
this,
);
createdAt = _is.ColumnDateTime(
'createdAt',
this,
);
}
late final OidcIdentityUpdateTable updateTable;
late final _is.ColumnUuid authUserId;
/// The person, who goes with their identities.
_iacs.AuthUserTable? _authUser;
/// The provider, by the key the host named it with (`OidcProvider.key`).
late final _is.ColumnString provider;
/// The provider's ID of the person, its ID token's `sub`, as it came.
late final _is.ColumnString subject;
late final _is.ColumnDateTime createdAt;
_iacs.AuthUserTable get authUser {
if (_authUser != null) return _authUser!;
_authUser = _is.createRelationTable(
relationFieldName: 'authUser',
field: OidcIdentity.t.authUserId,
foreignField: _iacs.AuthUser.t.id,
tableRelation: tableRelation,
createTable: (foreignTableRelation) =>
_iacs.AuthUserTable(tableRelation: foreignTableRelation),
);
return _authUser!;
}
@override
List<_is.Column> get columns => [
id,
authUserId,
provider,
subject,
createdAt,
];
@override
_is.Table? getRelationTable(String relationField) {
if (relationField == 'authUser') {
return authUser;
}
return null;
}
}
class OidcIdentityInclude extends _is.IncludeObject {
OidcIdentityInclude._({_iacs.AuthUserInclude? authUser}) {
_authUser = authUser;
}
_iacs.AuthUserInclude? _authUser;
@override
Map<String, _is.Include?> get includes => {'authUser': _authUser};
@override
_is.Table<_is.UuidValue?> get table => OidcIdentity.t;
}
class OidcIdentityIncludeList extends _is.IncludeList {
OidcIdentityIncludeList._({
_is.WhereExpressionBuilder<OidcIdentityTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(OidcIdentity.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<_is.UuidValue?> get table => OidcIdentity.t;
}
class OidcIdentityRepository {
const OidcIdentityRepository._();
final attachRow = const OidcIdentityAttachRowRepository._();
/// Returns a list of [OidcIdentity]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<OidcIdentity>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<OidcIdentityTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<OidcIdentityTable>? orderBy,
_is.OrderByListBuilder<OidcIdentityTable>? orderByList,
_is.Transaction? transaction,
OidcIdentityInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<OidcIdentity>(
where: where?.call(OidcIdentity.t),
orderBy: orderBy?.call(OidcIdentity.t),
orderByList: orderByList?.call(OidcIdentity.t),
limit: limit,
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [OidcIdentity] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<OidcIdentity?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<OidcIdentityTable>? where,
int? offset,
_is.OrderByBuilder<OidcIdentityTable>? orderBy,
_is.OrderByListBuilder<OidcIdentityTable>? orderByList,
_is.Transaction? transaction,
OidcIdentityInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<OidcIdentity>(
where: where?.call(OidcIdentity.t),
orderBy: orderBy?.call(OidcIdentity.t),
orderByList: orderByList?.call(OidcIdentity.t),
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [OidcIdentity] by its [id] or null if no such row exists.
Future<OidcIdentity?> findById(
_is.DatabaseSession session,
_is.UuidValue id, {
_is.Transaction? transaction,
OidcIdentityInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<OidcIdentity>(
id,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [OidcIdentity]s in the list and returns the inserted rows.
///
/// The returned [OidcIdentity]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcIdentity>> insert(
_is.DatabaseSession session,
List<OidcIdentity> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<OidcIdentity>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [OidcIdentity] and returns the inserted row.
///
/// The returned [OidcIdentity] will have its `id` field set.
Future<OidcIdentity> insertRow(
_is.DatabaseSession session,
OidcIdentity row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<OidcIdentity>(
row,
transaction: transaction,
);
}
/// Upserts all [OidcIdentity]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [OidcIdentity]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcIdentity>> upsert(
_is.DatabaseSession session,
List<OidcIdentity> rows, {
required _is.ColumnSelections<OidcIdentityTable> conflictColumns,
_is.ColumnSelections<OidcIdentityTable>? updateColumns,
_is.WhereExpressionBuilder<OidcIdentityTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<OidcIdentity>(
rows,
conflictColumns: conflictColumns(OidcIdentity.t),
updateColumns: updateColumns?.call(OidcIdentity.t),
updateWhere: updateWhere?.call(OidcIdentity.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [OidcIdentity] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [OidcIdentity] will have its `id` field set.
Future<OidcIdentity?> upsertRow(
_is.DatabaseSession session,
OidcIdentity row, {
required _is.ColumnSelections<OidcIdentityTable> conflictColumns,
_is.ColumnSelections<OidcIdentityTable>? updateColumns,
_is.WhereExpressionBuilder<OidcIdentityTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<OidcIdentity>(
row,
conflictColumns: conflictColumns(OidcIdentity.t),
updateColumns: updateColumns?.call(OidcIdentity.t),
updateWhere: updateWhere?.call(OidcIdentity.t),
transaction: transaction,
);
}
/// Updates all [OidcIdentity]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcIdentity>> update(
_is.DatabaseSession session,
List<OidcIdentity> rows, {
_is.ColumnSelections<OidcIdentityTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<OidcIdentity>(
rows,
columns: columns?.call(OidcIdentity.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [OidcIdentity]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<OidcIdentity> updateRow(
_is.DatabaseSession session,
OidcIdentity row, {
_is.ColumnSelections<OidcIdentityTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<OidcIdentity>(
row,
columns: columns?.call(OidcIdentity.t),
transaction: transaction,
);
}
/// Updates a single [OidcIdentity] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<OidcIdentity?> updateById(
_is.DatabaseSession session,
_is.UuidValue id, {
required _is.ColumnValueListBuilder<OidcIdentityUpdateTable> columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<OidcIdentity>(
id,
columnValues: columnValues(OidcIdentity.t.updateTable),
transaction: transaction,
);
}
/// Updates all [OidcIdentity]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcIdentity>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<OidcIdentityUpdateTable> columnValues,
required _is.WhereExpressionBuilder<OidcIdentityTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<OidcIdentityTable>? orderBy,
_is.OrderByListBuilder<OidcIdentityTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<OidcIdentity>(
columnValues: columnValues(OidcIdentity.t.updateTable),
where: where(OidcIdentity.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(OidcIdentity.t),
orderByList: orderByList?.call(OidcIdentity.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [OidcIdentity]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcIdentity>> delete(
_is.DatabaseSession session,
List<OidcIdentity> rows, {
_is.OrderByBuilder<OidcIdentityTable>? orderBy,
_is.OrderByListBuilder<OidcIdentityTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<OidcIdentity>(
rows,
orderBy: orderBy?.call(OidcIdentity.t),
orderByList: orderByList?.call(OidcIdentity.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [OidcIdentity].
Future<OidcIdentity> deleteRow(
_is.DatabaseSession session,
OidcIdentity row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<OidcIdentity>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<OidcIdentity>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<OidcIdentityTable> where,
_is.OrderByBuilder<OidcIdentityTable>? orderBy,
_is.OrderByListBuilder<OidcIdentityTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<OidcIdentity>(
where: where(OidcIdentity.t),
orderBy: orderBy?.call(OidcIdentity.t),
orderByList: orderByList?.call(OidcIdentity.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<OidcIdentityTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<OidcIdentity>(
where: where?.call(OidcIdentity.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [OidcIdentity] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<OidcIdentityTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<OidcIdentity>(
where: where(OidcIdentity.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}
class OidcIdentityAttachRowRepository {
const OidcIdentityAttachRowRepository._();
/// Creates a relation between the given [OidcIdentity] and [AuthUser]
/// by setting the [OidcIdentity]'s foreign key `authUserId` to refer to the [AuthUser].
Future<void> authUser(
_is.DatabaseSession session,
OidcIdentity oidcIdentity,
_iacs.AuthUser authUser, {
_is.Transaction? transaction,
}) async {
if (oidcIdentity.id == null) {
throw ArgumentError.notNull('oidcIdentity.id');
}
if (authUser.id == null) {
throw ArgumentError.notNull('authUser.id');
}
var $oidcIdentity = oidcIdentity.copyWith(authUserId: authUser.id);
await session.db.updateRow<OidcIdentity>(
$oidcIdentity,
columns: [OidcIdentity.t.authUserId],
transaction: transaction,
);
}
}

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/serverpod.dart' as _is;
/// Why a sign-in with an OpenID Connect provider was refused.
enum OidcRefusal implements _is.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/serverpod.dart' as _is;
import 'oidc_refusal.dart' as _i8gvcysd;
/// A sign-in with an OpenID Connect provider was refused; nothing changed.
abstract class OidcRefusedException
implements
_is.SerializableException,
_is.SerializableModel,
_is.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.
@_is.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.
@_is.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/serverpod.dart' as _is;
/// 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 _is.SerializableModel, _is.ProtocolSerialization {
OidcStart._({
required this.attempt,
required this.authorizationUrl,
required this.state,
});
factory OidcStart({
required _is.UuidValue attempt,
required Uri authorizationUrl,
required String state,
}) = _OidcStartImpl;
factory OidcStart.fromJson(Map<String, dynamic> jsonSerialization) {
return OidcStart(
attempt: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['attempt'],
),
authorizationUrl: _is.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.
_is.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.
@_is.useResult
OidcStart copyWith({
_is.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 _is.SerializationManager.encode(this);
}
}
class _OidcStartImpl extends OidcStart {
_OidcStartImpl({
required _is.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.
@_is.useResult
@override
OidcStart copyWith({
_is.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,481 @@
/* 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/protocol.dart' as _isp;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'oidc_attempt.dart' as _ihieazd8;
import 'oidc_identity.dart' as _iif3vjzw;
import 'oidc_refusal.dart' as _i8gvcysd;
import 'oidc_refused_exception.dart' as _i70wwg4b;
import 'oidc_start.dart' as _i4fk0f61;
export 'oidc_attempt.dart';
export 'oidc_identity.dart';
export 'oidc_refusal.dart';
export 'oidc_refused_exception.dart';
export 'oidc_start.dart';
class Protocol extends _is.DatabaseSerializationManager {
Protocol._();
factory Protocol() => _instance;
static final Protocol _instance = Protocol._();
final Set<_is.SerializationManager> _hostProtocols = {};
static List<_isp.TableDefinition> get targetTableDefinitions => [
_isp.TableDefinition(
name: 'halkyon_oidc_attempt',
dartName: 'OidcAttempt',
schema: 'public',
module: 'halkyon_oidc',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue?',
columnDefault: 'random',
),
_isp.ColumnDefinition(
name: 'provider',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'state',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'nonce',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'codeVerifier',
columnType: _isp.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
_isp.ColumnDefinition(
name: 'callerId',
columnType: _isp.ColumnType.uuid,
isNullable: true,
dartType: 'UuidValue?',
),
_isp.ColumnDefinition(
name: 'subject',
columnType: _isp.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
_isp.ColumnDefinition(
name: 'expiresAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
],
foreignKeys: [
_isp.ForeignKeyDefinition(
constraintName: 'halkyon_oidc_attempt_fk_0',
columns: ['callerId'],
referenceTable: 'serverpod_auth_core_user',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isp.ForeignKeyAction.noAction,
onDelete: _isp.ForeignKeyAction.cascade,
matchType: null,
),
],
indexes: [
_isp.IndexDefinition(
indexName: 'halkyon_oidc_attempt_expires_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'expiresAt',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'halkyon_oidc_identity',
dartName: 'OidcIdentity',
schema: 'public',
module: 'halkyon_oidc',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue?',
columnDefault: 'random_v7',
),
_isp.ColumnDefinition(
name: 'authUserId',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'provider',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'subject',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'createdAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
],
foreignKeys: [
_isp.ForeignKeyDefinition(
constraintName: 'halkyon_oidc_identity_fk_0',
columns: ['authUserId'],
referenceTable: 'serverpod_auth_core_user',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isp.ForeignKeyAction.noAction,
onDelete: _isp.ForeignKeyAction.cascade,
matchType: null,
),
],
indexes: [
_isp.IndexDefinition(
indexName: 'halkyon_oidc_identity_subject_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'provider',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'subject',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
_isp.IndexDefinition(
indexName: 'halkyon_oidc_identity_person_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'authUserId',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'provider',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),
..._iais.Protocol.targetTableDefinitions,
..._iacs.Protocol.targetTableDefinitions,
];
void registerHostProtocol(
String projectName,
_is.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 _is.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 == _ihieazd8.OidcAttempt) {
return _ihieazd8.OidcAttempt.fromJson(data) as T;
}
if (t == _iif3vjzw.OidcIdentity) {
return _iif3vjzw.OidcIdentity.fromJson(data) as 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 == _is.getType<_ihieazd8.OidcAttempt?>()) {
return (data != null ? _ihieazd8.OidcAttempt.fromJson(data) : null) as T;
}
if (t == _is.getType<_iif3vjzw.OidcIdentity?>()) {
return (data != null ? _iif3vjzw.OidcIdentity.fromJson(data) : null) as T;
}
if (t == _is.getType<_i8gvcysd.OidcRefusal?>()) {
return (data != null ? _i8gvcysd.OidcRefusal.fromJson(data) : null) as T;
}
if (t == _is.getType<_i70wwg4b.OidcRefusedException?>()) {
return (data != null
? _i70wwg4b.OidcRefusedException.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_i4fk0f61.OidcStart?>()) {
return (data != null ? _i4fk0f61.OidcStart.fromJson(data) : null) as T;
}
try {
return _iais.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
try {
return _iacs.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
try {
return _isp.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
return super.deserialize<T>(data, t);
}
static String? getClassNameForType(Type type) {
return switch (type) {
_ihieazd8.OidcAttempt => 'OidcAttempt',
_iif3vjzw.OidcIdentity => 'OidcIdentity',
_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 _ihieazd8.OidcAttempt():
return 'OidcAttempt';
case _iif3vjzw.OidcIdentity():
return 'OidcIdentity';
case _i8gvcysd.OidcRefusal():
return 'OidcRefusal';
case _i70wwg4b.OidcRefusedException():
return 'OidcRefusedException';
case _i4fk0f61.OidcStart():
return 'OidcStart';
}
className = _isp.Protocol().getClassNameForObject(data);
if (className != null) {
return className.contains('.') ? className : 'serverpod.$className';
}
return null;
}
@override
dynamic deserializeByClassName(Map<String, dynamic> data) {
var dataClassName = data['className'];
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'OidcAttempt') {
return deserialize<_ihieazd8.OidcAttempt>(data['data']);
}
if (dataClassName == 'OidcIdentity') {
return deserialize<_iif3vjzw.OidcIdentity>(data['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']);
}
if (dataClassName.startsWith('serverpod.')) {
data['className'] = dataClassName.substring(10);
return _isp.Protocol().deserializeByClassName(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
? _is.SerializationManager.toEncodableForProtocol(wrapped)
: _is.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 _is.DeserializationClassNameNotFoundException catch (_) {}
}
}
return deserializeByClassName(value);
}
@override
_is.Table? getTableForType(Type t) {
{
var table = _iais.Protocol().getTableForType(t);
if (table != null) {
return table;
}
}
{
var table = _iacs.Protocol().getTableForType(t);
if (table != null) {
return table;
}
}
{
var table = _isp.Protocol().getTableForType(t);
if (table != null) {
return table;
}
}
switch (t) {
case _ihieazd8.OidcAttempt:
return _ihieazd8.OidcAttempt.t;
case _iif3vjzw.OidcIdentity:
return _iif3vjzw.OidcIdentity.t;
}
return null;
}
@override
List<_isp.TableDefinition> getTargetTableDefinitions() =>
targetTableDefinitions;
@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 _iais.Protocol().mapRecordToJson(record);
} catch (_) {}
try {
return _iacs.Protocol().mapRecordToJson(record);
} catch (_) {}
throw Exception('Unsupported record type ${record.runtimeType}');
}
}

View file

@ -0,0 +1,187 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
// Checking an OpenID Connect ID token (ADR 0023, 5; ADR 0020, D12), with
// dart_jsonwebtoken's algorithms for the signature alone. Its `JWT.verify`
// asks for `typ: JWT` unless told otherwise, which SimpleLogin's tokens don't
// carry, and trusts the header's algorithm; so the algorithm and the key are
// chosen here, and the claims checked here, with the minute of clocks'
// difference OpenID Connect allows for.
/// An ID token proves nothing: [why] says what failed, for the host's logs
/// and tests, never for the person signing in.
final class IdTokenRejected implements Exception {
IdTokenRejected(this.why);
final String why;
@override
String toString() => 'IdTokenRejected: $why';
}
/// The provider's key that [kid] names (the token's header's; null where it
/// names none), or null for none: the provider's one key, for a token
/// without a `kid`, where it has a single one.
typedef KeyLookup = Future<JWTKey?> Function(String? kid);
/// How much the clocks of the provider and this server may differ.
const idTokenLeeway = Duration(minutes: 1);
/// The claims of [token], an ID token, once everything checks out: signed by
/// one of [algorithms] with the key [keyFor] gives for its `kid`, issued by
/// [issuer] (exactly) for [clientId] (its audience, or one of them with
/// [clientId] as `azp`), carrying [nonce], not expired and not issued ahead
/// ([idTokenLeeway] allowed either way), naming someone (`sub`). Throws
/// [IdTokenRejected] otherwise.
Future<Map<String, Object?>> checkIdToken(
String token, {
required String issuer,
required String clientId,
required Set<String> algorithms,
required String nonce,
required KeyLookup keyFor,
}) async {
final parts = token.split('.');
if (parts.length != 3 || parts.any((part) => part.isEmpty)) {
throw IdTokenRejected('not a signed token');
}
final header = _object(parts[0], 'header');
final algorithm = switch (header['alg']) {
final String name when algorithms.contains(name) => _algorithmNamed(name),
_ => null,
};
if (algorithm == null) {
throw IdTokenRejected('an algorithm the provider does not use');
}
final kid = switch (header['kid']) {
final String kid => kid,
null => null,
_ => throw IdTokenRejected('a kid that is no text'),
};
final key = await keyFor(kid);
if (key == null || !_fits(algorithm, key)) {
throw IdTokenRejected('no key of the provider for it');
}
// The signature over the token as it came, by the algorithm alone:
// `JWT.verify` would read the claims too, and fails on some it can't read
// (an empty audience) with an assertion rather than a refusal.
final bool signed;
try {
signed = algorithm.verify(
key,
Uint8List.fromList(utf8.encode('${parts[0]}.${parts[1]}')),
base64Url.decode(base64Url.normalize(parts[2])),
);
} on Exception {
throw IdTokenRejected('a signature that is no signature');
}
if (!signed) {
throw IdTokenRejected('signed by someone else');
}
final claims = _object(parts[1], 'payload');
_checkClaims(claims, issuer: issuer, clientId: clientId, nonce: nonce);
return claims;
}
void _checkClaims(
Map<String, Object?> claims, {
required String issuer,
required String clientId,
required String nonce,
}) {
if (claims['iss'] != issuer) {
throw IdTokenRejected('another issuer');
}
final audiences = switch (claims['aud']) {
final String audience => [audience],
final List<Object?> audiences => audiences,
_ => const <Object?>[],
};
if (!audiences.contains(clientId)) {
throw IdTokenRejected('for another client');
}
if ((audiences.length > 1 || claims.containsKey('azp')) &&
claims['azp'] != clientId) {
throw IdTokenRejected('authorized for another client');
}
final now = clock.now();
final expires = _time(claims['exp']);
if (expires == null || !expires.add(idTokenLeeway).isAfter(now)) {
throw IdTokenRejected('expired');
}
final issued = _time(claims['iat']);
if (issued == null || issued.isAfter(now.add(idTokenLeeway))) {
throw IdTokenRejected('issued ahead');
}
if (claims.containsKey('nbf')) {
final notBefore = _time(claims['nbf']);
if (notBefore == null || notBefore.isAfter(now.add(idTokenLeeway))) {
throw IdTokenRejected('not valid yet');
}
}
if (claims['nonce'] != nonce) {
throw IdTokenRejected('for another attempt');
}
if (claims['sub'] case final String subject when subject.isNotEmpty) {
return;
}
throw IdTokenRejected('naming nobody');
}
/// The JSON object [part] of a token encodes; [IdTokenRejected] for anything
/// else.
Map<String, Object?> _object(String part, String what) {
try {
final decoded = jsonDecode(
utf8.decode(base64Url.decode(base64Url.normalize(part))),
);
if (decoded is Map<String, Object?>) {
return decoded;
}
} on FormatException {
// Falls through to the refusal below.
}
throw IdTokenRejected('a $what that is no JSON object');
}
/// [seconds] since the epoch as a moment; null for anything but a number.
DateTime? _time(Object? seconds) => switch (seconds) {
final num seconds => DateTime.fromMillisecondsSinceEpoch(
(seconds * 1000).round(),
isUtc: true,
),
_ => null,
};
/// The asymmetric algorithms ID tokens may be signed with; HMAC, keyed with
/// the client's secret, is left out, as a provider's keys never check it.
JWTAlgorithm? _algorithmNamed(String name) => switch (name) {
'RS256' => JWTAlgorithm.RS256,
'RS384' => JWTAlgorithm.RS384,
'RS512' => JWTAlgorithm.RS512,
'PS256' => JWTAlgorithm.PS256,
'PS384' => JWTAlgorithm.PS384,
'PS512' => JWTAlgorithm.PS512,
'ES256' => JWTAlgorithm.ES256,
'ES384' => JWTAlgorithm.ES384,
'ES512' => JWTAlgorithm.ES512,
'EdDSA' => JWTAlgorithm.EdDSA,
_ => null,
};
/// Whether [key] is of the kind [algorithm] checks with: dart_jsonwebtoken
/// only asserts it, which a release build skips.
bool _fits(JWTAlgorithm algorithm, JWTKey key) => switch (algorithm.name) {
'RS256' ||
'RS384' ||
'RS512' ||
'PS256' ||
'PS384' ||
'PS512' => key is RSAPublicKey,
'ES256' || 'ES384' || 'ES512' => key is ECPublicKey,
'EdDSA' => key is EdDSAPublicKey,
_ => false,
};

View file

@ -0,0 +1,31 @@
### A sign-in a device began with a provider (`OidcIdp.begin`), until it is
### ended (`OidcIdp.verify`), once, within ten minutes (ADR 0023, 4); or the
### identity it proved, kept a while for the host to finish with
### (`OidcIdp.takeKept`).
class: OidcAttempt
serverOnly: true
table: halkyon_oidc_attempt
fields:
### PostgreSQL's gen_random_uuid(), 122 random bits: the secret only the
### device holds, which ends the attempt. Never sent to the provider, so a
### code caught on its way back ends nothing without it.
id: UuidValue?, defaultPersist=random
### The provider, by its key (`OidcProvider.key`).
provider: String
### The state sent with the person, which comes back with the code.
state: String
### The nonce the provider's ID token must carry.
nonce: String
### PKCE's code verifier; null for a provider without PKCE.
codeVerifier: String?
### Who began it signed in, to have the identity linked to; null for a
### sign-in.
caller: module:serverpod_auth_core:AuthUser?, relation(optional, onDelete=Cascade)
### The ID the provider proved, for an attempt kept after it was verified;
### null before.
subject: String?
### Until when it can be ended, or taken once verified.
expiresAt: DateTime
indexes:
halkyon_oidc_attempt_expires_idx:
fields: expiresAt

View file

@ -0,0 +1,23 @@
### A person's identity at an OpenID Connect provider: the provider's ID of
### them (`sub`) and nothing else of what it says (ADR 0023, 3).
class: OidcIdentity
serverOnly: true
table: halkyon_oidc_identity
fields:
id: UuidValue?, defaultPersist=random_v7
### The person, who goes with their identities.
authUser: module:serverpod_auth_core:AuthUser?, relation(onDelete=Cascade)
### The provider, by the key the host named it with (`OidcProvider.key`).
provider: String
### The provider's ID of the person, its ID token's `sub`, as it came.
subject: String
createdAt: DateTime, defaultModel=now
indexes:
### Each ID one person's at a provider.
halkyon_oidc_identity_subject_idx:
fields: provider, subject
unique: true
### One identity per person and provider.
halkyon_oidc_identity_person_idx:
fields: authUserId, provider
unique: true

View file

@ -0,0 +1,24 @@
### Why a sign-in with an OpenID Connect provider was refused.
enum: OidcRefusal
serialized: byName
values:
### 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

View file

@ -0,0 +1,5 @@
### A sign-in with an OpenID Connect provider was refused; nothing changed.
exception: OidcRefusedException
fields:
### Why it was refused.
reason: OidcRefusal

View file

@ -0,0 +1,12 @@
### 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.
class: OidcStart
fields:
### The attempt's ID, which ends it (`verify`); never shown, never sent
### anywhere but to this server.
attempt: UuidValue
### The provider's authorization address, with everything it needs.
authorizationUrl: Uri
### The state the provider sends back with the code, which the device
### compares with what comes back before it goes on.
state: String

View file

@ -0,0 +1,646 @@
import 'dart:async';
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:halkyon_oidc_server/src/generated/protocol.dart';
import 'package:halkyon_oidc_server/src/id_token.dart';
import 'package:halkyon_oidc_server/src/oidc_provider.dart';
import 'package:halkyon_oidc_server/src/provider_keys.dart';
import 'package:http/http.dart' as http;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// What a provider proved: who the person is there ([provider]'s key and
/// [subject], its `sub`), what its ID token says besides ([claims], which
/// nothing keeps; empty for a kept attempt taken later), and who began the
/// sign-in signed in ([caller]; null for a sign-in).
@immutable
final class VerifiedOidcIdentity {
const VerifiedOidcIdentity({
required this.provider,
required this.subject,
required this.claims,
required this.caller,
});
final String provider;
final String subject;
final Map<String, Object?> claims;
final UuidValue? caller;
}
/// Signing in with OpenID Connect providers, as a host sets it up: passed to
/// `AuthServices.set` with Serverpod's own providers (ADR 0023).
final class OidcIdpConfig extends IdentityProviderBuilder<OidcIdp> {
OidcIdpConfig({
required this.providers,
this.attemptsLast = const Duration(minutes: 10),
this.beginsPerNetwork = defaultBegins,
this.timeout = const Duration(seconds: 10),
http.Client? httpClient,
}) : httpClient = httpClient ?? http.Client() {
final keys = <String>{};
for (final provider in providers) {
if (!keys.add(provider.key)) {
throw ArgumentError.value(
provider.key,
'providers',
'Two providers have the same key',
);
}
}
}
/// The sign-ins one network begins at most, unless a host says otherwise:
/// 30 an hour, as halkyon_base allows password sign-ins.
static const defaultBegins = RateLimit(
maxAttempts: 30,
timeframe: Duration(hours: 1),
);
/// The providers people sign in with, each under a key of its own.
final List<OidcProvider> providers;
/// How long an attempt can be ended after it was begun.
final Duration attemptsLast;
/// How many sign-ins one network (`begin`'s `network`) may begin; null for
/// no limit.
final RateLimit? beginsPerNetwork;
/// How long a provider may take to answer one request.
final Duration timeout;
/// What the providers are asked through.
final http.Client httpClient;
@override
OidcIdp build({
required TokenManager tokenManager,
required AuthUsers authUsers,
required UserProfiles userProfiles,
}) => OidcIdp(this, tokenIssuer: tokenManager, authUsers: authUsers);
}
/// Signing in with OpenID Connect providers (ADR 0023): its parts for a host
/// with rules of its own ([begin], [verify], [keep], [takeKept], [link],
/// [accountOf], [unlink]), and the stock way on them ([login],
/// [linkToCaller]).
final class OidcIdp implements IdentityProvider {
OidcIdp(
this.config, {
required this._tokenIssuer,
this._authUsers = const AuthUsers(),
}) : _beginsLimiter = switch (config.beginsPerNetwork) {
final limit? => DatabaseRateLimiter(
RateLimiterConfig(
domain: 'halkyon_oidc',
source: 'begins_per_network',
maxAttempts: limit.maxAttempts,
timeframe: limit.timeframe,
),
),
null => null,
};
/// The method sessions issued here name.
static const oidcMethod = 'oidc';
@override
String get method => oidcMethod;
final OidcIdpConfig config;
final TokenIssuer _tokenIssuer;
final AuthUsers _authUsers;
final DatabaseRateLimiter? _beginsLimiter;
final _endpoints = <String, Future<OidcEndpoints>>{};
final _keys = <String, ProviderKeys>{};
/// The provider the host names [key]; [OidcRefusal.unknownProvider] for
/// none.
OidcProvider provider(String key) {
for (final provider in config.providers) {
if (provider.key == key) {
return provider;
}
}
throw OidcRefusedException(reason: OidcRefusal.unknownProvider);
}
/// Begins a sign-in with [provider] (its key): an attempt, ended within
/// [OidcIdpConfig.attemptsLast] by [verify], and where to send the person.
/// [caller] began it signed in, to have the identity linked to them; the
/// state begins with [statePrefix] (a marker a forwarding page reads,
/// `addOidcCallbackForwarding`). Counted for [network], where given, the
/// requests of one network limited ([OidcIdpConfig.beginsPerNetwork],
/// [OidcRefusal.tooManyAttempts]). Attempts whose time ran out are
/// forgotten on the way.
Future<OidcStart> begin(
Session session, {
required String provider,
UuidValue? caller,
String statePrefix = '',
String? network,
}) async {
final named = this.provider(provider);
if (network != null &&
!(await _beginsLimiter?.tryRecordAttempt(session, key: network) ??
true)) {
throw OidcRefusedException(reason: OidcRefusal.tooManyAttempts);
}
final endpoints = await _available(() => _endpointsOf(named));
await forgetExpiredAttempts(session);
final state = '$statePrefix${newRandomValue()}';
final nonce = newRandomValue();
final codeVerifier = named.pkce ? newRandomValue() : null;
// INSERT INTO "halkyon_oidc_attempt" ("provider", "state", "nonce",
// "codeVerifier", "callerId", "expiresAt") VALUES (…) RETURNING "id"
final attempt = await OidcAttempt.db.insertRow(
session,
OidcAttempt(
provider: named.key,
state: state,
nonce: nonce,
codeVerifier: codeVerifier,
callerId: caller,
expiresAt: clock.now().toUtc().add(config.attemptsLast),
),
);
return OidcStart(
attempt: attempt.id!,
authorizationUrl: authorizationUrl(
named,
endpoints,
state: state,
nonce: nonce,
codeVerifier: codeVerifier,
),
state: state,
);
}
/// Ends [attempt] with the [code] the provider sent back: the identity its
/// ID token proves, once everything in it checks out (`checkIdToken`).
/// The attempt is taken first, so it is ended once whatever follows;
/// refused when nobody began it, it ended or its time ran out
/// ([OidcRefusal.unknownAttempt]), when [caller] isn't who began it
/// ([OidcRefusal.wrongCaller]; null for both signed out), when the provider
/// refuses the code or its token proves nothing ([OidcRefusal.rejected]),
/// and when it can't be reached ([OidcRefusal.providerUnavailable]).
Future<VerifiedOidcIdentity> verify(
Session session, {
required UuidValue attempt,
required String code,
UuidValue? caller,
}) async {
final taken = await _take(
session,
attempt,
caller: caller,
verified: false,
);
final named = provider(taken.provider);
final endpoints = await _available(() => _endpointsOf(named));
final idToken = await _exchange(
named,
endpoints,
code: code,
codeVerifier: taken.codeVerifier,
);
final Map<String, Object?> claims;
try {
claims = await _available(
() => checkIdToken(
idToken,
issuer: named.issuer,
clientId: named.clientId,
algorithms: named.algorithms,
nonce: taken.nonce,
keyFor: (kid) => _keysOf(named, endpoints).keyFor(kid),
),
);
} on IdTokenRejected catch (error) {
session.log(
'An ID token of ${named.key} was refused: ${error.why}.',
level: LogLevel.info,
);
throw OidcRefusedException(reason: OidcRefusal.rejected);
}
return VerifiedOidcIdentity(
provider: named.key,
subject: claims['sub']! as String,
claims: claims,
caller: taken.callerId,
);
}
/// Keeps [identity], which [verify] proved for [attempt], for [keepFor]
/// under the attempt's ID, for the host to take once ([takeKept]): as when
/// the person chooses a name before their account is made.
Future<void> keep(
Session session, {
required UuidValue attempt,
required VerifiedOidcIdentity identity,
required Duration keepFor,
}) async {
// INSERT INTO "halkyon_oidc_attempt" ("id", "provider", "state", "nonce",
// "callerId", "subject", "expiresAt") VALUES ($1, …): the attempt
// again, verified, its state and nonce spent.
await OidcAttempt.db.insertRow(
session,
OidcAttempt(
id: attempt,
provider: identity.provider,
state: '',
nonce: '',
callerId: identity.caller,
subject: identity.subject,
expiresAt: clock.now().toUtc().add(keepFor),
),
);
}
/// The identity [keep] kept under [attempt], taken once, by who began it
/// ([caller]); refusals as [verify]'s.
Future<VerifiedOidcIdentity> takeKept(
Session session, {
required UuidValue attempt,
UuidValue? caller,
}) async {
final taken = await _take(session, attempt, caller: caller, verified: true);
return VerifiedOidcIdentity(
provider: taken.provider,
subject: taken.subject!,
claims: const {},
caller: taken.callerId,
);
}
/// The attempt [id], taken out: verified ([OidcAttempt.subject] set) or
/// not as asked, its time not run out, begun by [caller].
Future<OidcAttempt> _take(
Session session,
UuidValue id, {
required UuidValue? caller,
required bool verified,
}) async {
// DELETE FROM "halkyon_oidc_attempt" WHERE "id" = $1
// AND "subject" IS [NOT] NULL RETURNING *
final taken = await OidcAttempt.db.deleteWhere(
session,
where: (t) =>
t.id.equals(id) &
(verified ? t.subject.notEquals(null) : t.subject.equals(null)),
);
final attempt = taken.singleOrNull;
if (attempt == null || !clock.now().isBefore(attempt.expiresAt)) {
throw OidcRefusedException(reason: OidcRefusal.unknownAttempt);
}
if (attempt.callerId != caller) {
throw OidcRefusedException(reason: OidcRefusal.wrongCaller);
}
return attempt;
}
/// The ID token [provider]'s token endpoint gives for [code], asked as its
/// client.
Future<String> _exchange(
OidcProvider provider,
OidcEndpoints endpoints, {
required String code,
required String? codeVerifier,
}) async {
final basic =
provider.clientAuthentication == OidcClientAuthentication.basic;
final http.Response response;
try {
response = await config.httpClient
.post(
endpoints.token,
headers: {
'accept': 'application/json',
if (basic)
'authorization':
'Basic ${base64.encode(utf8.encode('${_formEncoded(provider.clientId)}:${_formEncoded(provider.clientSecret)}'))}',
},
body: {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': '${provider.callbackUrl}',
'code_verifier': ?codeVerifier,
if (!basic) ...{
'client_id': provider.clientId,
'client_secret': provider.clientSecret,
},
},
)
.timeout(config.timeout);
} on Exception {
// Out of reach (a ClientException, a SocketException) or too slow.
throw OidcRefusedException(reason: OidcRefusal.providerUnavailable);
}
if (response.statusCode >= 500) {
throw OidcRefusedException(reason: OidcRefusal.providerUnavailable);
}
if (response.statusCode != 200) {
// The code refused: unknown, taken, expired, another client's.
throw OidcRefusedException(reason: OidcRefusal.rejected);
}
final Object? answer;
try {
answer = jsonDecode(response.body);
} on FormatException {
throw OidcRefusedException(reason: OidcRefusal.providerUnavailable);
}
return switch (answer) {
{'id_token': final String idToken} => idToken,
_ => throw OidcRefusedException(reason: OidcRefusal.rejected),
};
}
Future<OidcEndpoints> _endpointsOf(OidcProvider provider) {
if (provider.endpoints case final endpoints?) {
return Future.value(endpoints);
}
final discovering = _endpoints[provider.key] ??= _discover(provider);
// A discovery that failed is tried again by the next sign-in.
unawaited(
discovering.then<void>(
(_) {},
onError: (Object _) {
if (identical(_endpoints[provider.key], discovering)) {
_endpoints.remove(provider.key);
}
},
),
);
return discovering;
}
Future<OidcEndpoints> _discover(OidcProvider provider) async =>
OidcEndpoints.fromDiscovery(
await _get(discoveryUrl(provider.issuer)),
issuer: provider.issuer,
);
ProviderKeys _keysOf(OidcProvider provider, OidcEndpoints endpoints) =>
_keys[provider.key] ??= ProviderKeys(() => _get(endpoints.jwks));
/// The body [url] answers with 200; [ProviderUnavailable] otherwise.
Future<String> _get(Uri url) async {
final http.Response response;
try {
response = await config.httpClient
.get(url, headers: {'accept': 'application/json'})
.timeout(config.timeout);
} on Exception {
throw ProviderUnavailable('$url out of reach');
}
if (response.statusCode != 200) {
throw ProviderUnavailable('$url answered ${response.statusCode}');
}
return response.body;
}
/// What [ask] answers, a provider that can't be had refused as
/// [OidcRefusal.providerUnavailable].
Future<T> _available<T>(Future<T> Function() ask) async {
try {
return await ask();
} on ProviderUnavailable {
throw OidcRefusedException(reason: OidcRefusal.providerUnavailable);
}
}
/// The person whose identity at [provider] is [subject]; null for nobody.
Future<UuidValue?> accountOf(
Session session, {
required String provider,
required String subject,
Transaction? transaction,
}) async {
// SELECT * FROM "halkyon_oidc_identity"
// WHERE "provider" = $1 AND "subject" = $2
final identity = await OidcIdentity.db.findFirstRow(
session,
where: (t) => t.provider.equals(provider) & t.subject.equals(subject),
transaction: transaction,
);
return identity?.authUserId;
}
/// Whether [authUserId] has an identity, at [provider] or, without, at any.
Future<bool> hasIdentity(
Session session, {
required UuidValue authUserId,
String? provider,
Transaction? transaction,
}) async =>
// SELECT count(*) FROM "halkyon_oidc_identity"
// WHERE "authUserId" = $1 [AND "provider" = $2]
await OidcIdentity.db.count(
session,
where: (t) => provider == null
? t.authUserId.equals(authUserId)
: t.authUserId.equals(authUserId) & t.provider.equals(provider),
transaction: transaction,
) >
0;
/// Links the identity [subject] at [provider] to [authUserId]; nothing
/// for one linked to them already. Refused, nothing changed, for one that
/// is another person's ([OidcRefusal.takenByAnotherPerson]) and where
/// they have another at [provider] ([OidcRefusal.otherIdentityLinked]).
Future<void> link(
Session session, {
required UuidValue authUserId,
required String provider,
required String subject,
Transaction? transaction,
}) => DatabaseUtil.runInTransactionOrSavepoint(
session.db,
transaction,
(transaction) async {
// SELECT * FROM "halkyon_oidc_identity" WHERE "provider" = $1
// AND ("subject" = $2 OR "authUserId" = $3)
final identities = await OidcIdentity.db.find(
session,
where: (t) =>
t.provider.equals(provider) &
(t.subject.equals(subject) | t.authUserId.equals(authUserId)),
transaction: transaction,
);
for (final identity in identities) {
if (identity.subject == subject && identity.authUserId == authUserId) {
return;
}
}
if (identities.any((identity) => identity.subject == subject)) {
throw OidcRefusedException(reason: OidcRefusal.takenByAnotherPerson);
}
if (identities.isNotEmpty) {
throw OidcRefusedException(reason: OidcRefusal.otherIdentityLinked);
}
// INSERT INTO "halkyon_oidc_identity" ("authUserId", "provider",
// "subject", "createdAt") VALUES ($1, $2, $3, now())
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: authUserId,
provider: provider,
subject: subject,
),
transaction: transaction,
);
},
);
/// Unlinks [authUserId]'s identity at [provider]; nothing without one.
Future<void> unlink(
Session session, {
required UuidValue authUserId,
required String provider,
Transaction? transaction,
}) async {
// DELETE FROM "halkyon_oidc_identity"
// WHERE "authUserId" = $1 AND "provider" = $2
await OidcIdentity.db.deleteWhere(
session,
where: (t) =>
t.authUserId.equals(authUserId) & t.provider.equals(provider),
transaction: transaction,
);
}
/// The stock sign-in: [attempt], begun signed out, ended with [code]; a
/// session for the person with the identity proven, made for one nobody
/// has. Refusals as [verify]'s.
Future<AuthSuccess> login(
Session session, {
required UuidValue attempt,
required String code,
}) async {
final verified = await verify(session, attempt: attempt, code: code);
return await DatabaseUtil.runInTransactionOrSavepoint(session.db, null, (
transaction,
) async {
var person = await accountOf(
session,
provider: verified.provider,
subject: verified.subject,
transaction: transaction,
);
if (person == null) {
person = (await _authUsers.create(
session,
transaction: transaction,
)).id;
await link(
session,
authUserId: person,
provider: verified.provider,
subject: verified.subject,
transaction: transaction,
);
}
final scopes = (await _authUsers.get(
session,
authUserId: person,
transaction: transaction,
)).scopes;
return await _tokenIssuer.issueToken(
session,
authUserId: person,
method: method,
scopes: scopes,
transaction: transaction,
);
});
}
/// The stock way to add an identity: [attempt], begun by [caller] signed
/// in, ended with [code], its identity linked to them. Refusals as
/// [verify]'s and [link]'s.
Future<void> linkToCaller(
Session session, {
required UuidValue attempt,
required String code,
required UuidValue caller,
}) async {
final verified = await verify(
session,
attempt: attempt,
code: code,
caller: caller,
);
await link(
session,
authUserId: caller,
provider: verified.provider,
subject: verified.subject,
);
}
/// Deletes the attempts whose time ran out by now, and says how many:
/// [begin] does on the way; a host may also daily.
Future<int> forgetExpiredAttempts(Session session) async {
// DELETE FROM "halkyon_oidc_attempt" WHERE "expiresAt" <= $1
final forgotten = await OidcAttempt.db.deleteWhere(
session,
where: (t) => t.expiresAt <= clock.now().toUtc(),
);
return forgotten.length;
}
/// Moves the identities of [userToRemoveId] to [userToKeepId]; throws a
/// [StateError], rolling the merge back, where both have one at the same
/// provider: which of the two the person signs in with is theirs to say.
@override
Future<void> mergeAuthUsers(
Session session, {
required UuidValue userToKeepId,
required UuidValue userToRemoveId,
required Transaction transaction,
}) async {
// SELECT * FROM "halkyon_oidc_identity" WHERE "authUserId" IN ($1, $2)
final identities = await OidcIdentity.db.find(
session,
where: (t) => t.authUserId.inSet({userToKeepId, userToRemoveId}),
transaction: transaction,
);
final keptProviders = {
for (final identity in identities)
if (identity.authUserId == userToKeepId) identity.provider,
};
final moving = [
for (final identity in identities)
if (identity.authUserId == userToRemoveId) identity,
];
if (moving.any((identity) => keptProviders.contains(identity.provider))) {
throw StateError(
'Both people have an identity at the same OpenID Connect provider.',
);
}
// UPDATE "halkyon_oidc_identity" SET "authUserId" = $1 WHERE "id" IN (…)
await OidcIdentity.db.update(
session,
[
for (final identity in moving)
identity.copyWith(authUserId: userToKeepId),
],
columns: (t) => [t.authUserId],
transaction: transaction,
);
}
}
/// [value] form-encoded, as HTTP Basic authentication of a client wants its
/// ID and secret (RFC 6749, 2.3.1).
String _formEncoded(String value) => Uri.encodeQueryComponent(value);
/// The OpenID Connect sign-in a host set up.
extension OidcIdpGetter on AuthServices {
/// The [OidcIdp] built of the host's [OidcIdpConfig].
OidcIdp get oidcIdp => AuthServices.getIdentityProvider<OidcIdp>();
}

View file

@ -0,0 +1,185 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// How the client proves itself to a provider's token endpoint: HTTP Basic
/// authentication (`client_secret_basic`, OpenID Connect's default), or the
/// ID and secret in the request's body (`client_secret_post`).
enum OidcClientAuthentication { basic, post }
/// An OpenID Connect provider a host signs people in with (ADR 0023, 2): one
/// that follows the code flow, its ID tokens signed with keys it publishes.
final class OidcProvider {
const OidcProvider({
required this.key,
required this.issuer,
required this.clientId,
required this.clientSecret,
required this.callbackUrl,
this.scopes = const ['openid'],
this.pkce = true,
this.algorithms = const {'RS256'},
this.endpoints,
this.clientAuthentication = OidcClientAuthentication.basic,
});
/// SimpleLogin (`app.simplelogin.io`), as it answered on 23 Oct 2026: no
/// PKCE (its authorization doesn't know it, and its token endpoint takes the
/// secret alone), RS256, its ID tokens without `typ`, and the consent page
/// sharing an address, a name and an avatar whatever the scopes, so
/// `openid` alone. Its callbacks must be https addresses, each registered
/// exactly: a desktop app's loopback can't be one, which the forwarding
/// page is for (`addOidcCallbackForwarding`).
factory OidcProvider.simpleLogin({
String key = 'simplelogin',
required String clientId,
required String clientSecret,
required Uri callbackUrl,
}) => OidcProvider(
key: key,
issuer: 'https://app.simplelogin.io',
clientId: clientId,
clientSecret: clientSecret,
callbackUrl: callbackUrl,
pkce: false,
endpoints: OidcEndpoints(
authorization: Uri.https('app.simplelogin.io', '/oauth2/authorize'),
token: Uri.https('app.simplelogin.io', '/oauth2/token'),
jwks: Uri.https('app.simplelogin.io', '/jwks'),
),
);
/// The host's name for the provider, which identities and attempts keep:
/// never change it once people signed in with it.
final String key;
/// The provider's issuer, exactly as its ID tokens name it (`iss`), which
/// its discovery document must name too.
final String issuer;
/// The client the host registered with the provider.
final String clientId;
/// The client's secret, which never leaves the server.
final String clientSecret;
/// Where the provider sends a person back, as registered with it.
final Uri callbackUrl;
/// The scopes asked for; `openid` among them.
final List<String> scopes;
/// Whether the authorization carries PKCE's challenge (S256) and the
/// exchange its verifier.
final bool pkce;
/// The algorithms the provider's ID tokens may be signed with.
final Set<String> algorithms;
/// Where its endpoints are; null to read them from its discovery document.
final OidcEndpoints? endpoints;
/// How the client proves itself at the token endpoint.
final OidcClientAuthentication clientAuthentication;
}
/// Where a provider's endpoints are.
final class OidcEndpoints {
const OidcEndpoints({
required this.authorization,
required this.token,
required this.jwks,
});
/// The endpoints [document] (a discovery document's JSON) names, which must
/// be [issuer]'s own; throws [ProviderUnavailable] otherwise.
factory OidcEndpoints.fromDiscovery(
String document, {
required String issuer,
}) {
final Object? read;
try {
read = jsonDecode(document);
} on FormatException {
throw ProviderUnavailable('a discovery document that is no JSON');
}
if (read is! Map<String, Object?>) {
throw ProviderUnavailable('a discovery document that is no JSON object');
}
final named = read;
if (named['issuer'] != issuer) {
throw ProviderUnavailable('a discovery document of another issuer');
}
Uri endpoint(String name) => switch (named[name]) {
final String address when Uri.tryParse(address)?.hasAuthority ?? false =>
Uri.parse(address),
_ => throw ProviderUnavailable('a discovery document without $name'),
};
return OidcEndpoints(
authorization: endpoint('authorization_endpoint'),
token: endpoint('token_endpoint'),
jwks: endpoint('jwks_uri'),
);
}
final Uri authorization;
final Uri token;
final Uri jwks;
}
/// A provider couldn't be asked, or answered nothing readable: [why] for the
/// host's logs.
final class ProviderUnavailable implements Exception {
ProviderUnavailable(this.why);
final String why;
@override
String toString() => 'ProviderUnavailable: $why';
}
/// Where [issuer]'s discovery document is (OpenID Connect Discovery 1.0, 4).
Uri discoveryUrl(String issuer) {
final base = issuer.endsWith('/') ? issuer : '$issuer/';
return Uri.parse('$base.well-known/openid-configuration');
}
/// The address [provider]'s authorization is at ([endpoints]'), asking for a
/// code for its client, sent back to its callback with [state], its ID token
/// to carry [nonce]; with PKCE's challenge of [codeVerifier] where given.
Uri authorizationUrl(
OidcProvider provider,
OidcEndpoints endpoints, {
required String state,
required String nonce,
String? codeVerifier,
}) => endpoints.authorization.replace(
queryParameters: {
...endpoints.authorization.queryParameters,
'response_type': 'code',
'client_id': provider.clientId,
'redirect_uri': '${provider.callbackUrl}',
'scope': provider.scopes.join(' '),
'state': state,
'nonce': nonce,
if (codeVerifier != null) ...{
'code_challenge': pkceChallenge(codeVerifier),
'code_challenge_method': 'S256',
},
},
);
/// PKCE's S256 challenge of [verifier] (RFC 7636, 4.2).
String pkceChallenge(String verifier) =>
_unpadded(base64Url.encode(sha256.convert(ascii.encode(verifier)).bytes));
/// 32 random bytes from the system's secure source, as base64url without
/// padding: a state, a nonce or PKCE's verifier.
String newRandomValue() => _unpadded(
base64Url.encode([for (var i = 0; i < 32; i++) _random.nextInt(256)]),
);
final _random = Random.secure();
String _unpadded(String base64) => base64.replaceAll('=', '');

Some files were not shown because too many files have changed in this diff Show more