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:
parent
630226b51e
commit
f549fa59df
21 changed files with 15870 additions and 2 deletions
|
|
@ -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';
|
||||
13
packages/halkyon_oidc_server/lib/oidc.dart
Normal file
13
packages/halkyon_oidc_server/lib/oidc.dart
Normal 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;
|
||||
198
packages/halkyon_oidc_server/lib/src/callback_forwarding.dart
Normal file
198
packages/halkyon_oidc_server/lib/src/callback_forwarding.dart
Normal 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'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
852
packages/halkyon_oidc_server/lib/src/generated/oidc_attempt.dart
Normal file
852
packages/halkyon_oidc_server/lib/src/generated/oidc_attempt.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
114
packages/halkyon_oidc_server/lib/src/generated/oidc_start.dart
Normal file
114
packages/halkyon_oidc_server/lib/src/generated/oidc_start.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
481
packages/halkyon_oidc_server/lib/src/generated/protocol.dart
Normal file
481
packages/halkyon_oidc_server/lib/src/generated/protocol.dart
Normal 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}');
|
||||
}
|
||||
}
|
||||
187
packages/halkyon_oidc_server/lib/src/id_token.dart
Normal file
187
packages/halkyon_oidc_server/lib/src/id_token.dart
Normal 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,
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
646
packages/halkyon_oidc_server/lib/src/oidc_idp.dart
Normal file
646
packages/halkyon_oidc_server/lib/src/oidc_idp.dart
Normal 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>();
|
||||
}
|
||||
185
packages/halkyon_oidc_server/lib/src/oidc_provider.dart
Normal file
185
packages/halkyon_oidc_server/lib/src/oidc_provider.dart
Normal 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
Loading…
Reference in a new issue