From 71fd68b8747ac487b931856dace9f2fae75c2eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:14:35 +0100 Subject: [PATCH 01/41] feat: Bearer auth aware Sabre HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- lib/private/Files/Storage/DAV.php | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 49a91cced9c28..681aa16857b7b 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -38,6 +38,43 @@ use Sabre\HTTP\ClientHttpException; use Sabre\HTTP\RequestInterface; +/* + * Class BearerAuthAwareSabreClient + * + * This is an extension of the Sabre HTTP Client + * to provide it with the ability to make bearer authn requests. + * + * @package OC\Files\Storage + */ +class BearerAuthAwareSabreClient extends Client +{ + /** + * Bearer authentication. + */ + const AUTH_BEARER = 8; + + /** + * Constructor. + * + * See Sabre\DAV\Client + * + */ + public function __construct(array $settings) + { + parent::__construct($settings); + + if (isset($settings['userName']) && isset($settings['authType']) && ($settings['authType'] & self::AUTH_BEARER)) { + $userName = $settings['userName']; + + $curlType = $this->curlSettings[CURLOPT_HTTPAUTH]; + $curlType |= CURLAUTH_BEARER; + + $this->addCurlSetting(CURLOPT_HTTPAUTH, $curlType); + $this->addCurlSetting(CURLOPT_XOAUTH2_BEARER, $userName); + } + } +} + /** * Class DAV * From 046105df5bebf547a4153e8fceca8c3c6dc07f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:15:39 +0100 Subject: [PATCH 02/41] feat(dav): Add token endpoint to exchange refresh tokens for access tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/Controller/TokenController.php | 198 ++++++++++++++++++ lib/private/OCM/Model/OCMProvider.php | 32 +++ lib/private/OCM/OCMDiscoveryService.php | 8 +- 5 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 apps/dav/lib/Controller/TokenController.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 6716b6c5ac1cd..22c356260a8cf 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -261,6 +261,7 @@ 'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => $baseDir . '/../lib/Controller/OutOfOfficeController.php', + 'OCA\\DAV\\Controller\\TokenController' => $baseDir . '/../lib/Controller/TokenController.php', 'OCA\\DAV\\Controller\\UpcomingEventsController' => $baseDir . '/../lib/Controller/UpcomingEventsController.php', 'OCA\\DAV\\DAV\\CustomPropertiesBackend' => $baseDir . '/../lib/DAV/CustomPropertiesBackend.php', 'OCA\\DAV\\DAV\\GroupPrincipalBackend' => $baseDir . '/../lib/DAV/GroupPrincipalBackend.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 5b5b1e3fcb413..272c88aacf525 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -276,6 +276,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => __DIR__ . '/..' . '/../lib/Controller/OutOfOfficeController.php', + 'OCA\\DAV\\Controller\\TokenController' => __DIR__ . '/..' . '/../lib/Controller/TokenController.php', 'OCA\\DAV\\Controller\\UpcomingEventsController' => __DIR__ . '/..' . '/../lib/Controller/UpcomingEventsController.php', 'OCA\\DAV\\DAV\\CustomPropertiesBackend' => __DIR__ . '/..' . '/../lib/DAV/CustomPropertiesBackend.php', 'OCA\\DAV\\DAV\\GroupPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/GroupPrincipalBackend.php', diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php new file mode 100644 index 0000000000000..24093fdc8fa71 --- /dev/null +++ b/apps/dav/lib/Controller/TokenController.php @@ -0,0 +1,198 @@ +signatureManager->getIncomingSignedRequest($this->signatoryManager); + $this->logger->debug('Token request signature verified', [ + 'origin' => $signedRequest->getOrigin() + ]); + return $signedRequest; + } catch (SignatureNotFoundException|SignatoryNotFoundException $e) { + $this->logger->debug('Token request not signed', ['exception' => $e]); + + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { + $this->logger->notice('Rejected unsigned token request', ['exception' => $e]); + throw new IncomingRequestException('Unsigned request not allowed'); + } + return null; + } catch (SignatureException $e) { + $this->logger->warning('Invalid token request signature', ['exception' => $e]); + throw new IncomingRequestException('Invalid signature'); + } + } + + /** + * Exchange a refresh token for a short-lived access token + * + * @return DataResponse|DataResponse + * + * 200: Access token successfully generated + * 400: Bad request - missing refresh token or invalid request format + * 401: Unauthorized - invalid or expired refresh token, or invalid signature + */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')] + public function accessToken(): DataResponse { + try { + $signedRequest = $this->verifySignedRequest(); + } catch (IncomingRequestException $e) { + $this->logger->warning('Token request signature verification failed', [ + 'exception' => $e + ]); + return new DataResponse( + ['error' => 'invalid_request'], + Http::STATUS_UNAUTHORIZED + ); + } + + $body = file_get_contents('php://input'); + $data = json_decode($body, true); + + if (!is_array($data)) { + return new DataResponse( + ['error' => 'invalid_request'], + Http::STATUS_BAD_REQUEST + ); + } + + $refreshToken = $data['code'] ?? ''; + $grantType = $data['grant_type'] ?? ''; + + if ($grantType !== 'authorization_code') { + return new DataResponse( + ['error' => 'unsupported_grant_type'], + Http::STATUS_BAD_REQUEST + ); + } + + if (empty($refreshToken)) { + return new DataResponse( + ['error' => 'refresh_token is required'], + Http::STATUS_BAD_REQUEST + ); + } + + try { + $token = $this->tokenProvider->getToken($refreshToken); + + if ($token->getType() !== IToken::PERMANENT_TOKEN) { + $this->logger->warning('Attempted to use non-permanent token as refresh token', [ + 'tokenId' => $token->getId(), + ]); + return new DataResponse( + ['error' => 'invalid_grant'], + Http::STATUS_UNAUTHORIZED + ); + } + + $accessTokenString = $this->random->generate( + 72, + ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS + ); + + $expiresIn = 3600; // 1 hour in seconds + $expiresAt = $this->timeFactory->getTime() + $expiresIn; + + $accessToken = $this->tokenProvider->generateToken( + $accessTokenString, + $refreshToken, // Keep refresh token with access token as UID + $token->getLoginName(), + null, // No password for access tokens + 'OCM Access Token', + IToken::TEMPORARY_TOKEN, + IToken::DO_NOT_REMEMBER + ); + + $accessToken->setExpires($expiresAt); + $this->tokenProvider->updateToken($accessToken); + + return new DataResponse([ + 'access_token' => $accessTokenString, + 'token_type' => 'Bearer', + 'expires_in' => $expiresIn, + ], Http::STATUS_OK); + } catch (InvalidTokenException $e) { + $this->logger->info('Invalid refresh token provided', [ + 'exception' => $e, + ]); + return new DataResponse( + ['error' => 'invalid_grant'], + Http::STATUS_UNAUTHORIZED + ); + } catch (ExpiredTokenException $e) { + $this->logger->info('Expired refresh token provided', [ + 'exception' => $e, + ]); + return new DataResponse( + ['error' => 'invalid_grant'], + Http::STATUS_UNAUTHORIZED + ); + } catch (\Exception $e) { + $this->logger->error('Error generating access token', [ + 'exception' => $e, + ]); + return new DataResponse( + ['error' => 'server_error'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/lib/private/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index bbbace0d882c6..928aa74613642 100644 --- a/lib/private/OCM/Model/OCMProvider.php +++ b/lib/private/OCM/Model/OCMProvider.php @@ -24,6 +24,7 @@ class OCMProvider implements IOCMProvider { private string $inviteAcceptDialog = ''; private array $capabilities = []; private string $endPoint = ''; + private string $tokenEndPoint = ''; /** @var IOCMResource[] */ private array $resourceTypes = []; private ?Signatory $signatory = null; @@ -111,6 +112,27 @@ public function getEndPoint(): string { return $this->endPoint; } + /** + * @param string $tokenEndPoint + * + * @return $this + */ + public function setTokenEndPoint(string $endPoint): static { + $this->tokenEndPoint = $endPoint; + + return $this; + } + + /** + * @return string + */ + public function getTokenEndPoint(): string { + if (in_array('exchange-token', $this->capabilities)) { + return $this->tokenEndPoint; + } + return ''; + } + /** * @return string */ @@ -250,6 +272,12 @@ public function import(array $data): static { $this->setSignatory($signatory); } } + if (isset($data['capabilities'])) { + $this->setCapabilities($data['capabilities']); + } + if (isset($data['tokenEndPoint'])) { + $this->setTokenEndPoint($data['tokenEndPoint']); + } if (!$this->looksValid()) { throw new OCMProviderException('remote provider does not look valid'); @@ -289,6 +317,10 @@ public function jsonSerialize(): array { if ($capabilities) { $response['capabilities'] = $capabilities; } + $tokenEndpoint = $this->getTokenEndPoint(); + if ($tokenEndpoint) { + $response['tokenEndPoint'] = $tokenEndpoint; + } $inviteAcceptDialog = $this->getInviteAcceptDialog(); if ($inviteAcceptDialog !== '') { $response['inviteAcceptDialog'] = $inviteAcceptDialog; diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 17a84c12d5007..00e3842a2807e 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -49,7 +49,7 @@ #[Consumable(since: '28.0.0')] final class OCMDiscoveryService implements IOCMDiscoveryService { private ICache $cache; - public const API_VERSION = '1.1.0'; + public const API_VERSION = '1.1.2'; private ?IOCMProvider $localProvider = null; /** @var array */ private array $remoteProviders = []; @@ -91,6 +91,7 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider } if (array_key_exists($remote, $this->remoteProviders)) { + return $this->remoteProviders[$remote]; } @@ -127,7 +128,6 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider $remote . '/ocm-provider', ]; - foreach ($urls as $url) { $exception = null; $body = null; @@ -191,6 +191,7 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { } $url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare'); + $tokenUrl = $this->urlGenerator->linkToRouteAbsolute('dav.Token.accessToken'); $pos = strrpos($url, '/'); if ($pos === false) { $this->logger->debug('generated route should contain a slash character'); @@ -200,7 +201,8 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { $provider->setEnabled(true); $provider->setApiVersion(self::API_VERSION); $provider->setEndPoint(substr($url, 0, $pos)); - $provider->setCapabilities(['invite-accepted', 'notifications', 'shares']); + $provider->setCapabilities(['invite-accepted', 'notifications', 'shares', 'exchange-token']); + $provider->setTokenEndPoint($tokenUrl); // The inviteAcceptDialog is available from the contacts app, if this config value is set $inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG); From a413da0c39e00f1dda808dc5943d26c0ac7476ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:16:51 +0100 Subject: [PATCH 03/41] feat(dav): Add Bearer auth backend for webdav requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/dav/appinfo/v1/publicwebdav.php | 16 ++++++++++++++-- apps/dav/lib/Connector/Sabre/BearerAuth.php | 12 ++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/dav/appinfo/v1/publicwebdav.php b/apps/dav/appinfo/v1/publicwebdav.php index 2eaf05ca2d9b6..53ff67109756d 100644 --- a/apps/dav/appinfo/v1/publicwebdav.php +++ b/apps/dav/appinfo/v1/publicwebdav.php @@ -9,6 +9,7 @@ use OC\Files\Storage\Wrapper\PermissionsMask; use OC\Files\View; use OCA\DAV\Connector\LegacyPublicAuth; +use OCA\DAV\Connector\Sabre\BearerAuth; use OCA\DAV\Connector\Sabre\ServerFactory; use OCA\DAV\Files\Sharing\FilesDropPlugin; use OCA\DAV\Files\Sharing\PublicLinkCheckPlugin; @@ -49,7 +50,14 @@ Server::get(ISession::class), Server::get(IThrottler::class) ); +$bearerAuthBackend = new BearerAuth( + Server::get(IUserSession::class), + Server::get(ISession::class), + Server::get(IRequest::class), + Server::get(IConfig::class), +); $authPlugin = new \Sabre\DAV\Auth\Plugin($authBackend); +$authPlugin->addBackend($bearerAuthBackend); /** @var IEventDispatcher $eventDispatcher */ $eventDispatcher = Server::get(IEventDispatcher::class); @@ -80,6 +88,7 @@ $authPlugin, function (\Sabre\DAV\Server $server) use ( $authBackend, + $bearerAuthBackend, $linkCheckPlugin, $filesDropPlugin ) { @@ -90,8 +99,11 @@ function (\Sabre\DAV\Server $server) use ( // this is what is thrown when trying to access a non-existing share throw new \Sabre\DAV\Exception\NotAuthenticated(); } - - $share = $authBackend->getShare(); + try { + $share = $authBackend->getShare(); + } catch (AssertionError $e) { + $share = $bearerAuthBackend->getShare(); + } $owner = $share->getShareOwner(); $isReadable = $share->getPermissions() & Constants::PERMISSION_READ; $fileId = $share->getNodeId(); diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index 23453ae8efbab..ba0228a70d64e 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -12,6 +12,9 @@ use OCP\IRequest; use OCP\ISession; use OCP\IUserSession; +use OCP\Server; +use OCP\Share\IManager; +use OCP\Share\IShare; use Sabre\DAV\Auth\Backend\AbstractBearer; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -23,6 +26,7 @@ public function __construct( private IRequest $request, private IConfig $config, private string $principalPrefix = 'principals/users/', + private string $token = '', ) { // setup realm $defaults = new Defaults(); @@ -40,6 +44,7 @@ private function setupUserFs($userId) { */ public function validateBearerToken($bearerToken) { \OC_Util::setupFS(); + $this->token = $bearerToken; if (!$this->userSession->isLoggedIn()) { $this->userSession->tryTokenLogin($this->request); @@ -51,6 +56,13 @@ public function validateBearerToken($bearerToken) { return false; } + public function getShare(): IShare { + $shareManager = Server::get(IManager::class); + $share = $shareManager->getShareByToken($this->token); + assert($share !== null); + return $share; + } + /** * \Sabre\DAV\Auth\Backend\AbstractBearer::challenge sets an WWW-Authenticate * header which some DAV clients can't handle. Thus we override this function From ca53d2e26692fa2f2b99e48920f4ce4d00921c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:17:59 +0100 Subject: [PATCH 04/41] feat(dav): New method doTryTokenLogin to allow to try token login with token rather that request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/dav/lib/Connector/Sabre/BearerAuth.php | 3 +++ lib/private/User/Session.php | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index ba0228a70d64e..609a47dbbe6de 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -49,6 +49,9 @@ public function validateBearerToken($bearerToken) { if (!$this->userSession->isLoggedIn()) { $this->userSession->tryTokenLogin($this->request); } + if (!$this->userSession->isLoggedIn()) { + $this->userSession->doTryTokenLogin($bearerToken); + } if ($this->userSession->isLoggedIn()) { return $this->setupUserFs($this->userSession->getUser()->getUID()); } diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 811c5ba4bc326..8664be8656812 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -830,6 +830,10 @@ public function tryTokenLogin(IRequest $request) { } else { return false; } + return $this->doTryTokenLogin($token); + } + + public function doTryTokenLogin($token) { if (!$this->loginWithToken($token)) { return false; From 3fcfff816de6d72d57da8ac7697b1e048cb564d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:18:45 +0100 Subject: [PATCH 05/41] feat(federatedfilesharing): Create permanent refresh token when creating a remote share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../lib/FederatedShareProvider.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 1d1c00772c5bf..4eb5e4df5dc5f 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -7,6 +7,8 @@ */ namespace OCA\FederatedFileSharing; +use OC\Authentication\Token\IToken; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; use OCP\Constants; @@ -22,6 +24,8 @@ use OCP\IDBConnection; use OCP\IL10N; use OCP\IUserManager; +use OCP\Security\ISecureRandom; +use OCP\Server; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IShare; @@ -170,7 +174,15 @@ public function create(IShare $share): IShare { * @throws \Exception */ protected function createFederatedShare(IShare $share): string { - $token = $this->tokenHandler->generateToken(); + + $provider = Server::get(PublicKeyTokenProvider::class); + $token = Server::get(ISecureRandom::class)->generate(32, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + $uid = $share->getSharedBy(); + $user = $this->userManager->get($uid); + $name = $user->getDisplayName(); + $pass = $share->getPassword(); + + $dbToken = $provider->generateToken($token, $uid, $uid, $pass, $name, type: IToken::PERMANENT_TOKEN); $shareId = $this->addShareToDB( $share->getNodeId(), $share->getNodeType(), From cd4e445857a6ff793884a6520be18f9220269031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:19:42 +0100 Subject: [PATCH 06/41] feat(federatedfilesharing): When a remote requests a share with a token, it may be an access token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../Controller/RequestHandlerController.php | 8 ++++ apps/dav/lib/Controller/TokenController.php | 17 ++++---- .../lib/FederatedShareProvider.php | 14 +++++++ lib/private/Files/Storage/DAV.php | 40 +++++++++---------- lib/private/Share20/Manager.php | 16 ++++++++ lib/private/User/Session.php | 27 +++++++++++-- 6 files changed, 89 insertions(+), 33 deletions(-) diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index bfccb2fe20eaf..0567eaa1be118 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -7,6 +7,7 @@ namespace OCA\CloudFederationAPI\Controller; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Db\FederatedInviteMapper; @@ -43,6 +44,7 @@ use OCP\Security\Signature\Exceptions\SignatoryNotFoundException; use OCP\Security\Signature\IIncomingSignedRequest; use OCP\Security\Signature\ISignatureManager; +use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Util; use Psr\Log\LoggerInterface; @@ -490,6 +492,12 @@ private function confirmNotificationIdentity( $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); if ($provider instanceof ISignedCloudFederationProvider) { $identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification); + if ($identity === '') { + $tokenProvider = Server::get(PublicKeyTokenProvider::class); + $accessTokenDb = $tokenProvider->getToken($sharedSecret); + $refreshToken = $accessTokenDb->getUID(); + $identity = $provider->getFederationIdFromSharedSecret($refreshToken, $notification); + } } else { $this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]); return; diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php index 24093fdc8fa71..afc63d8e9ba23 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -7,7 +7,14 @@ namespace OCA\DAV\Controller; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\ISignatureManager; use OC\Authentication\Token\IProvider; +use OC\OCM\OCMSignatoryManager; +use OC\Security\Signature\Model\IncomingSignedRequest; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; @@ -18,18 +25,10 @@ use OCP\Authentication\Exceptions\ExpiredTokenException; use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Authentication\Token\IToken; +use OCP\IAppConfig; use OCP\IRequest; use OCP\Security\ISecureRandom; use Psr\Log\LoggerInterface; -use OCP\IAppConfig; -use OC\OCM\OCMSignatoryManager; -use NCU\Security\Signature\ISignatureManager; -use NCU\Security\Signature\Exceptions\SignatureNotFoundException; -use NCU\Security\Signature\Exceptions\SignatureException; -use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; -use NCU\Security\Signature\Model\IIncomingSignedRequest; -use NCU\Security\Signature\Exceptions\IncomingRequestException; -use OC\Security\Signature\Model\IncomingSignedRequest; /** * Controller for the /token endpoint diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 4eb5e4df5dc5f..d596bb28968a8 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -751,6 +751,20 @@ public function getShareByToken(string $token): IShare { $data = $cursor->fetchAssociative(); + if ($data === false) { + + $provider = Server::get(PublicKeyTokenProvider::class); + $accessTokenDb = $provider->getToken($token); + $refreshToken = $accessTokenDb->getUID(); + + $cursor = $qb->select('*') + ->from('share') + ->where($qb->expr()->in('share_type', $qb->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($refreshToken))) + ->executeQuery(); + + $data = $cursor->fetch(); + } if ($data === false) { throw new ShareNotFound('Share not found', $this->l->t('Could not find share')); } diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 681aa16857b7b..1939576221f8e 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -46,33 +46,31 @@ * * @package OC\Files\Storage */ -class BearerAuthAwareSabreClient extends Client -{ - /** - * Bearer authentication. - */ - const AUTH_BEARER = 8; - - /** - * Constructor. +class BearerAuthAwareSabreClient extends Client { + /** + * Bearer authentication. + */ + public const AUTH_BEARER = 8; + + /** + * Constructor. * * See Sabre\DAV\Client * - */ - public function __construct(array $settings) - { - parent::__construct($settings); + */ + public function __construct(array $settings) { + parent::__construct($settings); - if (isset($settings['userName']) && isset($settings['authType']) && ($settings['authType'] & self::AUTH_BEARER)) { - $userName = $settings['userName']; + if (isset($settings['userName']) && isset($settings['authType']) && ($settings['authType'] & self::AUTH_BEARER)) { + $userName = $settings['userName']; - $curlType = $this->curlSettings[CURLOPT_HTTPAUTH]; - $curlType |= CURLAUTH_BEARER; + $curlType = $this->curlSettings[CURLOPT_HTTPAUTH]; + $curlType |= CURLAUTH_BEARER; - $this->addCurlSetting(CURLOPT_HTTPAUTH, $curlType); - $this->addCurlSetting(CURLOPT_XOAUTH2_BEARER, $userName); - } - } + $this->addCurlSetting(CURLOPT_HTTPAUTH, $curlType); + $this->addCurlSetting(CURLOPT_XOAUTH2_BEARER, $userName); + } + } } /** diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 1d72ec63cb0ff..5396892ea9019 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -8,6 +8,7 @@ namespace OC\Share20; use ArrayIterator; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Core\AppInfo\ConfigLexicon; use OC\Files\Filesystem; use OC\Files\Mount\MoveableMount; @@ -42,6 +43,7 @@ use OCP\Security\IHasher; use OCP\Security\ISecureRandom; use OCP\Security\PasswordContext; +use OCP\Server; use OCP\Share; use OCP\Share\Events\BeforeShareCreatedEvent; use OCP\Share\Events\BeforeShareDeletedEvent; @@ -1390,6 +1392,20 @@ public function getShareByToken(string $token): IShare { } } + // Try to fetch a federated share by access token + if ($share === null) { + try { + $provider = $this->factory->getProviderForType(IShare::TYPE_REMOTE); + $tokenProvider = Server::get(PublicKeyTokenProvider::class); + $accessTokenDb = $tokenProvider->getToken($token); + $refreshToken = $accessTokenDb->getUID(); + $share = $provider->getShareByToken($refreshToken); + } catch (ProviderException $e) { + } catch (ShareNotFound $e) { + } + } + + // If it is not a link share try to fetch a mail share by token if ($share === null && $this->shareProviderExists(IShare::TYPE_EMAIL)) { try { diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 8664be8656812..6786e0156c850 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -619,14 +619,35 @@ private function loginWithToken($token) { // Ignore and use empty string instead } - $this->manager->emit('\OC\User', 'preLogin', [$dbToken->getLoginName(), $password]); - $user = $this->manager->get($uid); if (is_null($user)) { + // Maybe this is an access token. We keep the refresh tokens as UID of access tokens + try { + $token = $uid; + $dbToken = $this->tokenProvider->getToken($token); + } catch (InvalidTokenException $ex) { + return false; + } + $uid = $dbToken->getUID(); + + // When logging in with token, the password must be decrypted first before passing to login hook + $password = ''; + try { + $password = $this->tokenProvider->getPassword($dbToken, $token); + } catch (PasswordlessTokenException $ex) { + // Ignore and use empty string instead + } // user does not exist - return false; + $user = $this->manager->get($uid); + if (is_null($user)) { + return false; + } } + $this->manager->emit('\OC\User', 'preLogin', [$dbToken->getLoginName(), $password]); + + // See line 173 in this module, needed for completeLogin + OC_User::setIncognitoMode(false); return $this->completeLogin( $user, [ From 8c3883cc1855dc045693b5d0e6611590b1d8a10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:20:39 +0100 Subject: [PATCH 07/41] feat(files_sharing): When requesting a remote share with bearer auth, get an access token to use as bearer token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/files_sharing/lib/External/Storage.php | 11 +- lib/private/Files/Storage/DAV.php | 109 +++++++++++++++++++- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index e8de4bc63b5d3..c17f5add3adc7 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -66,10 +66,16 @@ public function __construct($options) { $ocmProvider = $discoveryService->discover($this->cloudId->getRemote()); $webDavEndpoint = $ocmProvider->extractProtocolEntry('file', 'webdav'); $remote = $ocmProvider->getEndPoint(); + $authType = \Sabre\DAV\Client::AUTH_BASIC; + $capabilities = $ocmProvider->getCapabilities(); + if (in_array('exchange-token', $capabilities)) { + $authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER; + } } catch (OCMProviderException|OCMArgumentException $e) { $this->logger->notice('exception while retrieving webdav endpoint', ['exception' => $e]); $webDavEndpoint = '/public.php/webdav'; $remote = $this->cloudId->getRemote(); + $authType = \Sabre\DAV\Client::AUTH_BASIC; } $host = parse_url($remote, PHP_URL_HOST); @@ -92,8 +98,9 @@ public function __construct($options) { 'host' => $host, 'root' => $webDavEndpoint, 'user' => $options['token'], - 'authType' => \Sabre\DAV\Client::AUTH_BASIC, - 'password' => (string)$options['password'] + 'authType' => $authType, + 'password' => (string)$options['password'], + 'discoveryService' => $discoveryService, ] ); } diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 1939576221f8e..d1c9b0b81be33 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -10,8 +10,10 @@ use Exception; use Icewind\Streams\CallbackWrapper; use Icewind\Streams\IteratorDirectory; +use NCU\Security\Signature\ISignatureManager; use OC\Files\Filesystem; use OC\MemCache\ArrayCache; +use OC\OCM\OCMSignatoryManager; use OCP\AppFramework\Http; use OCP\Constants; use OCP\Diagnostics\IEventLogger; @@ -24,10 +26,14 @@ use OCP\Files\StorageNotAvailableException; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; +use OCP\IAppConfig; use OCP\ICertificateManager; use OCP\IConfig; use OCP\ITempManager; use OCP\Lock\LockedException; +use OCP\OCM\Exceptions\OCMArgumentException; +use OCP\OCM\Exceptions\OCMProviderException; +use OCP\OCM\IOCMDiscoveryService; use OCP\Server; use OCP\Util; use Psr\Http\Message\ResponseInterface; @@ -38,7 +44,7 @@ use Sabre\HTTP\ClientHttpException; use Sabre\HTTP\RequestInterface; -/* +/** * Class BearerAuthAwareSabreClient * * This is an extension of the Sabre HTTP Client @@ -106,6 +112,10 @@ class DAV extends Common { protected LoggerInterface $logger; protected IEventLogger $eventLogger; protected IMimeTypeDetector $mimeTypeDetector; + protected IOCMDiscoveryService $discoveryService; + protected ISignatureManager $signatureManager; + protected OCMSignatoryManager $signatoryManager; + protected IAppConfig $appConfig; /** @var int */ private $timeout; @@ -128,6 +138,11 @@ class DAV extends Common { public function __construct(array $parameters) { $this->statCache = new ArrayCache(); $this->httpClientService = Server::get(IClientService::class); + if (isset($parameters['discoveryService'])) { + $this->discoveryService = $parameters['discoveryService']; + } else { + $this->discoveryService = Server::get(IOCMDiscoveryService::class); + } if (isset($parameters['host']) && isset($parameters['user']) && isset($parameters['password'])) { $host = $parameters['host']; //remove leading http[s], will be generated in createBaseUri() @@ -166,6 +181,9 @@ public function __construct(array $parameters) { // This timeout value will be used for the download and upload of files $this->timeout = Server::get(IConfig::class)->getSystemValueInt('davstorage.request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT); $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class); + $this->signatureManager = Server::get(ISignatureManager::class); + $this->signatoryManager = Server::get(OCMSignatoryManager::class); + $this->appConfig = Server::get(IAppConfig::class); } protected function init(): void { @@ -174,9 +192,82 @@ protected function init(): void { } $this->ready = true; + // If using Bearer auth, exchange refresh token for access token + $userName = $this->user; + if ($this->authType !== null && ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER)) { + try { + $host = 'https://' . $this->host; + $ocmProvider = $this->discoveryService->discover($host); + $tokenEndpoint = $ocmProvider->getTokenEndPoint(); + + if ($tokenEndPoint === '') { + $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not discover token endpoint'); + } + + $client = $this->httpClientService->newClient(); + $payload = [ + 'grant_type' => 'authorization_code', + 'client_id' => 'receiver.example.org', + 'code' => $this->user, + ]; + + $options = [ + 'body' => json_encode($payload), + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + + // Try signing the request + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['app' => 'dav']); + } catch (\Exception $e) { + $this->logger->warning('Failed to sign token request, continuing without signature', [ + 'app' => 'dav', + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + } + } + + $response = $client->post($tokenEndpoint, $options); + + $body = $response->getBody(); + $data = json_decode($body, true); + + if (isset($data['access_token'])) { + $userName = $data['access_token']; + $this->user = $userName; + $this->logger->debug('Successfully exchanged refresh token for access token', ['app' => 'dav']); + } else { + $this->logger->error('Failed to get access token from response', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not obtain access token'); + } + } catch (OCMProviderException|OCMArgumentException $e) { + $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not discover token endpoint'); + } catch (\Exception $e) { + $this->logger->error('Error exchanging refresh token for access token: ' . $e->getMessage(), [ + 'app' => 'dav', + 'exception' => $e, + ]); + throw new StorageNotAvailableException('Could not obtain access token: ' . $e->getMessage()); + } + } + $settings = [ 'baseUri' => $this->createBaseUri(), - 'userName' => $this->user, + 'userName' => $userName, 'password' => $this->password, ]; if ($this->authType !== null) { @@ -188,7 +279,7 @@ protected function init(): void { $settings['proxy'] = $proxy; } - $this->client = new Client($settings); + $this->client = new BearerAuthAwareSabreClient($settings); $this->client->setThrowExceptions(true); if ($this->secure === true) { @@ -392,10 +483,14 @@ public function fopen(string $path, string $mode) { case 'r': case 'rb': try { + $auth = [$this->user, $this->password]; + if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { + $auth = [$this->user, '', 'bearer']; + } $response = $this->httpClientService ->newClient() ->get($this->createBaseUri() . $this->encodePath($path), [ - 'auth' => [$this->user, $this->password], + 'auth' => $auth, 'stream' => true, // set download timeout for users with slow connections or large files 'timeout' => $this->timeout @@ -542,11 +637,15 @@ protected function uploadFile(string $path, string $target): void { $this->statCache->remove($target); $source = fopen($path, 'r'); + $auth = [$this->user, $this->password]; + if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { + $auth = [$this->user, '', 'bearer']; + } $this->httpClientService ->newClient() ->put($this->createBaseUri() . $this->encodePath($target), [ 'body' => $source, - 'auth' => [$this->user, $this->password], + 'auth' => $auth, // set upload timeout for users with slow connections or large files 'timeout' => $this->timeout ]); From 1f1ea36538f5d7f7a0d4d6298446836318850ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:21:38 +0100 Subject: [PATCH 08/41] feat: adapt to guzzle api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- lib/private/Files/Storage/DAV.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index d1c9b0b81be33..03dd0479164d5 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -484,12 +484,15 @@ public function fopen(string $path, string $mode) { case 'rb': try { $auth = [$this->user, $this->password]; + $headers = []; if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { - $auth = [$this->user, '', 'bearer']; + $auth = []; + $headers = ['Authorization' => 'Bearer ' . $this->user]; } $response = $this->httpClientService ->newClient() ->get($this->createBaseUri() . $this->encodePath($path), [ + 'headers' => $headers, 'auth' => $auth, 'stream' => true, // set download timeout for users with slow connections or large files @@ -638,13 +641,16 @@ protected function uploadFile(string $path, string $target): void { $source = fopen($path, 'r'); $auth = [$this->user, $this->password]; + $headers = []; if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { - $auth = [$this->user, '', 'bearer']; + $auth = []; + $headers = ['Authorization' => 'Bearer ' . $this->user]; } $this->httpClientService ->newClient() ->put($this->createBaseUri() . $this->encodePath($target), [ 'body' => $source, + 'headers' => $headers, 'auth' => $auth, // set upload timeout for users with slow connections or large files 'timeout' => $this->timeout From 4c0e3ca0cb7c2824aa6cd209613b68542412dc7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Tue, 23 Dec 2025 13:22:30 +0100 Subject: [PATCH 09/41] feat(cloud_federation_api): adapt to new format for share creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../Controller/RequestHandlerController.php | 19 +++++-- .../Federation/CloudFederationFactory.php | 57 ++++++++++++++++++- .../Federation/CloudFederationShare.php | 46 ++++++++++++--- lib/private/Server.php | 6 +- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 0567eaa1be118..87e2854d9ec80 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -93,7 +93,7 @@ public function __construct( * @param string|null $ownerDisplayName Display name of the user who shared the item * @param string|null $sharedBy Provider specific UID of the user who shared the resource * @param string|null $sharedByDisplayName Display name of the user who shared the resource - * @param array{name: list, options: array} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]] + * @param array{name: string, options?: array, webdav?: array} $protocol Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] * @param string $shareType 'group' or 'user' share * @param string $resourceType 'file', 'calendar',... * @@ -128,9 +128,6 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ || $shareType === null || !is_array($protocol) || !isset($protocol['name']) - || !isset($protocol['options']) - || !is_array($protocol['options']) - || !isset($protocol['options']['sharedSecret']) ) { return new JSONResponse( [ @@ -141,6 +138,20 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ ); } + $protocolName = $protocol['name']; + $hasOldFormat = isset($protocol['options']) && is_array($protocol['options']) && isset($protocol['options']['sharedSecret']); + $hasNewFormat = isset($protocol[$protocolName]) && is_array($protocol[$protocolName]) && isset($protocol[$protocolName]['sharedSecret']); + + if (!$hasOldFormat && !$hasNewFormat) { + return new JSONResponse( + [ + 'message' => 'Missing sharedSecret in protocol', + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + $supportedShareTypes = $this->config->getSupportedShareTypes($resourceType); if (!in_array($shareType, $supportedShareTypes)) { return new JSONResponse( diff --git a/lib/private/Federation/CloudFederationFactory.php b/lib/private/Federation/CloudFederationFactory.php index d06de0f2f588e..d7b5d79efcbbd 100644 --- a/lib/private/Federation/CloudFederationFactory.php +++ b/lib/private/Federation/CloudFederationFactory.php @@ -9,8 +9,18 @@ use OCP\Federation\ICloudFederationFactory; use OCP\Federation\ICloudFederationNotification; use OCP\Federation\ICloudFederationShare; +use OCP\Federation\ICloudIdManager; +use OCP\OCM\IOCMDiscoveryService; +use OCP\OCM\Exceptions\OCMProviderException; +use Psr\Log\LoggerInterface; class CloudFederationFactory implements ICloudFederationFactory { + public function __construct( + private IOCMDiscoveryService $ocmDiscoveryService, + private ICloudIdManager $cloudIdManager, + private LoggerInterface $logger, + ) { + } /** * get a CloudFederationShare Object to prepare a share you want to send * @@ -30,7 +40,52 @@ class CloudFederationFactory implements ICloudFederationFactory { * @since 14.0.0 */ public function getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $sharedSecret, $shareType, $resourceType) { - return new CloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $shareType, $resourceType, $sharedSecret); + $useExchangeToken = false; + $remoteDomain = null; + + try { + $cloudId = $this->cloudIdManager->resolveCloudId($shareWith); + $remoteDomain = $cloudId->getRemote(); + + try { + $remoteProvider = $this->ocmDiscoveryService->discover($remoteDomain); + $capabilities = $remoteProvider->getCapabilities(); + + $useExchangeToken = in_array('exchange-token', $capabilities, true); + + $this->logger->debug('OCM provider capabilities discovered', [ + 'remote' => $remoteDomain, + 'capabilities' => $capabilities, + 'useExchangeToken' => $useExchangeToken, + ]); + } catch (OCMProviderException $e) { + $this->logger->warning('Failed to discover OCM provider, using legacy share method', [ + 'remote' => $remoteDomain, + 'exception' => $e->getMessage(), + ]); + } + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid cloud ID format, using legacy share method', [ + 'shareWith' => $shareWith, + 'exception' => $e->getMessage(), + ]); + } + + return new CloudFederationShare( + $shareWith, + $name, + $description, + $providerId, + $owner, + $ownerDisplayName, + $sharedBy, + $sharedByDisplayName, + $shareType, + $resourceType, + $sharedSecret, + $useExchangeToken, + $remoteDomain + ); } /** diff --git a/lib/private/Federation/CloudFederationShare.php b/lib/private/Federation/CloudFederationShare.php index 6bd35cea763e4..2befc99cb5035 100644 --- a/lib/private/Federation/CloudFederationShare.php +++ b/lib/private/Federation/CloudFederationShare.php @@ -40,6 +40,8 @@ class CloudFederationShare implements ICloudFederationShare { * @param string $shareType ('group' or 'user' share) * @param string $resourceType ('file', 'calendar',...) * @param string $sharedSecret + * @param bool $useExchangeToken whether to use exchange-token protocol (new way) or sharedSecret (old way) + * @param string|null $remoteDomain remote domain for constructing webdav URI */ public function __construct($shareWith = '', $name = '', @@ -52,6 +54,8 @@ public function __construct($shareWith = '', $shareType = '', $resourceType = '', $sharedSecret = '', + $useExchangeToken = false, + $remoteDomain = null, ) { $this->setShareWith($shareWith); $this->setResourceName($name); @@ -61,13 +65,27 @@ public function __construct($shareWith = '', $this->setOwnerDisplayName($ownerDisplayName); $this->setSharedBy($sharedBy); $this->setSharedByDisplayName($sharedByDisplayName); - $this->setProtocol([ - 'name' => 'webdav', - 'options' => [ - 'sharedSecret' => $sharedSecret, - 'permissions' => '{http://open-cloud-mesh.org/ns}share-permissions' - ] - ]); + + if ($useExchangeToken) { + $webdavUri = $remoteDomain ? 'https://' . $remoteDomain . '/public.php/webdav/' : ''; + $this->setProtocol([ + 'name' => 'webdav', + 'webdav' => [ + 'uri' => $webdavUri, + 'sharedSecret' => $sharedSecret, + 'permissions' => ['{http://open-cloud-mesh.org/ns}share-permissions'] + ] + ]); + } else { + $this->setProtocol([ + 'name' => 'webdav', + 'options' => [ + 'sharedSecret' => $sharedSecret, + 'permissions' => '{http://open-cloud-mesh.org/ns}share-permissions' + ] + ]); + } + $this->setShareType($shareType); $this->setResourceType($resourceType); } @@ -328,7 +346,19 @@ public function getShareType() { * @since 14.0.0 */ public function getShareSecret() { - return $this->share['protocol']['options']['sharedSecret']; + $protocol = $this->share['protocol']; + if (isset($protocol['options']['sharedSecret'])) { + return $protocol['options']['sharedSecret']; + } + + if (isset($protocol['name'])) { + $protocolName = $protocol['name']; + if (isset($protocol[$protocolName]['sharedSecret'])) { + return $protocol[$protocolName]['sharedSecret']; + } + } + + return ''; } /** diff --git a/lib/private/Server.php b/lib/private/Server.php index f608d3ee26d28..1f0fd189966e7 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1197,7 +1197,11 @@ public function __construct( $this->registerAlias(\OCP\GlobalScale\IConfig::class, \OC\GlobalScale\Config::class); $this->registerAlias(ICloudFederationProviderManager::class, CloudFederationProviderManager::class); $this->registerService(ICloudFederationFactory::class, function (Server $c) { - return new CloudFederationFactory(); + return new CloudFederationFactory( + $c->get(\OCP\OCM\IOCMDiscoveryService::class), + $c->get(\OCP\Federation\ICloudIdManager::class), + $c->get(\Psr\Log\LoggerInterface::class) + ); }); $this->registerAlias(IControllerMethodReflector::class, ControllerMethodReflector::class); From b8313145dedb577669488ee8baebf6fbdaedcfa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 8 Jan 2026 12:25:54 +0100 Subject: [PATCH 10/41] feat(cloud_federation_api): support multi protocol for share creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../lib/Controller/RequestHandlerController.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 87e2854d9ec80..7f323b9f3086c 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -93,7 +93,7 @@ public function __construct( * @param string|null $ownerDisplayName Display name of the user who shared the item * @param string|null $sharedBy Provider specific UID of the user who shared the resource * @param string|null $sharedByDisplayName Display name of the user who shared the resource - * @param array{name: string, options?: array, webdav?: array} $protocol Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] + * @param array{name: string, options?: array, webdav?: array} $protocol Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]] * @param string $shareType 'group' or 'user' share * @param string $resourceType 'file', 'calendar',... * @@ -142,7 +142,20 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ $hasOldFormat = isset($protocol['options']) && is_array($protocol['options']) && isset($protocol['options']['sharedSecret']); $hasNewFormat = isset($protocol[$protocolName]) && is_array($protocol[$protocolName]) && isset($protocol[$protocolName]['sharedSecret']); - if (!$hasOldFormat && !$hasNewFormat) { + // For multi-protocol, we only consider webdav + $hasMultiFormat = false; + if ($protocolName === 'multi') { + if (isset($protocol['webdav']) && is_array($protocol['webdav']) && isset($protocol['webdav']['sharedSecret'])) { + $hasMultiFormat = true; + $protocol = [ + 'name' => 'webdav', + 'webdav' => $protocol['webdav'] + ]; + $protocolName = 'webdav'; + } + } + + if (!$hasOldFormat && !$hasNewFormat && !$hasMultiFormat) { return new JSONResponse( [ 'message' => 'Missing sharedSecret in protocol', From 9643221c2b9e2161d8b9343ae43c979a1fdbb54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 8 Jan 2026 12:26:26 +0100 Subject: [PATCH 11/41] fix(dav): data sent to token endpoint must be application/x-www-form-urlencoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/dav/lib/Controller/TokenController.php | 9 +-------- lib/private/Files/Storage/DAV.php | 10 +++++++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php index afc63d8e9ba23..11c4890c57d41 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -103,14 +103,7 @@ public function accessToken(): DataResponse { } $body = file_get_contents('php://input'); - $data = json_decode($body, true); - - if (!is_array($data)) { - return new DataResponse( - ['error' => 'invalid_request'], - Http::STATUS_BAD_REQUEST - ); - } + parse_str($body, $data); $refreshToken = $data['code'] ?? ''; $grantType = $data['grant_type'] ?? ''; diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 03dd0479164d5..6eaef07900ffd 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -30,6 +30,7 @@ use OCP\ICertificateManager; use OCP\IConfig; use OCP\ITempManager; +use OCP\IURLGenerator; use OCP\Lock\LockedException; use OCP\OCM\Exceptions\OCMArgumentException; use OCP\OCM\Exceptions\OCMProviderException; @@ -116,6 +117,7 @@ class DAV extends Common { protected ISignatureManager $signatureManager; protected OCMSignatoryManager $signatoryManager; protected IAppConfig $appConfig; + protected IURLGenerator $urlGenerator; /** @var int */ private $timeout; @@ -184,6 +186,7 @@ public function __construct(array $parameters) { $this->signatureManager = Server::get(ISignatureManager::class); $this->signatoryManager = Server::get(OCMSignatoryManager::class); $this->appConfig = Server::get(IAppConfig::class); + $this->urlGenerator = Server::get(IURLGenerator::class); } protected function init(): void { @@ -206,16 +209,17 @@ protected function init(): void { } $client = $this->httpClientService->newClient(); + $clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST); $payload = [ 'grant_type' => 'authorization_code', - 'client_id' => 'receiver.example.org', + 'client_id' => $clientId, 'code' => $this->user, ]; $options = [ - 'body' => json_encode($payload), + 'body' => http_build_query($payload), 'headers' => [ - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', ], 'timeout' => 10, 'connect_timeout' => 10, From 3fd96261842a3e6e57a4d6141b80479ddd128b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 9 Jan 2026 16:22:24 +0100 Subject: [PATCH 12/41] fix(dav): when receiving a share, account for the must-exchange-token requirement, in addition to the exchaange-token capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/dav/lib/Controller/TokenController.php | 2 +- .../lib/FederatedShareProvider.php | 30 ++-- .../lib/OCM/CloudFederationProviderFiles.php | 83 ++++++++- apps/files_sharing/lib/External/Manager.php | 19 +++ apps/files_sharing/lib/External/Storage.php | 68 +++++++- lib/private/Files/Storage/DAV.php | 161 ++++++++++-------- 6 files changed, 275 insertions(+), 88 deletions(-) diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php index 11c4890c57d41..b942193e35eb0 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -136,7 +136,7 @@ public function accessToken(): DataResponse { } $accessTokenString = $this->random->generate( - 72, + 64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS ); diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index d596bb28968a8..74a1f2440b745 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -10,6 +10,7 @@ use OC\Authentication\Token\IToken; use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; +use OCP\Authentication\Exceptions\InvalidTokenException; use OC\Share20\Share; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -752,18 +753,23 @@ public function getShareByToken(string $token): IShare { $data = $cursor->fetchAssociative(); if ($data === false) { - - $provider = Server::get(PublicKeyTokenProvider::class); - $accessTokenDb = $provider->getToken($token); - $refreshToken = $accessTokenDb->getUID(); - - $cursor = $qb->select('*') - ->from('share') - ->where($qb->expr()->in('share_type', $qb->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY))) - ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($refreshToken))) - ->executeQuery(); - - $data = $cursor->fetch(); + // Token not found as refresh token, try looking it up as access token + try { + $provider = Server::get(PublicKeyTokenProvider::class); + $accessTokenDb = $provider->getToken($token); + $refreshToken = $accessTokenDb->getUID(); + + $qb2 = $this->dbConnection->getQueryBuilder(); + $cursor = $qb2->select('*') + ->from('share') + ->where($qb2->expr()->in('share_type', $qb2->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb2->expr()->eq('token', $qb2->createNamedParameter($refreshToken))) + ->executeQuery(); + + $data = $cursor->fetch(); + } catch (InvalidTokenException) { + // Token is not a valid access token, share not found + } } if ($data === false) { throw new ShareNotFound('Share not found', $this->l->t('Could not find share')); diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index a5ae8c87abd25..f5fcff394fc4f 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -41,6 +41,8 @@ use OCP\Notification\IManager as INotificationManager; use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; +use OCP\Http\Client\IClientService; +use OCP\OCM\IOCMDiscoveryService; use OCP\Share\IManager; use OCP\Share\IProviderFactory; use OCP\Share\IShare; @@ -70,6 +72,8 @@ public function __construct( private readonly IProviderFactory $shareProviderFactory, private readonly ISetupManager $setupManager, private readonly ExternalShareMapper $externalShareMapper, + private readonly IOCMDiscoveryService $discoveryService, + private readonly IClientService $clientService, ) { } @@ -106,6 +110,31 @@ public function shareReceived(ICloudFederationShare $share): string { $ownerFederatedId = $share->getOwner(); $shareType = $this->mapShareTypeToNextcloud($share->getShareType()); + // Check for must-exchange-token requirement + $requirements = $protocol['webdav']['requirements'] ?? $protocol['options']['requirements'] ?? []; + $mustExchangeToken = in_array('must-exchange-token', $requirements); + $accessToken = ''; + + if ($mustExchangeToken) { + // Exchange the sharedSecret for an access token (required) + $accessToken = $this->exchangeToken($remote, $token); + if ($accessToken === null) { + throw new ProviderCouldNotAddShareException('Failed to exchange token as required by must-exchange-token', '', Http::STATUS_BAD_REQUEST); + } + } else { + // Check if remote has exchange-token capability and try to exchange (optional) + try { + $ocmProvider = $this->discoveryService->discover(rtrim($remote, '/')); + $capabilities = $ocmProvider->getCapabilities(); + if (in_array('exchange-token', $capabilities)) { + $accessToken = $this->exchangeToken($remote, $token) ?? ''; + $this->logger->debug('Exchanged token for remote with exchange-token capability', ['remote' => $remote, 'success' => !empty($accessToken)]); + } + } catch (\Exception $e) { + $this->logger->debug('Could not discover remote capabilities for token exchange', ['remote' => $remote, 'exception' => $e]); + } + } + // if no explicit information about the person who created the share was sent // we assume that the share comes from the owner if ($sharedByFederatedId === null) { @@ -146,8 +175,8 @@ public function shareReceived(ICloudFederationShare $share): string { $externalShare->generateId(); $externalShare->setRemote($remote); $externalShare->setRemoteId($remoteId); - $externalShare->setShareToken($token); - $externalShare->setPassword(''); + $externalShare->setShareToken($token); // refresh token (sharedSecret) + $externalShare->setPassword($accessToken); // access token (empty if no token exchange) $externalShare->setName($name); $externalShare->setOwner($owner); $externalShare->setShareType($shareType); @@ -684,4 +713,54 @@ public function getFederationIdFromSharedSecret( return $share->getShareOwner(); } } + + /** + * Exchange a sharedSecret (refresh token) for an access token via the remote server's token endpoint + * + * @param string $remote The remote server URL + * @param string $sharedSecret The shared secret to exchange + * @return string|null The access token, or null on failure + */ + private function exchangeToken(string $remote, #[SensitiveParameter] string $sharedSecret): ?string { + try { + $ocmProvider = $this->discoveryService->discover(rtrim($remote, '/')); + $tokenEndpoint = $ocmProvider->getTokenEndPoint(); + + if ($tokenEndpoint === '') { + $this->logger->warning('Remote server does not expose tokenEndPoint', ['remote' => $remote]); + return null; + } + + $client = $this->clientService->newClient(); + $clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST); + + $payload = [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'code' => $sharedSecret, + ]; + + $response = $client->post($tokenEndpoint, [ + 'body' => http_build_query($payload), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'timeout' => 10, + 'connect_timeout' => 10, + ]); + + $data = json_decode($response->getBody(), true); + + if (isset($data['access_token'])) { + $this->logger->debug('Successfully exchanged token for access token', ['remote' => $remote]); + return $data['access_token']; + } + + $this->logger->warning('Token exchange response missing access_token', ['remote' => $remote, 'response' => $data]); + return null; + } catch (\Exception $e) { + $this->logger->warning('Failed to exchange token', ['remote' => $remote, 'exception' => $e]); + return null; + } + } } diff --git a/apps/files_sharing/lib/External/Manager.php b/apps/files_sharing/lib/External/Manager.php index 44bf97512ef98..fb39e2d10fd6a 100644 --- a/apps/files_sharing/lib/External/Manager.php +++ b/apps/files_sharing/lib/External/Manager.php @@ -563,4 +563,23 @@ public function getAcceptedShares(): array { return []; } } + + /** + * Update the access token for a share. + * + * @param string $shareToken The share token (refresh token) to identify the share + * @param string $accessToken The new access token to store + */ + public function updateAccessToken(string $shareToken, string $accessToken): void { + try { + $share = $this->externalShareMapper->getShareByToken($shareToken); + $share->setPassword($accessToken); + $this->externalShareMapper->update($share); + $this->logger->debug('Updated access token for share', ['shareToken' => substr($shareToken, 0, 8) . '...']); + } catch (DoesNotExistException $e) { + $this->logger->warning('Could not find share to update access token', ['shareToken' => substr($shareToken, 0, 8) . '...']); + } catch (Exception $e) { + $this->logger->error('Failed to update access token', ['exception' => $e]); + } + } } diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index c17f5add3adc7..2b306f1e339e9 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -48,6 +48,7 @@ class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, private bool $updateChecked = false; private ExternalShareManager $manager; private IConfig $config; + private bool $tokenRefreshed = false; /** * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, password: ?string}|array $options @@ -78,6 +79,12 @@ public function __construct($options) { $authType = \Sabre\DAV\Client::AUTH_BASIC; } + // If we have a stored access token (password), use Bearer auth regardless of discovery + // This handles the case where the share was created with must-exchange-token + if (!empty($options['password'])) { + $authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER; + } + $host = parse_url($remote, PHP_URL_HOST); $port = parse_url($remote, PHP_URL_PORT); $host .= ($port === null) ? '' : ':' . $port; // we add port if available @@ -105,6 +112,63 @@ public function __construct($options) { ); } + /** + * Refresh the access token by exchanging the refresh token. + * Updates both the in-memory password and the database. + * + * @return bool True if token was refreshed successfully + */ + protected function refreshAccessToken(): bool { + if ($this->tokenRefreshed) { + // only try to refresh once per request + return false; + } + $this->tokenRefreshed = true; + + try { + $newAccessToken = $this->exchangeRefreshToken(); + $this->password = $newAccessToken; + + $this->manager->updateAccessToken($this->token, $newAccessToken); + + $this->ready = false; + $this->client = null; + + $this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']); + return true; + } catch (\Exception $e) { + $this->logger->warning('Failed to refresh access token', [ + 'app' => 'files_sharing', + 'exception' => $e, + ]); + return false; + } + } + + /** + * Execute an operation with automatic token refresh on 401 errors. + * + * @template T + * @param callable(): T $operation The operation to execute + * @return T + * @throws \Exception + */ + protected function withTokenRefresh(callable $operation) { + try { + return $operation(); + } catch (\Sabre\HTTP\ClientHttpException $e) { + if ($e->getHttpStatus() === 401 && $this->refreshAccessToken()) { + return $operation(); + } + throw $e; + } catch (\Sabre\DAV\Exception\NotAuthenticated $e) { + if ($this->refreshAccessToken()) { + return $operation(); + } + throw $e; + } + } + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { $storage = $this; @@ -166,7 +230,7 @@ public function hasUpdated(string $path, int $time): bool { } $this->updateChecked = true; try { - return parent::hasUpdated('', $time); + return $this->withTokenRefresh(fn () => parent::hasUpdated('', $time)); } catch (StorageInvalidException $e) { // check if it needs to be removed $this->checkStorageAvailability(); @@ -180,7 +244,7 @@ public function hasUpdated(string $path, int $time): bool { public function test(): bool { try { - return parent::test(); + return $this->withTokenRefresh(fn () => parent::test()); } catch (StorageInvalidException $e) { // check if it needs to be removed $this->checkStorageAvailability(); diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 6eaef07900ffd..9f678102a0254 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -102,6 +102,8 @@ class DAV extends Common { protected $certPath; /** @var bool */ protected $ready; + /** @var string The resolved bearer token for AUTH_BEARER (access token or exchanged token) */ + protected $bearerToken; /** @var Client */ protected $client; /** @var ArrayCache */ @@ -195,78 +197,16 @@ protected function init(): void { } $this->ready = true; - // If using Bearer auth, exchange refresh token for access token + // If using Bearer auth, use stored access token or exchange refresh token for access token $userName = $this->user; if ($this->authType !== null && ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER)) { - try { - $host = 'https://' . $this->host; - $ocmProvider = $this->discoveryService->discover($host); - $tokenEndpoint = $ocmProvider->getTokenEndPoint(); - - if ($tokenEndPoint === '') { - $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); - throw new StorageNotAvailableException('Could not discover token endpoint'); - } - - $client = $this->httpClientService->newClient(); - $clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST); - $payload = [ - 'grant_type' => 'authorization_code', - 'client_id' => $clientId, - 'code' => $this->user, - ]; - - $options = [ - 'body' => http_build_query($payload), - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - 'timeout' => 10, - 'connect_timeout' => 10, - ]; - - // Try signing the request - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { - try { - $options = $this->signatureManager->signOutgoingRequestIClientPayload( - $this->signatoryManager, - $options, - 'post', - $tokenEndpoint - ); - $this->logger->debug('Token request signed successfully', ['app' => 'dav']); - } catch (\Exception $e) { - $this->logger->warning('Failed to sign token request, continuing without signature', [ - 'app' => 'dav', - 'exception' => $e, - 'endpoint' => $tokenEndpoint, - ]); - } - } - - $response = $client->post($tokenEndpoint, $options); - - $body = $response->getBody(); - $data = json_decode($body, true); - - if (isset($data['access_token'])) { - $userName = $data['access_token']; - $this->user = $userName; - $this->logger->debug('Successfully exchanged refresh token for access token', ['app' => 'dav']); - } else { - $this->logger->error('Failed to get access token from response', ['app' => 'dav']); - throw new StorageNotAvailableException('Could not obtain access token'); - } - } catch (OCMProviderException|OCMArgumentException $e) { - $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); - throw new StorageNotAvailableException('Could not discover token endpoint'); - } catch (\Exception $e) { - $this->logger->error('Error exchanging refresh token for access token: ' . $e->getMessage(), [ - 'app' => 'dav', - 'exception' => $e, - ]); - throw new StorageNotAvailableException('Could not obtain access token: ' . $e->getMessage()); + // Check if we already have an access token stored (password field) + if (!empty($this->password)) { + $userName = $this->password; + } else { + $userName = $this->exchangeRefreshToken(); } + $this->bearerToken = $userName; } $settings = [ @@ -309,6 +249,85 @@ protected function init(): void { }); } + /** + * Exchange refresh token for access token via the remote server's token endpoint + * + * @return string The access token + * @throws StorageNotAvailableException If token exchange fails + */ + protected function exchangeRefreshToken(): string { + try { + $host = 'https://' . $this->host; + $ocmProvider = $this->discoveryService->discover($host); + $tokenEndpoint = $ocmProvider->getTokenEndPoint(); + + if ($tokenEndpoint === '') { + $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not discover token endpoint'); + } + + $client = $this->httpClientService->newClient(); + $clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST); + $payload = [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'code' => $this->user, // refresh token is stored in user field + ]; + + $options = [ + 'body' => http_build_query($payload), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + + // Try signing the request + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['app' => 'dav']); + } catch (\Exception $e) { + $this->logger->warning('Failed to sign token request, continuing without signature', [ + 'app' => 'dav', + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + } + } + + $response = $client->post($tokenEndpoint, $options); + + $body = $response->getBody(); + $data = json_decode($body, true); + + if (isset($data['access_token'])) { + $this->logger->debug('Successfully exchanged refresh token for access token', ['app' => 'dav']); + return $data['access_token']; + } else { + $this->logger->error('Failed to get access token from response', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not obtain access token'); + } + } catch (OCMProviderException|OCMArgumentException $e) { + $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not discover token endpoint'); + } catch (StorageNotAvailableException $e) { + throw $e; + } catch (\Exception $e) { + $this->logger->error('Error exchanging refresh token for access token: ' . $e->getMessage(), [ + 'app' => 'dav', + 'exception' => $e, + ]); + throw new StorageNotAvailableException('Could not obtain access token: ' . $e->getMessage()); + } + } + /** * Clear the stat cache */ @@ -491,7 +510,7 @@ public function fopen(string $path, string $mode) { $headers = []; if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { $auth = []; - $headers = ['Authorization' => 'Bearer ' . $this->user]; + $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; } $response = $this->httpClientService ->newClient() @@ -648,7 +667,7 @@ protected function uploadFile(string $path, string $target): void { $headers = []; if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { $auth = []; - $headers = ['Authorization' => 'Bearer ' . $this->user]; + $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; } $this->httpClientService ->newClient() From 09a227b3c6343e57bec515b097b37f6d09f0f8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 12 Jan 2026 11:00:01 +0100 Subject: [PATCH 13/41] fix(federatedfilesharing): POSTs to token endpoint should be signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../lib/OCM/CloudFederationProviderFiles.php | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index f5fcff394fc4f..1118f6e98a463 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -7,6 +7,7 @@ namespace OCA\FederatedFileSharing\OCM; use OC\AppFramework\Http; +use OC\OCM\OCMSignatoryManager; use OC\Files\Filesystem; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; @@ -33,6 +34,7 @@ use OCP\Files\ISetupManager; use OCP\Files\NotFoundException; use OCP\HintException; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroupManager; use OCP\IURLGenerator; @@ -46,6 +48,7 @@ use OCP\Share\IManager; use OCP\Share\IProviderFactory; use OCP\Share\IShare; +use OCP\Security\Signature\ISignatureManager; use OCP\Util; use Override; use Psr\Log\LoggerInterface; @@ -74,6 +77,9 @@ public function __construct( private readonly ExternalShareMapper $externalShareMapper, private readonly IOCMDiscoveryService $discoveryService, private readonly IClientService $clientService, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, + private readonly IAppConfig $appConfig, ) { } @@ -740,14 +746,35 @@ private function exchangeToken(string $remote, #[SensitiveParameter] string $sha 'code' => $sharedSecret, ]; - $response = $client->post($tokenEndpoint, [ + $options = [ 'body' => http_build_query($payload), 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', ], 'timeout' => 10, 'connect_timeout' => 10, - ]); + ]; + + // Try signing the request + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['remote' => $remote]); + } catch (\Exception $e) { + $this->logger->warning('Failed to sign token request, continuing without signature', [ + 'remote' => $remote, + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + } + } + + $response = $client->post($tokenEndpoint, $options); $data = json_decode($response->getBody(), true); From 6f1a9078e5e09ef37138885785dc433f57a4bda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 12 Jan 2026 11:12:11 +0100 Subject: [PATCH 14/41] fix(federatedfilesharing): POSTs to token endpoint MUST be signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../lib/OCM/CloudFederationProviderFiles.php | 32 +++++++++---------- lib/private/Files/Storage/DAV.php | 32 +++++++++---------- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 1118f6e98a463..42381cbf884fb 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -755,23 +755,21 @@ private function exchangeToken(string $remote, #[SensitiveParameter] string $sha 'connect_timeout' => 10, ]; - // Try signing the request - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { - try { - $options = $this->signatureManager->signOutgoingRequestIClientPayload( - $this->signatoryManager, - $options, - 'post', - $tokenEndpoint - ); - $this->logger->debug('Token request signed successfully', ['remote' => $remote]); - } catch (\Exception $e) { - $this->logger->warning('Failed to sign token request, continuing without signature', [ - 'remote' => $remote, - 'exception' => $e, - 'endpoint' => $tokenEndpoint, - ]); - } + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['remote' => $remote]); + } catch (\Exception $e) { + $this->logger->error('Failed to sign token request', [ + 'remote' => $remote, + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + return null; } $response = $client->post($tokenEndpoint, $options); diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 9f678102a0254..0b88111875351 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -283,23 +283,21 @@ protected function exchangeRefreshToken(): string { 'connect_timeout' => 10, ]; - // Try signing the request - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { - try { - $options = $this->signatureManager->signOutgoingRequestIClientPayload( - $this->signatoryManager, - $options, - 'post', - $tokenEndpoint - ); - $this->logger->debug('Token request signed successfully', ['app' => 'dav']); - } catch (\Exception $e) { - $this->logger->warning('Failed to sign token request, continuing without signature', [ - 'app' => 'dav', - 'exception' => $e, - 'endpoint' => $tokenEndpoint, - ]); - } + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['app' => 'dav']); + } catch (\Exception $e) { + $this->logger->error('Failed to sign token request', [ + 'app' => 'dav', + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + throw new StorageNotAvailableException('Could not sign token request: ' . $e->getMessage()); } $response = $client->post($tokenEndpoint, $options); From ca0106b671fefdb602be3326e4816ad5e836db3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 8 Jan 2026 12:32:53 +0100 Subject: [PATCH 15/41] fix: federated share provider tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/dav/lib/Connector/Sabre/BearerAuth.php | 9 ++++++--- apps/federatedfilesharing/lib/FederatedShareProvider.php | 2 +- .../tests/FederatedShareProviderTest.php | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index 609a47dbbe6de..be36b05519040 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -46,13 +46,16 @@ public function validateBearerToken($bearerToken) { \OC_Util::setupFS(); $this->token = $bearerToken; - if (!$this->userSession->isLoggedIn()) { + $loggedIn = $this->userSession->isLoggedIn(); + if (!$loggedIn) { $this->userSession->tryTokenLogin($this->request); + $loggedIn = $this->userSession->isLoggedIn(); } - if (!$this->userSession->isLoggedIn()) { + if (!$loggedIn) { $this->userSession->doTryTokenLogin($bearerToken); + $loggedIn = $this->userSession->isLoggedIn(); } - if ($this->userSession->isLoggedIn()) { + if ($loggedIn) { return $this->setupUserFs($this->userSession->getUser()->getUID()); } diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 74a1f2440b745..c11bd721143b1 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -180,7 +180,7 @@ protected function createFederatedShare(IShare $share): string { $token = Server::get(ISecureRandom::class)->generate(32, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); $uid = $share->getSharedBy(); $user = $this->userManager->get($uid); - $name = $user->getDisplayName(); + $name = $user?->getDisplayName() ?? $uid; $pass = $share->getPassword(); $dbToken = $provider->generateToken($token, $uid, $uid, $pass, $name, type: IToken::PERMANENT_TOKEN); diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index 0852a4b548904..b84a4b3e122fc 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php @@ -227,7 +227,7 @@ public function testCreateCouldNotFindServer(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->matchesRegularExpression('/^[A-Za-z0-9]{32}$/'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -280,7 +280,7 @@ public function testCreateException(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->matchesRegularExpression('/^[A-Za-z0-9]{32}$/'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), From f93799b9e58fb03ed3bf30ed83d8ab0a68108e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 8 Jan 2026 12:33:33 +0100 Subject: [PATCH 16/41] fix: share manager test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- lib/private/Share20/Manager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 5396892ea9019..220226c83a760 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -8,6 +8,7 @@ namespace OC\Share20; use ArrayIterator; +use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Core\AppInfo\ConfigLexicon; use OC\Files\Filesystem; @@ -1400,8 +1401,7 @@ public function getShareByToken(string $token): IShare { $accessTokenDb = $tokenProvider->getToken($token); $refreshToken = $accessTokenDb->getUID(); $share = $provider->getShareByToken($refreshToken); - } catch (ProviderException $e) { - } catch (ShareNotFound $e) { + } catch (ProviderException|ShareNotFound|InvalidTokenException $e) { } } From a1029d75ed4d157f9a284878bd86adaecea70efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 10:52:48 +0100 Subject: [PATCH 17/41] fix(federatedfilesharing): fix federated share provider tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../tests/FederatedShareProviderTest.php | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index b84a4b3e122fc..28ef66d4f2e6f 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php @@ -9,11 +9,13 @@ namespace OCA\FederatedFileSharing\Tests; use LogicException; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Federation\CloudIdManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\Notifications; use OCA\FederatedFileSharing\TokenHandler; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\Contacts\IManager as IContactsManager; use OCP\EventDispatcher\IEventDispatcher; @@ -27,6 +29,7 @@ use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; +use OCP\Security\ISecureRandom; use OCP\Server; use OCP\Share\IManager; use OCP\Share\IShare; @@ -87,6 +90,19 @@ protected function setUp(): void { $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); + // Mock ISecureRandom to return a predictable token (must be 32+ chars) + $secureRandom = $this->createMock(ISecureRandom::class); + $secureRandom->method('generate') + ->willReturn('tokentokentokentokentokentokenab'); + $this->overwriteService(ISecureRandom::class, $secureRandom); + + // Mock PublicKeyTokenProvider to avoid database token creation + $tokenProvider = $this->createMock(PublicKeyTokenProvider::class); + $mockToken = $this->createMock(IToken::class); + $tokenProvider->method('generateToken') + ->willReturn($mockToken); + $this->overwriteService(PublicKeyTokenProvider::class, $tokenProvider); + $this->provider = new FederatedShareProvider( $this->connection, $this->addressHandler, @@ -145,7 +161,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('tokentokentokentokentokentokenab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -183,7 +199,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate 'file_source' => 42, 'permissions' => 19, 'accepted' => 0, - 'token' => 'token', + 'token' => 'tokentokentokentokentokentokenab', 'expiration' => $expectedDataDate, ]; foreach (array_keys($expected) as $key) { @@ -198,7 +214,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate $this->assertEquals('file', $share->getNodeType()); $this->assertEquals(42, $share->getNodeId()); $this->assertEquals(19, $share->getPermissions()); - $this->assertEquals('token', $share->getToken()); + $this->assertEquals('tokentokentokentokentokentokenab', $share->getToken()); $this->assertEquals($expirationDate, $share->getExpirationDate()); } @@ -369,7 +385,7 @@ public function testCreateAlreadyShared(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('tokentokentokentokentokentokenab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -440,7 +456,7 @@ public function testUpdate(string $owner, string $sharedBy, ?\DateTime $expirati $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('tokentokentokentokentokentokenab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), From 76858fdec917126c82610f17bf17e5aa3b83bd93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 11:20:14 +0100 Subject: [PATCH 18/41] fix(federatedfilesharing): fixing federated share provider tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../tests/FederatedShareProviderTest.php | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index 28ef66d4f2e6f..21f6556cf48d7 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php @@ -90,10 +90,14 @@ protected function setUp(): void { $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); - // Mock ISecureRandom to return a predictable token (must be 32+ chars) + // Mock ISecureRandom to return predictable tokens (must be 32+ chars) $secureRandom = $this->createMock(ISecureRandom::class); + $tokenCounter = 0; $secureRandom->method('generate') - ->willReturn('tokentokentokentokentokentokenab'); + ->willReturnCallback(function () use (&$tokenCounter) { + $tokenCounter++; + return 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'ab'; + }); $this->overwriteService(ISecureRandom::class, $secureRandom); // Mock PublicKeyTokenProvider to avoid database token creation @@ -161,7 +165,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('tokentokentokentokentokentokenab'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -199,7 +203,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate 'file_source' => 42, 'permissions' => 19, 'accepted' => 0, - 'token' => 'tokentokentokentokentokentokenab', + 'token' => 'token1token1token1token1token1ab', 'expiration' => $expectedDataDate, ]; foreach (array_keys($expected) as $key) { @@ -214,7 +218,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate $this->assertEquals('file', $share->getNodeType()); $this->assertEquals(42, $share->getNodeId()); $this->assertEquals(19, $share->getPermissions()); - $this->assertEquals('tokentokentokentokentokentokenab', $share->getToken()); + $this->assertEquals('token1token1token1token1token1ab', $share->getToken()); $this->assertEquals($expirationDate, $share->getExpirationDate()); } @@ -385,7 +389,7 @@ public function testCreateAlreadyShared(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('tokentokentokentokentokentokenab'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -456,7 +460,7 @@ public function testUpdate(string $owner, string $sharedBy, ?\DateTime $expirati $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('tokentokentokentokentokentokenab'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -930,11 +934,11 @@ public function testGetAccessList(): void { $result = $this->provider->getAccessList([$file1], true); $this->assertEquals(['remote' => [ 'user@server.com' => [ - 'token' => 'token1', + 'token' => 'token1token1token1token1token1ab', 'node_id' => $file1->getId(), ], 'foobar@localhost' => [ - 'token' => 'token2', + 'token' => 'token2token2token2token2token2ab', 'node_id' => $file1->getId(), ], ]], $result); From a78da1988bf741a2a03fc60df9cf0cb5e361aa6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 11:39:24 +0100 Subject: [PATCH 19/41] fix(federatedfilesharing): fixing federated share provider tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../tests/FederatedShareProviderTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index 21f6556cf48d7..1c4630c88201b 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php @@ -893,9 +893,9 @@ public function testGetAccessList(): void { $folder1 = $rootFolder->getUserFolder($u1->getUID())->newFolder('foo'); $file1 = $folder1->newFile('bar1'); - $this->tokenHandler->expects($this->exactly(2)) - ->method('generateToken') - ->willReturnOnConsecutiveCalls('token1', 'token2'); + // Token generation now uses ISecureRandom instead of tokenHandler + $this->tokenHandler->expects($this->never()) + ->method('generateToken'); $this->notifications->expects($this->atLeastOnce()) ->method('sendRemoteShare') ->willReturn(true); From fab3be80c3451c19b89cc27f302e48d80db708cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 12:37:26 +0100 Subject: [PATCH 20/41] fix: fixing code style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/federatedfilesharing/lib/FederatedShareProvider.php | 2 +- .../lib/OCM/CloudFederationProviderFiles.php | 7 ++++--- lib/private/Federation/CloudFederationFactory.php | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index c11bd721143b1..c0d622d808a26 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -10,8 +10,8 @@ use OC\Authentication\Token\IToken; use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; -use OCP\Authentication\Exceptions\InvalidTokenException; use OC\Share20\Share; +use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Federation\ICloudFederationProviderManager; diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 42381cbf884fb..2b2f56b830afa 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -7,8 +7,9 @@ namespace OCA\FederatedFileSharing\OCM; use OC\AppFramework\Http; -use OC\OCM\OCMSignatoryManager; use OC\Files\Filesystem; +use OC\Files\SetupManager; +use OC\OCM\OCMSignatoryManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\Federation\TrustedServers; @@ -34,6 +35,7 @@ use OCP\Files\ISetupManager; use OCP\Files\NotFoundException; use OCP\HintException; +use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroupManager; @@ -41,10 +43,9 @@ use OCP\IUser; use OCP\IUserManager; use OCP\Notification\IManager as INotificationManager; +use OCP\OCM\IOCMDiscoveryService; use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; -use OCP\Http\Client\IClientService; -use OCP\OCM\IOCMDiscoveryService; use OCP\Share\IManager; use OCP\Share\IProviderFactory; use OCP\Share\IShare; diff --git a/lib/private/Federation/CloudFederationFactory.php b/lib/private/Federation/CloudFederationFactory.php index d7b5d79efcbbd..cf39852a891f8 100644 --- a/lib/private/Federation/CloudFederationFactory.php +++ b/lib/private/Federation/CloudFederationFactory.php @@ -10,8 +10,8 @@ use OCP\Federation\ICloudFederationNotification; use OCP\Federation\ICloudFederationShare; use OCP\Federation\ICloudIdManager; -use OCP\OCM\IOCMDiscoveryService; use OCP\OCM\Exceptions\OCMProviderException; +use OCP\OCM\IOCMDiscoveryService; use Psr\Log\LoggerInterface; class CloudFederationFactory implements ICloudFederationFactory { From ce09a18831f092eeb687c4a37dc76fdc08c78df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 12:56:32 +0100 Subject: [PATCH 21/41] fix: fixing openapi specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/cloud_federation_api/openapi.json | 16 +++++++++------- apps/dav/openapi.json | 7 ++++++- openapi.json | 20 +++++++++++++------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/cloud_federation_api/openapi.json b/apps/cloud_federation_api/openapi.json index 21f669a2c5f79..a284ee1fcce1b 100644 --- a/apps/cloud_federation_api/openapi.json +++ b/apps/cloud_federation_api/openapi.json @@ -161,23 +161,25 @@ }, "protocol": { "type": "object", - "description": "e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]", + "description": "Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]]", "required": [ - "name", - "options" + "name" ], "properties": { "name": { - "type": "array", - "items": { - "type": "string" - } + "type": "string" }, "options": { "type": "object", "additionalProperties": { "type": "object" } + }, + "webdav": { + "type": "object", + "additionalProperties": { + "type": "object" + } } } }, diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index 8b7c6221eaeba..6d00810c6dbab 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1153,5 +1153,10 @@ } } }, - "tags": [] + "tags": [ + { + "name": "token", + "description": "Controller for the /token endpoint Exchanges long-lived refresh tokens for short-lived access tokens" + } + ] } diff --git a/openapi.json b/openapi.json index d034244903413..4bbefec06331a 100644 --- a/openapi.json +++ b/openapi.json @@ -41,6 +41,10 @@ "name": "cloud_federation_api/request_handler", "description": "Open-Cloud-Mesh-API" }, + { + "name": "dav/token", + "description": "Controller for the /token endpoint Exchanges long-lived refresh tokens for short-lived access tokens" + }, { "name": "federatedfilesharing/mount_public_link", "description": "Class MountPublicLinkController convert public links to federated shares" @@ -16788,23 +16792,25 @@ }, "protocol": { "type": "object", - "description": "e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]", + "description": "Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]]", "required": [ - "name", - "options" + "name" ], "properties": { "name": { - "type": "array", - "items": { - "type": "string" - } + "type": "string" }, "options": { "type": "object", "additionalProperties": { "type": "object" } + }, + "webdav": { + "type": "object", + "additionalProperties": { + "type": "object" + } } } }, From cd9d7c4c1aa2d4b897172484c770abb0d54e6019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 13:41:10 +0100 Subject: [PATCH 22/41] fix: fix psalm issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/dav/lib/Connector/Sabre/BearerAuth.php | 1 - apps/dav/lib/Controller/TokenController.php | 9 +++++---- .../lib/FederatedShareProvider.php | 2 +- .../Authentication/Token/PublicKeyToken.php | 4 ++++ lib/private/Files/Storage/DAV.php | 3 ++- lib/private/User/Session.php | 3 +-- lib/public/Authentication/Token/IToken.php | 7 +++++++ lib/public/IUserSession.php | 9 +++++++++ lib/public/OCM/ICapabilityAwareOCMProvider.php | 17 +++++++++++++++++ 9 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index be36b05519040..e4eae2143cb65 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -65,7 +65,6 @@ public function validateBearerToken($bearerToken) { public function getShare(): IShare { $shareManager = Server::get(IManager::class); $share = $shareManager->getShareByToken($this->token); - assert($share !== null); return $share; } diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php index b942193e35eb0..4f5e10ab0b4ac 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -12,9 +12,9 @@ use NCU\Security\Signature\Exceptions\SignatureException; use NCU\Security\Signature\Exceptions\SignatureNotFoundException; use NCU\Security\Signature\ISignatureManager; +use NCU\Security\Signature\Model\IIncomingSignedRequest; use OC\Authentication\Token\IProvider; use OC\OCM\OCMSignatoryManager; -use OC\Security\Signature\Model\IncomingSignedRequest; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; @@ -53,10 +53,10 @@ public function __construct( /** * Verify the signature of incoming request if available * - * @return IncomingSignedRequest|null null if remote does not support signed requests + * @return IIncomingSignedRequest|null null if remote does not support signed requests * @throws IncomingRequestException if signature is required but invalid */ - private function verifySignedRequest(): ?IncomingSignedRequest { + private function verifySignedRequest(): ?IIncomingSignedRequest { try { $signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager); $this->logger->debug('Token request signature verified', [ @@ -80,11 +80,12 @@ private function verifySignedRequest(): ?IncomingSignedRequest { /** * Exchange a refresh token for a short-lived access token * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: Access token successfully generated * 400: Bad request - missing refresh token or invalid request format * 401: Unauthorized - invalid or expired refresh token, or invalid signature + * 500: Internal server error */ #[PublicPage] #[NoCSRFRequired] diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index c0d622d808a26..71d1522423eba 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -7,8 +7,8 @@ */ namespace OCA\FederatedFileSharing; -use OC\Authentication\Token\IToken; use OC\Authentication\Token\PublicKeyTokenProvider; +use OCP\Authentication\Token\IToken; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; use OCP\Authentication\Exceptions\InvalidTokenException; diff --git a/lib/private/Authentication/Token/PublicKeyToken.php b/lib/private/Authentication/Token/PublicKeyToken.php index cf3a8b16141bc..eca54e41ab9f9 100644 --- a/lib/private/Authentication/Token/PublicKeyToken.php +++ b/lib/private/Authentication/Token/PublicKeyToken.php @@ -191,6 +191,10 @@ public function getRemember(): int { return parent::getRemember(); } + public function getType(): int { + return parent::getType(); + } + public function setToken(string $token): void { parent::setToken($token); } diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 0b88111875351..bd993e81dddd4 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -71,6 +71,7 @@ public function __construct(array $settings) { if (isset($settings['userName']) && isset($settings['authType']) && ($settings['authType'] & self::AUTH_BEARER)) { $userName = $settings['userName']; + /** @psalm-suppress InvalidArrayOffset */ $curlType = $this->curlSettings[CURLOPT_HTTPAUTH]; $curlType |= CURLAUTH_BEARER; @@ -90,7 +91,7 @@ class DAV extends Common { protected $password; /** @var string */ protected $user; - /** @var string|null */ + /** @var int|null */ protected $authType; /** @var string */ protected $host; diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 6786e0156c850..48b1a60f71ce6 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -854,8 +854,7 @@ public function tryTokenLogin(IRequest $request) { return $this->doTryTokenLogin($token); } - public function doTryTokenLogin($token) { - + public function doTryTokenLogin(string $token): bool { if (!$this->loginWithToken($token)) { return false; } diff --git a/lib/public/Authentication/Token/IToken.php b/lib/public/Authentication/Token/IToken.php index 546e6a4225550..8ad8f298f1942 100644 --- a/lib/public/Authentication/Token/IToken.php +++ b/lib/public/Authentication/Token/IToken.php @@ -130,4 +130,11 @@ public function setPassword(string $password): void; * @since 28.0.0 */ public function setExpires(?int $expires): void; + + /** + * Get the type of the token + * @return int One of IToken::TEMPORARY_TOKEN, IToken::PERMANENT_TOKEN, or IToken::WIPE_TOKEN + * @since 32.0.0 + */ + public function getType(): int; } diff --git a/lib/public/IUserSession.php b/lib/public/IUserSession.php index 42cc437aabae1..9d80bb95735ab 100644 --- a/lib/public/IUserSession.php +++ b/lib/public/IUserSession.php @@ -92,4 +92,13 @@ public function getImpersonatingUserID(): ?string; * @since 18.0.0 */ public function setImpersonatingUserID(bool $useCurrentUser = true): void; + + /** + * Try to login with the given token + * + * @param string $token + * @return bool true if successful + * @since 32.0.0 + */ + public function doTryTokenLogin(string $token): bool; } diff --git a/lib/public/OCM/ICapabilityAwareOCMProvider.php b/lib/public/OCM/ICapabilityAwareOCMProvider.php index faf44067d1255..0bfc9aaad38a1 100644 --- a/lib/public/OCM/ICapabilityAwareOCMProvider.php +++ b/lib/public/OCM/ICapabilityAwareOCMProvider.php @@ -15,4 +15,21 @@ * @deprecated 33.0.0 {@see IOCMProvider} */ interface ICapabilityAwareOCMProvider extends IOCMProvider { + /** + * get the token endpoint URL + * + * @return string + * @since 32.0.0 + */ + public function getTokenEndPoint(): string; + + /** + * set the token endpoint URL + * + * @param string $endPoint + * + * @return $this + * @since 32.0.0 + */ + public function setTokenEndPoint(string $endPoint): static; } From 8d2006204d169e755df0a98b0648242a784f40bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 13:44:05 +0100 Subject: [PATCH 23/41] fix: reorder import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/federatedfilesharing/lib/FederatedShareProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 71d1522423eba..4b0ef725dc1a7 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -8,10 +8,10 @@ namespace OCA\FederatedFileSharing; use OC\Authentication\Token\PublicKeyTokenProvider; -use OCP\Authentication\Token\IToken; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Federation\ICloudFederationProviderManager; From e4200568b9712ad27de05f7e93299e255266e20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 19 Jan 2026 15:57:20 +0100 Subject: [PATCH 24/41] fix(dav): do not import from NCU ns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- 3rdparty | 2 +- apps/dav/lib/Controller/TokenController.php | 12 ++++++------ lib/private/Files/Storage/DAV.php | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/3rdparty b/3rdparty index 8f97d8cef37b3..557f8c008eada 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 8f97d8cef37b32d25e36c16fc2f7be36f1a46901 +Subproject commit 557f8c008eada7d84e65850c04143c852da8f07a diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php index 4f5e10ab0b4ac..fbad90dd629a8 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -7,12 +7,6 @@ namespace OCA\DAV\Controller; -use NCU\Security\Signature\Exceptions\IncomingRequestException; -use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; -use NCU\Security\Signature\Exceptions\SignatureException; -use NCU\Security\Signature\Exceptions\SignatureNotFoundException; -use NCU\Security\Signature\ISignatureManager; -use NCU\Security\Signature\Model\IIncomingSignedRequest; use OC\Authentication\Token\IProvider; use OC\OCM\OCMSignatoryManager; use OCP\AppFramework\ApiController; @@ -28,6 +22,12 @@ use OCP\IAppConfig; use OCP\IRequest; use OCP\Security\ISecureRandom; +use OCP\Security\Signature\Exceptions\IncomingRequestException; +use OCP\Security\Signature\Exceptions\SignatoryNotFoundException; +use OCP\Security\Signature\Exceptions\SignatureException; +use OCP\Security\Signature\Exceptions\SignatureNotFoundException; +use OCP\Security\Signature\IIncomingSignedRequest; +use OCP\Security\Signature\ISignatureManager; use Psr\Log\LoggerInterface; /** diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index bd993e81dddd4..ce65f4ad972a0 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -10,7 +10,6 @@ use Exception; use Icewind\Streams\CallbackWrapper; use Icewind\Streams\IteratorDirectory; -use NCU\Security\Signature\ISignatureManager; use OC\Files\Filesystem; use OC\MemCache\ArrayCache; use OC\OCM\OCMSignatoryManager; @@ -35,6 +34,7 @@ use OCP\OCM\Exceptions\OCMArgumentException; use OCP\OCM\Exceptions\OCMProviderException; use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\Signature\ISignatureManager; use OCP\Server; use OCP\Util; use Psr\Http\Message\ResponseInterface; From 4d924764eae8edb34c9114f81ce04edc5c24cbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 23 Jan 2026 14:16:38 +0100 Subject: [PATCH 25/41] fix: fix sqlite integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- build/integration/features/bootstrap/Sharing.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index bd12f1f2455a6..9669d8f5648b6 100644 --- a/build/integration/features/bootstrap/Sharing.php +++ b/build/integration/features/bootstrap/Sharing.php @@ -316,7 +316,8 @@ public function isFieldInResponse($field, $contentExpected) { if (count($data->element) > 0) { foreach ($data as $element) { if ($contentExpected == 'A_TOKEN') { - return (strlen((string)$element->$field) == 15); + $tokenLength = strlen((string)$element->$field); + return $tokenLength == 15 || $tokenLength == 32; } elseif ($contentExpected == 'A_NUMBER') { return is_numeric((string)$element->$field); } elseif ($contentExpected == 'AN_URL') { @@ -331,7 +332,8 @@ public function isFieldInResponse($field, $contentExpected) { return false; } else { if ($contentExpected == 'A_TOKEN') { - return (strlen((string)$data->$field) == 15); + $tokenLength = strlen((string)$data->$field); + return $tokenLength == 15 || $tokenLength == 32; } elseif ($contentExpected == 'A_NUMBER') { return is_numeric((string)$data->$field); } elseif ($contentExpected == 'AN_URL') { @@ -617,9 +619,10 @@ private function assertFieldIsInReturnedShare(string $field, string $contentExpe if ($contentExpected === 'A_NUMBER') { Assert::assertTrue(is_numeric((string)$returnedShare->$field), "Field '$field' is not a number: " . $returnedShare->$field); } elseif ($contentExpected === 'A_TOKEN') { - // A token is composed by 15 characters from - // ISecureRandom::CHAR_HUMAN_READABLE. - Assert::assertRegExp('/^[abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789]{15}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); + // A token is either: + // - 15 characters from ISecureRandom::CHAR_HUMAN_READABLE (legacy), or + // - 32 characters from ISecureRandom::CHAR_ALPHANUMERIC (new OCM tokens) + Assert::assertRegExp('/^[a-zA-Z0-9]{15,32}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); } elseif (strpos($contentExpected, 'REGEXP ') === 0) { Assert::assertRegExp(substr($contentExpected, strlen('REGEXP ')), (string)$returnedShare->$field, "Field '$field' does not match"); } else { From eb14f881f98113ce23e8934c012aa7b4ec9786f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 23 Jan 2026 14:26:31 +0100 Subject: [PATCH 26/41] fix(federatedfilesharing): order of imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../lib/OCM/CloudFederationProviderFiles.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 2b2f56b830afa..1f9faa5039fae 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -44,12 +44,12 @@ use OCP\IUserManager; use OCP\Notification\IManager as INotificationManager; use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\Signature\ISignatureManager; use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; use OCP\Share\IProviderFactory; use OCP\Share\IShare; -use OCP\Security\Signature\ISignatureManager; use OCP\Util; use Override; use Psr\Log\LoggerInterface; From 72e714ad5574e31e4805e932456b411fdcb95501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 23 Jan 2026 14:36:51 +0100 Subject: [PATCH 27/41] fix: fix session tests using Session::loginWithToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- tests/lib/User/SessionTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/lib/User/SessionTest.php b/tests/lib/User/SessionTest.php index 84d5bc898a057..d435bc63dc327 100644 --- a/tests/lib/User/SessionTest.php +++ b/tests/lib/User/SessionTest.php @@ -331,6 +331,13 @@ public function testPasswordlessLoginNoLastCheckUpdate(): void { ->getMock(); $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('foo'); + $user->method('isEnabled')->willReturn(true); + $manager->method('get') + ->with('foo') + ->willReturn($user); + $session->expects($this->never()) ->method('set'); $session->expects($this->once()) @@ -369,6 +376,13 @@ public function testLoginLastCheckUpdate(): void { ->getMock(); $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('foo'); + $user->method('isEnabled')->willReturn(true); + $manager->method('get') + ->with('foo') + ->willReturn($user); + $session->expects($this->never()) ->method('set'); $session->expects($this->once()) From 26e9feb64e1bd5a3817836a4325ebb3a2ccfa28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 23 Jan 2026 14:49:45 +0100 Subject: [PATCH 28/41] fix: fix public key token provider test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php index 51915fc1d4b5a..3af393f3a3bd4 100644 --- a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php +++ b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php @@ -451,6 +451,7 @@ public function testRenewSessionTokenWithPassword(): void { public function testGetToken(): void { $token = new PublicKeyToken(); + $token->setType(IToken::TEMPORARY_TOKEN); $this->config->method('getSystemValue') ->with('secret') From 90be277ef84ce1c6accdeb5807ef00ed77d1f269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 26 Jan 2026 11:44:08 +0100 Subject: [PATCH 29/41] fix: Fixed undefined $request variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- lib/private/User/Session.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 48b1a60f71ce6..6b1eaad18a4c8 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -874,6 +874,7 @@ public function doTryTokenLogin(string $token): bool { $this->session->set('app_password', $token); } elseif ($dbToken instanceof PublicKeyToken && $dbToken->getType() === IToken::ONETIME_TOKEN) { $this->tokenProvider->invalidateTokenById($dbToken->getUID(), $dbToken->getId()); + $request = \OCP\Server::get(IRequest::class); if ($request->getPathInfo() !== '/core/getapppassword-onetime') { return false; } From 7f0715fde173d28bbcf61f55dabad26e9977de52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 26 Jan 2026 11:45:55 +0100 Subject: [PATCH 30/41] fix(files_external): Added missing doTryTokenLogin() method to implement the IUserSession interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/files_external/lib/Migration/DummyUserSession.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/files_external/lib/Migration/DummyUserSession.php b/apps/files_external/lib/Migration/DummyUserSession.php index 1ebf0e1ec4f85..3946d8fad381c 100644 --- a/apps/files_external/lib/Migration/DummyUserSession.php +++ b/apps/files_external/lib/Migration/DummyUserSession.php @@ -54,4 +54,8 @@ public function getImpersonatingUserID() : ?string { public function setImpersonatingUserID(bool $useCurrentUser = true): void { //no OP } + + public function doTryTokenLogin(string $token): bool { + return false; + } } From 5d61de4fae48caebe851a15a6cac45014e92d855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 26 Jan 2026 11:47:27 +0100 Subject: [PATCH 31/41] fix: Fixed parent::getType() to use ->getter('type') to avoid Psalm magic method detection issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- lib/private/Authentication/Token/PublicKeyToken.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/Authentication/Token/PublicKeyToken.php b/lib/private/Authentication/Token/PublicKeyToken.php index eca54e41ab9f9..29348e5d2d435 100644 --- a/lib/private/Authentication/Token/PublicKeyToken.php +++ b/lib/private/Authentication/Token/PublicKeyToken.php @@ -192,7 +192,7 @@ public function getRemember(): int { } public function getType(): int { - return parent::getType(); + return $this->getter('type'); } public function setToken(string $token): void { From 1133eb4811000ec6709eec6793a3c3356c50048c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 26 Jan 2026 11:48:22 +0100 Subject: [PATCH 32/41] fix: Added getTokenEndPoint() and setTokenEndPoint() methods that should have been moved from ICapabilityAwareOCMProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- lib/public/OCM/IOCMProvider.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/public/OCM/IOCMProvider.php b/lib/public/OCM/IOCMProvider.php index 83f2871d5c5d1..12b620bcbc8f8 100644 --- a/lib/public/OCM/IOCMProvider.php +++ b/lib/public/OCM/IOCMProvider.php @@ -158,8 +158,25 @@ public function setCapabilities(array $capabilities): static; * @return $this * @since 33.0.0 */ - public function setInviteAcceptDialog(string $inviteAcceptDialog): static; + + /** + * get the token endpoint URL + * + * @return string + * @since 33.0.0 + */ + public function getTokenEndPoint(): string; + + /** + * set the token endpoint URL + * + * @param string $endPoint + * + * @return $this + * @since 33.0.0 + */ + public function setTokenEndPoint(string $endPoint): static; /** * extract a specific string value from the listing of protocols, based on resource-name and protocol-name * From 647c8c3706822db96981b258808920db0f3959b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Wed, 28 Jan 2026 18:18:53 +0100 Subject: [PATCH 33/41] fix: fix session tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- tests/lib/User/SessionTest.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/lib/User/SessionTest.php b/tests/lib/User/SessionTest.php index d435bc63dc327..9383ce7b4d0dd 100644 --- a/tests/lib/User/SessionTest.php +++ b/tests/lib/User/SessionTest.php @@ -338,8 +338,6 @@ public function testPasswordlessLoginNoLastCheckUpdate(): void { ->with('foo') ->willReturn($user); - $session->expects($this->never()) - ->method('set'); $session->expects($this->once()) ->method('regenerateId'); $token = new PublicKeyToken(); @@ -383,8 +381,6 @@ public function testLoginLastCheckUpdate(): void { ->with('foo') ->willReturn($user); - $session->expects($this->never()) - ->method('set'); $session->expects($this->once()) ->method('regenerateId'); $token = new PublicKeyToken(); From e72275bc0197848fa30b633a92deeca217fdb047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 29 Jan 2026 11:52:46 +0100 Subject: [PATCH 34/41] fix(federatedfilesharing): remove unused import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../lib/OCM/CloudFederationProviderFiles.php | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 1f9faa5039fae..bf642fcc639b5 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -8,7 +8,6 @@ use OC\AppFramework\Http; use OC\Files\Filesystem; -use OC\Files\SetupManager; use OC\OCM\OCMSignatoryManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; From 2e5a70fe233c5f88c30122f3e95f12bcb5834efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 29 Jan 2026 12:14:52 +0100 Subject: [PATCH 35/41] fix: fix user session tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- tests/lib/User/SessionTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/lib/User/SessionTest.php b/tests/lib/User/SessionTest.php index 9383ce7b4d0dd..3d05913f5c6f5 100644 --- a/tests/lib/User/SessionTest.php +++ b/tests/lib/User/SessionTest.php @@ -341,6 +341,7 @@ public function testPasswordlessLoginNoLastCheckUpdate(): void { $session->expects($this->once()) ->method('regenerateId'); $token = new PublicKeyToken(); + $token->setId(1); $token->setLoginName('foo'); $token->setLastCheck(0); // Never $token->setUid('foo'); @@ -384,6 +385,7 @@ public function testLoginLastCheckUpdate(): void { $session->expects($this->once()) ->method('regenerateId'); $token = new PublicKeyToken(); + $token->setId(1); $token->setLoginName('foo'); $token->setLastCheck(0); // Never $token->setUid('foo'); From 8a6a85ae6c65b69e36242a1f873a39166170a6b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 29 Jan 2026 12:22:36 +0100 Subject: [PATCH 36/41] fix: update 3rdparty submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- 3rdparty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rdparty b/3rdparty index 557f8c008eada..8f97d8cef37b3 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 557f8c008eada7d84e65850c04143c852da8f07a +Subproject commit 8f97d8cef37b32d25e36c16fc2f7be36f1a46901 From b2c9b2d5b1a92024f4b84fd8474f27c068bbfa6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 29 Jan 2026 15:37:49 +0100 Subject: [PATCH 37/41] test: test token controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../unit/Controller/TokenControllerTest.php | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 apps/dav/tests/unit/Controller/TokenControllerTest.php diff --git a/apps/dav/tests/unit/Controller/TokenControllerTest.php b/apps/dav/tests/unit/Controller/TokenControllerTest.php new file mode 100644 index 0000000000000..82de3c864e2c3 --- /dev/null +++ b/apps/dav/tests/unit/Controller/TokenControllerTest.php @@ -0,0 +1,377 @@ +request = $this->createMock(IRequest::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->random = $this->createMock(ISecureRandom::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + + $this->controller = new TokenController( + $this->request, + $this->tokenProvider, + $this->random, + $this->timeFactory, + $this->logger, + $this->signatureManager, + $this->signatoryManager, + $this->appConfig, + ); + } + + public function testAccessTokenSuccess(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->with($this->signatoryManager) + ->willReturn($signedRequest); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshToken->method('getId')->willReturn(123); + $refreshToken->method('getLoginName')->willReturn('testuser'); + + $this->tokenProvider->method('getToken') + ->with('valid-refresh-token') + ->willReturn($refreshToken); + + $this->random->method('generate') + ->with(64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS) + ->willReturn('generated-access-token'); + + $this->timeFactory->method('getTime')->willReturn(1000000); + + $accessToken = $this->createMock(IToken::class); + $this->tokenProvider->method('generateToken') + ->with( + 'generated-access-token', + 'valid-refresh-token', + 'testuser', + null, + 'OCM Access Token', + IToken::TEMPORARY_TOKEN, + IToken::DO_NOT_REMEMBER + ) + ->willReturn($accessToken); + + $accessToken->expects($this->once()) + ->method('setExpires') + ->with(1000000 + 3600); + + $this->tokenProvider->expects($this->once()) + ->method('updateToken') + ->with($accessToken); + + // Simulate POST body + $this->simulatePostBody('grant_type=authorization_code&code=valid-refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertInstanceOf(DataResponse::class, $result); + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + $this->assertEquals([ + 'access_token' => 'generated-access-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ], $result->getData()); + } + + public function testAccessTokenWithoutSignatureEnforcementDisabled(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatureNotFoundException()); + + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, false, true) + ->willReturn(false); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshToken->method('getLoginName')->willReturn('testuser'); + + $this->tokenProvider->method('getToken') + ->willReturn($refreshToken); + + $this->random->method('generate')->willReturn('generated-access-token'); + $this->timeFactory->method('getTime')->willReturn(1000000); + + $accessToken = $this->createMock(IToken::class); + $this->tokenProvider->method('generateToken')->willReturn($accessToken); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + } + + public function testAccessTokenWithoutSignatureEnforcementEnabled(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatureNotFoundException()); + + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, false, true) + ->willReturn(true); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_request'], $result->getData()); + } + + public function testAccessTokenInvalidSignature(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatureException('Invalid signature')); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_request'], $result->getData()); + } + + public function testAccessTokenUnsupportedGrantType(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->simulatePostBody('grant_type=password&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $result->getStatus()); + $this->assertEquals(['error' => 'unsupported_grant_type'], $result->getData()); + } + + public function testAccessTokenMissingGrantType(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->simulatePostBody('code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $result->getStatus()); + $this->assertEquals(['error' => 'unsupported_grant_type'], $result->getData()); + } + + public function testAccessTokenMissingRefreshToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->simulatePostBody('grant_type=authorization_code'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $result->getStatus()); + $this->assertEquals(['error' => 'refresh_token is required'], $result->getData()); + } + + public function testAccessTokenNonPermanentToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::TEMPORARY_TOKEN); + $refreshToken->method('getId')->willReturn(123); + + $this->tokenProvider->method('getToken') + ->with('non-permanent-token') + ->willReturn($refreshToken); + + $this->simulatePostBody('grant_type=authorization_code&code=non-permanent-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_grant'], $result->getData()); + } + + public function testAccessTokenInvalidToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->tokenProvider->method('getToken') + ->with('invalid-token') + ->willThrowException(new InvalidTokenException()); + + $this->simulatePostBody('grant_type=authorization_code&code=invalid-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_grant'], $result->getData()); + } + + public function testAccessTokenExpiredToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->tokenProvider->method('getToken') + ->with('expired-token') + ->willThrowException(new ExpiredTokenException($this->createMock(IToken::class))); + + $this->simulatePostBody('grant_type=authorization_code&code=expired-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_grant'], $result->getData()); + } + + public function testAccessTokenServerError(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->tokenProvider->method('getToken') + ->willThrowException(new \RuntimeException('Database connection failed')); + + $this->simulatePostBody('grant_type=authorization_code&code=some-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_INTERNAL_SERVER_ERROR, $result->getStatus()); + $this->assertEquals(['error' => 'server_error'], $result->getData()); + } + + public function testAccessTokenWithSignatoryNotFoundException(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatoryNotFoundException()); + + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, false, true) + ->willReturn(false); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshToken->method('getLoginName')->willReturn('testuser'); + + $this->tokenProvider->method('getToken')->willReturn($refreshToken); + $this->random->method('generate')->willReturn('generated-access-token'); + $this->timeFactory->method('getTime')->willReturn(1000000); + + $accessToken = $this->createMock(IToken::class); + $this->tokenProvider->method('generateToken')->willReturn($accessToken); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + } + + private function simulatePostBody(string $body): void { + // We need to use a stream wrapper to simulate php://input + stream_wrapper_unregister('php'); + stream_wrapper_register('php', TestPhpInputStream::class); + TestPhpInputStream::$body = $body; + } + + protected function tearDown(): void { + // Restore the original php stream wrapper + stream_wrapper_restore('php'); + parent::tearDown(); + } +} + +/** + * Helper class to simulate php://input + */ +class TestPhpInputStream { + public static string $body = ''; + private int $position = 0; + public mixed $context = null; + + public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool { + if ($path === 'php://input') { + $this->position = 0; + return true; + } + return false; + } + + public function stream_read(int $count): string { + $result = substr(self::$body, $this->position, $count); + $this->position += strlen($result); + return $result; + } + + public function stream_eof(): bool { + return $this->position >= strlen(self::$body); + } + + public function stream_stat(): array { + return []; + } +} From 7afabf79c5fa74c66d7daad72edbc0e7a01ba626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Thu, 29 Jan 2026 15:38:30 +0100 Subject: [PATCH 38/41] test: test doTryTokenLogin method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- tests/lib/User/SessionTest.php | 132 +++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/lib/User/SessionTest.php b/tests/lib/User/SessionTest.php index 3d05913f5c6f5..cc6c42fa88c20 100644 --- a/tests/lib/User/SessionTest.php +++ b/tests/lib/User/SessionTest.php @@ -1336,4 +1336,136 @@ public function testLogClientInThrottlerEmail(): void { $this->assertFalse($userSession->logClientIn('john@foo.bar', 'I-AM-A-PASSWORD', $request, $this->throttler)); } + + public function testDoTryTokenLoginSuccess(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $user->method('isEnabled')->willReturn(true); + + $manager->method('get') + ->with('testuser') + ->willReturn($user); + + $token = $this->createMock(PublicKeyToken::class); + $token->method('getUID')->willReturn('testuser'); + $token->method('getLoginName')->willReturn('testuser'); + $token->method('getType')->willReturn(\OCP\Authentication\Token\IToken::PERMANENT_TOKEN); + $token->method('getLastCheck')->willReturn($this->timeFactory->getTime()); + + $this->tokenProvider->method('getToken') + ->with('valid-token') + ->willReturn($token); + + $appPasswordSet = false; + $session->expects($this->atLeastOnce()) + ->method('set') + ->willReturnCallback(function ($key, $value) use (&$appPasswordSet) { + // We expect app_password to be set for permanent tokens + if ($key === 'app_password') { + $appPasswordSet = true; + $this->assertEquals('valid-token', $value); + } + return true; + }); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->assertTrue($userSession->doTryTokenLogin('valid-token')); + $this->assertTrue($appPasswordSet, 'app_password should be set for permanent tokens'); + } + + public function testDoTryTokenLoginInvalidToken(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $this->tokenProvider->method('getToken') + ->with('invalid-token') + ->willThrowException(new InvalidTokenException()); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->assertFalse($userSession->doTryTokenLogin('invalid-token')); + } + + public function testDoTryTokenLoginTemporaryToken(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $user->method('isEnabled')->willReturn(true); + + $manager->method('get') + ->with('testuser') + ->willReturn($user); + + $token = $this->createMock(PublicKeyToken::class); + $token->method('getUID')->willReturn('testuser'); + $token->method('getLoginName')->willReturn('testuser'); + $token->method('getType')->willReturn(\OCP\Authentication\Token\IToken::TEMPORARY_TOKEN); + $token->method('getLastCheck')->willReturn($this->timeFactory->getTime()); + + $this->tokenProvider->method('getToken') + ->with('temp-token') + ->willReturn($token); + + // app_password should NOT be set for temporary tokens + $session->expects($this->atLeastOnce()) + ->method('set') + ->willReturnCallback(function ($key, $value) { + $this->assertNotEquals('app_password', $key, 'app_password should not be set for temporary tokens'); + return true; + }); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->assertTrue($userSession->doTryTokenLogin('temp-token')); + } + + public function testDoTryTokenLoginDisabledUser(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $user->method('isEnabled')->willReturn(false); + + $manager->method('get') + ->with('testuser') + ->willReturn($user); + + $token = $this->createMock(PublicKeyToken::class); + $token->method('getUID')->willReturn('testuser'); + $token->method('getLoginName')->willReturn('testuser'); + $token->method('getType')->willReturn(\OCP\Authentication\Token\IToken::PERMANENT_TOKEN); + $token->method('getLastCheck')->willReturn($this->timeFactory->getTime()); + + $this->tokenProvider->method('getToken') + ->with('valid-token') + ->willReturn($token); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->expectException(LoginException::class); + $userSession->doTryTokenLogin('valid-token'); + } } From 5d50d7521d003e03dd515601a1b47a6b51b5d56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 2 Feb 2026 11:30:46 +0100 Subject: [PATCH 39/41] fix(dav): remove unused import in TokenController test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/dav/tests/unit/Controller/TokenControllerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/dav/tests/unit/Controller/TokenControllerTest.php b/apps/dav/tests/unit/Controller/TokenControllerTest.php index 82de3c864e2c3..17e4038955d53 100644 --- a/apps/dav/tests/unit/Controller/TokenControllerTest.php +++ b/apps/dav/tests/unit/Controller/TokenControllerTest.php @@ -21,7 +21,6 @@ use OCP\IAppConfig; use OCP\IRequest; use OCP\Security\ISecureRandom; -use OCP\Security\Signature\Exceptions\IncomingRequestException; use OCP\Security\Signature\Exceptions\SignatoryNotFoundException; use OCP\Security\Signature\Exceptions\SignatureException; use OCP\Security\Signature\Exceptions\SignatureNotFoundException; From 0b9b1e20740ec33dee0cf8163aed143a82d42392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Mon, 2 Feb 2026 17:39:38 +0100 Subject: [PATCH 40/41] feat(dav): refresh expired tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- lib/private/Files/Storage/DAV.php | 102 +++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 17 deletions(-) diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index ce65f4ad972a0..c40a9686b9fb7 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -327,6 +327,25 @@ protected function exchangeRefreshToken(): string { } } + /** + * Check if bearer authentication is being used + */ + protected function isBearerAuth(): bool { + return $this->authType !== null && + ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER); + } + + /** + * Reinitialize the client with a fresh access token + * Used when the current bearer token has expired (401 response) + */ + protected function reinitWithFreshToken(): void { + $this->logger->debug('Bearer token expired, refreshing token', ['app' => 'dav']); + $this->ready = false; + $this->password = ''; // Clear to force token exchange in init() + $this->init(); + } + /** * Clear the stat cache */ @@ -419,12 +438,13 @@ public function getPropfindPropertyValue(string $path, string $propertyName): mi * If not, request it from the server then store to cache. * * @param string $path path to propfind + * @param bool $retryOnUnauthorized whether to retry on 401 response (used to prevent infinite loops) * * @return array|false propfind response or false if the entry was not found * * @throws ClientHttpException */ - protected function propfind(string $path): array|false { + protected function propfind(string $path, bool $retryOnUnauthorized = true): array|false { $path = $this->cleanPath($path); $cachedResponse = $this->statCache->get($path); // we either don't know it, or we know it exists but need more details @@ -441,6 +461,9 @@ protected function propfind(string $path): array|false { if ($e->getHttpStatus() === 404 || $e->getHttpStatus() === 405) { $this->statCache->clear($path . '/'); $this->statCache->set($path, false); + } elseif ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + return $this->propfind($path, false); } else { $this->convertException($e, $path); } @@ -498,7 +521,7 @@ public function unlink(string $path): bool { return $result; } - public function fopen(string $path, string $mode) { + public function fopen(string $path, string $mode, bool $retryOnUnauthorized = true) { $this->init(); $path = $this->cleanPath($path); switch ($mode) { @@ -524,6 +547,11 @@ public function fopen(string $path, string $mode) { if ($e->getResponse() instanceof ResponseInterface && $e->getResponse()->getStatusCode() === 404) { return false; + } elseif ($e->getResponse() instanceof ResponseInterface + && $e->getResponse()->getStatusCode() === 401 + && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + return $this->fopen($path, $mode, false); } else { throw $e; } @@ -610,7 +638,7 @@ public function free_space(string $path): int|float|false { } } - public function touch(string $path, ?int $mtime = null): bool { + public function touch(string $path, ?int $mtime = null, bool $retryOnUnauthorized = true): bool { $this->init(); if (is_null($mtime)) { $mtime = time(); @@ -635,6 +663,10 @@ public function touch(string $path, ?int $mtime = null): bool { if ($e->getHttpStatus() === 501) { return false; } + if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + return $this->touch($path, $mtime, false); + } $this->convertException($e, $path); return false; } catch (\Exception $e) { @@ -654,7 +686,7 @@ public function file_put_contents(string $path, mixed $data): int|float|false { return $result; } - protected function uploadFile(string $path, string $target): void { + protected function uploadFile(string $path, string $target, bool $retryOnUnauthorized = true): void { $this->init(); // invalidate @@ -668,20 +700,31 @@ protected function uploadFile(string $path, string $target): void { $auth = []; $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; } - $this->httpClientService - ->newClient() - ->put($this->createBaseUri() . $this->encodePath($target), [ - 'body' => $source, - 'headers' => $headers, - 'auth' => $auth, - // set upload timeout for users with slow connections or large files - 'timeout' => $this->timeout - ]); + try { + $this->httpClientService + ->newClient() + ->put($this->createBaseUri() . $this->encodePath($target), [ + 'body' => $source, + 'headers' => $headers, + 'auth' => $auth, + // set upload timeout for users with slow connections or large files + 'timeout' => $this->timeout + ]); + } catch (\GuzzleHttp\Exception\ClientException $e) { + if ($e->getResponse() instanceof ResponseInterface + && $e->getResponse()->getStatusCode() === 401 + && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + $this->uploadFile($path, $target, false); + return; + } + throw $e; + } $this->removeCachedFile($target); } - public function rename(string $source, string $target): bool { + public function rename(string $source, string $target, bool $retryOnUnauthorized = true): bool { $this->init(); $source = $this->cleanPath($source); $target = $this->cleanPath($target); @@ -706,13 +749,19 @@ public function rename(string $source, string $target): bool { $this->removeCachedFile($source); $this->removeCachedFile($target); return true; + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + return $this->rename($source, $target, false); + } + $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); } return false; } - public function copy(string $source, string $target): bool { + public function copy(string $source, string $target, bool $retryOnUnauthorized = true): bool { $this->init(); $source = $this->cleanPath($source); $target = $this->cleanPath($target); @@ -734,6 +783,12 @@ public function copy(string $source, string $target): bool { $this->statCache->set($target, true); $this->removeCachedFile($target); return true; + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + return $this->copy($source, $target, false); + } + $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); } @@ -834,11 +889,12 @@ protected function encodePath(string $path): string { } /** + * @param bool $retryOnUnauthorized whether to retry on 401 response (used to prevent infinite loops) * @return bool * @throws StorageInvalidException * @throws StorageNotAvailableException */ - protected function simpleResponse(string $method, string $path, ?string $body, int $expected): bool { + protected function simpleResponse(string $method, string $path, ?string $body, int $expected, bool $retryOnUnauthorized = true): bool { $path = $this->cleanPath($path); try { $response = $this->client->request($method, $this->encodePath($path), $body); @@ -850,6 +906,11 @@ protected function simpleResponse(string $method, string $path, ?string $body, i return false; } + if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + return $this->simpleResponse($method, $path, $body, $expected, false); + } + $this->convertException($e, $path); } catch (\Exception $e) { $this->convertException($e, $path); @@ -1006,7 +1067,7 @@ protected function convertException(Exception $e, string $path = ''): void { // TODO: only log for now, but in the future need to wrap/rethrow exception } - public function getDirectoryContent(string $directory): \Traversable { + public function getDirectoryContent(string $directory, bool $retryOnUnauthorized = true): \Traversable { $this->init(); $directory = $this->cleanPath($directory); try { @@ -1028,6 +1089,13 @@ public function getDirectoryContent(string $directory): \Traversable { $this->statCache->set($file, $response); yield $this->getMetaFromPropfind($file, $response); } + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { + $this->reinitWithFreshToken(); + yield from $this->getDirectoryContent($directory, false); + return; + } + $this->convertException($e, $directory); } catch (\Exception $e) { $this->convertException($e, $directory); } From d8cf07b127a195df3ce28085925076d31b527e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Wed, 4 Feb 2026 16:07:26 +0100 Subject: [PATCH 41/41] fix(files_sharing): refactor refreshing access tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- apps/files_sharing/lib/External/Storage.php | 34 +--- lib/private/Files/Storage/DAV.php | 196 +++++++++++--------- 2 files changed, 110 insertions(+), 120 deletions(-) diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index 2b306f1e339e9..4be7193c4ef54 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -113,12 +113,11 @@ public function __construct($options) { } /** - * Refresh the access token by exchanging the refresh token. - * Updates both the in-memory password and the database. + * Refresh the bearer token. Extends parent to also persist to database. * * @return bool True if token was refreshed successfully */ - protected function refreshAccessToken(): bool { + protected function refreshBearerToken(): bool { if ($this->tokenRefreshed) { // only try to refresh once per request return false; @@ -133,6 +132,7 @@ protected function refreshAccessToken(): bool { $this->ready = false; $this->client = null; + $this->init(); $this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']); return true; @@ -145,30 +145,6 @@ protected function refreshAccessToken(): bool { } } - /** - * Execute an operation with automatic token refresh on 401 errors. - * - * @template T - * @param callable(): T $operation The operation to execute - * @return T - * @throws \Exception - */ - protected function withTokenRefresh(callable $operation) { - try { - return $operation(); - } catch (\Sabre\HTTP\ClientHttpException $e) { - if ($e->getHttpStatus() === 401 && $this->refreshAccessToken()) { - return $operation(); - } - throw $e; - } catch (\Sabre\DAV\Exception\NotAuthenticated $e) { - if ($this->refreshAccessToken()) { - return $operation(); - } - throw $e; - } - } - public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { $storage = $this; @@ -230,7 +206,7 @@ public function hasUpdated(string $path, int $time): bool { } $this->updateChecked = true; try { - return $this->withTokenRefresh(fn () => parent::hasUpdated('', $time)); + return parent::hasUpdated('', $time); } catch (StorageInvalidException $e) { // check if it needs to be removed $this->checkStorageAvailability(); @@ -244,7 +220,7 @@ public function hasUpdated(string $path, int $time): bool { public function test(): bool { try { - return $this->withTokenRefresh(fn () => parent::test()); + return parent::test(); } catch (StorageInvalidException $e) { // check if it needs to be removed $this->checkStorageAvailability(); diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index c40a9686b9fb7..fb8e1c6fba464 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -331,19 +331,73 @@ protected function exchangeRefreshToken(): string { * Check if bearer authentication is being used */ protected function isBearerAuth(): bool { - return $this->authType !== null && - ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER); + return $this->authType !== null + && ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER); } /** - * Reinitialize the client with a fresh access token - * Used when the current bearer token has expired (401 response) + * @var bool Flag to prevent infinite retry loops during token refresh */ - protected function reinitWithFreshToken(): void { + private bool $retryingAuth = false; + + /** + * Execute an operation with automatic retry on 401 Unauthorized when using Bearer auth. + * Handles both Sabre ClientHttpException and Guzzle ClientException. + * + * @template T + * @param callable(): T $operation The operation to execute + * @return T The result of the operation + * @throws ClientHttpException + * @throws \GuzzleHttp\Exception\ClientException + */ + protected function withAuthRetry(callable $operation): mixed { + try { + return $operation(); + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 401 && !$this->retryingAuth && $this->isBearerAuth()) { + return $this->retryWithFreshToken($operation); + } + throw $e; + } catch (\GuzzleHttp\Exception\ClientException $e) { + if ($e->getResponse() instanceof ResponseInterface + && $e->getResponse()->getStatusCode() === 401 + && !$this->retryingAuth && $this->isBearerAuth()) { + return $this->retryWithFreshToken($operation); + } + throw $e; + } + } + + /** + * Refresh the bearer token and retry the operation. + * + * @template T + * @param callable(): T $operation The operation to retry + * @return T The result of the operation + */ + private function retryWithFreshToken(callable $operation): mixed { + $this->retryingAuth = true; + try { + if (!$this->refreshBearerToken()) { + throw new StorageNotAvailableException('Failed to refresh bearer token'); + } + return $operation(); + } finally { + $this->retryingAuth = false; + } + } + + /** + * Refresh the bearer token. Override in subclasses to add persistence logic. + * + * @return bool True if token was refreshed successfully + */ + protected function refreshBearerToken(): bool { $this->logger->debug('Bearer token expired, refreshing token', ['app' => 'dav']); $this->ready = false; $this->password = ''; // Clear to force token exchange in init() $this->init(); + return true; } /** @@ -438,13 +492,12 @@ public function getPropfindPropertyValue(string $path, string $propertyName): mi * If not, request it from the server then store to cache. * * @param string $path path to propfind - * @param bool $retryOnUnauthorized whether to retry on 401 response (used to prevent infinite loops) * * @return array|false propfind response or false if the entry was not found * * @throws ClientHttpException */ - protected function propfind(string $path, bool $retryOnUnauthorized = true): array|false { + protected function propfind(string $path): array|false { $path = $this->cleanPath($path); $cachedResponse = $this->statCache->get($path); // we either don't know it, or we know it exists but need more details @@ -452,18 +505,15 @@ protected function propfind(string $path, bool $retryOnUnauthorized = true): arr $this->init(); $response = false; try { - $response = $this->client->propFind( + $response = $this->withAuthRetry(fn () => $this->client->propFind( $this->encodePath($path), $this->getPropfindProperties() - ); + )); $this->statCache->set($path, $response); } catch (ClientHttpException $e) { if ($e->getHttpStatus() === 404 || $e->getHttpStatus() === 405) { $this->statCache->clear($path . '/'); $this->statCache->set($path, false); - } elseif ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - return $this->propfind($path, false); } else { $this->convertException($e, $path); } @@ -521,40 +571,36 @@ public function unlink(string $path): bool { return $result; } - public function fopen(string $path, string $mode, bool $retryOnUnauthorized = true) { + public function fopen(string $path, string $mode) { $this->init(); $path = $this->cleanPath($path); switch ($mode) { case 'r': case 'rb': try { - $auth = [$this->user, $this->password]; - $headers = []; - if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { - $auth = []; - $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; - } - $response = $this->httpClientService - ->newClient() - ->get($this->createBaseUri() . $this->encodePath($path), [ - 'headers' => $headers, - 'auth' => $auth, - 'stream' => true, - // set download timeout for users with slow connections or large files - 'timeout' => $this->timeout - ]); + $response = $this->withAuthRetry(function () use ($path) { + $auth = [$this->user, $this->password]; + $headers = []; + if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { + $auth = []; + $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; + } + return $this->httpClientService + ->newClient() + ->get($this->createBaseUri() . $this->encodePath($path), [ + 'headers' => $headers, + 'auth' => $auth, + 'stream' => true, + // set download timeout for users with slow connections or large files + 'timeout' => $this->timeout + ]); + }); } catch (\GuzzleHttp\Exception\ClientException $e) { if ($e->getResponse() instanceof ResponseInterface && $e->getResponse()->getStatusCode() === 404) { return false; - } elseif ($e->getResponse() instanceof ResponseInterface - && $e->getResponse()->getStatusCode() === 401 - && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - return $this->fopen($path, $mode, false); - } else { - throw $e; } + throw $e; } if ($response->getStatusCode() !== Http::STATUS_OK) { @@ -638,7 +684,7 @@ public function free_space(string $path): int|float|false { } } - public function touch(string $path, ?int $mtime = null, bool $retryOnUnauthorized = true): bool { + public function touch(string $path, ?int $mtime = null): bool { $this->init(); if (is_null($mtime)) { $mtime = time(); @@ -649,9 +695,9 @@ public function touch(string $path, ?int $mtime = null, bool $retryOnUnauthorize if ($this->file_exists($path)) { try { $this->statCache->remove($path); - $this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime]); + $this->withAuthRetry(fn () => $this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime])); // non-owncloud clients might not have accepted the property, need to recheck it - $response = $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0); + $response = $this->withAuthRetry(fn () => $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0)); if (isset($response['{DAV:}getlastmodified'])) { $remoteMtime = strtotime($response['{DAV:}getlastmodified']); if ($remoteMtime !== $mtime) { @@ -663,10 +709,6 @@ public function touch(string $path, ?int $mtime = null, bool $retryOnUnauthorize if ($e->getHttpStatus() === 501) { return false; } - if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - return $this->touch($path, $mtime, false); - } $this->convertException($e, $path); return false; } catch (\Exception $e) { @@ -686,21 +728,21 @@ public function file_put_contents(string $path, mixed $data): int|float|false { return $result; } - protected function uploadFile(string $path, string $target, bool $retryOnUnauthorized = true): void { + protected function uploadFile(string $path, string $target): void { $this->init(); // invalidate $target = $this->cleanPath($target); $this->statCache->remove($target); - $source = fopen($path, 'r'); - $auth = [$this->user, $this->password]; - $headers = []; - if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { - $auth = []; - $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; - } - try { + $this->withAuthRetry(function () use ($path, $target) { + $source = fopen($path, 'r'); + $auth = [$this->user, $this->password]; + $headers = []; + if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { + $auth = []; + $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; + } $this->httpClientService ->newClient() ->put($this->createBaseUri() . $this->encodePath($target), [ @@ -710,21 +752,12 @@ protected function uploadFile(string $path, string $target, bool $retryOnUnautho // set upload timeout for users with slow connections or large files 'timeout' => $this->timeout ]); - } catch (\GuzzleHttp\Exception\ClientException $e) { - if ($e->getResponse() instanceof ResponseInterface - && $e->getResponse()->getStatusCode() === 401 - && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - $this->uploadFile($path, $target, false); - return; - } - throw $e; - } + }); $this->removeCachedFile($target); } - public function rename(string $source, string $target, bool $retryOnUnauthorized = true): bool { + public function rename(string $source, string $target): bool { $this->init(); $source = $this->cleanPath($source); $target = $this->cleanPath($target); @@ -734,14 +767,14 @@ public function rename(string $source, string $target, bool $retryOnUnauthorized // needs trailing slash in destination $target = rtrim($target, '/') . '/'; } - $this->client->request( + $this->withAuthRetry(fn () => $this->client->request( 'MOVE', $this->encodePath($source), null, [ 'Destination' => $this->createBaseUri() . $this->encodePath($target), ] - ); + )); $this->statCache->clear($source . '/'); $this->statCache->clear($target . '/'); $this->statCache->set($source, false); @@ -750,10 +783,6 @@ public function rename(string $source, string $target, bool $retryOnUnauthorized $this->removeCachedFile($target); return true; } catch (ClientHttpException $e) { - if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - return $this->rename($source, $target, false); - } $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); @@ -761,7 +790,7 @@ public function rename(string $source, string $target, bool $retryOnUnauthorized return false; } - public function copy(string $source, string $target, bool $retryOnUnauthorized = true): bool { + public function copy(string $source, string $target): bool { $this->init(); $source = $this->cleanPath($source); $target = $this->cleanPath($target); @@ -771,23 +800,19 @@ public function copy(string $source, string $target, bool $retryOnUnauthorized = // needs trailing slash in destination $target = rtrim($target, '/') . '/'; } - $this->client->request( + $this->withAuthRetry(fn () => $this->client->request( 'COPY', $this->encodePath($source), null, [ 'Destination' => $this->createBaseUri() . $this->encodePath($target), ] - ); + )); $this->statCache->clear($target . '/'); $this->statCache->set($target, true); $this->removeCachedFile($target); return true; } catch (ClientHttpException $e) { - if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - return $this->copy($source, $target, false); - } $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); @@ -889,15 +914,14 @@ protected function encodePath(string $path): string { } /** - * @param bool $retryOnUnauthorized whether to retry on 401 response (used to prevent infinite loops) * @return bool * @throws StorageInvalidException * @throws StorageNotAvailableException */ - protected function simpleResponse(string $method, string $path, ?string $body, int $expected, bool $retryOnUnauthorized = true): bool { + protected function simpleResponse(string $method, string $path, ?string $body, int $expected): bool { $path = $this->cleanPath($path); try { - $response = $this->client->request($method, $this->encodePath($path), $body); + $response = $this->withAuthRetry(fn () => $this->client->request($method, $this->encodePath($path), $body)); return $response['statusCode'] === $expected; } catch (ClientHttpException $e) { if ($e->getHttpStatus() === 404 && $method === 'DELETE') { @@ -906,11 +930,6 @@ protected function simpleResponse(string $method, string $path, ?string $body, i return false; } - if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - return $this->simpleResponse($method, $path, $body, $expected, false); - } - $this->convertException($e, $path); } catch (\Exception $e) { $this->convertException($e, $path); @@ -1067,15 +1086,15 @@ protected function convertException(Exception $e, string $path = ''): void { // TODO: only log for now, but in the future need to wrap/rethrow exception } - public function getDirectoryContent(string $directory, bool $retryOnUnauthorized = true): \Traversable { + public function getDirectoryContent(string $directory): \Traversable { $this->init(); $directory = $this->cleanPath($directory); try { - $responses = $this->client->propFind( + $responses = $this->withAuthRetry(fn () => $this->client->propFind( $this->encodePath($directory), $this->getPropfindProperties(), 1 - ); + )); array_shift($responses); //the first entry is the current directory if (!$this->statCache->hasKey($directory)) { @@ -1090,11 +1109,6 @@ public function getDirectoryContent(string $directory, bool $retryOnUnauthorized yield $this->getMetaFromPropfind($file, $response); } } catch (ClientHttpException $e) { - if ($e->getHttpStatus() === 401 && $retryOnUnauthorized && $this->isBearerAuth()) { - $this->reinitWithFreshToken(); - yield from $this->getDirectoryContent($directory, false); - return; - } $this->convertException($e, $directory); } catch (\Exception $e) { $this->convertException($e, $directory);