diff --git a/apps/federatedfilesharing/composer/composer/autoload_classmap.php b/apps/federatedfilesharing/composer/composer/autoload_classmap.php index a5ec2ecd822..27a3710e120 100644 --- a/apps/federatedfilesharing/composer/composer/autoload_classmap.php +++ b/apps/federatedfilesharing/composer/composer/autoload_classmap.php @@ -17,6 +17,7 @@ return array( 'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => $baseDir . '/../lib/Listeners/LoadAdditionalScriptsListener.php', 'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => $baseDir . '/../lib/Migration/Version1010Date20200630191755.php', 'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => $baseDir . '/../lib/Migration/Version1011Date20201120125158.php', + 'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => $baseDir . '/../lib/Migration/Version1012Date20260306120000.php', 'OCA\\FederatedFileSharing\\Notifications' => $baseDir . '/../lib/Notifications.php', 'OCA\\FederatedFileSharing\\Notifier' => $baseDir . '/../lib/Notifier.php', 'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => $baseDir . '/../lib/OCM/CloudFederationProviderFiles.php', diff --git a/apps/federatedfilesharing/composer/composer/autoload_static.php b/apps/federatedfilesharing/composer/composer/autoload_static.php index c415c51b592..77ce59fe005 100644 --- a/apps/federatedfilesharing/composer/composer/autoload_static.php +++ b/apps/federatedfilesharing/composer/composer/autoload_static.php @@ -32,6 +32,7 @@ class ComposerStaticInitFederatedFileSharing 'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScriptsListener.php', 'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630191755.php', 'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => __DIR__ . '/..' . '/../lib/Migration/Version1011Date20201120125158.php', + 'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1012Date20260306120000.php', 'OCA\\FederatedFileSharing\\Notifications' => __DIR__ . '/..' . '/../lib/Notifications.php', 'OCA\\FederatedFileSharing\\Notifier' => __DIR__ . '/..' . '/../lib/Notifier.php', 'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => __DIR__ . '/..' . '/../lib/OCM/CloudFederationProviderFiles.php', diff --git a/apps/federatedfilesharing/lib/Controller/RequestHandlerController.php b/apps/federatedfilesharing/lib/Controller/RequestHandlerController.php index 3ecbee61080..219aabaecb5 100644 --- a/apps/federatedfilesharing/lib/Controller/RequestHandlerController.php +++ b/apps/federatedfilesharing/lib/Controller/RequestHandlerController.php @@ -398,7 +398,7 @@ class RequestHandlerController extends OCSController { ->set('owner', $qb->createNamedParameter($cloudId->getUser())) ->set('remote_id', $qb->createNamedParameter($newRemoteId)) ->where($qb->expr()->eq('remote_id', $qb->createNamedParameter($id))) - ->andWhere($qb->expr()->eq('share_token', $qb->createNamedParameter($token))); + ->andWhere($qb->expr()->eq('refresh_token', $qb->createNamedParameter($token))); $affected = $query->executeStatement(); if ($affected > 0) { diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 0957b1c0ef2..63751fe26b0 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -8,8 +8,12 @@ namespace OCA\FederatedFileSharing; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; +use OCA\CloudFederationAPI\Db\OcmTokenMapMapper; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Federation\ICloudFederationProviderManager; @@ -23,6 +27,8 @@ use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; use OCP\IUserManager; +use OCP\Security\ISecureRandom; +use OCP\Server; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IShare; @@ -137,7 +143,7 @@ class FederatedShareProvider implements IShareProvider, IShareProviderSupportsAl $ownerCloudId = $this->cloudIdManager->getCloudId($remoteShare['owner'], $remoteShare['remote']); $shareId = $this->addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $ownerCloudId->getId(), $permissions, 'tmp_token_' . time(), $shareType, $expirationDate); [$token, $remoteId] = $this->notifications->requestReShare( - $remoteShare['share_token'], + $remoteShare['refresh_token'], $remoteShare['remote_id'], $shareId, $remoteShare['remote'], @@ -170,7 +176,15 @@ class FederatedShareProvider implements IShareProvider, IShareProviderSupportsAl * @throws \Exception */ protected function createFederatedShare(IShare $share): string { - $token = $this->tokenHandler->generateToken(); + + $provider = Server::get(PublicKeyTokenProvider::class); + $token = Server::get(ISecureRandom::class)->generate(32, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + $uid = $share->getSharedBy(); + $user = $this->userManager->get($uid); + $name = $user?->getDisplayName() ?? $uid; + $pass = $share->getPassword(); + + $dbToken = $provider->generateToken($token, $uid, $uid, $pass, $name, type: IToken::PERMANENT_TOKEN); $shareId = $this->addShareToDB( $share->getNodeId(), $share->getNodeType(), @@ -721,6 +735,24 @@ class FederatedShareProvider implements IShareProvider, IShareProviderSupportsAl $data = $cursor->fetchAssociative(); + if ($data === false) { + // Token not found as refresh token, try looking it up as access token + try { + $accessTokenDb = Server::get(PublicKeyTokenProvider::class)->getToken($token); + $mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId()); + + $qb2 = $this->dbConnection->getQueryBuilder(); + $cursor = $qb2->select('*') + ->from('share') + ->where($qb2->expr()->in('share_type', $qb2->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb2->expr()->eq('token', $qb2->createNamedParameter($mapping->getRefreshToken()))) + ->executeQuery(); + + $data = $cursor->fetch(); + } catch (InvalidTokenException|\OCP\AppFramework\Db\DoesNotExistException) { + // Token is not a valid access token or has no mapping, share not found + } + } if ($data === false) { throw new ShareNotFound('Share not found', $this->l->t('Could not find share')); } diff --git a/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php new file mode 100644 index 00000000000..d34f05427ae --- /dev/null +++ b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php @@ -0,0 +1,119 @@ +getQueryBuilder(); + $result = $qb->select('id', 'token', 'uid_initiator') + ->from('share') + ->where($qb->expr()->in( + 'share_type', + $qb->createNamedParameter( + [IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], + IQueryBuilder::PARAM_INT_ARRAY + ) + )) + ->executeQuery(); + + $registered = 0; + $skipped = 0; + + while ($row = $result->fetchAssociative()) { + $shareId = (int)$row['id']; + $token = (string)$row['token']; + $uid = (string)$row['uid_initiator']; + + if (strlen($token) < PublicKeyTokenProvider::TOKEN_MIN_LENGTH) { + // Old short token from TokenHandler — leave it as-is. + // Replacing it would invalidate the token stored on the receiving instance, + // breaking Basic-auth access to those shares. These shares keep working via + // Basic auth and are simply not eligible for the OCM token exchange flow. + $skipped++; + continue; + } + + // Long token — check if it's already in oc_authtoken. + try { + $tokenProvider->getToken($token); + $skipped++; + continue; + } catch (InvalidTokenException) { + // Not registered yet — fall through to create it. + } + + $user = $userManager->get($uid); + $name = $user?->getDisplayName() ?? $uid; + + try { + $tokenProvider->generateToken( + $token, + $uid, + $uid, + null, + $name, + IToken::PERMANENT_TOKEN, + ); + $registered++; + } catch (\Exception $e) { + $output->warning(sprintf( + 'Could not register auth token for share %d (uid=%s): %s', + $shareId, + $uid, + $e->getMessage() + )); + } + } + + $result->closeCursor(); + + $output->info(sprintf( + 'Federated share token migration: %d registered, %d skipped (already up-to-date or legacy short token).', + $registered, + $skipped + )); + } +} diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 419d9ebd7c6..f0b6843c649 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -9,6 +9,8 @@ namespace OCA\FederatedFileSharing\OCM; use OC\AppFramework\Http; use OC\Files\Filesystem; +use OC\OCM\OCMSignatoryManager; +use OC\OCM\Rfc9421SignatoryManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\Federation\TrustedServers; @@ -34,12 +36,16 @@ use OCP\Files\IFilenameValidator; use OCP\Files\ISetupManager; use OCP\Files\NotFoundException; use OCP\HintException; +use OCP\Http\Client\IClientService; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroupManager; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\Notification\IManager as INotificationManager; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\Signature\ISignatureManager; use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; @@ -71,6 +77,11 @@ class CloudFederationProviderFiles implements ISignedCloudFederationProvider { private readonly IProviderFactory $shareProviderFactory, private readonly ISetupManager $setupManager, private readonly ExternalShareMapper $externalShareMapper, + private readonly IOCMDiscoveryService $discoveryService, + private readonly IClientService $clientService, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, + private readonly IAppConfig $appConfig, ) { } @@ -107,6 +118,30 @@ class CloudFederationProviderFiles implements ISignedCloudFederationProvider { $ownerFederatedId = $share->getOwner(); $shareType = $this->mapShareTypeToNextcloud($share->getShareType()); + // Check for must-exchange-token requirement + $requirements = $protocol['webdav']['requirements'] ?? $protocol['options']['requirements'] ?? []; + $mustExchangeToken = in_array('must-exchange-token', $requirements); + $accessToken = ''; + + if ($mustExchangeToken) { + // Exchange the sharedSecret for an access token (required) + $accessToken = $this->exchangeToken($remote, $token); + if ($accessToken === null) { + throw new ProviderCouldNotAddShareException('Failed to exchange token as required by must-exchange-token', '', Http::STATUS_BAD_REQUEST); + } + } else { + // Check if remote has exchange-token capability and try to exchange (optional) + try { + $ocmProvider = $this->discoveryService->discover(rtrim($remote, '/')); + if ($ocmProvider->getCapabilities()->hasExchangeToken()) { + $accessToken = $this->exchangeToken($remote, $token) ?? ''; + $this->logger->debug('Exchanged token for remote with exchange-token capability', ['remote' => $remote, 'success' => !empty($accessToken)]); + } + } catch (\Exception $e) { + $this->logger->debug('Could not discover remote capabilities for token exchange', ['remote' => $remote, 'exception' => $e]); + } + } + // if no explicit information about the person who created the share was sent // we assume that the share comes from the owner if ($sharedByFederatedId === null) { @@ -147,8 +182,8 @@ class CloudFederationProviderFiles implements ISignedCloudFederationProvider { $externalShare->generateId(); $externalShare->setRemote($remote); $externalShare->setRemoteId($remoteId); - $externalShare->setShareToken($token); - $externalShare->setPassword(''); + $externalShare->setRefreshToken($token); // refresh token (sharedSecret) + $externalShare->setAccessToken($accessToken ?: null); $externalShare->setName($name); $externalShare->setOwner($owner); $externalShare->setShareType($shareType); @@ -685,4 +720,98 @@ class CloudFederationProviderFiles implements ISignedCloudFederationProvider { return $share->getShareOwner(); } } + + /** + * Exchange a sharedSecret (refresh token) for an access token via the remote server's token endpoint + * + * @param string $remote The remote server URL + * @param string $sharedSecret The shared secret to exchange + * @return string|null The access token, or null on failure + */ + private function exchangeToken(string $remote, #[SensitiveParameter] string $sharedSecret): ?string { + try { + $ocmProvider = $this->discoveryService->discover(rtrim($remote, '/')); + $tokenEndpoint = $ocmProvider->getTokenEndPoint(); + + if ($tokenEndpoint === '') { + $this->logger->warning('Remote server does not expose tokenEndPoint', ['remote' => $remote]); + return null; + } + + $client = $this->clientService->newClient(); + $clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST); + + $payload = [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'code' => $sharedSecret, + ]; + + $options = [ + 'body' => http_build_query($payload), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + new Rfc9421SignatoryManager($this->signatoryManager), + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['remote' => $remote]); + } catch (\Exception $e) { + $this->logger->error('Failed to sign token request', [ + 'remote' => $remote, + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + return null; + } + + $response = $client->post($tokenEndpoint, $options); + + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200) { + $this->logger->warning('Token exchange returned unexpected HTTP status', [ + 'remote' => $remote, + 'status' => $statusCode, + ]); + return null; + } + + $data = json_decode($response->getBody(), true); + + if (!is_array($data)) { + $this->logger->warning('Token exchange response is not valid JSON', ['remote' => $remote]); + return null; + } + + $accessToken = $data['access_token'] ?? null; + $tokenType = $data['token_type'] ?? null; + + if (!is_string($accessToken) || $accessToken === '') { + $this->logger->warning('Token exchange response missing or invalid access_token', ['remote' => $remote]); + return null; + } + + if (!is_string($tokenType) || strtolower($tokenType) !== 'bearer') { + $this->logger->warning('Token exchange response has unexpected token_type', [ + 'remote' => $remote, + 'token_type' => $tokenType, + ]); + return null; + } + + $this->logger->debug('Successfully exchanged token for access token', ['remote' => $remote]); + return $accessToken; + } catch (\Exception $e) { + $this->logger->warning('Failed to exchange token', ['remote' => $remote, 'exception' => $e]); + return null; + } + } } diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php index 2aa6f8790cd..fff9fb23d48 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php @@ -133,7 +133,7 @@ class FederatedShareProviderReshareRemoteTest extends \Test\TestCase { 'share_type' => 0, 'remote' => 'https://origin.test/', 'remote_id' => '10', - 'share_token' => 'share_token1', + 'refresh_token' => 'share_token1', 'password' => '', 'name' => '/Share1', 'owner' => 'jane', // owner in share_external is the user on the remote instance @@ -167,7 +167,7 @@ class FederatedShareProviderReshareRemoteTest extends \Test\TestCase { 'share_type' => 0, 'remote' => 'https://origin.test/', 'remote_id' => '10', - 'share_token' => 'share_token2', + 'refresh_token' => 'share_token2', 'password' => '', 'name' => '/Share1', 'owner' => 'jane', // owner in share_external is the user on the remote instance diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index ddf16bb4e06..a4d0dd56955 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php @@ -10,11 +10,13 @@ declare(strict_types=1); namespace OCA\FederatedFileSharing\Tests; use LogicException; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Federation\CloudIdManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\Notifications; use OCA\FederatedFileSharing\TokenHandler; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\Contacts\IManager as IContactsManager; use OCP\EventDispatcher\IEventDispatcher; @@ -28,6 +30,7 @@ use OCP\IDBConnection; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; +use OCP\Security\ISecureRandom; use OCP\Server; use OCP\Share\IManager; use OCP\Share\IShare; @@ -88,6 +91,23 @@ class FederatedShareProviderTest extends \Test\TestCase { $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); + // Mock ISecureRandom to return predictable tokens (must be 32+ chars) + $secureRandom = $this->createMock(ISecureRandom::class); + $tokenCounter = 0; + $secureRandom->method('generate') + ->willReturnCallback(function () use (&$tokenCounter) { + $tokenCounter++; + return 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'ab'; + }); + $this->overwriteService(ISecureRandom::class, $secureRandom); + + // Mock PublicKeyTokenProvider to avoid database token creation + $tokenProvider = $this->createMock(PublicKeyTokenProvider::class); + $mockToken = $this->createMock(IToken::class); + $tokenProvider->method('generateToken') + ->willReturn($mockToken); + $this->overwriteService(PublicKeyTokenProvider::class, $tokenProvider); + $this->provider = new FederatedShareProvider( $this->connection, $this->addressHandler, @@ -147,7 +167,7 @@ class FederatedShareProviderTest extends \Test\TestCase { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -185,7 +205,7 @@ class FederatedShareProviderTest extends \Test\TestCase { 'file_source' => 42, 'permissions' => 19, 'accepted' => 0, - 'token' => 'token', + 'token' => 'token1token1token1token1token1ab', 'expiration' => $expectedDataDate, ]; foreach (array_keys($expected) as $key) { @@ -200,7 +220,7 @@ class FederatedShareProviderTest extends \Test\TestCase { $this->assertEquals('file', $share->getNodeType()); $this->assertEquals(42, $share->getNodeId()); $this->assertEquals(19, $share->getPermissions()); - $this->assertEquals('token', $share->getToken()); + $this->assertEquals('token1token1token1token1token1ab', $share->getToken()); $this->assertEquals($expirationDate, $share->getExpirationDate()); } @@ -230,7 +250,7 @@ class FederatedShareProviderTest extends \Test\TestCase { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->matchesRegularExpression('/^[A-Za-z0-9]{32}$/'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -284,7 +304,7 @@ class FederatedShareProviderTest extends \Test\TestCase { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->matchesRegularExpression('/^[A-Za-z0-9]{32}$/'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -373,7 +393,7 @@ class FederatedShareProviderTest extends \Test\TestCase { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -445,7 +465,7 @@ class FederatedShareProviderTest extends \Test\TestCase { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -883,9 +903,9 @@ class FederatedShareProviderTest extends \Test\TestCase { $folder1 = $rootFolder->getUserFolder($u1->getUID())->newFolder('foo'); $file1 = $folder1->newFile('bar1'); - $this->tokenHandler->expects($this->exactly(2)) - ->method('generateToken') - ->willReturnOnConsecutiveCalls('token1', 'token2'); + // Token generation now uses ISecureRandom instead of tokenHandler + $this->tokenHandler->expects($this->never()) + ->method('generateToken'); $this->notifications->expects($this->atLeastOnce()) ->method('sendRemoteShare') ->willReturn(true); @@ -926,11 +946,11 @@ class FederatedShareProviderTest extends \Test\TestCase { $result = $this->provider->getAccessList([$file1], true); $this->assertEquals(['remote' => [ 'user@server.com' => [ - 'token' => 'token1', + 'token' => 'token1token1token1token1token1ab', 'node_id' => $file1->getId(), ], 'foobar@localhost' => [ - 'token' => 'token2', + 'token' => 'token2token2token2token2token2ab', 'node_id' => $file1->getId(), ], ]], $result); diff --git a/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php b/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php new file mode 100644 index 00000000000..15cd8078ead --- /dev/null +++ b/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php @@ -0,0 +1,312 @@ +appManager = $this->createMock(IAppManager::class); + $this->federatedShareProvider = $this->createMock(FederatedShareProvider::class); + $this->addressHandler = $this->createMock(AddressHandler::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->shareManager = $this->createMock(IManager::class); + $this->cloudIdManager = $this->createMock(ICloudIdManager::class); + $this->activityManager = $this->createMock(IActivityManager::class); + $this->notificationManager = $this->createMock(INotificationManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class); + $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->config = $this->createMock(IConfig::class); + $this->externalShareManager = $this->createMock(Manager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->filenameValidator = $this->createMock(IFilenameValidator::class); + $this->shareProviderFactory = $this->createMock(IProviderFactory::class); + $this->setupManager = $this->createMock(ISetupManager::class); + $this->externalShareMapper = $this->createMock(ExternalShareMapper::class); + $this->discoveryService = $this->createMock(IOCMDiscoveryService::class); + $this->clientService = $this->createMock(IClientService::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + + $this->provider = new CloudFederationProviderFiles( + $this->appManager, + $this->federatedShareProvider, + $this->addressHandler, + $this->userManager, + $this->shareManager, + $this->cloudIdManager, + $this->activityManager, + $this->notificationManager, + $this->urlGenerator, + $this->cloudFederationFactory, + $this->cloudFederationProviderManager, + $this->groupManager, + $this->config, + $this->externalShareManager, + $this->logger, + $this->filenameValidator, + $this->shareProviderFactory, + $this->setupManager, + $this->externalShareMapper, + $this->discoveryService, + $this->clientService, + $this->signatureManager, + $this->signatoryManager, + $this->appConfig, + ); + } + + private function enableS2S(): void { + $this->appManager->method('isEnabledForUser') + ->with('files_sharing') + ->willReturn(true); + $this->federatedShareProvider->method('isIncomingServer2serverShareEnabled') + ->willReturn(true); + } + + private function buildShare(array $requirements = []): ICloudFederationShare&MockObject { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getProtocol')->willReturn([ + 'name' => 'webdav', + 'webdav' => ['requirements' => $requirements], + ]); + $share->method('getOwner')->willReturn('owner@example.com'); + $share->method('getOwnerDisplayName')->willReturn('Owner Name'); + $share->method('getShareSecret')->willReturn('refresh-token-abc'); + $share->method('getResourceName')->willReturn('/SharedFolder'); + $share->method('getShareWith')->willReturn('localuser'); + $share->method('getProviderId')->willReturn('42'); + $share->method('getSharedBy')->willReturn('owner@example.com'); + $share->method('getShareType')->willReturn('user'); + return $share; + } + + /** + * When must-exchange-token is required but the remote has no token endpoint, + * shareReceived must throw rather than silently accept the share. + */ + public function testShareReceivedMustExchangeTokenThrowsWhenExchangeFails(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(['must-exchange-token']); + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn(''); + + $this->discoveryService->method('discover') + ->willReturn($ocmProvider); + + $this->expectException(ProviderCouldNotAddShareException::class); + + $this->provider->shareReceived($share); + } + + /** + * When must-exchange-token is required and the token exchange succeeds, + * the access token is stored on the share (we drive through share creation + * up to the "user does not exist" guard to avoid a full integration setup). + */ + public function testShareReceivedMustExchangeTokenStoresAccessToken(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(['must-exchange-token']); + + $tokenEndpoint = 'https://example.com/index.php/ocm/token'; + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn($tokenEndpoint); + $ocmProvider->method('getCapabilities')->willReturn(new OCMCapabilities([])); + + $this->discoveryService->method('discover')->willReturn($ocmProvider); + + $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://local.example/'); + + $signedOptions = [ + 'body' => 'grant_type=authorization_code&client_id=local.example&code=refresh-token-abc', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded', 'Signature' => 'sig'], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + $this->signatureManager->method('signOutgoingRequestIClientPayload') + ->willReturn($signedOptions); + + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn(json_encode([ + 'access_token' => 'access-token-xyz', + 'token_type' => 'Bearer', + ])); + + $httpClient = $this->createMock(\OCP\Http\Client\IClient::class); + $httpClient->method('post')->willReturn($response); + $this->clientService->method('newClient')->willReturn($httpClient); + + // Exchange succeeds → share creation continues; we stop it at the user + // lookup stage to avoid a full integration setup. + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } + + /** + * When exchange-token capability is present but the discovery service throws, + * shareReceived must not propagate the exception — the token exchange is optional. + */ + public function testShareReceivedOptionalExchangeGracefulOnDiscoveryFailure(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + // Build a share with no must-exchange-token requirement + $share = $this->buildShare(); + + $this->discoveryService->method('discover') + ->willThrowException(new \Exception('network error')); + + // Discovery failure is caught and logged; share creation continues. + // We stop it at the user lookup stage. + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } + + /** + * When exchange-token capability is present and the exchange succeeds, + * the access token is set (we stop at user-not-found to avoid full setup). + */ + public function testShareReceivedOptionalExchangeStoresAccessTokenOnSuccess(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(); + + $tokenEndpoint = 'https://example.com/index.php/ocm/token'; + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn($tokenEndpoint); + $ocmProvider->method('getCapabilities')->willReturn(new OCMCapabilities(['exchange-token'])); + + $this->discoveryService->method('discover')->willReturn($ocmProvider); + + $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://local.example/'); + + $signedOptions = [ + 'body' => 'grant_type=authorization_code&client_id=local.example&code=refresh-token-abc', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded', 'Signature' => 'sig'], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + $this->signatureManager->method('signOutgoingRequestIClientPayload') + ->willReturn($signedOptions); + + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn(json_encode([ + 'access_token' => 'access-token-xyz', + 'token_type' => 'Bearer', + ])); + + $httpClient = $this->createMock(\OCP\Http\Client\IClient::class); + $httpClient->method('post')->willReturn($response); + $this->clientService->method('newClient')->willReturn($httpClient); + + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } +} diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index c0a41208d5a..6420b2218fc 100644 --- a/build/integration/features/bootstrap/Sharing.php +++ b/build/integration/features/bootstrap/Sharing.php @@ -333,7 +333,8 @@ trait Sharing { if (count($data->element) > 0) { foreach ($data as $element) { if ($contentExpected == 'A_TOKEN') { - return (strlen((string)$element->$field) == 15); + $tokenLength = strlen((string)$element->$field); + return $tokenLength == 15 || $tokenLength == 32; } elseif ($contentExpected == 'A_NUMBER') { return is_numeric((string)$element->$field); } elseif ($contentExpected == 'AN_URL') { @@ -348,7 +349,8 @@ trait Sharing { return false; } else { if ($contentExpected == 'A_TOKEN') { - return (strlen((string)$data->$field) == 15); + $tokenLength = strlen((string)$data->$field); + return $tokenLength == 15 || $tokenLength == 32; } elseif ($contentExpected == 'A_NUMBER') { return is_numeric((string)$data->$field); } elseif ($contentExpected == 'AN_URL') { @@ -640,9 +642,10 @@ trait Sharing { if ($contentExpected === 'A_NUMBER') { Assert::assertTrue(is_numeric((string)$returnedShare->$field), "Field '$field' is not a number: " . $returnedShare->$field); } elseif ($contentExpected === 'A_TOKEN') { - // A token is composed by 15 characters from - // ISecureRandom::CHAR_HUMAN_READABLE. - Assert::assertMatchesRegularExpression('/^[abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789]{15}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); + // A token is either: + // - 15 characters from ISecureRandom::CHAR_HUMAN_READABLE (legacy), or + // - 32 characters from ISecureRandom::CHAR_ALPHANUMERIC (new OCM tokens) + Assert::assertMatchesRegularExpression('/^([a-zA-Z0-9]{15}|[a-zA-Z0-9]{32})$/', (string)$returnedShare->$field, "Field '$field' is not a token"); } elseif (strpos($contentExpected, 'REGEXP ') === 0) { Assert::assertMatchesRegularExpression(substr($contentExpected, strlen('REGEXP ')), (string)$returnedShare->$field, "Field '$field' does not match"); } else { diff --git a/build/integration/federation_features/federated.feature b/build/integration/federation_features/federated.feature index d3a414cb804..e6979221874 100644 --- a/build/integration/federation_features/federated.feature +++ b/build/integration/federation_features/federated.feature @@ -111,7 +111,7 @@ Feature: federated | id | A_NUMBER | | remote | LOCAL | | remote_id | A_NUMBER | - | share_token | A_TOKEN | + | refresh_token | A_TOKEN | | name | /textfile0.txt | | owner | user0 | | user | user1 | @@ -140,7 +140,7 @@ Feature: federated | id | A_NUMBER | | remote | LOCAL | | remote_id | A_NUMBER | - | share_token | A_TOKEN | + | refresh_token | A_TOKEN | | name | /textfile0.txt | | owner | gs-user0 | | user | group1 | @@ -154,7 +154,7 @@ Feature: federated | id | A_NUMBER | | remote | LOCAL | | remote_id | A_NUMBER | - | share_token | A_TOKEN | + | refresh_token | A_TOKEN | | name | /textfile0.txt | | owner | gs-user0 | | user | group1 |