Skip to content

Commit 33d1d6f

Browse files
feat(files_sharing): store and refresh OCM access tokens for external shares
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 8748044 commit 33d1d6f

17 files changed

Lines changed: 779 additions & 71 deletions

apps/files_sharing/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@
8888
'OCA\\Files_Sharing\\Migration\\Version31000Date20240821142813' => $baseDir . '/../lib/Migration/Version31000Date20240821142813.php',
8989
'OCA\\Files_Sharing\\Migration\\Version32000Date20251017081948' => $baseDir . '/../lib/Migration/Version32000Date20251017081948.php',
9090
'OCA\\Files_Sharing\\Migration\\Version33000Date20251030081948' => $baseDir . '/../lib/Migration/Version33000Date20251030081948.php',
91+
'OCA\\Files_Sharing\\Migration\\Version33000Date20260306120000' => $baseDir . '/../lib/Migration/Version33000Date20260306120000.php',
92+
'OCA\\Files_Sharing\\Migration\\Version33000Date20260306150000' => $baseDir . '/../lib/Migration/Version33000Date20260306150000.php',
9193
'OCA\\Files_Sharing\\MountProvider' => $baseDir . '/../lib/MountProvider.php',
9294
'OCA\\Files_Sharing\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php',
9395
'OCA\\Files_Sharing\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',

apps/files_sharing/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ class ComposerStaticInitFiles_Sharing
103103
'OCA\\Files_Sharing\\Migration\\Version31000Date20240821142813' => __DIR__ . '/..' . '/../lib/Migration/Version31000Date20240821142813.php',
104104
'OCA\\Files_Sharing\\Migration\\Version32000Date20251017081948' => __DIR__ . '/..' . '/../lib/Migration/Version32000Date20251017081948.php',
105105
'OCA\\Files_Sharing\\Migration\\Version33000Date20251030081948' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20251030081948.php',
106+
'OCA\\Files_Sharing\\Migration\\Version33000Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20260306120000.php',
107+
'OCA\\Files_Sharing\\Migration\\Version33000Date20260306150000' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20260306150000.php',
106108
'OCA\\Files_Sharing\\MountProvider' => __DIR__ . '/..' . '/../lib/MountProvider.php',
107109
'OCA\\Files_Sharing\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php',
108110
'OCA\\Files_Sharing\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',

