Skip to content

Commit 21029c7

Browse files
committed
feat(files_sharing): make sabre http client that transparently handles access tokens
Signed-off-by: Enrique Pérez Arnaud <enrique@cazalla.net>
1 parent aba9f04 commit 21029c7

3 files changed

Lines changed: 120 additions & 90 deletions

File tree

apps/files_sharing/lib/External/Storage.php

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public function __construct($options) {
138138
}
139139

140140
/**
141-
* Refresh the bearer token. Extends parent to also persist to database.
141+
* Refresh the access token. Extends parent to also persist to database.
142142
*
143143
* Uses expiry timestamps instead of a boolean flag so that concurrent
144144
* processes can detect that another process already obtained a fresh token
@@ -149,15 +149,16 @@ public function __construct($options) {
149149
* The DB is still consulted during backoff in case a concurrent process
150150
* succeeded; only the outgoing exchange call is suppressed.
151151
*
152-
* @return bool True if token was refreshed (or reused from DB) successfully
152+
* @return string|null the access token (freshly exchanged or reused from
153+
* DB), or null if refresh is currently not possible
153154
*/
154155
#[\Override]
155-
protected function refreshBearerToken(): bool {
156+
protected function refreshAccessToken(): ?string {
156157
$now = time();
157158

158159
// Fast path: in-memory token is still valid (single-process guard).
159-
if ($this->tokenExpiresAt > $now) {
160-
return false;
160+
if ($this->tokenExpiresAt > $now && !empty($this->password)) {
161+
return $this->password;
161162
}
162163

163164
// Slow path: check DB — a concurrent process may have already refreshed.
@@ -168,44 +169,39 @@ protected function refreshBearerToken(): bool {
168169
if ($dbExpiry !== null && $dbExpiry > $now && $dbToken !== null) {
169170
// Another process already refreshed — reuse DB token and reset failure state.
170171
$this->password = $dbToken;
172+
$this->bearerToken = $dbToken;
171173
$this->tokenExpiresAt = $dbExpiry;
172174
$this->refreshFailureCount = 0;
173175
$this->refreshBackoffUntil = 0;
174-
$this->ready = false;
175-
$this->client = null;
176-
$this->init();
177176
$this->logger->debug('Reused access token refreshed by another process', ['app' => 'files_sharing']);
178-
return true;
177+
return $dbToken;
179178
}
180179
}
181180

182181
// Gave up after max attempts: stop trying for the lifetime of this instance.
183182
if ($this->refreshFailureCount >= self::REFRESH_MAX_ATTEMPTS) {
184-
return false;
183+
return null;
185184
}
186185

187186
// Still within the inter-attempt wait: don't hit the endpoint yet.
188187
if ($this->refreshBackoffUntil > $now) {
189-
return false;
188+
return null;
190189
}
191190

192191
// No valid token in DB — perform the exchange ourselves.
193192
try {
194193
$expiresAt = $now + 3600; // access tokens are valid for 1 hour
195194
$newAccessToken = $this->exchangeRefreshToken();
196195
$this->password = $newAccessToken;
196+
$this->bearerToken = $newAccessToken;
197197
$this->tokenExpiresAt = $expiresAt;
198198
$this->refreshFailureCount = 0;
199199
$this->refreshBackoffUntil = 0;
200200

201201
$this->manager->updateAccessToken($this->token, $newAccessToken, $expiresAt);
202202

203-
$this->ready = false;
204-
$this->client = null;
205-
$this->init();
206-
207203
$this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']);
208-
return true;
204+
return $newAccessToken;
209205
} catch (\Exception $e) {
210206
$this->refreshFailureCount++;
211207
$this->refreshBackoffUntil = $now + self::REFRESH_BACKOFF_SECONDS;
@@ -215,7 +211,7 @@ protected function refreshBearerToken(): bool {
215211
'max' => self::REFRESH_MAX_ATTEMPTS,
216212
'exception' => $e,
217213
]);
218-
return false;
214+
return null;
219215
}
220216
}
221217

apps/files_sharing/lib/Migration/Version33000Date20260306120000.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,9 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
6161
}
6262

