Skip to content

Commit 8748044

Browse files
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>
1 parent 958dffc commit 8748044

11 files changed

Lines changed: 643 additions & 27 deletions

File tree

apps/federatedfilesharing/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => $baseDir . '/../lib/Listeners/LoadAdditionalScriptsListener.php',
1818
'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => $baseDir . '/../lib/Migration/Version1010Date20200630191755.php',
1919
'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => $baseDir . '/../lib/Migration/Version1011Date20201120125158.php',
20+
'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => $baseDir . '/../lib/Migration/Version1012Date20260306120000.php',
2021
'OCA\\FederatedFileSharing\\Notifications' => $baseDir . '/../lib/Notifications.php',
2122
'OCA\\FederatedFileSharing\\Notifier' => $baseDir . '/../lib/Notifier.php',
2223
'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => $baseDir . '/../lib/OCM/CloudFederationProviderFiles.php',

apps/federatedfilesharing/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class ComposerStaticInitFederatedFileSharing
3232
'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScriptsListener.php',
3333
'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630191755.php',
3434
'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => __DIR__ . '/..' . '/../lib/Migration/Version1011Date20201120125158.php',
35+
'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1012Date20260306120000.php',
3536
'OCA\\FederatedFileSharing\\Notifications' => __DIR__ . '/..' . '/../lib/Notifications.php',
3637
'OCA\\FederatedFileSharing\\Notifier' => __DIR__ . '/..' . '/../lib/Notifier.php',
3738
'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => __DIR__ . '/..' . '/../lib/OCM/CloudFederationProviderFiles.php',

apps/federatedfilesharing/lib/Controller/RequestHandlerController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ public function move(int $id, ?string $token = null, ?string $remote = null, ?st
399399
->set('owner', $qb->createNamedParameter($cloudId->getUser()))
400400
->set('remote_id', $qb->createNamedParameter($newRemoteId))
401401
->where($qb->expr()->eq('remote_id', $qb->createNamedParameter($id)))
402-
->andWhere($qb->expr()->eq('share_token', $qb->createNamedParameter($token)));
402+
->andWhere($qb->expr()->eq('refresh_token', $qb->createNamedParameter($token)));
403403
$affected = $query->executeStatement();
404404

405405
if ($affected > 0) {

apps/federatedfilesharing/lib/FederatedShareProvider.php

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@
77
*/
88
namespace OCA\FederatedFileSharing;
99

10+
use OC\Authentication\Token\PublicKeyTokenProvider;
1011
use OC\Share20\Exception\InvalidShare;
1112
use OC\Share20\Share;
13+
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
14+
use OCP\Authentication\Exceptions\InvalidTokenException;
15+
use OCP\Authentication\Token\IToken;
1216
use OCP\Constants;
1317
use OCP\DB\QueryBuilder\IQueryBuilder;
1418
use OCP\Federation\ICloudFederationProviderManager;
@@ -22,6 +26,8 @@
2226
use OCP\IDBConnection;
2327
use OCP\IL10N;
2428
use OCP\IUserManager;
29+
use OCP\Security\ISecureRandom;
30+
use OCP\Server;
2531
use OCP\Share\Exceptions\GenericShareException;
2632
use OCP\Share\Exceptions\ShareNotFound;
2733
use OCP\Share\IShare;
@@ -137,7 +143,7 @@ public function create(IShare $share): IShare {
137143
$ownerCloudId = $this->cloudIdManager->getCloudId($remoteShare['owner'], $remoteShare['remote']);
138144
$shareId = $this->addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $ownerCloudId->getId(), $permissions, 'tmp_token_' . time(), $shareType, $expirationDate);
139145
[$token, $remoteId] = $this->notifications->requestReShare(
140-
$remoteShare['share_token'],
146+
$remoteShare['refresh_token'],
141147
$remoteShare['remote_id'],
142148
$shareId,
143149
$remoteShare['remote'],
@@ -170,7 +176,15 @@ public function create(IShare $share): IShare {
170176
* @throws \Exception
171177
*/
172178
protected function createFederatedShare(IShare $share): string {
173-
$token = $this->tokenHandler->generateToken();
179+
180+
$provider = Server::get(PublicKeyTokenProvider::class);
181+
$token = Server::get(ISecureRandom::class)->generate(32, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS);
182+
$uid = $share->getSharedBy();
183+
$user = $this->userManager->get($uid);
184+
$name = $user?->getDisplayName() ?? $uid;
185+
$pass = $share->getPassword();
186+
187+
$dbToken = $provider->generateToken($token, $uid, $uid, $pass, $name, type: IToken::PERMANENT_TOKEN);
174188
$shareId = $this->addShareToDB(
175189
$share->getNodeId(),
176190
$share->getNodeType(),
@@ -724,6 +738,24 @@ public function getShareByToken(string $token): IShare {
724738

725739
$data = $cursor->fetchAssociative();
726740

741+
if ($data === false) {
742+
// Token not found as refresh token, try looking it up as access token
743+
try {
744+
$accessTokenDb = Server::get(PublicKeyTokenProvider::class)->getToken($token);
745+
$mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId());
746+
747+
$qb2 = $this->dbConnection->getQueryBuilder();
748+
$cursor = $qb2->select('*')
749+
->from('share')
750+
->where($qb2->expr()->in('share_type', $qb2->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY)))
751+
->andWhere($qb2->expr()->eq('token', $qb2->createNamedParameter($mapping->getRefreshToken())))
752+
->executeQuery();
753+
754+
$data = $cursor->fetch();
755+
} catch (InvalidTokenException|\OCP\AppFramework\Db\DoesNotExistException) {
756+
// Token is not a valid access token or has no mapping, share not found
757+
}
758+
}
727759
if ($data === false) {
728760
throw new ShareNotFound('Share not found', $this->l->t('Could not find share'));
729761
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\FederatedFileSharing\Migration;
11+
12+
use Closure;
13+
use OC\Authentication\Token\PublicKeyTokenProvider;
14+
use OCP\Authentication\Exceptions\InvalidTokenException;
15+
use OCP\Authentication\Token\IToken;
16+
use OCP\DB\ISchemaWrapper;
17+
use OCP\DB\QueryBuilder\IQueryBuilder;
18+
use OCP\IDBConnection;
19+
use OCP\IUserManager;
20+
use OCP\Migration\IOutput;
21+
use OCP\Migration\SimpleMigrationStep;
22+
use OCP\Server;
23+
use OCP\Share\IShare;
24+
25+
/**
26+
* Ensure all existing federated share tokens are registered in oc_authtoken
27+
* as permanent tokens, which is required for the OCM token exchange flow.
28+
*
29+
* Shares created before this fork used TokenHandler (15-char tokens) and never
30+
* registered in oc_authtoken. Those legacy short tokens are left untouched so
31+
* that the receiving instance can continue to authenticate via Basic auth with
32+
* the original token. They will never participate in the token exchange flow,
33+
* but they will keep working until the share is re-created with a new token.
34+
*
35+
* Shares created by this fork (32-char tokens) that are somehow missing from
36+
* oc_authtoken are silently repaired.
37+
*/
38+
class Version1012Date20260306120000 extends SimpleMigrationStep {
39+
#[\Override]
40+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
41+
return null;
42+
}
43+
44+
#[\Override]
45+
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
46+
$db = Server::get(IDBConnection::class);
47+
$tokenProvider = Server::get(PublicKeyTokenProvider::class);
48+
$userManager = Server::get(IUserManager::class);
49+
50+
$qb = $db->getQueryBuilder();
51+
$result = $qb->select('id', 'token', 'uid_initiator')
52+
->from('share')
53+
->where($qb->expr()->in(
54+
'share_type',
55+
$qb->createNamedParameter(
56+
[IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP],
57+
IQueryBuilder::PARAM_INT_ARRAY
58+
)
59+
))
60+
->executeQuery();
61+
62+
$registered = 0;
63+
$skipped = 0;
64+
65+
while ($row = $result->fetchAssociative()) {
66+
$shareId = (int)$row['id'];
67+
$token = (string)$row['token'];
68+
$uid = (string)$row['uid_initiator'];
69+
70+
if (strlen($token) < PublicKeyTokenProvider::TOKEN_MIN_LENGTH) {
71+
// Old short token from TokenHandler — leave it as-is.
72+
// Replacing it would invalidate the token stored on the receiving instance,
73+
// breaking Basic-auth access to those shares. These shares keep working via
74+
// Basic auth and are simply not eligible for the OCM token exchange flow.
75+
$skipped++;
76+
continue;
77+
}
78+
79+
// Long token — check if it's already in oc_authtoken.
80+
try {
81+
$tokenProvider->getToken($token);
82+
$skipped++;
83+
continue;
84+
} catch (InvalidTokenException) {
85+
// Not registered yet — fall through to create it.
86+
}
87+
88+
$user = $userManager->get($uid);
89+
$name = $user?->getDisplayName() ?? $uid;
90+
91+
try {
92+
$tokenProvider->generateToken(
93+
$token,
94+
$uid,
95+
$uid,
96+
null,
97+
$name,
98+
IToken::PERMANENT_TOKEN,
99+
);
100+
$registered++;
101+
} catch (\Exception $e) {
102+
$output->warning(sprintf(
103+
'Could not register auth token for share %d (uid=%s): %s',
104+
$shareId,
105+
$uid,
106+
$e->getMessage()
107+
));
108+
}
109+
}
110+
111+
$result->closeCursor();
112+
113+
$output->info(sprintf(
114+
'Federated share token migration: %d registered, %d skipped (already up-to-date or legacy short token).',
115+
$registered,
116+
$skipped
117+
));
118+
}
119+
}

apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
use OC\AppFramework\Http;
1010
use OC\Files\Filesystem;
11+
use OC\OCM\OCMSignatoryManager;
12+
use OC\OCM\Rfc9421SignatoryManager;
1113
use OCA\FederatedFileSharing\AddressHandler;
1214
use OCA\FederatedFileSharing\FederatedShareProvider;
1315
use OCA\Federation\TrustedServers;
@@ -33,12 +35,16 @@
3335
use OCP\Files\ISetupManager;
3436
use OCP\Files\NotFoundException;
3537
use OCP\HintException;
38+
use OCP\Http\Client\IClientService;
39+
use OCP\IAppConfig;
3640
use OCP\IConfig;
3741
use OCP\IGroupManager;
3842
use OCP\IURLGenerator;
3943
use OCP\IUser;
4044
use OCP\IUserManager;
4145
use OCP\Notification\IManager as INotificationManager;
46+
use OCP\OCM\IOCMDiscoveryService;
47+
use OCP\Security\Signature\ISignatureManager;
4248
use OCP\Server;
4349
use OCP\Share\Exceptions\ShareNotFound;
4450
use OCP\Share\IManager;
@@ -70,6 +76,11 @@ public function __construct(
7076
private readonly IProviderFactory $shareProviderFactory,
7177
private readonly ISetupManager $setupManager,
7278
private readonly ExternalShareMapper $externalShareMapper,
79+
private readonly IOCMDiscoveryService $discoveryService,
80+
private readonly IClientService $clientService,
81+
private readonly ISignatureManager $signatureManager,
82+
private readonly OCMSignatoryManager $signatoryManager,
83+
private readonly IAppConfig $appConfig,
7384
) {
7485
}
7586

@@ -106,6 +117,30 @@ public function shareReceived(ICloudFederationShare $share): string {
106117
$ownerFederatedId = $share->getOwner();
107118
$shareType = $this->mapShareTypeToNextcloud($share->getShareType());
108119

120+
// Check for must-exchange-token requirement
121+
$requirements = $protocol['webdav']['requirements'] ?? $protocol['options']['requirements'] ?? [];
122+
$mustExchangeToken = in_array('must-exchange-token', $requirements);
123+
$accessToken = '';
124+
125+
if ($mustExchangeToken) {
126+
// Exchange the sharedSecret for an access token (required)
127+
$accessToken = $this->exchangeToken($remote, $token);
128+
if ($accessToken === null) {
129+
throw new ProviderCouldNotAddShareException('Failed to exchange token as required by must-exchange-token', '', Http::STATUS_BAD_REQUEST);
130+
}
131+
} else {
132+
// Check if remote has exchange-token capability and try to exchange (optional)
133+
try {
134+
$ocmProvider = $this->discoveryService->discover(rtrim($remote, '/'));
135+
if ($ocmProvider->getCapabilities()->hasExchangeToken()) {
136+
$accessToken = $this->exchangeToken($remote, $token) ?? '';
137+
$this->logger->debug('Exchanged token for remote with exchange-token capability', ['remote' => $remote, 'success' => !empty($accessToken)]);
138+
}
139+
} catch (\Exception $e) {
140+
$this->logger->debug('Could not discover remote capabilities for token exchange', ['remote' => $remote, 'exception' => $e]);
141+
}
142+
}
143+
109144
// if no explicit information about the person who created the share was sent
110145
// we assume that the share comes from the owner
111146
if ($sharedByFederatedId === null) {
@@ -146,8 +181,8 @@ public function shareReceived(ICloudFederationShare $share): string {
146181
$externalShare->generateId();
147182
$externalShare->setRemote($remote);
148183
$externalShare->setRemoteId($remoteId);
149-
$externalShare->setShareToken($token);
150-
$externalShare->setPassword('');
184+
$externalShare->setRefreshToken($token); // refresh token (sharedSecret)
185+
$externalShare->setAccessToken($accessToken ?: null);
151186
$externalShare->setName($name);
152187
$externalShare->setOwner($owner);
153188
$externalShare->setShareType($shareType);
@@ -687,4 +722,98 @@ public function getFederationIdFromSharedSecret(
687722
return $share->getShareOwner();
688723
}
689724
}
725+
726+
/**
727+
* Exchange a sharedSecret (refresh token) for an access token via the remote server's token endpoint
728+
*
729+
* @param string $remote The remote server URL
730+
* @param string $sharedSecret The shared secret to exchange
731+
* @return string|null The access token, or null on failure
732+
*/
733+
private function exchangeToken(string $remote, #[SensitiveParameter] string $sharedSecret): ?string {
734+
try {
735+
$ocmProvider = $this->discoveryService->discover(rtrim($remote, '/'));
736+
$tokenEndpoint = $ocmProvider->getTokenEndPoint();
737+
738+
if ($tokenEndpoint === '') {
739+
$this->logger->warning('Remote server does not expose tokenEndPoint', ['remote' => $remote]);
740+
return null;
741+
}
742+
743+
$client = $this->clientService->newClient();
744+
$clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST);
745+
746+
$payload = [
747+
'grant_type' => 'authorization_code',
748+
'client_id' => $clientId,
749+
'code' => $sharedSecret,
750+
];
751+
752+
$options = [
753+
'body' => http_build_query($payload),
754+
'headers' => [
755+
'Content-Type' => 'application/x-www-form-urlencoded',
756+
],
757+
'timeout' => 10,
758+
'connect_timeout' => 10,
759+
];
760+
761+
try {
762+
$options = $this->signatureManager->signOutgoingRequestIClientPayload(
763+
new Rfc9421SignatoryManager($this->signatoryManager),
764+
$options,
765+
'post',
766+
$tokenEndpoint
767+
);
768+
$this->logger->debug('Token request signed successfully', ['remote' => $remote]);
769+
} catch (\Exception $e) {
770+
$this->logger->error('Failed to sign token request', [
771+
'remote' => $remote,
772+
'exception' => $e,
773+
'endpoint' => $tokenEndpoint,
774+
]);
775+
return null;
776+
}
777+
778+
$response = $client->post($tokenEndpoint, $options);
779+
780+
$statusCode = $response->getStatusCode();
781+
if ($statusCode !== 200) {
782+
$this->logger->warning('Token exchange returned unexpected HTTP status', [
783+
'remote' => $remote,
784+
'status' => $statusCode,
785+
]);
786+
return null;
787+
}
788+
789+
$data = json_decode($response->getBody(), true);
790+
791+
if (!is_array($data)) {
792+
$this->logger->warning('Token exchange response is not valid JSON', ['remote' => $remote]);
793+
return null;
794+
}
795+
796+
$accessToken = $data['access_token'] ?? null;
797+
$tokenType = $data['token_type'] ?? null;
798+
799+
if (!is_string($accessToken) || $accessToken === '') {
800+
$this->logger->warning('Token exchange response missing or invalid access_token', ['remote' => $remote]);
801+
return null;
802+
}
803+
804+
if (!is_string($tokenType) || strtolower($tokenType) !== 'bearer') {
805+
$this->logger->warning('Token exchange response has unexpected token_type', [
806+
'remote' => $remote,
807+
'token_type' => $tokenType,
808+
]);
809+
return null;
810+
}
811+
812+
$this->logger->debug('Successfully exchanged token for access token', ['remote' => $remote]);
813+
return $accessToken;
814+
} catch (\Exception $e) {
815+
$this->logger->warning('Failed to exchange token', ['remote' => $remote, 'exception' => $e]);
816+
return null;
817+
}
818+
}
690819
}

0 commit comments

Comments
 (0)