diff --git a/appinfo/info.xml b/appinfo/info.xml index ecbdf8608..66cc5c1ef 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -76,6 +76,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\CirclesModeratorsDiscover + OCA\Circles\Command\CirclesModeratorsSync + OCA\Circles\Command\CirclesOidcSync @@ -100,5 +105,7 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Settings\Admin + OCA\Circles\Settings\Personal + OCA\Circles\Settings\PersonalSection diff --git a/appinfo/routes.php b/appinfo/routes.php index ed5137e22..8e152206d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -100,6 +100,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/Capabilities.php b/lib/AppInfo/Capabilities.php index 6210557d2..2fda963ef 100644 --- a/lib/AppInfo/Capabilities.php +++ b/lib/AppInfo/Capabilities.php @@ -107,7 +107,8 @@ private function getCapabilitiesCircleConstants(): array { Circle::CFG_ROOT => $this->l10n->t('Root'), 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_MOUNTPOINT => $this->l10n->t('Mount point'), + Circle::CFG_THIRD_PARTY => $this->l10n->t('Third party') ], 'source' => [ diff --git a/lib/Command/CirclesModeratorsDiscover.php b/lib/Command/CirclesModeratorsDiscover.php new file mode 100644 index 000000000..4c3fc667f --- /dev/null +++ b/lib/Command/CirclesModeratorsDiscover.php @@ -0,0 +1,37 @@ +setName('circles:moderators:discover') + ->setDescription('discover the 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/CirclesModeratorsSync.php b/lib/Command/CirclesModeratorsSync.php new file mode 100644 index 000000000..e5772a777 --- /dev/null +++ b/lib/Command/CirclesModeratorsSync.php @@ -0,0 +1,37 @@ +setName('circles:moderators:sync') + ->setDescription('add moderators from each configured remote instance into 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/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/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 2a83a9997..2650174bc 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -25,6 +25,27 @@ class ConfigLexicon implements ILexicon { public const FEDERATED_TEAMS_FRONTAL = 'federated_teams_frontal'; public const REMOVE_SHARE_TOKENS_DONE = 'remove_share_tokens_done'; + // 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; } @@ -34,6 +55,24 @@ public function getAppConfigs(): array { new Entry(key: self::FEDERATED_TEAMS_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable Federated Teams', lazy: true), new Entry(key: self::FEDERATED_TEAMS_FRONTAL, type: ValueType::STRING, defaultRaw: '', definition: 'domain name used to auth public request', lazy: true), 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), + // 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..0082b98a7 --- /dev/null +++ b/lib/Controller/OidcController.php @@ -0,0 +1,168 @@ +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['access_token'])) { + $this->logger->warning('OIDC provider did not return a access_token for user ' . $userId); + } + if (empty($data['refresh_token'])) { + $this->logger->warning('OIDC provider did not return a refresh_token for user ' . $userId); + } + + $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/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/Model/Circle.php b/lib/Model/Circle.php index 5b236097f..766fd3fc4 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 @@ -116,7 +117,8 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow 16384 => 'CI|Circle Invite', 32768 => 'F|Federated', 65536 => 'M|Nountpoint', - 131072 => 'A|App' + 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..e1af4fad9 --- /dev/null +++ b/lib/Service/OidcService.php @@ -0,0 +1,204 @@ +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($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, ''); + $moderator = $this->circleRequest->getFederatedUserBySingleId($moderatorSingleId); + } + $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 $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); + + 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..be8e81036 --- /dev/null +++ b/lib/Service/ScimService.php @@ -0,0 +1,136 @@ +fetchCircles(); + foreach ($circles as $circle) { + $circleId = $this->generateCircleIdFromString($circle['id']); + + 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]); + } + } + } + + private function fetchCircles(): array { + return $this->mockGroups(); + + $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 []; + } + + $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 { + $this->federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + $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); + } + + 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 e35c0252b..18e3a4ac7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ export default (env) => createAppConfig({ main: join(import.meta.dirname, 'src/main.ts'), dashboard: join(import.meta.dirname, 'src/dashboard.ts'), 'settings-admin': join(import.meta.dirname, 'src/settings-admin.ts'), + 'settings-personal': join(import.meta.dirname, 'src/settings-personal.ts'), }, { appName: 'teams', emptyOutputDirectory: { additionalDirectories: ['css'] },
+ {{ t('circles', 'Your account is connected.') }} +