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