feat(federatedfilesharing): create refresh tokens and sign token exchange

Co-authored-by: Micke Nordin <kano@sunet.se>
Signed-off-by: Micke Nordin <kano@sunet.se>
Signed-off-by: Enrique Pérez Arnaud <enrique@cazalla.net>
This commit is contained in:
Enrique Pérez Arnaud 2026-05-27 21:33:37 +02:00 committed by Micke Nordin
parent 4d56c74ba7
commit fc31f97018
No known key found for this signature in database
GPG key ID: 4A10941FAD116B7E
11 changed files with 644 additions and 27 deletions

View file

@ -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',

View file

@ -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',

View file

@ -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) {

View file

@ -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'));
}

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\FederatedFileSharing\Migration;
use Closure;
use OC\Authentication\Token\PublicKeyTokenProvider;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Token\IToken;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use OCP\Server;
use OCP\Share\IShare;
/**
* Ensure all existing federated share tokens are registered in oc_authtoken
* as permanent tokens, which is required for the OCM token exchange flow.
*
* Shares created before this fork used TokenHandler (15-char tokens) and never
* registered in oc_authtoken. Those legacy short tokens are left untouched so
* that the receiving instance can continue to authenticate via Basic auth with
* the original token. They will never participate in the token exchange flow,
* but they will keep working until the share is re-created with a new token.
*
* Shares created by this fork (32-char tokens) that are somehow missing from
* oc_authtoken are silently repaired.
*/
class Version1012Date20260306120000 extends SimpleMigrationStep {
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
return null;
}
#[\Override]
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$db = Server::get(IDBConnection::class);
$tokenProvider = Server::get(PublicKeyTokenProvider::class);
$userManager = Server::get(IUserManager::class);
$qb = $db->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
));
}
}

View file

@ -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;
}
}
}

View file

@ -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

View file

@ -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);

View file

@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\FederatedFileSharing\Tests\OCM;
use OC\OCM\OCMSignatoryManager;
use OCA\FederatedFileSharing\AddressHandler;
use OCA\FederatedFileSharing\FederatedShareProvider;
use OCA\FederatedFileSharing\OCM\CloudFederationProviderFiles;
use OCA\Files_Sharing\External\ExternalShareMapper;
use OCA\Files_Sharing\External\Manager;
use OCP\Activity\IManager as IActivityManager;
use OCP\App\IAppManager;
use OCP\Federation\Exceptions\ProviderCouldNotAddShareException;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudFederationShare;
use OCP\Federation\ICloudIdManager;
use OCP\Files\IFilenameValidator;
use OCP\Files\ISetupManager;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\Notification\IManager as INotificationManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\OCM\IOCMProvider;
use OCP\OCM\OCMCapabilities;
use OCP\Security\Signature\ISignatureManager;
use OCP\Share\IManager;
use OCP\Share\IProviderFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class CloudFederationProviderFilesTest extends TestCase {
private IAppManager&MockObject $appManager;
private FederatedShareProvider&MockObject $federatedShareProvider;
private AddressHandler&MockObject $addressHandler;
private IUserManager&MockObject $userManager;
private IManager&MockObject $shareManager;
private ICloudIdManager&MockObject $cloudIdManager;
private IActivityManager&MockObject $activityManager;
private INotificationManager&MockObject $notificationManager;
private IURLGenerator&MockObject $urlGenerator;
private ICloudFederationFactory&MockObject $cloudFederationFactory;
private ICloudFederationProviderManager&MockObject $cloudFederationProviderManager;
private IGroupManager&MockObject $groupManager;
private IConfig&MockObject $config;
private Manager&MockObject $externalShareManager;
private LoggerInterface&MockObject $logger;
private IFilenameValidator&MockObject $filenameValidator;
private IProviderFactory&MockObject $shareProviderFactory;
private ISetupManager&MockObject $setupManager;
private ExternalShareMapper&MockObject $externalShareMapper;
private IOCMDiscoveryService&MockObject $discoveryService;
private IClientService&MockObject $clientService;
private ISignatureManager&MockObject $signatureManager;
private OCMSignatoryManager&MockObject $signatoryManager;
private IAppConfig&MockObject $appConfig;
private CloudFederationProviderFiles $provider;
protected function setUp(): void {
parent::setUp();
$this->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);
}
}

View file

@ -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 {

View file

@ -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 |