6363
if (!$table->hasColumn('refresh_token')) {
64-
$source = $table->getColumn('share_token');
65-
$table->addColumn('refresh_token', $source->getType()->getName(), [
64+
$table->addColumn('refresh_token', Types::STRING, [
6665
'notnull' => false,
67-
'length' => $source->getLength(),
66+
'length' => 64,
6867
'default' => null,
6968
]);
7069
$changed = true;

lib/private/Files/Storage/DAV.php

Lines changed: 105 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,14 @@
4444
use Sabre\HTTP\ClientException;
4545
use Sabre\HTTP\ClientHttpException;
4646
use Sabre\HTTP\RequestInterface;
47+
use Sabre\HTTP\ResponseInterface as SabreResponseInterface;
4748

4849
/**
49-
* Class BearerAuthAwareSabreClient
50-
*
51-
* This is an extension of the Sabre HTTP Client
52-
* to provide it with the ability to make bearer authn requests.
50+
* Sabre HTTP Client extended with Bearer-token authentication and transparent
51+
* refresh-on-401: when a request fails with HTTP 401 the client invokes a
52+
* registered refresh callback once, applies the new token, and replays the
53+
* request. Callers can use the client normally without thinking about token
54+
* expiry.
5355
*
5456
* @package OC\Files\Storage
5557
*/
@@ -59,26 +61,60 @@ class BearerAuthAwareSabreClient extends Client {
5961
*/
6062
public const AUTH_BEARER = 8;
6163

62-
/**
63-
* Constructor.
64-
*
65-
* See Sabre\DAV\Client
66-
*
67-
*/
64+
/** @var (\Closure(): ?string)|null returns a fresh bearer token, or null if it cannot be refreshed */
65+
private ?\Closure $refreshTokenCallback = null;
66+
67+
/** Guard against re-entry if the replayed request also returns 401. */
68+
private bool $retrying = false;
69+
6870
public function __construct(array $settings) {
6971
parent::__construct($settings);
7072

7173
if (isset($settings['userName']) && isset($settings['authType']) && ($settings['authType'] & self::AUTH_BEARER)) {
72-
$userName = $settings['userName'];
74+
$this->applyBearerToken((string)$settings['userName']);
75+
}
76+
}
7377

74-
/** @psalm-suppress InvalidArrayOffset */
75-
$curlType = $this->curlSettings[CURLOPT_HTTPAUTH];
76-
$curlType |= CURLAUTH_BEARER;
78+
/**
79+
* Register a callback invoked when a request comes back with HTTP 401. The
80+
* callback should return a fresh bearer token, or null to give up. When a
81+
* non-empty token is returned the failing request is replayed once.
82+
*
83+
* @param (callable(): ?string)|null $callback
84+
*/
85+
public function setRefreshTokenCallback(?callable $callback): void {
86+
$this->refreshTokenCallback = $callback === null ? null : \Closure::fromCallable($callback);
87+
}
7788

78-
$this->addCurlSetting(CURLOPT_HTTPAUTH, $curlType);
79-
$this->addCurlSetting(CURLOPT_XOAUTH2_BEARER, $userName);
89+
#[\Override]
90+
public function send(RequestInterface $request): SabreResponseInterface {
91+
try {
92+
return parent::send($request);
93+
} catch (ClientHttpException $e) {
94+
if ($e->getHttpStatus() !== 401 || $this->retrying || $this->refreshTokenCallback === null) {
95+
throw $e;
96+
}
97+
$this->retrying = true;
98+
try {
99+
$newToken = ($this->refreshTokenCallback)();
100+
if (!is_string($newToken) || $newToken === '') {
101+
throw $e;
102+
}
103+
$this->applyBearerToken($newToken);
104+
return parent::send($request);
105+
} finally {
106+
$this->retrying = false;
107+
}
80108
}
81109
}
110+
111+
private function applyBearerToken(string $token): void {
112+
/** @psalm-suppress InvalidArrayOffset */
113+
$curlType = $this->curlSettings[CURLOPT_HTTPAUTH] ?? 0;
114+
$curlType |= CURLAUTH_BEARER;
115+
$this->addCurlSetting(CURLOPT_HTTPAUTH, $curlType);
116+
$this->addCurlSetting(CURLOPT_XOAUTH2_BEARER, $token);
117+
}
82118
}
83119

84120
/**
@@ -231,6 +267,13 @@ protected function init(): void {
231267
$this->client = new BearerAuthAwareSabreClient($settings);
232268
$this->client->setThrowExceptions(true);
233269

270+
// Bearer mode: arm the client with a refresh callback so it can
271+
// transparently exchange a new access token and replay a request that
272+
// came back 401.
273+
if ($this->isBearerAuth()) {
274+
$this->client->setRefreshTokenCallback(fn (): ?string => $this->refreshAccessToken());
275+
}
276+
234277
if ($this->secure === true) {
235278
if ($this->verify === false) {
236279
$this->client->addCurlSetting(CURLOPT_SSL_VERIFYPEER, false);
@@ -366,69 +409,61 @@ protected function isBearerAuth(): bool {
366409
&& ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER);
367410
}
368411

369-
/**
370-
* @var bool Flag to prevent infinite retry loops during token refresh
371-
*/
412+
/** Guard against re-entry while a Guzzle-path 401 is being recovered. */
372413
private bool $retryingAuth = false;
373414

374415
/**
375-
* Execute an operation with automatic retry on 401 Unauthorized when using Bearer auth.
376-
* Handles both Sabre ClientHttpException and Guzzle ClientException.
416+
* Wrap a Guzzle-based operation with retry-on-401 using the bearer-token
417+
* refresh. Sabre-based operations don't need this — {@see BearerAuthAwareSabreClient}
418+
* handles 401 transparently on its own.
377419
*
378420
* @template T
379-
* @param callable(): T $operation The operation to execute
380-
* @return T The result of the operation
381-
* @throws ClientHttpException
421+
* @param callable(): T $operation
422+
* @return T
382423
* @throws \GuzzleHttp\Exception\ClientException
383424
*/
384425
protected function withAuthRetry(callable $operation): mixed {
385426
try {
386427
return $operation();
387-
} catch (ClientHttpException $e) {
388-
if ($e->getHttpStatus() === 401 && !$this->retryingAuth && $this->isBearerAuth()) {
389-
return $this->retryWithFreshToken($operation);
390-
}
391-
throw $e;
392428
} catch (\GuzzleHttp\Exception\ClientException $e) {
393-
if ($e->getResponse() instanceof ResponseInterface
394-
&& $e->getResponse()->getStatusCode() === 401
395-
&& !$this->retryingAuth && $this->isBearerAuth()) {
396-
return $this->retryWithFreshToken($operation);
429+
if (!$this->isBearerAuth()
430+
|| $this->retryingAuth
431+
|| !($e->getResponse() instanceof ResponseInterface)
432+
|| $e->getResponse()->getStatusCode() !== 401) {
433+
throw $e;
434+
}
435+
$this->retryingAuth = true;
436+
try {
437+
if ($this->refreshAccessToken() === null) {
438+
throw $e;
439+
}
440+
return $operation();
441+
} finally {
442+
$this->retryingAuth = false;
397443
}
398-
throw $e;
399444
}
400445
}
401446

402447
/**
403-
* Refresh the bearer token and retry the operation.
448+
* Exchange the long-lived refresh token for a new short-lived access token
449+
* and update {@see $bearerToken} (and the stored password so subsequent
450+
* init() calls reuse the same token). Used as the refresh callback for the
451+
* Sabre client and by {@see withAuthRetry} for the Guzzle paths.
404452
*
405-
* @template T
406-
* @param callable(): T $operation The operation to retry
407-
* @return T The result of the operation
453+
* @return string|null new access token, or null if the exchange failed
408454
*/
409-
private function retryWithFreshToken(callable $operation): mixed {
410-
$this->retryingAuth = true;
455+
protected function refreshAccessToken(): ?string {
456+
$this->logger->debug('Bearer token expired, exchanging for a fresh access token', ['app' => 'dav']);
411457
try {
412-
if (!$this->refreshBearerToken()) {
413-
throw new StorageNotAvailableException('Failed to refresh bearer token');
414-
}
415-
return $operation();
416-
} finally {
417-
$this->retryingAuth = false;
458+
$this->password = ''; // force a fresh exchange instead of reusing the expired one
459+
$newToken = $this->exchangeRefreshToken();
460+
} catch (\Exception $e) {
461+
$this->logger->warning('Failed to refresh bearer token: ' . $e->getMessage(), ['app' => 'dav', 'exception' => $e]);
462+
return null;
418463
}
419-
}
420-
421-
/**
422-
* Refresh the bearer token. Override in subclasses to add persistence logic.
423-
*
424-
* @return bool True if token was refreshed successfully
425-
*/
426-
protected function refreshBearerToken(): bool {
427-
$this->logger->debug('Bearer token expired, refreshing token', ['app' => 'dav']);
428-
$this->ready = false;
429-
$this->password = ''; // Clear to force token exchange in init()
430-
$this->init();
431-
return true;
464+
$this->bearerToken = $newToken;
465+
$this->password = $newToken;
466+
return $newToken;
432467
}
433468

434469
/**
@@ -540,10 +575,10 @@ protected function propfind(string $path): array|false {
540575
$this->init();
541576
$response = false;
542577
try {
543-
$response = $this->withAuthRetry(fn () => $this->client->propFind(
578+
$response = $this->client->propFind(
544579
$this->encodePath($path),
545580
$this->getPropfindProperties()
546-
));
581+
);
547582
$this->statCache->set($path, $response);
548583
} catch (ClientHttpException $e) {
549584
if ($e->getHttpStatus() === 404 || $e->getHttpStatus() === 405) {
@@ -738,9 +773,9 @@ public function touch(string $path, ?int $mtime = null): bool {
738773
if ($this->file_exists($path)) {
739774
try {
740775
$this->statCache->remove($path);
741-
$this->withAuthRetry(fn () => $this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime]));
776+
$this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime]);
742777
// non-owncloud clients might not have accepted the property, need to recheck it
743-
$response = $this->withAuthRetry(fn () => $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0));
778+
$response = $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0);
744779
if (isset($response['{DAV:}getlastmodified'])) {
745780
$remoteMtime = strtotime($response['{DAV:}getlastmodified']);
746781
if ($remoteMtime !== $mtime) {
@@ -813,14 +848,14 @@ public function rename(string $source, string $target): bool {
813848
// needs trailing slash in destination
814849
$target = rtrim($target, '/') . '/';
815850
}
816-
$this->withAuthRetry(fn () => $this->client->request(
851+
$this->client->request(
817852
'MOVE',
818853
$this->encodePath($source),
819854
null,
820855
[
821856
'Destination' => $this->createBaseUri() . $this->encodePath($target),
822857
]
823-
));
858+
);
824859
$this->statCache->clear($source . '/');
825860
$this->statCache->clear($target . '/');
826861
$this->statCache->set($source, false);
@@ -847,14 +882,14 @@ public function copy(string $source, string $target): bool {
847882
// needs trailing slash in destination
848883
$target = rtrim($target, '/') . '/';
849884
}
850-
$this->withAuthRetry(fn () => $this->client->request(
885+
$this->client->request(
851886
'COPY',
852887
$this->encodePath($source),
853888
null,
854889
[
855890
'Destination' => $this->createBaseUri() . $this->encodePath($target),
856891
]
857-
));
892+
);
858893
$this->statCache->clear($target . '/');
859894
$this->statCache->set($target, true);
860895
$this->removeCachedFile($target);
@@ -972,7 +1007,7 @@ protected function encodePath(string $path): string {
9721007
protected function simpleResponse(string $method, string $path, ?string $body, int $expected): bool {
9731008
$path = $this->cleanPath($path);
9741009
try {
975-
$response = $this->withAuthRetry(fn () => $this->client->request($method, $this->encodePath($path), $body));
1010+
$response = $this->client->request($method, $this->encodePath($path), $body);
9761011
return $response['statusCode'] === $expected;
9771012
} catch (ClientHttpException $e) {
9781013
if ($e->getHttpStatus() === 404 && $method === 'DELETE') {
@@ -1149,11 +1184,11 @@ public function getDirectoryContent(string $directory): \Traversable {
11491184
$this->init();
11501185
$directory = $this->cleanPath($directory);
11511186
try {
1152-
$responses = $this->withAuthRetry(fn () => $this->client->propFind(
1187+
$responses = $this->client->propFind(
11531188
$this->encodePath($directory),
11541189
$this->getPropfindProperties(),
11551190
1
1156-
));
1191+
);
11571192

11581193
array_shift($responses); //the first entry is the current directory
11591194
if (!$this->statCache->hasKey($directory)) {

0 commit comments

Comments
 (0)