feat(server): GitHub sign-in that keeps only the GitHub ID
As decided, a GitHub sign-in keeps nothing but the numeric GitHub ID, so the login is our own, on Serverpod's GitHub helpers (lib/src/auth/github.dart): Serverpod's stock login also stores the name and email in a profile and downloads the avatar, and its endpoint is never exposed. The server exchanges the authorization code with the client secret, reads the ID, and drops the access token. GitHubUpgradeEndpoint links the GitHub ID to the signed-in anonymous person, grants identified (to their existing sessions too) and removes the anonymous identity, in one transaction: the person, their spaces and their studysets stay the same. GitHubSignInEndpoint gives a session for a person who has the GitHub account already; it's called without a session, since Serverpod issues no session for another person inside a signed-in call, and it never creates people. InstanceEndpoint.info() tells the app the instance's GitHub client ID. Refusals are GitHubSignInRefusedException with a reason: GitHub sign-in off here, the account taken by another person, another GitHub account linked already, an unknown account. GitHub sign-in is on only where the passwords hold githubClientId and githubClientSecret (empty counts as missing, as Compose passes unset variables on as empty strings); production takes them from GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in deploy/.env. grantIdentified can now join a caller's transaction. No migration: the GitHub accounts table comes with the auth module. Tests, refusals first: upgrading when not signed in (checked by switching the guard off: the test fails), both ways without a GitHub app, an account taken by someone else with nothing changed, a second GitHub account, an unknown account with nobody created; then linking keeps the person and reaches their sessions and stores no email, linking twice changes nothing, signing in reaches the right person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b8de289645
commit
17dcf75219
19 changed files with 1409 additions and 41 deletions
|
|
@ -6,12 +6,13 @@ import 'package:serverpod_auth_idp_server/core.dart';
|
|||
Future<void> run(List<String> args) async {
|
||||
// Initialize Serverpod. The generated Serverpod class is already connected
|
||||
// with your project's generated code.
|
||||
final pod = Serverpod(args)
|
||||
final pod = Serverpod(args);
|
||||
pod
|
||||
// Token managers validate and issue authentication keys; identity
|
||||
// providers are the ways people can sign in (see auth_config.dart).
|
||||
..initializeAuthServices(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(pod),
|
||||
)
|
||||
// Files live in Postgres until the self-hosted S3-compatible storage arrives later.
|
||||
..addCloudStorage(DatabaseCloudStorage('private'))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:halkyon_learn_server/src/auth/github.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
|
||||
import 'package:serverpod_auth_idp_server/providers/github.dart';
|
||||
|
||||
// How people authenticate. The server and its tests use this same
|
||||
// configuration, so the tests check what production runs.
|
||||
|
|
@ -13,14 +16,21 @@ final List<TokenManagerBuilder> tokenManagerBuilders = [
|
|||
),
|
||||
];
|
||||
|
||||
/// Anonymous accounts, the trial mode for solo use. At most 30 new ones per IP
|
||||
/// address and hour (Serverpod's default is 100): a school class behind one
|
||||
/// The ways people sign in on [pod].
|
||||
///
|
||||
/// Anonymous accounts are the trial mode for solo use. At most 30 new ones per
|
||||
/// IP address and hour (Serverpod's default is 100): a school class behind one
|
||||
/// address can still start together, while one abuser gets at most 720 a day.
|
||||
const List<IdentityProviderBuilder> identityProviderBuilders = [
|
||||
AnonymousIdpConfig(
|
||||
///
|
||||
/// GitHub only where the operator registered a GitHub OAuth app (see
|
||||
/// [gitHubSignInAvailable]); its helpers serve our own slim login
|
||||
/// (`github.dart`), and Serverpod's stock GitHub login endpoint isn't exposed.
|
||||
List<IdentityProviderBuilder> identityProviderBuilders(Serverpod pod) => [
|
||||
const AnonymousIdpConfig(
|
||||
perIpAddressRateLimit: RateLimit(
|
||||
maxAttempts: 30,
|
||||
timeframe: Duration(hours: 1),
|
||||
),
|
||||
),
|
||||
if (gitHubSignInAvailable(pod)) GitHubIdpConfigFromPasswords(),
|
||||
];
|
||||
|
|
|
|||
139
halkyon_learn_server/lib/src/auth/github.dart
Normal file
139
halkyon_learn_server/lib/src/auth/github.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:halkyon_learn_server/src/auth/identified.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:serverpod_auth_idp_server/providers/github.dart';
|
||||
|
||||
// Signing in with GitHub keeps only the numeric GitHub ID (decided 2026-09-30).
|
||||
// So the login is our own, on Serverpod's GitHub helpers: Serverpod's stock
|
||||
// login also stores the name and the email in a profile and downloads the
|
||||
// avatar to the server.
|
||||
|
||||
/// Serverpod's name for GitHub sign-in on session rows (`GitHubIdp.method`).
|
||||
const gitHubMethod = 'github';
|
||||
|
||||
/// Whether this instance's operator registered a GitHub OAuth app: its client
|
||||
/// ID and secret are in the passwords (`githubClientId`,
|
||||
/// `githubClientSecret`). Without them, GitHub sign-in is off and the app
|
||||
/// hides it. Empty values count as missing: Compose passes unset variables on
|
||||
/// as empty strings.
|
||||
bool gitHubSignInAvailable(Serverpod pod) =>
|
||||
(pod.getPassword('githubClientId') ?? '').isNotEmpty &&
|
||||
(pod.getPassword('githubClientSecret') ?? '').isNotEmpty;
|
||||
|
||||
/// The numeric GitHub ID that [code], from the app's GitHub sign-in, proves.
|
||||
///
|
||||
/// The code is exchanged here, with the client secret, which never leaves the
|
||||
/// server. GitHub's answer also carries the name and possibly an email
|
||||
/// address; neither is kept, and the access token is used once and dropped.
|
||||
Future<String> verifiedGitHubUserId(
|
||||
Session session, {
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
if (!gitHubSignInAvailable(session.serverpod)) {
|
||||
throw GitHubSignInRefusedException(
|
||||
reason: GitHubSignInRefusal.unavailable,
|
||||
);
|
||||
}
|
||||
final utils = AuthServices.instance.githubIdp.utils;
|
||||
final accessToken = await utils.exchangeCodeForToken(
|
||||
session,
|
||||
code: code,
|
||||
codeVerifier: codeVerifier,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
final details = await utils.fetchAccountDetails(
|
||||
session,
|
||||
accessToken: accessToken,
|
||||
);
|
||||
return details.userIdentifier;
|
||||
}
|
||||
|
||||
/// Links the GitHub account [gitHubUserId] to the person [authUserId], in one
|
||||
/// transaction: they become `identified` (on their existing sessions too) and
|
||||
/// lose their anonymous identity, since they now sign in with GitHub. The
|
||||
/// person stays the same, so their spaces and studysets stay where they are.
|
||||
///
|
||||
/// Linking the account the person already has changes nothing. Refused, with
|
||||
/// nothing changed, when the account belongs to someone else or the person
|
||||
/// has another GitHub account ([GitHubSignInRefusedException]).
|
||||
Future<void> linkGitHubAccount(
|
||||
Session session, {
|
||||
required UuidValue authUserId,
|
||||
required String gitHubUserId,
|
||||
}) => session.db.transaction((transaction) async {
|
||||
// SELECT * FROM "serverpod_auth_idp_github_account"
|
||||
// WHERE "userIdentifier" = $1 OR "authUserId" = $2
|
||||
final accounts = await GitHubAccount.db.find(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.userIdentifier.equals(gitHubUserId) | t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (accounts.any(
|
||||
(account) =>
|
||||
account.userIdentifier == gitHubUserId &&
|
||||
account.authUserId == authUserId,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (accounts.any((account) => account.userIdentifier == gitHubUserId)) {
|
||||
throw GitHubSignInRefusedException(
|
||||
reason: GitHubSignInRefusal.takenByAnotherPerson,
|
||||
);
|
||||
}
|
||||
if (accounts.isNotEmpty) {
|
||||
throw GitHubSignInRefusedException(
|
||||
reason: GitHubSignInRefusal.otherGitHubAccountLinked,
|
||||
);
|
||||
}
|
||||
|
||||
// INSERT INTO "serverpod_auth_idp_github_account"
|
||||
// ("userIdentifier", "authUserId", "created") VALUES ($1, $2, $3)
|
||||
await GitHubAccount.db.insertRow(
|
||||
session,
|
||||
GitHubAccount(userIdentifier: gitHubUserId, authUserId: authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
await grantIdentified(session, authUserId, transaction: transaction);
|
||||
// DELETE FROM "serverpod_auth_idp_anonymous_account" WHERE "authUserId" = $1
|
||||
await AnonymousAccount.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
|
||||
/// A new session for the person who has the GitHub account [gitHubUserId],
|
||||
/// with their scopes; used to switch to that person on another device.
|
||||
///
|
||||
/// Nobody is created here: people start anonymous and link GitHub later
|
||||
/// ([linkGitHubAccount]). An unknown account is refused
|
||||
/// ([GitHubSignInRefusal.unknownAccount]).
|
||||
Future<AuthSuccess> signInWithGitHubAccount(
|
||||
Session session, {
|
||||
required String gitHubUserId,
|
||||
}) async {
|
||||
// SELECT * FROM "serverpod_auth_idp_github_account" WHERE "userIdentifier" = $1
|
||||
final account = await GitHubAccount.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.userIdentifier.equals(gitHubUserId),
|
||||
);
|
||||
if (account == null) {
|
||||
throw GitHubSignInRefusedException(
|
||||
reason: GitHubSignInRefusal.unknownAccount,
|
||||
);
|
||||
}
|
||||
final person = await AuthServices.instance.authUsers.get(
|
||||
session,
|
||||
authUserId: account.authUserId,
|
||||
);
|
||||
return await AuthServices.instance.tokenManager.issueToken(
|
||||
session,
|
||||
authUserId: account.authUserId,
|
||||
method: gitHubMethod,
|
||||
scopes: person.scopeNames.map(Scope.new).toSet(),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:halkyon_learn_server/src/auth/github.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// Signs in the person who already has a GitHub account, e.g. when they switch
|
||||
/// to it on another device. Called without a session: Serverpod issues no
|
||||
/// session for another person inside a signed-in call.
|
||||
class GitHubSignInEndpoint extends Endpoint {
|
||||
/// A session for the person with the GitHub account that [code] proves;
|
||||
/// refusals as in [signInWithGitHubAccount].
|
||||
Future<AuthSuccess> signIn(
|
||||
Session session, {
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final gitHubUserId = await verifiedGitHubUserId(
|
||||
session,
|
||||
code: code,
|
||||
codeVerifier: codeVerifier,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
return await signInWithGitHubAccount(session, gitHubUserId: gitHubUserId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
### Why signing in with GitHub, or linking a GitHub account, was refused.
|
||||
enum: GitHubSignInRefusal
|
||||
serialized: byName
|
||||
values:
|
||||
### This instance has no GitHub OAuth app (no githubClientId and
|
||||
### githubClientSecret in its passwords).
|
||||
- unavailable
|
||||
### The GitHub account already belongs to another person.
|
||||
- takenByAnotherPerson
|
||||
### The person already has a different GitHub account.
|
||||
- otherGitHubAccountLinked
|
||||
### No person has this GitHub account; only linking one creates it.
|
||||
- unknownAccount
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
### Signing in with GitHub, or linking a GitHub account, was refused; nothing
|
||||
### changed.
|
||||
exception: GitHubSignInRefusedException
|
||||
fields:
|
||||
### Why it was refused.
|
||||
reason: GitHubSignInRefusal
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:halkyon_learn_server/src/auth/github.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// Links a GitHub account to the signed-in person: the lossless upgrade from
|
||||
/// an anonymous account. The person, their spaces and their studysets stay
|
||||
/// where they are; only the way they sign in changes.
|
||||
class GitHubUpgradeEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// Links the GitHub account that [code] proves to the caller; refusals as in
|
||||
/// [linkGitHubAccount].
|
||||
Future<void> upgrade(
|
||||
Session session, {
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final gitHubUserId = await verifiedGitHubUserId(
|
||||
session,
|
||||
code: code,
|
||||
codeVerifier: codeVerifier,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
await linkGitHubAccount(
|
||||
session,
|
||||
authUserId: session.authenticated!.authUserId,
|
||||
gitHubUserId: gitHubUserId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,38 +6,52 @@ import 'package:serverpod_auth_idp_server/core.dart';
|
|||
///
|
||||
/// Sessions keep the copy of the scopes they were started with, so the
|
||||
/// person's existing sessions get the scope too; otherwise it would only reach
|
||||
/// sessions started later.
|
||||
Future<void> grantIdentified(Session session, UuidValue authUserId) =>
|
||||
session.db.transaction((transaction) async {
|
||||
final authUsers = AuthServices.instance.authUsers;
|
||||
final person = await authUsers.get(
|
||||
session,
|
||||
authUserId: authUserId,
|
||||
transaction: transaction,
|
||||
);
|
||||
await authUsers.update(
|
||||
session,
|
||||
authUserId: authUserId,
|
||||
scopes: {...person.scopeNames.map(Scope.new), identified},
|
||||
transaction: transaction,
|
||||
);
|
||||
/// sessions started later. Runs in [transaction] if given, so linking an
|
||||
/// identity and granting the scope happen together.
|
||||
Future<void> grantIdentified(
|
||||
Session session,
|
||||
UuidValue authUserId, {
|
||||
Transaction? transaction,
|
||||
}) => transaction == null
|
||||
? session.db.transaction(
|
||||
(transaction) => _grant(session, authUserId, transaction),
|
||||
)
|
||||
: _grant(session, authUserId, transaction);
|
||||
|
||||
// UPDATE "serverpod_auth_core_session" SET "scopeNames" = …
|
||||
// WHERE "id" = $1 (one row per session of the person)
|
||||
final sessions = await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
await ServerSideSession.db.update(
|
||||
session,
|
||||
[
|
||||
for (final serverSideSession in sessions)
|
||||
serverSideSession.copyWith(
|
||||
scopeNames: {...serverSideSession.scopeNames, identified.name!},
|
||||
),
|
||||
],
|
||||
columns: (t) => [t.scopeNames],
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
Future<void> _grant(
|
||||
Session session,
|
||||
UuidValue authUserId,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
final authUsers = AuthServices.instance.authUsers;
|
||||
final person = await authUsers.get(
|
||||
session,
|
||||
authUserId: authUserId,
|
||||
transaction: transaction,
|
||||
);
|
||||
await authUsers.update(
|
||||
session,
|
||||
authUserId: authUserId,
|
||||
scopes: {...person.scopeNames.map(Scope.new), identified},
|
||||
transaction: transaction,
|
||||
);
|
||||
|
||||
// UPDATE "serverpod_auth_core_session" SET "scopeNames" = …
|
||||
// WHERE "id" = $1 (one row per session of the person)
|
||||
final sessions = await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
await ServerSideSession.db.update(
|
||||
session,
|
||||
[
|
||||
for (final serverSideSession in sessions)
|
||||
serverSideSession.copyWith(
|
||||
scopeNames: {...serverSideSession.scopeNames, identified.name!},
|
||||
),
|
||||
],
|
||||
columns: (t) => [t.scopeNames],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/* 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 signing in with GitHub, or linking a GitHub account, was refused.
|
||||
enum GitHubSignInRefusal implements _is.SerializableModel {
|
||||
/// This instance has no GitHub OAuth app (no githubClientId and
|
||||
/// githubClientSecret in its passwords).
|
||||
unavailable,
|
||||
|
||||
/// The GitHub account already belongs to another person.
|
||||
takenByAnotherPerson,
|
||||
|
||||
/// The person already has a different GitHub account.
|
||||
otherGitHubAccountLinked,
|
||||
|
||||
/// No person has this GitHub account; only linking one creates it.
|
||||
unknownAccount;
|
||||
|
||||
static GitHubSignInRefusal fromJson(String name) {
|
||||
switch (name) {
|
||||
case 'unavailable':
|
||||
return GitHubSignInRefusal.unavailable;
|
||||
case 'takenByAnotherPerson':
|
||||
return GitHubSignInRefusal.takenByAnotherPerson;
|
||||
case 'otherGitHubAccountLinked':
|
||||
return GitHubSignInRefusal.otherGitHubAccountLinked;
|
||||
case 'unknownAccount':
|
||||
return GitHubSignInRefusal.unknownAccount;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "GitHubSignInRefusal"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toJson() => name;
|
||||
|
||||
@override
|
||||
String toString() => name;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/* 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 '../auth/github_sign_in_refusal.dart' as _irgbwy1y;
|
||||
|
||||
/// Signing in with GitHub, or linking a GitHub account, was refused; nothing
|
||||
/// changed.
|
||||
abstract class GitHubSignInRefusedException
|
||||
implements
|
||||
_is.SerializableException,
|
||||
_is.SerializableModel,
|
||||
_is.ProtocolSerialization {
|
||||
GitHubSignInRefusedException._({required this.reason});
|
||||
|
||||
factory GitHubSignInRefusedException({
|
||||
required _irgbwy1y.GitHubSignInRefusal reason,
|
||||
}) = _GitHubSignInRefusedExceptionImpl;
|
||||
|
||||
factory GitHubSignInRefusedException.fromJson(
|
||||
Map<String, dynamic> jsonSerialization,
|
||||
) {
|
||||
return GitHubSignInRefusedException(
|
||||
reason: _irgbwy1y.GitHubSignInRefusal.fromJson(
|
||||
(jsonSerialization['reason'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Why it was refused.
|
||||
_irgbwy1y.GitHubSignInRefusal reason;
|
||||
|
||||
/// Returns a shallow copy of this [GitHubSignInRefusedException]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
GitHubSignInRefusedException copyWith({
|
||||
_irgbwy1y.GitHubSignInRefusal? reason,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'GitHubSignInRefusedException',
|
||||
'reason': reason.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'GitHubSignInRefusedException',
|
||||
'reason': reason.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GitHubSignInRefusedException(reason: $reason)';
|
||||
}
|
||||
}
|
||||
|
||||
class _GitHubSignInRefusedExceptionImpl extends GitHubSignInRefusedException {
|
||||
_GitHubSignInRefusedExceptionImpl({
|
||||
required _irgbwy1y.GitHubSignInRefusal reason,
|
||||
}) : super._(reason: reason);
|
||||
|
||||
/// Returns a shallow copy of this [GitHubSignInRefusedException]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
GitHubSignInRefusedException copyWith({
|
||||
_irgbwy1y.GitHubSignInRefusal? reason,
|
||||
}) {
|
||||
return GitHubSignInRefusedException(reason: reason ?? this.reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,9 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
|
|||
as _izehhkf5;
|
||||
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
|
||||
import '../auth/devices_endpoint.dart' as _iduct7ue;
|
||||
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
|
||||
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
|
||||
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
|
||||
|
||||
class Endpoints extends _is.EndpointDispatch {
|
||||
@override
|
||||
|
|
@ -36,6 +39,24 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'devices',
|
||||
null,
|
||||
),
|
||||
'gitHubSignIn': _iasw79m5.GitHubSignInEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'gitHubSignIn',
|
||||
null,
|
||||
),
|
||||
'gitHubUpgrade': _ivnugbf2.GitHubUpgradeEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'gitHubUpgrade',
|
||||
null,
|
||||
),
|
||||
'instance': _i3g8bfc2.InstanceEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'instance',
|
||||
null,
|
||||
),
|
||||
};
|
||||
connectors['anonymousIdp'] = _is.EndpointConnector(
|
||||
name: 'anonymousIdp',
|
||||
|
|
@ -98,6 +119,99 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
),
|
||||
},
|
||||
);
|
||||
connectors['gitHubSignIn'] = _is.EndpointConnector(
|
||||
name: 'gitHubSignIn',
|
||||
endpoint: endpoints['gitHubSignIn']!,
|
||||
methodConnectors: {
|
||||
'signIn': _is.MethodConnector(
|
||||
name: 'signIn',
|
||||
params: {
|
||||
'code': _is.ParameterDescription(
|
||||
name: 'code',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'codeVerifier': _is.ParameterDescription(
|
||||
name: 'codeVerifier',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'redirectUri': _is.ParameterDescription(
|
||||
name: 'redirectUri',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['gitHubSignIn'] as _iasw79m5.GitHubSignInEndpoint)
|
||||
.signIn(
|
||||
session,
|
||||
code: params['code'],
|
||||
codeVerifier: params['codeVerifier'],
|
||||
redirectUri: params['redirectUri'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
connectors['gitHubUpgrade'] = _is.EndpointConnector(
|
||||
name: 'gitHubUpgrade',
|
||||
endpoint: endpoints['gitHubUpgrade']!,
|
||||
methodConnectors: {
|
||||
'upgrade': _is.MethodConnector(
|
||||
name: 'upgrade',
|
||||
params: {
|
||||
'code': _is.ParameterDescription(
|
||||
name: 'code',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'codeVerifier': _is.ParameterDescription(
|
||||
name: 'codeVerifier',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'redirectUri': _is.ParameterDescription(
|
||||
name: 'redirectUri',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['gitHubUpgrade']
|
||||
as _ivnugbf2.GitHubUpgradeEndpoint)
|
||||
.upgrade(
|
||||
session,
|
||||
code: params['code'],
|
||||
codeVerifier: params['codeVerifier'],
|
||||
redirectUri: params['redirectUri'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
connectors['instance'] = _is.EndpointConnector(
|
||||
name: 'instance',
|
||||
endpoint: endpoints['instance']!,
|
||||
methodConnectors: {
|
||||
'info': _is.MethodConnector(
|
||||
name: 'info',
|
||||
params: {},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['instance'] as _i3g8bfc2.InstanceEndpoint)
|
||||
.info(session),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_offline_sync'] = _izehhkf5.Endpoints()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
/* 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 this instance offers, so the app can adapt: every instance is run by
|
||||
/// someone else, with its own configuration.
|
||||
abstract class InstanceInfo
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
InstanceInfo._({this.gitHubClientId});
|
||||
|
||||
factory InstanceInfo({String? gitHubClientId}) = _InstanceInfoImpl;
|
||||
|
||||
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return InstanceInfo(
|
||||
gitHubClientId: jsonSerialization['gitHubClientId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// The client ID of this instance's GitHub OAuth app, or null when GitHub
|
||||
/// sign-in is off here.
|
||||
String? gitHubClientId;
|
||||
|
||||
/// Returns a shallow copy of this [InstanceInfo]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
InstanceInfo copyWith({String? gitHubClientId});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'InstanceInfo',
|
||||
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'InstanceInfo',
|
||||
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _InstanceInfoImpl extends InstanceInfo {
|
||||
_InstanceInfoImpl({String? gitHubClientId})
|
||||
: super._(gitHubClientId: gitHubClientId);
|
||||
|
||||
/// Returns a shallow copy of this [InstanceInfo]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
InstanceInfo copyWith({Object? gitHubClientId = _Undefined}) {
|
||||
return InstanceInfo(
|
||||
gitHubClientId: gitHubClientId is String?
|
||||
? gitHubClientId
|
||||
: this.gitHubClientId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,10 +22,16 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
|||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
|
||||
as _izehhkf5;
|
||||
import 'auth/device.dart' as _i6c5ny7n;
|
||||
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
|
||||
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
||||
import 'common/not_found_exception.dart' as _i8oee73b;
|
||||
import 'instance/instance_info.dart' as _iskrg2g3;
|
||||
import 'studyset/studyset.dart' as _ikbclmae;
|
||||
export 'auth/device.dart';
|
||||
export 'auth/github_sign_in_refusal.dart';
|
||||
export 'auth/github_sign_in_refused_exception.dart';
|
||||
export 'common/not_found_exception.dart';
|
||||
export 'instance/instance_info.dart';
|
||||
export 'studyset/studyset.dart';
|
||||
export 'sync_tables.dart';
|
||||
|
||||
|
|
@ -140,19 +146,43 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _i6c5ny7n.Device) {
|
||||
return _i6c5ny7n.Device.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ir1gd9p5.GitHubSignInRefusal) {
|
||||
return _ir1gd9p5.GitHubSignInRefusal.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i0xfcu5n.GitHubSignInRefusedException) {
|
||||
return _i0xfcu5n.GitHubSignInRefusedException.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i8oee73b.NotFoundException) {
|
||||
return _i8oee73b.NotFoundException.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iskrg2g3.InstanceInfo) {
|
||||
return _iskrg2g3.InstanceInfo.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ikbclmae.Studyset) {
|
||||
return _ikbclmae.Studyset.fromJson(data) as T;
|
||||
}
|
||||
if (t == _is.getType<_i6c5ny7n.Device?>()) {
|
||||
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_ir1gd9p5.GitHubSignInRefusal?>()) {
|
||||
return (data != null
|
||||
? _ir1gd9p5.GitHubSignInRefusal.fromJson(data)
|
||||
: null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_i0xfcu5n.GitHubSignInRefusedException?>()) {
|
||||
return (data != null
|
||||
? _i0xfcu5n.GitHubSignInRefusedException.fromJson(data)
|
||||
: null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_i8oee73b.NotFoundException?>()) {
|
||||
return (data != null ? _i8oee73b.NotFoundException.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_iskrg2g3.InstanceInfo?>()) {
|
||||
return (data != null ? _iskrg2g3.InstanceInfo.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_ikbclmae.Studyset?>()) {
|
||||
return (data != null ? _ikbclmae.Studyset.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -180,7 +210,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
static String? getClassNameForType(Type type) {
|
||||
return switch (type) {
|
||||
_i6c5ny7n.Device => 'Device',
|
||||
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
|
||||
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
|
||||
_i8oee73b.NotFoundException => 'NotFoundException',
|
||||
_iskrg2g3.InstanceInfo => 'InstanceInfo',
|
||||
_ikbclmae.Studyset => 'Studyset',
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -201,8 +234,14 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
switch (data) {
|
||||
case _i6c5ny7n.Device():
|
||||
return 'Device';
|
||||
case _ir1gd9p5.GitHubSignInRefusal():
|
||||
return 'GitHubSignInRefusal';
|
||||
case _i0xfcu5n.GitHubSignInRefusedException():
|
||||
return 'GitHubSignInRefusedException';
|
||||
case _i8oee73b.NotFoundException():
|
||||
return 'NotFoundException';
|
||||
case _iskrg2g3.InstanceInfo():
|
||||
return 'InstanceInfo';
|
||||
case _ikbclmae.Studyset():
|
||||
return 'Studyset';
|
||||
}
|
||||
|
|
@ -240,9 +279,18 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'Device') {
|
||||
return deserialize<_i6c5ny7n.Device>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'GitHubSignInRefusal') {
|
||||
return deserialize<_ir1gd9p5.GitHubSignInRefusal>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'GitHubSignInRefusedException') {
|
||||
return deserialize<_i0xfcu5n.GitHubSignInRefusedException>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'NotFoundException') {
|
||||
return deserialize<_i8oee73b.NotFoundException>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'InstanceInfo') {
|
||||
return deserialize<_iskrg2g3.InstanceInfo>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Studyset') {
|
||||
return deserialize<_ikbclmae.Studyset>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,9 @@ anonymousIdp:
|
|||
devices:
|
||||
- list:
|
||||
- signOut:
|
||||
gitHubSignIn:
|
||||
- signIn:
|
||||
gitHubUpgrade:
|
||||
- upgrade:
|
||||
instance:
|
||||
- info:
|
||||
|
|
|
|||
13
halkyon_learn_server/lib/src/instance/instance_endpoint.dart
Normal file
13
halkyon_learn_server/lib/src/instance/instance_endpoint.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import 'package:halkyon_learn_server/src/auth/github.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// What this instance offers. Public: the app asks before anyone is signed in.
|
||||
class InstanceEndpoint extends Endpoint {
|
||||
/// This instance's offer; GitHub sign-in only with the operator's OAuth app.
|
||||
Future<InstanceInfo> info(Session session) async => InstanceInfo(
|
||||
gitHubClientId: gitHubSignInAvailable(session.serverpod)
|
||||
? session.serverpod.getPassword('githubClientId')
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
### What this instance offers, so the app can adapt: every instance is run by
|
||||
### someone else, with its own configuration.
|
||||
class: InstanceInfo
|
||||
fields:
|
||||
### The client ID of this instance's GitHub OAuth app, or null when GitHub
|
||||
### sign-in is off here.
|
||||
gitHubClientId: String?
|
||||
216
halkyon_learn_server/test/integration/auth/github_test.dart
Normal file
216
halkyon_learn_server/test/integration/auth/github_test.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import 'package:halkyon_learn_server/src/auth/github.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod('GitHub sign-in', (sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
/// Identified, with the GitHub account 1001.
|
||||
late UuidValue ben;
|
||||
|
||||
setUp(() async {
|
||||
useServerAuth();
|
||||
session = sessionBuilder.build();
|
||||
anna = await _newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await GitHubAccount.db.insertRow(
|
||||
session,
|
||||
GitHubAccount(userIdentifier: '1001', authUserId: ben),
|
||||
);
|
||||
});
|
||||
|
||||
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
|
||||
group('refuses', () {
|
||||
test('upgrading when not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
notSignedIn(),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('both ways on an instance without a GitHub OAuth app', () async {
|
||||
// The test configuration has no githubClientId/githubClientSecret.
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.unavailable)),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.gitHubSignIn.signIn(
|
||||
notSignedIn(),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.unavailable)),
|
||||
);
|
||||
});
|
||||
|
||||
test('linking a GitHub account that belongs to someone else; nothing '
|
||||
'changes', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '1001'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.takenByAnotherPerson)),
|
||||
);
|
||||
|
||||
expect(await _gitHubAccountsOf(session, anna), isEmpty);
|
||||
expect(await _isAnonymous(session, anna), isTrue);
|
||||
expect(
|
||||
await _scopesOf(session, anna),
|
||||
isNot(contains(identified.name)),
|
||||
);
|
||||
expect(
|
||||
[
|
||||
for (final account in await _gitHubAccountsOf(session, ben))
|
||||
account.userIdentifier,
|
||||
],
|
||||
['1001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a second GitHub account for the same person', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: ben, gitHubUserId: '2002'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.otherGitHubAccountLinked)),
|
||||
);
|
||||
|
||||
expect(await _gitHubAccount(session, '2002'), isNull);
|
||||
});
|
||||
|
||||
test('signing in with a GitHub account nobody has; nobody is '
|
||||
'created', () async {
|
||||
final people = await AuthUser.db.count(session);
|
||||
|
||||
await expectLater(
|
||||
signInWithGitHubAccount(session, gitHubUserId: '3003'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.unknownAccount)),
|
||||
);
|
||||
|
||||
expect(await AuthUser.db.count(session), people);
|
||||
});
|
||||
});
|
||||
|
||||
test('linking keeps the person and makes them identified, on every '
|
||||
'session', () async {
|
||||
await linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002');
|
||||
|
||||
final account = await _gitHubAccount(session, '2002');
|
||||
// The same person, so their space and studysets stay where they are.
|
||||
expect(account?.authUserId, anna);
|
||||
// Only the GitHub ID is kept.
|
||||
expect(account?.email, isNull);
|
||||
expect(await _scopesOf(session, anna), contains(identified.name));
|
||||
expect(
|
||||
(await ServerSideSession.db.findById(session, annasPhone))?.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(await _isAnonymous(session, anna), isFalse);
|
||||
});
|
||||
|
||||
test('linking the account a person already has changes nothing', () async {
|
||||
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
|
||||
|
||||
expect(
|
||||
[
|
||||
for (final account in await _gitHubAccountsOf(session, ben))
|
||||
account.userIdentifier,
|
||||
],
|
||||
['1001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('signing in with a known GitHub account starts a session for that '
|
||||
'person', () async {
|
||||
final authSuccess = await signInWithGitHubAccount(
|
||||
session,
|
||||
gitHubUserId: '1001',
|
||||
);
|
||||
|
||||
expect(authSuccess.authUserId, ben);
|
||||
expect(authSuccess.scopeNames, contains(identified.name));
|
||||
});
|
||||
|
||||
test('tells the app that this instance has no GitHub OAuth app', () async {
|
||||
final info = await endpoints.instance.info(notSignedIn());
|
||||
|
||||
expect(info.gitHubClientId, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The loopback callback the desktop apps use.
|
||||
const _redirectUri = 'http://127.0.0.1:43823/github-callback';
|
||||
|
||||
Matcher _refusal(GitHubSignInRefusal reason) =>
|
||||
isA<GitHubSignInRefusedException>().having(
|
||||
(exception) => exception.reason,
|
||||
'reason',
|
||||
reason,
|
||||
);
|
||||
|
||||
/// A person as the app's first launch creates them: anonymous.
|
||||
Future<UuidValue> _newAnonymousPerson(Session session) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: person),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0;
|
||||
|
||||
Future<Set<String>> _scopesOf(Session session, UuidValue person) async =>
|
||||
(await AuthServices.instance.authUsers.get(
|
||||
session,
|
||||
authUserId: person,
|
||||
)).scopeNames;
|
||||
|
||||
Future<List<GitHubAccount>> _gitHubAccountsOf(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
) => GitHubAccount.db.find(session, where: (t) => t.authUserId.equals(person));
|
||||
|
||||
Future<GitHubAccount?> _gitHubAccount(Session session, String gitHubUserId) =>
|
||||
GitHubAccount.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.userIdentifier.equals(gitHubUserId),
|
||||
);
|
||||
|
|
@ -5,7 +5,7 @@ import 'package:serverpod_auth_idp_server/core.dart';
|
|||
/// Sets up authentication exactly as the server runs it (lib/server.dart).
|
||||
void useServerAuth() => AuthServices.set(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(Serverpod.instance),
|
||||
);
|
||||
|
||||
/// Signs [person] in on a new device and returns the new session's id.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import 'dart:async' as _ida;
|
|||
import 'dart:io' as _idi;
|
||||
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
|
||||
as _ipdmo8cq;
|
||||
import 'package:halkyon_learn_server/src/generated/instance/instance_info.dart'
|
||||
as _iof1vv19;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
as _iacs;
|
||||
|
|
@ -155,6 +157,12 @@ class TestEndpoints {
|
|||
late final _AnonymousIdpEndpoint anonymousIdp;
|
||||
|
||||
late final _DevicesEndpoint devices;
|
||||
|
||||
late final _GitHubSignInEndpoint gitHubSignIn;
|
||||
|
||||
late final _GitHubUpgradeEndpoint gitHubUpgrade;
|
||||
|
||||
late final _InstanceEndpoint instance;
|
||||
}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
|
|
@ -172,6 +180,18 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
gitHubSignIn = _GitHubSignInEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
gitHubUpgrade = _GitHubUpgradeEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
instance = _InstanceEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,3 +308,140 @@ class _DevicesEndpoint {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _GitHubSignInEndpoint {
|
||||
_GitHubSignInEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_iacs.AuthSuccess> signIn(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'gitHubSignIn',
|
||||
method: 'signIn',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'gitHubSignIn',
|
||||
methodName: 'signIn',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'code': code,
|
||||
'codeVerifier': codeVerifier,
|
||||
'redirectUri': redirectUri,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iacs.AuthSuccess>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _GitHubUpgradeEndpoint {
|
||||
_GitHubUpgradeEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<void> upgrade(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'gitHubUpgrade',
|
||||
method: 'upgrade',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'gitHubUpgrade',
|
||||
methodName: 'upgrade',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'code': code,
|
||||
'codeVerifier': codeVerifier,
|
||||
'redirectUri': redirectUri,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _InstanceEndpoint {
|
||||
_InstanceEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_iof1vv19.InstanceInfo> info(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instance',
|
||||
method: 'info',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instance',
|
||||
methodName: 'info',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iof1vv19.InstanceInfo>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue