4444use Sabre \HTTP \ClientException ;
4545use Sabre \HTTP \ClientHttpException ;
4646use 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