Skip to content

Commit ff08245

Browse files
enriquepablomickenordin
authored andcommitted
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 49c6aec commit ff08245

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
@@ -398,7 +398,7 @@ public function move(int $id, ?string $token = null, ?string $remote = null, ?st
398398
->set('owner', $qb->createNamedParameter($cloudId->getUser()))
399399
->set('remote_id', $qb->createNamedParameter($newRemoteId))
400400
->where($qb->expr()->eq('remote_id', $qb->createNamedParameter($id)))
401-
->andWhere($qb->expr()->eq('share_token', $qb->createNamedParameter($token)));
401+
->andWhere($qb->expr()->eq('refresh_token', $qb->createNamedParameter($token)));
402402
$affected = $query->executeStatement();
403403

404404
if ($affected > 0) {

apps/federatedfilesharing/lib/FederatedShareProvider.php

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

99
namespace OCA\FederatedFileSharing;
1010

11+
use OC\Authentication\Token\PublicKeyTokenProvider;
1112
use OC\Share20\Exception\InvalidShare;
1213
use OC\Share20\Share;
14+
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
15+
use OCP\Authentication\Exceptions\InvalidTokenException;
16+
use OCP\Authentication\Token\IToken;
1317
use OCP\Constants;
1418
use OCP\DB\QueryBuilder\IQueryBuilder;
1519
use OCP\Federation\ICloudFederationProviderManager;
@@ -23,6 +27,8 @@
2327
use OCP\IDBConnection;
2428
use OCP\IL10N;
2529
use OCP\IUserManager;
30+
use OCP\Security\ISecureRandom;
31+
use OCP\Server;
2632
use OCP\Share\Exceptions\GenericShareException;
2733
use OCP\Share\Exceptions\ShareNotFound;
2834
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(),
@@ -721,6 +735,24 @@ public function getShareByToken(string $token): IShare {
721735

722736
$data = $cursor->fetchAssociative();
723737

738+
if ($data === false) {
739+
// Token not found as refresh token, try looking it up as access token
740+
try {
741+
$accessTokenDb = Server::get(PublicKeyTokenProvider::class)->getToken($token);
742+
$mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId());
743+
744+
$qb2 = $this->dbConnection->getQueryBuilder();
745+
$cursor = $qb2->select('*')
746+
->from('share')
747+
->where($qb2->expr()->in('share_type', $qb2->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY)))
748+
->andWhere($qb2->expr()->eq('token', $qb2->createNamedParameter($mapping->getRefreshToken())))
749+
->executeQuery();
750+
751+
$data = $cursor->fetch();
752+
} catch (InvalidTokenException|\OCP\AppFramework\Db\DoesNotExistException) {
753+
// Token is not a valid access token or has no mapping, share not found
754+
}
755+
}
724756
if ($data === false) {
725757
throw new ShareNotFound('Share not found', $this->l->t('Could not find share'));
726758
}
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
@@ -9,6 +9,8 @@
99

1010
use OC\AppFramework\Http;
1111
use OC\Files\Filesystem;
12+
use OC\OCM\OCMSignatoryManager;
13+
use OC\OCM\Rfc9421SignatoryManager;
1214
use OCA\FederatedFileSharing\AddressHandler;
1315
use OCA\FederatedFileSharing\FederatedShareProvider;
1416
use OCA\Federation\TrustedServers;
@@ -34,12 +36,16 @@
3436
use OCP\Files\ISetupManager;
3537
use OCP\Files\NotFoundException;
3638
use OCP\HintException;
39+
use OCP\Http\Client\IClientService;
40+
use OCP\IAppConfig;
3741
use OCP\IConfig;
3842
use OCP\IGroupManager;
3943
use OCP\IURLGenerator;
4044
use OCP\IUser;
4145
use OCP\IUserManager;
4246
use OCP\Notification\IManager as INotificationManager;
47+
use OCP\OCM\IOCMDiscoveryService;
48+
use OCP\Security\Signature\ISignatureManager;
4349
use OCP\Server;
4450
use OCP\Share\Exceptions\ShareNotFound;
4551
use OCP\Share\IManager;
@@ -71,6 +77,11 @@ public function __construct(
7177
private readonly IProviderFactory $shareProviderFactory,
7278
private readonly ISetupManager $setupManager,
7379
private readonly ExternalShareMapper $externalShareMapper,
80+
private readonly IOCMDiscoveryService $discoveryService,
81+
private readonly IClientService $clientService,
82+
private readonly ISignatureManager $signatureManager,
83+
private readonly OCMSignatoryManager $signatoryManager,
84+
private readonly IAppConfig $appConfig,
7485
) {
7586
}
7687

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

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

0 commit comments

Comments
 (0)