apps/files_sharing/lib/Command/CleanupRemoteStorages.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ private function getRemoteStorages(): array {
160160
*/
161161
private function getRemoteShareIds(): array {
162162
$queryBuilder = $this->connection->getQueryBuilder();
163-
$queryBuilder->select(['id', 'share_token', 'owner', 'remote'])
163+
$queryBuilder->select(['id', 'refresh_token', 'owner', 'remote'])
164164
->from('share_external');
165165
$result = $queryBuilder->executeQuery();
166166

@@ -169,7 +169,7 @@ private function getRemoteShareIds(): array {
169169
while ($row = $result->fetchAssociative()) {
170170
$cloudId = $this->cloudIdManager->getCloudId($row['owner'], $row['remote']);
171171
$remote = $cloudId->getRemote();
172-
$remoteShareIds[$row['id']] = 'shared::' . md5($row['share_token'] . '@' . $remote);
172+
$remoteShareIds[$row['id']] = 'shared::' . md5($row['refresh_token'] . '@' . $remote);
173173
}
174174
$result->closeCursor();
175175

apps/files_sharing/lib/External/ExternalShare.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@
2626
* @method void setRemote(string $remote)
2727
* @method string getRemoteId()
2828
* @method void setRemoteId(string $remoteId)
29-
* @method string getShareToken()
30-
* @method void setShareToken(string $shareToken)
29+
* @method string getRefreshToken()
30+
* @method void setRefreshToken(string $refreshToken)
3131
* @method string|null getPassword()
3232
* @method void setPassword(?string $password)
33+
* @method string|null getAccessToken()
34+
* @method void setAccessToken(?string $accessToken)
35+
* @method int|null getAccessTokenExpires()
36+
* @method void setAccessTokenExpires(?int $accessTokenExpires)
3337
* @method string getName()
3438
* @method string getOwner()
3539
* @method void setOwner(string $owner)
@@ -48,8 +52,10 @@ class ExternalShare extends SnowflakeAwareEntity implements \JsonSerializable {
4852
protected ?int $shareType = null;
4953
protected ?string $remote = null;
5054
protected ?string $remoteId = null;
51-
protected ?string $shareToken = null;
55+
protected ?string $refreshToken = null;
5256
protected ?string $password = null;
57+
protected ?string $accessToken = null;
58+
protected ?int $accessTokenExpires = null;
5359
protected ?string $name = null;
5460
protected ?string $owner = null;
5561
protected ?string $user = null;
@@ -63,8 +69,10 @@ public function __construct() {
6369
$this->addType('shareType', Types::INTEGER);
6470
$this->addType('remote', Types::STRING);
6571
$this->addType('remoteId', Types::STRING);
66-
$this->addType('shareToken', Types::STRING);
72+
$this->addType('refreshToken', Types::STRING);
6773
$this->addType('password', Types::STRING);
74+
$this->addType('accessToken', Types::STRING);
75+
$this->addType('accessTokenExpires', Types::INTEGER);
6876
$this->addType('name', Types::STRING);
6977
$this->addType('owner', Types::STRING);
7078
$this->addType('user', Types::STRING);
@@ -99,7 +107,7 @@ public function jsonSerialize(): array {
99107
'share_type' => $this->getShareType() ?? IShare::TYPE_USER, // unfortunately nullable on the DB level, but never null.
100108
'remote' => $this->getRemote(),
101109
'remote_id' => $this->getRemoteId(),
102-
'share_token' => $this->getShareToken(),
110+
'refresh_token' => $this->getRefreshToken(),
103111
'name' => $this->getName(),
104112
'owner' => $this->getOwner(),
105113
'user' => $this->getUser(),
@@ -126,7 +134,7 @@ public function clone(): self {
126134
$newShare->setShareType($this->getShareType());
127135
$newShare->setRemote($this->getRemote());
128136
$newShare->setRemoteId($this->getRemoteId());
129-
$newShare->setShareToken($this->getShareToken());
137+
$newShare->setRefreshToken($this->getRefreshToken());
130138
$newShare->setPassword($this->getPassword());
131139
$newShare->setName($this->getName());
132140
$newShare->setOwner($this->getOwner());

apps/files_sharing/lib/External/ExternalShareMapper.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public function getShareByToken(string $token): ExternalShare {
5555
$qb = $this->db->getQueryBuilder();
5656
$qb->select('*')
5757
->from(self::TABLE_NAME)
58-
->where($qb->expr()->eq('share_token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)))
58+
->where($qb->expr()->eq('refresh_token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)))
5959
->setMaxResults(1);
6060
return $this->findEntity($qb);
6161
}
@@ -238,7 +238,7 @@ public function getShareByRemoteIdAndToken(string $id, mixed $token): ?ExternalS
238238
->where(
239239
$qb->expr()->andX(
240240
$qb->expr()->eq('remote_id', $qb->createNamedParameter($id)),
241-
$qb->expr()->eq('share_token', $qb->createNamedParameter($token))
241+
$qb->expr()->eq('refresh_token', $qb->createNamedParameter($token))
242242
)
243243
);
244244

apps/files_sharing/lib/External/Manager.php

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,10 @@ public function addShare(ExternalShare $externalShare, IUser|IGroup|null $shareW
111111

112112
$options = [
113113
'remote' => $externalShare->getRemote(),
114-
'token' => $externalShare->getShareToken(),
114+
'token' => $externalShare->getRefreshToken(),
115115
'password' => $externalShare->getPassword(),
116+
'access_token' => $externalShare->getAccessToken(),
117+
'access_token_expires' => $externalShare->getAccessTokenExpires(),
116118
'mountpoint' => $externalShare->getMountpoint(),
117119
'owner' => $externalShare->getOwner(),
118120
'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'),
@@ -190,6 +192,7 @@ private function updateSubShare(ExternalShare $externalShare, IUser $user, ?stri
190192
$subShare->generateId();
191193
$subShare->setRemote($externalShare->getRemote());
192194
$subShare->setPassword($externalShare->getPassword());
195+
$subShare->setAccessToken($externalShare->getAccessToken());
193196
$subShare->setName($externalShare->getName());
194197
$subShare->setOwner($externalShare->getOwner());
195198
$subShare->setUser($user->getUID());
@@ -198,7 +201,7 @@ private function updateSubShare(ExternalShare $externalShare, IUser $user, ?stri
198201
$subShare->setRemoteId($externalShare->getRemoteId());
199202
$subShare->setParent((string)$externalShare->getId());
200203
$subShare->setShareType($externalShare->getShareType());
201-
$subShare->setShareToken($externalShare->getShareToken());
204+
$subShare->setRefreshToken($externalShare->getRefreshToken());
202205
$this->externalShareMapper->insert($subShare);
203206
}
204207
}
@@ -337,7 +340,7 @@ private function sendFeedbackToRemote(ExternalShare $externalShare, string $feed
337340
$endpoint = $federationEndpoints['share'] ?? '/ocs/v2.php/cloud/shares';
338341

339342
$url = rtrim($externalShare->getRemote(), '/') . $endpoint . '/' . $externalShare->getRemoteId() . '/' . $feedback . '?format=json';
340-
$fields = ['token' => $externalShare->getShareToken()];
343+
$fields = ['token' => $externalShare->getRefreshToken()];
341344

342345
$client = $this->clientService->newClient();
343346

@@ -373,7 +376,7 @@ protected function tryOCMEndPoint(ExternalShare $externalShare, string $feedback
373376
'file',
374377
$externalShare->getRemoteId(),
375378
[
376-
'sharedSecret' => $externalShare->getShareToken(),
379+
'sharedSecret' => $externalShare->getRefreshToken(),
377380
'message' => 'Recipient accept the share'
378381
]
379382

@@ -386,7 +389,7 @@ protected function tryOCMEndPoint(ExternalShare $externalShare, string $feedback
386389
'file',
387390
$externalShare->getRemoteId(),
388391
[
389-
'sharedSecret' => $externalShare->getShareToken(),
392+
'sharedSecret' => $externalShare->getRefreshToken(),
390393
'message' => 'Recipient declined the share'
391394
]
392395
);
@@ -566,4 +569,24 @@ public function getAcceptedShares(): array {
566569
return [];
567570
}
568571
}
572+
573+
/**
574+
* Update the access token for a share.
575+
*
576+
* @param string $refreshToken The refresh token to identify the share
577+
* @param string $accessToken The new access token to store
578+
*/
579+
public function updateAccessToken(string $refreshToken, string $accessToken, int $expiresAt): void {
580+
try {
581+
$share = $this->externalShareMapper->getShareByToken($refreshToken);
582+
$share->setAccessToken($accessToken);
583+
$share->setAccessTokenExpires($expiresAt);
584+
$this->externalShareMapper->update($share);
585+
$this->logger->debug('Updated access token for share', ['shareId' => $share->getId()]);
586+
} catch (DoesNotExistException $e) {
587+
$this->logger->warning('Could not find share to update access token', ['exception' => $e]);
588+
} catch (Exception $e) {
589+
$this->logger->error('Failed to update access token', ['exception' => $e]);
590+
}
591+
}
569592
}

apps/files_sharing/lib/External/MountProvider.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ private function getMount(IUser $user, array $data, IStorageFactory $storageFact
6060
#[\Override]
6161
public function getMountsForUser(IUser $user, IStorageFactory $loader): array {
6262
$qb = $this->connection->getQueryBuilder();
63-
$qb->select('id', 'remote', 'share_token', 'password', 'mountpoint', 'owner')
63+
$qb->select('id', 'remote', 'refresh_token', 'password', 'access_token', 'access_token_expires', 'mountpoint', 'owner')
6464
->from('share_external')
6565
->where($qb->expr()->eq('user', $qb->createNamedParameter($user->getUID())))
6666
->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT)));
6767
$result = $qb->executeQuery();
6868
$mounts = [];
6969
while ($row = $result->fetchAssociative()) {
7070
$row['manager'] = $this;
71-
$row['token'] = $row['share_token'];
71+
$row['token'] = $row['refresh_token'];
7272
$mounts[] = $this->getMount($user, $row, $loader);
7373
}
7474
$result->closeCursor();
@@ -101,7 +101,7 @@ public function getMountsForPath(
101101
}
102102

103103
$qb = $this->connection->getQueryBuilder();
104-
$qb->select('id', 'remote', 'share_token', 'password', 'mountpoint', 'owner')
104+
$qb->select('id', 'remote', 'refresh_token', 'password', 'access_token', 'access_token_expires', 'mountpoint', 'owner')
105105
->from('share_external')
106106
->where($qb->expr()->eq('user', $qb->createNamedParameter($user->getUID())))
107107
->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT)));
@@ -117,7 +117,7 @@ public function getMountsForPath(
117117
$mounts = [];
118118
while ($row = $result->fetchAssociative()) {
119119
$row['manager'] = $this;
120-
$row['token'] = $row['share_token'];
120+
$row['token'] = $row['refresh_token'];
121121
$mount = $this->getMount($user, $row, $loader);
122122
$mounts[$mount->getMountPoint()] = $mount;
123123
}

apps/files_sharing/lib/External/Storage.php

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,21 @@ class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage,
5050
private bool $updateChecked = false;
5151
private ExternalShareManager $manager;
5252
private IConfig $config;
53-
private IAppConfig $appConfig;
53+
protected IAppConfig $appConfig;
5454
private IShareManager $shareManager;
55+
private bool $tokenRefreshed = false;
56+
/** Unix timestamp until which the current access token is considered valid (0 = unknown/expired) */
57+
private int $tokenExpiresAt = 0;
58+
/** Number of consecutive token exchange failures (resets on success or DB-reuse) */
59+
private int $refreshFailureCount = 0;
60+
/** Unix timestamp before which the next exchange attempt must not be made (0 = no wait) */
61+
private int $refreshBackoffUntil = 0;
62+
63+
private const REFRESH_MAX_ATTEMPTS = 3;
64+
private const REFRESH_BACKOFF_SECONDS = 5;
5565

5666
/**
57-
* @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, password: ?string}|array $options
67+
* @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, access_token: ?string, access_token_expires: ?int}|array $options
5868
*/
5969
public function __construct($options) {
6070
$this->memcacheFactory = Server::get(ICacheFactory::class);
@@ -72,14 +82,30 @@ public function __construct($options) {
7282
$ocmProvider = $discoveryService->discover($this->cloudId->getRemote());
7383
$webDavEndpoint = $ocmProvider->extractProtocolEntry('file', 'webdav');
7484
$remote = $ocmProvider->getEndPoint();
85+
$authType = \Sabre\DAV\Client::AUTH_BASIC;
7586
} catch (OCMProviderException|OCMArgumentException $e) {
7687
$this->logger->notice('exception while retrieving webdav endpoint', ['exception' => $e]);
7788
$webDavEndpoint = '/public.php/webdav';
7889
$remote = $this->cloudId->getRemote();
90+
$authType = \Sabre\DAV\Client::AUTH_BASIC;
91+
}
92+
93+
// Only use Bearer auth when an access token is already stored.
94+
// Shares created before the exchange-token capability was introduced have no
95+
// stored token and must keep using basic auth for backwards compatibility.
96+
if (!empty($options['access_token'])) {
97+
$authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER;
7998
}
8099

81100
$host = parse_url($remote, PHP_URL_HOST);
101+
// If host extraction fails (e.g., endpoint has no scheme), fall back to cloudId's remote
102+
if ($host === null) {
103+
$host = parse_url($this->cloudId->getRemote(), PHP_URL_HOST);
104+
}
82105
$port = parse_url($remote, PHP_URL_PORT);
106+
if ($port === null) {
107+
$port = parse_url($this->cloudId->getRemote(), PHP_URL_PORT);
108+
}
83109
$host .= ($port === null) ? '' : ':' . $port; // we add port if available
84110

85111
// in case remote NC is on a sub folder and using deprecated ocm provider
@@ -90,20 +116,105 @@ public function __construct($options) {
90116

91117
$this->mountPoint = $options['mountpoint'];
92118
$this->token = $options['token'];
119+
$this->tokenExpiresAt = (int)($options['access_token_expires'] ?? 0);
120+
121+
// Determine scheme - fall back to cloudId's remote if $remote has no scheme
122+
$scheme = parse_url($remote, PHP_URL_SCHEME) ?? parse_url($this->cloudId->getRemote(), PHP_URL_SCHEME) ?? 'https';
93123

94124
parent::__construct(
95125
[
96-
'secure' => ((parse_url($remote, PHP_URL_SCHEME) ?? 'https') === 'https'),
126+
'secure' => ($scheme === 'https'),
97127
'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false),
98128
'host' => $host,
99129
'root' => $webDavEndpoint,
100130
'user' => $options['token'],
101-
'authType' => \Sabre\DAV\Client::AUTH_BASIC,
102-
'password' => (string)$options['password']
131+
'authType' => $authType,
132+
'password' => $authType === \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER
133+
? (string)($options['access_token'] ?? '')
134+
: (string)($options['password'] ?? ''),
135+
'discoveryService' => $discoveryService,
103136
]
104137
);
105138
}
106139

140+
/**
141+
* Refresh the access token. Extends parent to also persist to database.
142+
*
143+
* Uses expiry timestamps instead of a boolean flag so that concurrent
144+
* processes can detect that another process already obtained a fresh token
145+
* and reuse it rather than performing a redundant exchange.
146+
*
147+
* After a failed exchange, a 60-second backoff is applied so that
148+
* subsequent file operations do not hammer the remote token endpoint.
149+
* The DB is still consulted during backoff in case a concurrent process
150+
* succeeded; only the outgoing exchange call is suppressed.
151+
*
152+
* @return string|null the access token (freshly exchanged or reused from
153+
* DB), or null if refresh is currently not possible
154+
*/
155+
#[\Override]
156+
protected function refreshAccessToken(): ?string {
157+
$now = time();
158+
159+
// Fast path: in-memory token is still valid (single-process guard).
160+
if ($this->tokenExpiresAt > $now && !empty($this->password)) {
161+
return $this->password;
162+
}
163+
164+
// Slow path: check DB — a concurrent process may have already refreshed.
165+
$share = $this->manager->getShareByToken($this->token);
166+
if ($share !== false) {
167+
$dbExpiry = $share->getAccessTokenExpires();
168+
$dbToken = $share->getAccessToken();
169+
if ($dbExpiry !== null && $dbExpiry > $now && $dbToken !== null) {
170+
// Another process already refreshed — reuse DB token and reset failure state.
171+
$this->password = $dbToken;
172+
$this->bearerToken = $dbToken;
173+
$this->tokenExpiresAt = $dbExpiry;
174+
$this->refreshFailureCount = 0;
175+
$this->refreshBackoffUntil = 0;
176+
$this->logger->debug('Reused access token refreshed by another process', ['app' => 'files_sharing']);
177+
return $dbToken;
178+
}
179+
}
180+
181+
// Gave up after max attempts: stop trying for the lifetime of this instance.
182+
if ($this->refreshFailureCount >= self::REFRESH_MAX_ATTEMPTS) {
183+
return null;
184+
}
185+
186+
// Still within the inter-attempt wait: don't hit the endpoint yet.
187+
if ($this->refreshBackoffUntil > $now) {
188+
return null;
189+
}
190+
191+
// No valid token in DB — perform the exchange ourselves.
192+
try {
193+
$expiresAt = $now + 3600; // access tokens are valid for 1 hour
194+
$newAccessToken = $this->exchangeRefreshToken();
195+
$this->password = $newAccessToken;
196+
$this->bearerToken = $newAccessToken;
197+
$this->tokenExpiresAt = $expiresAt;
198+
$this->refreshFailureCount = 0;
199+
$this->refreshBackoffUntil = 0;
200+
201+
$this->manager->updateAccessToken($this->token, $newAccessToken, $expiresAt);
202+
203+
$this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']);
204+
return $newAccessToken;
205+
} catch (\Exception $e) {
206+
$this->refreshFailureCount++;
207+
$this->refreshBackoffUntil = $now + self::REFRESH_BACKOFF_SECONDS;
208+
$this->logger->warning('Failed to refresh access token (attempt {attempt}/{max})', [
209+
'app' => 'files_sharing',
210+
'attempt' => $this->refreshFailureCount,
211+
'max' => self::REFRESH_MAX_ATTEMPTS,
212+
'exception' => $e,
213+
]);
214+
return null;
215+
}
216+
}
217+
107218
#[\Override]
108219
public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher {
109220
if (!$storage) {

0 commit comments

Comments
 (0)