mirror of
https://github.com/nextcloud/server.git
synced 2026-06-19 21:49:43 -04:00
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:
parent
4d56c74ba7
commit
fc31f97018
11 changed files with 644 additions and 27 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue