@@ -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