Skip to content

Commit 7cbf61f

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

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
@@ -161,7 +161,7 @@ private function getRemoteStorages(): array {
161161
*/
162162
private function getRemoteShareIds(): array {
163163
$queryBuilder = $this->connection->getQueryBuilder();
164-
$queryBuilder->select(['id', 'share_token', 'owner', 'remote'])
164+
$queryBuilder->select(['id', 'refresh_token', 'owner', 'remote'])
165165
->from('share_external');
166166
$result = $queryBuilder->executeQuery();
167167

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

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

apps/files_sharing/lib/External/MountProvider.php

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

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

apps/files_sharing/lib/External/Storage.php

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

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

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

86112
// in case remote NC is on a sub folder and using deprecated ocm provider
@@ -91,20 +117,105 @@ public function __construct($options) {
91117

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

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

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

0 commit comments

Comments
 (0)