diff --git a/appinfo/info.xml b/appinfo/info.xml index dec5242e9..e60566455 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -36,6 +36,11 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Cron\Maintenance + OCA\Circles\Cron\ScimSync + OCA\Circles\Cron\RemoteModDiscover + OCA\Circles\Cron\OidcSync + OCA\Circles\BackgroundJob\RemoteModSync + OCA\Circles\BackgroundJob\OidcSyncUser @@ -76,6 +81,11 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Command\MembersRemove OCA\Circles\Command\MigrateCustomGroups + + OCA\Circles\Command\CirclesScimSync + OCA\Circles\Command\CirclesRemoteModDiscover + OCA\Circles\Command\CirclesRemoteModSync + OCA\Circles\Command\CirclesOidcSync @@ -102,5 +112,7 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Settings\Admin OCA\Circles\Settings\AdminTeamFolders OCA\Circles\Settings\AdminSection + OCA\Circles\Settings\Personal + OCA\Circles\Settings\PersonalSection diff --git a/appinfo/routes.php b/appinfo/routes.php index 9066dc52e..f84be0c6a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -103,6 +103,10 @@ ['name' => 'Remote#member', 'url' => '/member/{type}/{userId}/', 'verb' => 'GET'], ['name' => 'Remote#inherited', 'url' => '/inherited/{circleId}/', 'verb' => 'GET'], ['name' => 'Remote#memberships', 'url' => '/memberships/{circleId}/', 'verb' => 'GET'], + ['name' => 'Remote#moderator', 'url' => '/moderator/', 'verb' => 'POST'], + + ['name' => 'Oidc#connect', 'url' => '/oidc/connect', 'verb' => 'GET'], + ['name' => 'Oidc#callback', 'url' => '/oidc/callback', 'verb' => 'GET'], ['name' => 'Deprecated#listing', 'url' => '/listing', 'verb' => 'GET'], ] diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 26ecc546e..5d7cc24c6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -44,6 +44,7 @@ use OCA\Circles\Listeners\TeamFolderLifecycleListener; use OCA\Circles\Listeners\UserCreated; use OCA\Circles\Listeners\UserDeleted; +use OCA\Circles\Listeners\UserLoggedIn; use OCA\Circles\MountManager\CircleMountProvider; use OCA\Circles\Notification\Notifier; use OCA\Circles\Search\UnifiedSearchProvider; @@ -71,6 +72,7 @@ use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserCreatedEvent; use OCP\User\Events\UserDeletedEvent; +use OCP\User\Events\UserLoggedInEvent; use Psr\Container\ContainerInterface; use Throwable; @@ -105,6 +107,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserUpdatedEvent::class, AccountUpdated::class); $context->registerEventListener(UserChangedEvent::class, AccountUpdated::class); $context->registerEventListener(UserDeletedEvent::class, UserDeleted::class); + $context->registerEventListener(UserLoggedInEvent::class, UserLoggedIn::class); // Circle Events $context->registerEventListener(CircleMemberRemovedEvent::class, CircleMemberRemoved::class); diff --git a/lib/AppInfo/Capabilities.php b/lib/AppInfo/Capabilities.php index 48e151f78..7b7f55809 100644 --- a/lib/AppInfo/Capabilities.php +++ b/lib/AppInfo/Capabilities.php @@ -108,7 +108,8 @@ private function getCapabilitiesCircleConstants(): array { Circle::CFG_CIRCLE_INVITE => $this->l10n->t('Team invite'), Circle::CFG_FEDERATED => $this->l10n->t('Federated'), Circle::CFG_MOUNTPOINT => $this->l10n->t('Mount point'), - Circle::CFG_APP => $this->l10n->t('App') + Circle::CFG_APP => $this->l10n->t('App'), + Circle::CFG_THIRD_PARTY => $this->l10n->t('Third party'), ], 'source' => [ diff --git a/lib/BackgroundJob/OidcSyncUser.php b/lib/BackgroundJob/OidcSyncUser.php new file mode 100644 index 000000000..52bd09af4 --- /dev/null +++ b/lib/BackgroundJob/OidcSyncUser.php @@ -0,0 +1,39 @@ +oidcService->syncMembershipsForUser($userId); + } catch (Exception $e) { + $this->logger->warning('could not sync OIDC memberships on login', ['userId' => $userId, 'exception' => $e]); + } + } +} diff --git a/lib/BackgroundJob/RemoteModSync.php b/lib/BackgroundJob/RemoteModSync.php new file mode 100644 index 000000000..7c85cd55c --- /dev/null +++ b/lib/BackgroundJob/RemoteModSync.php @@ -0,0 +1,30 @@ +setAllowParallelRuns(false); + } + + protected function run($argument) { + $this->remoteModCircleService->syncModeratorCircles(); + } +} diff --git a/lib/Command/CirclesOidcSync.php b/lib/Command/CirclesOidcSync.php new file mode 100644 index 000000000..3031d2777 --- /dev/null +++ b/lib/Command/CirclesOidcSync.php @@ -0,0 +1,37 @@ +setName('circles:oidc:sync') + ->setDescription('fetch memberships from OIDC server and add users to corresponding circles if not a member'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->oidcService->syncMemberships(); + + $output->writeln('done'); + + return 0; + } +} diff --git a/lib/Command/CirclesRemoteModDiscover.php b/lib/Command/CirclesRemoteModDiscover.php new file mode 100644 index 000000000..e1c640aa3 --- /dev/null +++ b/lib/Command/CirclesRemoteModDiscover.php @@ -0,0 +1,37 @@ +setName('circles:remotemod:discover') + ->setDescription('discover the remote moderator circle id for each configured remote instance'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->remoteModCircleService->discoverModeratorCircles(); + + $output->writeln('done'); + + return 0; + } +} diff --git a/lib/Command/CirclesRemoteModSync.php b/lib/Command/CirclesRemoteModSync.php new file mode 100644 index 000000000..4ffd22909 --- /dev/null +++ b/lib/Command/CirclesRemoteModSync.php @@ -0,0 +1,37 @@ +setName('circles:remotemod:sync') + ->setDescription('ensure every discovered remote moderator circle is a member of every third-party circle'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->remoteModCircleService->syncModeratorCircles(); + + $output->writeln('done'); + + return 0; + } +} diff --git a/lib/Command/CirclesScimSync.php b/lib/Command/CirclesScimSync.php new file mode 100644 index 000000000..13d74b4f6 --- /dev/null +++ b/lib/Command/CirclesScimSync.php @@ -0,0 +1,37 @@ +setName('circles:scim:sync') + ->setDescription('fetch circles from SCIM server and create the corresponding circles if missing'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->scimService->syncCircles(); + + $output->writeln('done'); + + return 0; + } +} diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 4337fd4d1..97f641eeb 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -27,6 +27,27 @@ class ConfigLexicon implements ILexicon { public const TEAM_FOLDER_AUTO_CREATE = 'team_folder_auto_create'; public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota'; + // OIDC + public const OIDC_ENABLED = 'oidc_enabled'; + public const OIDC_ISSUER = 'oidc_issuer'; + public const OIDC_CLIENT_ID = 'oidc_client_id'; + public const OIDC_CLIENT_SECRET = 'oidc_client_secret'; + public const OIDC_AUTHORIZATION_ENDPOINT = 'oidc_authorization_endpoint'; + public const OIDC_TOKEN_ENDPOINT = 'oidc_token_endpoint'; + public const OIDC_USERINFO_ENDPOINT = 'oidc_userinfo_endpoint'; + public const OIDC_SCOPE = 'oidc_scope'; + public const OIDC_MEMBERSHIP_CLAIM = 'oidc_membership_claim'; + + // SCIM + public const SCIM_ENABLED = 'scim_enabled'; + public const SCIM_ENDPOINT = 'scim_endpoint'; + public const SCIM_TOKEN = 'scim_token'; + + // Remote moderator circle + public const REMOTE_MOD_CIRCLE_INSTANCES = 'remote_mod_circle_instances'; // without http/https + public const REMOTE_MOD_CIRCLE_MAPPING = 'remote_mod_circle_mapping'; + public const REMOTE_MOD_CIRCLE_LOCAL_ID = 'remote_mod_circle_local_id'; + public function getStrictness(): Strictness { return Strictness::IGNORE; } @@ -38,6 +59,24 @@ public function getAppConfigs(): array { new Entry(key: self::REMOVE_SHARE_TOKENS_DONE, type: ValueType::BOOL, defaultRaw: false, definition: 'whether the remove share tokens repair step has already been executed', lazy: true), new Entry(key: self::TEAM_FOLDER_AUTO_CREATE, type: ValueType::BOOL, defaultRaw: true, definition: 'automatically create a team folder when a new team is created', lazy: true), new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: 0, definition: 'default quota in bytes for auto-created team folders (0 means unlimited)', lazy: true), + // OIDC + new Entry(key: self::OIDC_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable OIDC integration', lazy: true), + new Entry(key: self::OIDC_ISSUER, type: ValueType::STRING, defaultRaw: '', definition: 'OIDC provider issuer URL', lazy: true), + new Entry(key: self::OIDC_CLIENT_ID, type: ValueType::STRING, defaultRaw: '', definition: 'OIDC client id', lazy: true), + new Entry(key: self::OIDC_CLIENT_SECRET, type: ValueType::STRING, defaultRaw: '', definition: 'OIDC client secret', lazy: true), + new Entry(key: self::OIDC_AUTHORIZATION_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'OIDC authorization endpoint', lazy: true), + new Entry(key: self::OIDC_TOKEN_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'OIDC token endpoint', lazy: true), + new Entry(key: self::OIDC_USERINFO_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'OIDC userinfo endpoint', lazy: true), + new Entry(key: self::OIDC_SCOPE, type: ValueType::STRING, defaultRaw: 'openid', definition: 'OIDC scope(s) requested during authorization', lazy: true), + new Entry(key: self::OIDC_MEMBERSHIP_CLAIM, type: ValueType::STRING, defaultRaw: '', definition: 'claim name containing group membership information', lazy: true), + // SCIM + new Entry(key: self::SCIM_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable SCIM integration', lazy: true), + new Entry(key: self::SCIM_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'SCIM server endpoint for group discovery', lazy: true), + new Entry(key: self::SCIM_TOKEN, type: ValueType::STRING, defaultRaw: '', definition: 'bearer token used to authenticate against the SCIM server', lazy: true), + // Remote moderator circle + new Entry(key: self::REMOTE_MOD_CIRCLE_INSTANCES, type: ValueType::ARRAY, defaultRaw: [], definition: 'list of remote instances to sync a moderator circle from', lazy: true), + new Entry(key: self::REMOTE_MOD_CIRCLE_MAPPING, type: ValueType::ARRAY, defaultRaw: [], definition: 'map of instance => circle id for known remote moderator circles', lazy: true), + new Entry(key: self::REMOTE_MOD_CIRCLE_LOCAL_ID, type: ValueType::STRING, defaultRaw: '', definition: 'circle id of the local circle acting as a moderator in remote circles', lazy: true), ]; } diff --git a/lib/Controller/OidcController.php b/lib/Controller/OidcController.php new file mode 100644 index 000000000..34bafdda1 --- /dev/null +++ b/lib/Controller/OidcController.php @@ -0,0 +1,164 @@ +appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OIDC_ENABLED, false)) { + return $this->redirectToPersonalSettings('disabled'); + } + + $state = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); + $userId = $this->userSession->getUser()?->getUID(); + if ($userId === null) { + return $this->redirectToPersonalSettings('error'); + } + $this->session->set(self::SESSION_STATE, $state); + $this->session->set(self::SESSION_USER_ID, $userId); + $this->session->close(); + + $authorizationEndpoint = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_AUTHORIZATION_ENDPOINT); + $clientId = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_CLIENT_ID); + $scope = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_SCOPE, 'openid'); + $redirectUri = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.Oidc.callback'); + + $authorizationUrl = $this->buildAuthorizationUrl($authorizationEndpoint, [ + 'response_type' => 'code', + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'scope' => $scope, + 'state' => $state, + 'prompt' => 'consent', + ]); + + $this->logger->debug('Redirecting user to OIDC provider: ' . $authorizationUrl); + + return new RedirectResponse($authorizationUrl); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function callback(string $state = '', string $code = '', string $error = '', string $error_description = ''): RedirectResponse { + if ($error !== '') { + $this->logger->warning('OIDC provider returned an error: ' . $error . ' - ' . $error_description); + return $this->redirectToPersonalSettings('error'); + } + + if ($state === '' || $state !== $this->session->get(self::SESSION_STATE)) { + $this->logger->warning('OIDC callback state mismatch'); + return $this->redirectToPersonalSettings('error'); + } + $userId = $this->userSession->getUser()?->getUID(); + if ($userId === null || $userId !== $this->session->get(self::SESSION_USER_ID)) { + $this->logger->warning('OIDC callback user mismatch: started as ' . $this->session->get(self::SESSION_USER_ID) . ', completed as ' . $userId); + return $this->redirectToPersonalSettings('error'); + } + $this->session->remove(self::SESSION_STATE); + $this->session->remove(self::SESSION_USER_ID); + + $tokenEndpoint = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_TOKEN_ENDPOINT); + $clientId = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_CLIENT_ID); + $clientSecret = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_CLIENT_SECRET); + $redirectUri = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.Oidc.callback'); + + $client = $this->clientService->newClient(); + try { + $response = $client->post($tokenEndpoint, [ + 'auth' => [$clientId, $clientSecret], + 'body' => [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirectUri, + ], + ]); + } catch (\Exception $e) { + $this->logger->error('OIDC token exchange failed', ['exception' => $e]); + return $this->redirectToPersonalSettings('error'); + } + + $data = json_decode($response->getBody(), true); + + if (empty($data['refresh_token'])) { + $this->logger->error('OIDC provider did not return a refresh_token for user ' . $userId); + return $this->redirectToPersonalSettings('error'); + } + + $this->credentialsManager->store($userId, OidcService::CREDENTIAL_REFRESH_TOKEN, $data['refresh_token']); + + // initial sync + if (!empty($data['access_token'])) { + $this->oidcService->syncMembershipsForUser($userId, $data['access_token']); + } + + return $this->redirectToPersonalSettings('success'); + } + + private function redirectToPersonalSettings(string $result): RedirectResponse { + return new RedirectResponse( + $this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'circles']) + . '?oidcResult=' . $result + ); + } + + private function buildAuthorizationUrl(string $authorizationEndpoint, array $params): string { + $parsedUrl = parse_url($authorizationEndpoint); + + $urlWithoutParams + = ($parsedUrl['scheme'] ?? '') . '://' + . ($parsedUrl['host'] ?? '') + . (isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '') + . ($parsedUrl['path'] ?? ''); + + $queryParams = $params; + if (isset($parsedUrl['query'])) { + parse_str($parsedUrl['query'], $existingParams); + $queryParams = array_merge($queryParams, $existingParams); + } + + return $urlWithoutParams . '?' . http_build_query($queryParams); + } +} diff --git a/lib/Controller/RemoteController.php b/lib/Controller/RemoteController.php index c750fccec..e847d19dd 100644 --- a/lib/Controller/RemoteController.php +++ b/lib/Controller/RemoteController.php @@ -11,8 +11,10 @@ use Exception; use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; +use OCA\Circles\AppInfo\Application; use OCA\Circles\ConfigLexicon; use OCA\Circles\Db\CircleRequest; +use OCA\Circles\Exceptions\CircleNotFoundException; use OCA\Circles\Exceptions\FederatedEventException; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\FederatedUserException; @@ -33,6 +35,7 @@ use OCA\Circles\Service\MemberService; use OCA\Circles\Service\MembershipService; use OCA\Circles\Service\RemoteDownstreamService; +use OCA\Circles\Service\RemoteModCircleService; use OCA\Circles\Service\RemoteStreamService; use OCA\Circles\Tools\Exceptions\InvalidItemException; use OCA\Circles\Tools\Exceptions\InvalidOriginException; @@ -71,6 +74,7 @@ public function __construct( private readonly IAppConfig $appConfig, private readonly RemoteStreamService $remoteStreamService, private readonly RemoteDownstreamService $remoteDownstreamService, + private readonly RemoteModCircleService $remoteModCircleService, private readonly FederatedUserService $federatedUserService, private readonly CircleService $circleService, private readonly MemberService $memberService, @@ -342,6 +346,43 @@ public function memberships(string $circleId): DataResponse { } } + #[PublicPage] + #[NoCSRFRequired] + public function moderator(): DataResponse { + try { + $this->extractDataFromFromRequest(); + } catch (Exception $e) { + return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + } + + try { + $circleId = $this->appConfig->getAppValueString(ConfigLexicon::REMOTE_MOD_CIRCLE_LOCAL_ID, ''); + if ($circleId !== '') { + try { + $circle = $this->circleRequest->getCircle($circleId); + if ($circle->getOwner()->getUserId() !== Application::APP_ID || $circle->getOwner()->getUserType() !== Member::TYPE_APP) { + $circleId = ''; + } + } catch (CircleNotFoundException) { + $circleId = ''; + } + } + + if ($circleId === '') { + $outcome = $this->remoteModCircleService->createCircle('remote-mod-circle'); + $circleId = $outcome['id']; + $this->appConfig->setAppValueString(ConfigLexicon::REMOTE_MOD_CIRCLE_LOCAL_ID, $circleId); + } + + return new DataResponse([ + 'circleId' => $circleId, + 'instance' => $this->interfaceService->getLocalInstance(), + ]); + } catch (Exception $e) { + return $this->exceptionResponse($e); + } + } + /** * @return FederatedEvent * @throws InvalidItemException diff --git a/lib/Cron/OidcSync.php b/lib/Cron/OidcSync.php new file mode 100644 index 000000000..a3abb8d8d --- /dev/null +++ b/lib/Cron/OidcSync.php @@ -0,0 +1,42 @@ +setInterval(24 * 3600); + // delay until low-load time + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + // only run one instance of this job at a time + $this->setAllowParallelRuns(false); + } + + protected function run($argument) { + if (!$this->appConfig->getAppValueBool(ConfigLexicon::OIDC_ENABLED)) { + return; + } + + $this->oidcService->syncMemberships(); + } +} diff --git a/lib/Cron/RemoteModDiscover.php b/lib/Cron/RemoteModDiscover.php new file mode 100644 index 000000000..ac8f66e95 --- /dev/null +++ b/lib/Cron/RemoteModDiscover.php @@ -0,0 +1,49 @@ +setInterval(12 * 3600); + // delay until low-load time + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + // only run one instance of this job at a time + $this->setAllowParallelRuns(false); + } + + protected function run($argument) { + $remoteModCircleInstances = $this->appConfig->getAppValueArray(ConfigLexicon::REMOTE_MOD_CIRCLE_INSTANCES); + if ($remoteModCircleInstances === []) { + return; + } + + $this->remoteModCircleService->discoverModeratorCircles(); + + // once discovery is done, run RemoteModSync right after + $this->jobList->scheduleAfter(RemoteModSync::class, time() + 60); + } +} diff --git a/lib/Cron/ScimSync.php b/lib/Cron/ScimSync.php new file mode 100644 index 000000000..3fa0f2362 --- /dev/null +++ b/lib/Cron/ScimSync.php @@ -0,0 +1,37 @@ +setInterval(12 * 3600); + } + + protected function run($argument) { + if (!$this->appConfig->getAppValueBool(ConfigLexicon::SCIM_ENABLED)) { + return; + } + + $this->scimService->syncCircles(); + } +} diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index 5cd18172e..f59f46cc5 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -497,6 +497,19 @@ public function getFederated(): array { return $this->getItemsFromRequest($qb); } + /** + * @return Circle[] + * @throws RequestBuilderException + */ + public function getThirdParty(): array { + $qb = $this->getCircleSelectSql(); + $qb->limitToConfigFlag(Circle::CFG_THIRD_PARTY, CoreQueryBuilder::CIRCLE); + + $qb->leftJoinOwner(CoreQueryBuilder::CIRCLE); + + return $this->getItemsFromRequest($qb); + } + /** * @param Circle $circle */ diff --git a/lib/Listeners/UserLoggedIn.php b/lib/Listeners/UserLoggedIn.php new file mode 100644 index 000000000..4fddd68f5 --- /dev/null +++ b/lib/Listeners/UserLoggedIn.php @@ -0,0 +1,40 @@ + */ +class UserLoggedIn implements IEventListener { + public function __construct( + private readonly IAppConfig $appConfig, + private readonly IJobList $jobList, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof UserLoggedInEvent)) { + return; + } + + if (!$this->appConfig->getAppValueBool(ConfigLexicon::OIDC_ENABLED)) { + return; + } + + $this->jobList->add(OidcSyncUser::class, ['userId' => $event->getUser()->getUID()]); + } +} diff --git a/lib/Model/Circle.php b/lib/Model/Circle.php index a29fa2e0b..ce8b57f8b 100644 --- a/lib/Model/Circle.php +++ b/lib/Model/Circle.php @@ -90,7 +90,8 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow public const CFG_FEDERATED = 32768; // Federated public const CFG_MOUNTPOINT = 65536; // Generate a Files folder for this Circle public const CFG_APP = 131072; // Some features are not available to the OCS API (ie. destroying Circle) - public static $DEF_CFG_MAX = 262143; + public const CFG_THIRD_PARTY = 262144; // Circle is managed by a third-party system (e.g. SCIM), not manually + public static $DEF_CFG_MAX = 524287; /** * Note: When editing those values, update lib/Application/Capabilities.php @@ -117,6 +118,7 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow 32768 => 'F|Federated', 65536 => 'M|Nountpoint', 131072 => 'A|App', + 262144 => 'TP|Third Party', ]; /** diff --git a/lib/Service/OidcService.php b/lib/Service/OidcService.php new file mode 100644 index 000000000..e93737e47 --- /dev/null +++ b/lib/Service/OidcService.php @@ -0,0 +1,216 @@ +appConfig->getValueString(Application::APP_ID, ConfigLexicon::REMOTE_MOD_CIRCLE_LOCAL_ID, ''); + $moderator = $this->circleRequest->getFederatedUserBySingleId($moderatorSingleId); + + $this->userManager->callForSeenUsers(function (IUser $user) use ($moderator): void { + $this->syncMembershipsForUser($user->getUID(), moderator: $moderator); + }); + } + + public function syncMembershipsForUser(string $userId, ?string $accessToken = null, ?FederatedUser $moderator = null): void { + if ($accessToken === null) { + $refreshToken = $this->credentialsManager->retrieve($userId, self::CREDENTIAL_REFRESH_TOKEN); + if (empty($refreshToken)) { + return; + } + + $accessToken = $this->refreshAccessToken($userId, $refreshToken); + if ($accessToken === null) { + $this->logger->error('could not refresh OIDC access token', ['userId' => $userId]); + return; + } + } + + $rawMemberships = $this->fetchMemberships($accessToken); + if ($rawMemberships === null) { + // don't assume user has no memberships on failed request, to avoid removing existing memberships + $this->logger->debug('could not fetch OIDC memberships, skipping reconciliation', ['userId' => $userId]); + return; + } + + if ($moderator === null) { + $moderatorSingleId = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::REMOTE_MOD_CIRCLE_LOCAL_ID, ''); + if ($moderatorSingleId === '') { + $this->logger->warning('remote_mod_circle_local_id not set yet, please run circles:remotemod:discover on the instance that owns the third party circles'); + return; + } + try { + $moderator = $this->circleRequest->getFederatedUserBySingleId($moderatorSingleId); + } catch (Exception $e) { + $this->logger->error('could not find remote moderator circle on this instance', ['moderatorSingleId' => $moderatorSingleId, 'exception' => $e]); + return; + } + } + $this->federatedUserService->setCurrentUser($moderator); + + // ensure user is a member of circles matching OIDC memberships + $desiredCircleIds = []; + foreach ($rawMemberships as $rawMembership) { + $circleId = $this->generateCircleIdFromString($rawMembership); + $desiredCircleIds[] = $circleId; + $this->ensureMember($userId, $circleId); + } + + // remove user from third-party circles not present in the current OIDC memberships + $currentCircleIds = $this->getThirdPartyCirclesForUser($userId); + foreach ($currentCircleIds as $circleId) { + if (in_array($circleId, $desiredCircleIds, true)) { + continue; + } + $this->removeMember($userId, $circleId); + } + } + + private function ensureMember(string $userId, string $circleId): void { + try { + $this->circleRequest->getCircle($circleId); + } catch (CircleNotFoundException) { + return; + } + + try { + $this->memberRequest->getMemberByUserId($circleId, $userId); + return; + } catch (MemberNotFoundException) { + } + + try { + $federatedUser = $this->federatedUserService->getLocalFederatedUser($userId); + $this->memberService->addMember($circleId, $federatedUser); + } catch (Exception $e) { + $this->logger->error('could not add user to circle', ['userId' => $userId, 'circleId' => $circleId, 'exception' => $e]); + } + } + + private function removeMember(string $userId, string $circleId): void { + try { + $member = $this->memberRequest->getMemberByUserId($circleId, $userId); + $this->memberService->removeMember($member->getId()); + } catch (Exception $e) { + $this->logger->error('could not remove user from circle', ['userId' => $userId, 'circleId' => $circleId, 'exception' => $e]); + } + } + + /** + * @return string|null fresh access token or null on failure + */ + private function refreshAccessToken(string $userId, string $refreshToken): ?string { + $tokenEndpoint = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_TOKEN_ENDPOINT, ''); + $clientId = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_CLIENT_ID, ''); + $clientSecret = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_CLIENT_SECRET, ''); + + $client = $this->clientService->newClient(); + try { + $response = $client->post($tokenEndpoint, [ + 'auth' => [$clientId, $clientSecret], + 'body' => [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ], + ]); + } catch (Exception $e) { + $this->logger->error('OIDC token refresh failed', ['exception' => $e]); + return null; + } + + $data = json_decode($response->getBody(), true); + + if (!empty($data['refresh_token'])) { + $this->credentialsManager->store($userId, self::CREDENTIAL_REFRESH_TOKEN, $data['refresh_token']); + } + + return $data['access_token'] ?? null; + } + + /** + * @return list|null raw membership entries (e.g. "urn:geant:company.co:group:my_group#login.company.co") + * null if the request failed + */ + private function fetchMemberships(string $accessToken): ?array { + $userinfoEndpoint = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_USERINFO_ENDPOINT, ''); + $membershipClaim = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::OIDC_MEMBERSHIP_CLAIM, ''); + + $client = $this->clientService->newClient(); + try { + $response = $client->get($userinfoEndpoint, [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken], + ]); + } catch (Exception $e) { + $this->logger->error('OIDC userinfo request failed', ['exception' => $e]); + return null; + } + + $response = json_decode($response->getBody(), true); + $this->logger->debug('OIDC userinfo response: ' . json_encode($response)); + + $rawMemberships = $response[$membershipClaim] ?? []; + if (!is_array($rawMemberships)) { + $rawMemberships = [$rawMemberships]; + } + + $this->logger->debug('OIDC raw memberships (' . $membershipClaim . '): ' . json_encode($rawMemberships)); + + return $rawMemberships; + } + + /** + * @return list circleIds of third-party circles the user currently belongs to + */ + private function getThirdPartyCirclesForUser(string $userId): array { + $circleIds = []; + foreach ($this->circleRequest->getThirdParty() as $circle) { + try { + $this->memberRequest->getMemberByUserId($circle->getSingleId(), $userId); + $circleIds[] = $circle->getSingleId(); + } catch (MemberNotFoundException) { + } + } + + return $circleIds; + } +} diff --git a/lib/Service/RemoteModCircleService.php b/lib/Service/RemoteModCircleService.php new file mode 100644 index 000000000..e37412670 --- /dev/null +++ b/lib/Service/RemoteModCircleService.php @@ -0,0 +1,196 @@ +interfaceService->setCurrentInterface(InterfaceService::IFACE_FRONTAL); + + $remoteInstances = $this->appConfig->getAppValueArray(ConfigLexicon::REMOTE_MOD_CIRCLE_INSTANCES); + if ($remoteInstances === []) { + $this->logger->debug('no remote instance configured, skipping discovery'); + return; + } + + $remoteModeratorMapping = []; + foreach ($remoteInstances as $remoteInstance) { + try { + $moderatorCircleId = $this->requestModerator($remoteInstance); + $remoteModeratorMapping[$remoteInstance] = $moderatorCircleId; + } catch (Exception $e) { + $this->logger->error('could not discover moderator from remote instance', ['instance' => $remoteInstance, 'exception' => $e]); + } + } + + $this->appConfig->setAppValueArray(ConfigLexicon::REMOTE_MOD_CIRCLE_MAPPING, $remoteModeratorMapping); + } + + public function syncModeratorCircles(): void { + $this->federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + $currentApp = $this->federatedUserService->getCurrentApp(); + $this->federatedUserService->setCurrentUser($currentApp); + + $mapping = $this->appConfig->getAppValueArray(ConfigLexicon::REMOTE_MOD_CIRCLE_MAPPING); + if ($mapping === []) { + $this->logger->debug('no remote moderator known, skipping reconciliation'); + return; + } + + $thirdPartyCircles = $this->circleRequest->getThirdParty(); + if ($thirdPartyCircles === []) { + $this->logger->debug('no third party circle known, skipping reconciliation'); + return; + } + + foreach ($thirdPartyCircles as $circle) { + foreach ($mapping as $remoteInstance => $moderatorSingleId) { + $this->ensureModerator($circle->getSingleId(), $remoteInstance, $moderatorSingleId); + } + } + } + + /** + * @throws Exception + */ + private function requestModerator(string $remoteInstance): string { + $request = new NCRequest('', Request::TYPE_POST); + $request->basedOnUrl(rtrim($remoteInstance, '/') . '/index.php/apps/circles/moderator/'); + $request->setFollowLocation(true); + $request->setLocalAddressAllowed(true); + $request->setTimeout(5); + // data cannot be an empty array + $request->setData(['payload' => true]); + + $app = $this->remoteStreamService->getAppSignatory(); + $signedRequest = $this->remoteStreamService->signOutgoingRequest($request, $app); + $outgoingRequest = $signedRequest->getOutgoingRequest(); + $outgoingRequest->setLocalAddressAllowed(true); + $outgoingRequest->setFollowLocation(true); + + $this->doRequest($outgoingRequest); + + $result = $outgoingRequest->getResult(); + if ($result->getStatusCode() !== 200) { + throw new Exception('HTTP ' . $result->getStatusCode()); + } + + $data = json_decode($result->getContent(), true); + if (empty($data['circleId'])) { + throw new Exception('unexpected response'); + } + + return $data['circleId']; + } + + private function ensureModerator(string $circleId, string $remoteInstance, string $moderatorSingleId): void { + try { + $this->memberRequest->getMember($circleId, $moderatorSingleId); + return; + } catch (MemberNotFoundException) { + } + + try { + $federatedUser = $this->federatedUserService->getFederatedUser($moderatorSingleId . '@' . $remoteInstance, Member::TYPE_CIRCLE); + $circle = $this->circleRequest->getCircle($circleId, $this->federatedUserService->getCurrentUser()); + + $member = new Member(); + $member->importFromIFederatedUser($federatedUser); + + $this->federatedUserService->setMemberPatron($member); + + $event = new FederatedEvent(SingleMemberAdd::class); + $event->setCircle($circle); + $event->setMember($member); + $event->setAsync(false); + $this->federatedEventService->newEvent($event); + + $addedMember = $event->getMember(); + $this->memberService->memberLevel($addedMember->getId(), Member::LEVEL_MODERATOR); + + $this->logger->debug('moderator from remote instance added to circle', ['circleId' => $circleId, 'memberId' => $addedMember->getId(), 'remoteInstance' => $remoteInstance]); + } catch (Exception $e) { + $this->logger->error('could not add moderator from remote instance to circle', ['circleId' => $circleId, 'remoteInstance' => $remoteInstance, 'exception' => $e]); + } + } + + /** + * @throws Exception + */ + public function createCircle(string $name): array { + $this->federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + $owner = $this->federatedUserService->getCurrentApp(); + + $config = Circle::CFG_BACKEND; + + $circle = new Circle(); + $circle->setName($this->circleService->cleanCircleName($name)) + ->setSingleId($this->token(ManagedModel::ID_LENGTH)) + ->setSource(Member::APP_CIRCLES) + ->setConfig($config); + + $this->circleService->confirmName($circle); + $this->permissionService->confirmAllowedCircleTypes($circle); + + $member = new Member(); + $member->importFromIFederatedUser($owner); + $member->setId($this->token(ManagedModel::ID_LENGTH)) + ->setCircleId($circle->getSingleId()) + ->setLevel(Member::LEVEL_OWNER) + ->setStatus(Member::STATUS_MEMBER); + + $this->federatedUserService->setMemberPatron($member); + + $circle->setOwner($member) + ->setInitiator($member); + + $event = new FederatedEvent(CircleCreate::class); + $event->setCircle($circle); + $this->federatedEventService->newEvent($event); + + return $event->getOutcome(); + } +} diff --git a/lib/Service/ScimService.php b/lib/Service/ScimService.php new file mode 100644 index 000000000..386f709c8 --- /dev/null +++ b/lib/Service/ScimService.php @@ -0,0 +1,163 @@ +federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + + $circles = $this->fetchCircles(); + if ($circles === null) { + // don't assume no groups exist on a failed request, to avoid destroying existing circles + $this->logger->debug('could not fetch SCIM groups, skipping reconciliation'); + return; + } + + $desiredCircleIds = []; + foreach ($circles as $circle) { + $circleId = $this->generateCircleIdFromString($circle['id']); + $desiredCircleIds[] = $circleId; + try { + $this->circleRequest->getCircle($circleId); + continue; + } catch (CircleNotFoundException) { + } + try { + $this->createCircle($circleId, $circle['displayName']); + $this->logger->debug('circle created from SCIM group', ['scimGroupId' => $circle['id'], 'circleId' => $circleId, 'displayName' => $circle['displayName']]); + } catch (Exception $e) { + $this->logger->error('could not create circle from SCIM group', ['scimGroupId' => $circle['id'], 'exception' => $e]); + } + } + + // destroy third-party circles no longer present in SCIM server + foreach ($this->circleRequest->getThirdParty() as $circle) { + if (in_array($circle->getSingleId(), $desiredCircleIds, true)) { + continue; + } + try { + $this->circleService->destroy($circle->getSingleId()); + $this->logger->debug('circle destroyed, no longer present in SCIM', ['circleId' => $circle->getSingleId()]); + } catch (Exception $e) { + $this->logger->error('could not destroy circle no longer present in SCIM', ['circleId' => $circle->getSingleId(), 'exception' => $e]); + } + } + } + + /** + * TODO: this method needs more work before it's usable. It hasn't been + * tested against a SCIM server yet. For this first development + * iteration, it was assumed the response contains certain keys. This + * needs to be validated (and adjusted if needed) once access to a test + * SCIM server is available. + */ + private function fetchCircles(): ?array { + $endpoint = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::SCIM_ENDPOINT, ''); + $token = $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::SCIM_TOKEN, ''); + + $client = $this->clientService->newClient(); + try { + $response = $client->get(rtrim($endpoint, '/') . '/Groups', [ + 'headers' => ['Authorization' => 'Bearer ' . $token], + ]); + } catch (Exception $e) { + $this->logger->error('SCIM groups request failed', ['exception' => $e]); + return null; + } + + $response = json_decode($response->getBody(), true); + $this->logger->debug('SCIM groups response: ' . json_encode($response)); + + $resources = $response['Resources'] ?? []; + + return array_map( + static fn (array $resource): array => [ + 'id' => (string)($resource['id'] ?? ''), + 'displayName' => (string)($resource['displayName'] ?? ''), + ], + $resources + ); + } + + /** + * @throws Exception + */ + public function createCircle(string $singleId, string $name): void { + $owner = $this->federatedUserService->getCurrentApp(); + + $config = Circle::CFG_ROOT + Circle::CFG_FEDERATED + Circle::CFG_THIRD_PARTY; + + $circle = new Circle(); + $circle->setName($this->circleService->cleanCircleName($name)) + ->setSingleId($singleId) + ->setSource(Member::APP_CIRCLES) + ->setConfig($config); + + $this->circleService->confirmName($circle); + $this->permissionService->confirmAllowedCircleTypes($circle); + + $member = new Member(); + $member->importFromIFederatedUser($owner); + $member->setId($this->token(ManagedModel::ID_LENGTH)) + ->setCircleId($circle->getSingleId()) + ->setLevel(Member::LEVEL_OWNER) + ->setStatus(Member::STATUS_MEMBER); + + $this->federatedUserService->setMemberPatron($member); + + $circle->setOwner($member) + ->setInitiator($member); + + $event = new FederatedEvent(CircleCreate::class); + $event->setCircle($circle); + $this->federatedEventService->newEvent($event); + } + + /** + * TODO: remove this method once fetchCircles() has been validated against a SCIM server + */ + private function mockGroups(): array { + return [ + ['id' => 'urn:geant:company.co:group:dev_vo1#login.company.co', 'displayName' => 'dev_vo1'], + ['id' => 'urn:geant:company.co:group:dev_vo2#login.company.co', 'displayName' => 'dev_vo2'], + ['id' => 'urn:geant:company.co:group:dev_vo3#login.company.co', 'displayName' => 'dev_vo3'], + ['id' => 'urn:geant:company.co:group:dev_vo4#login.company.co', 'displayName' => 'dev_vo4'], + ]; + } +} diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php new file mode 100644 index 000000000..99502a42e --- /dev/null +++ b/lib/Settings/Personal.php @@ -0,0 +1,65 @@ +userSession->getUser()?->getUID(); + $oidcEnabled = $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OIDC_ENABLED, false); + + $oidcConnected = $userId !== null && $this->credentialsManager->retrieve($userId, OidcService::CREDENTIAL_REFRESH_TOKEN) !== null; + + $this->initialState->provideInitialState('oidc_enabled', $oidcEnabled); + $this->initialState->provideInitialState('oidc_connected', $oidcConnected); + + Util::addScript(Application::APP_ID, 'teams-settings-personal'); + Util::addStyle(Application::APP_ID, 'teams-settings-personal'); + + return new TemplateResponse(Application::APP_ID, 'settings-personal', renderAs: ''); + } + + #[\Override] + public function getSection(): ?string { + if (!$this->shouldDisplaySection()) { + return null; + } + return Application::APP_ID; + } + + #[\Override] + public function getPriority(): int { + return 80; + } + + private function shouldDisplaySection(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OIDC_ENABLED, false); + } +} diff --git a/lib/Settings/PersonalSection.php b/lib/Settings/PersonalSection.php new file mode 100644 index 000000000..5cf8922de --- /dev/null +++ b/lib/Settings/PersonalSection.php @@ -0,0 +1,42 @@ +l->t('Teams'); + } + + #[\Override] + public function getPriority(): int { + return 80; + } + + #[\Override] + public function getIcon(): string { + return $this->url->imagePath('core', 'apps/circles.svg'); + } +} diff --git a/lib/Tools/Traits/TStringTools.php b/lib/Tools/Traits/TStringTools.php index 300a33050..5ac6fe38d 100644 --- a/lib/Tools/Traits/TStringTools.php +++ b/lib/Tools/Traits/TStringTools.php @@ -35,6 +35,31 @@ protected function token(int $length = 15): string { return $str; } + /** + * the same given source string always returns the same generated value + * useful for generating a circle ID from an external identifier + * (e.g. "urn:geant:company.co:group:my_group#login.company.co") + * + * @param string $source identifier to generate a circle ID from + */ + protected function generateCircleIdFromString(string $source): string { + $chars = 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890'; + $max = strlen($chars); + $length = \OCA\Circles\Model\ManagedModel::ID_LENGTH; + + $bytes = ''; + for ($i = 0; strlen($bytes) < $length; $i++) { + $bytes .= hash('sha256', $source . '|' . $i, true); + } + + $str = ''; + for ($i = 0; $i < $length; $i++) { + $str .= $chars[ord($bytes[$i]) % $max]; + } + + return $str; + } + /** * Generate uuid: 2b5a7a87-8db1-445f-a17b-405790f91c80 * diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue new file mode 100644 index 000000000..46c515f81 --- /dev/null +++ b/src/components/PersonalSettings.vue @@ -0,0 +1,43 @@ + + + + + + + + + {{ t('circles', 'Your account is connected.') }} + + + {{ oidcConnected ? t('circles', 'Reconnect') : t('circles', 'Connect') }} + + + + + + diff --git a/src/settings-personal.ts b/src/settings-personal.ts new file mode 100644 index 000000000..526f734bc --- /dev/null +++ b/src/settings-personal.ts @@ -0,0 +1,10 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import PersonalSettings from './components/PersonalSettings.vue' + +const app = createApp(PersonalSettings) +app.mount('#vue-personal-circles') diff --git a/templates/settings-personal.php b/templates/settings-personal.php new file mode 100644 index 000000000..f5ccb207c --- /dev/null +++ b/templates/settings-personal.php @@ -0,0 +1,8 @@ + + + diff --git a/vite.config.ts b/vite.config.ts index e1f62a13d..7bf7c5f4e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export default (env) => createAppConfig({ dashboard: join(import.meta.dirname, 'src/dashboard.ts'), 'settings-admin': join(import.meta.dirname, 'src/settings-admin.ts'), 'settings-team-folders': join(import.meta.dirname, 'src/settings-team-folders.ts'), + 'settings-personal': join(import.meta.dirname, 'src/settings-personal.ts'), }, { appName: 'teams', emptyOutputDirectory: { additionalDirectories: ['css'] },
+ {{ t('circles', 'Your account is connected.') }} +