From 0a1e470cd6d015d96497ebc53aed8bbedb04e054 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/13] feat(dav): bearer-auth-aware Sabre HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow Bearer token auth for WebDAV requests. 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 0c74abdfee1a2..97cd0e427e3f9 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 5665f4ebc0f32b654704a6409a0fa3e12d5b94fd 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/13] feat: OCM bearer authentication for federated shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end OCM bearer-auth flow: - a token endpoint (apps/dav) that exchanges a long-lived refresh token for a short-lived JWT access token, with form-urlencoded payloads, exposed via OCM discovery - a Sabre Bearer-auth backend that validates incoming WebDAV requests carrying an OCM access token - `IUserSession::doTryTokenLogin($token)` to authenticate by token without an `IRequest` - federated share provider issues permanent refresh tokens to remotes on share creation; signs POSTs to the remote token endpoint; honours the `must-exchange-token` capability - files_sharing client exchanges refresh tokens for access tokens when mounting a remote share configured for bearer auth, and uses the access token as the WebDAV Bearer header - cloud_federation_api adapted to the multi-protocol share creation format from the latest OCM draft - federated WebDAV client adapted to Guzzle 7 Signed-off-by: Enrique Pérez Arnaud --- 3rdparty | 2 +- .../Controller/RequestHandlerController.php | 41 +- apps/dav/appinfo/v1/publicwebdav.php | 16 +- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/Connector/Sabre/BearerAuth.php | 18 +- apps/dav/lib/Controller/TokenController.php | 191 +++++++++ .../unit/Controller/TokenControllerTest.php | 376 ++++++++++++++++++ .../lib/FederatedShareProvider.php | 35 +- .../lib/OCM/CloudFederationProviderFiles.php | 108 ++++- .../tests/FederatedShareProviderTest.php | 44 +- .../OCM/CloudFederationProviderFilesTest.php | 310 +++++++++++++++ apps/files_sharing/lib/External/Manager.php | 19 + apps/files_sharing/lib/External/Storage.php | 81 +++- .../Authentication/Token/PublicKeyToken.php | 4 + .../Federation/CloudFederationFactory.php | 57 ++- .../Federation/CloudFederationShare.php | 46 ++- lib/private/Files/Storage/DAV.php | 176 ++++++-- lib/private/OCM/Model/OCMProvider.php | 32 ++ lib/private/OCM/OCMDiscoveryService.php | 8 +- lib/private/Server.php | 6 +- lib/private/Share20/Manager.php | 16 + lib/private/User/Session.php | 41 +- lib/public/Authentication/Token/IToken.php | 9 + lib/public/OCM/IOCMProvider.php | 19 +- .../Token/PublicKeyTokenProviderTest.php | 1 + tests/lib/User/SessionTest.php | 21 +- 27 files changed, 1602 insertions(+), 77 deletions(-) create mode 100644 apps/dav/lib/Controller/TokenController.php create mode 100644 apps/dav/tests/unit/Controller/TokenControllerTest.php create mode 100644 apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php diff --git a/3rdparty b/3rdparty index 5d09a7f56e2d0..8f97d8cef37b3 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 5d09a7f56e2d01b5f4083e65db77c4f7aa775252 +Subproject commit 8f97d8cef37b32d25e36c16fc2f7be36f1a46901 diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 06480df1ad5a4..ecbd6749c047e 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -7,11 +7,13 @@ namespace OCA\CloudFederationAPI\Controller; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Db\FederatedInviteMapper; use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent; use OCA\CloudFederationAPI\ResponseDefinitions; +use OCA\DAV\Db\OcmTokenMapMapper; use OCA\FederatedFileSharing\AddressHandler; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -43,6 +45,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; @@ -91,7 +94,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' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]] * @param string $shareType 'group' or 'user' share * @param string $resourceType 'file', 'calendar',... * @@ -126,9 +129,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( [ @@ -139,6 +139,33 @@ 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']); + + // 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', + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + $supportedShareTypes = $this->config->getSupportedShareTypes($resourceType); if (!in_array($shareType, $supportedShareTypes)) { return new JSONResponse( @@ -490,6 +517,12 @@ private function confirmNotificationIdentity( $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); if ($provider instanceof ISignedCloudFederationProvider || $provider instanceof \NCU\Federation\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/appinfo/v1/publicwebdav.php b/apps/dav/appinfo/v1/publicwebdav.php index 316a84d9767ee..d0b33c86c1c5a 100644 --- a/apps/dav/appinfo/v1/publicwebdav.php +++ b/apps/dav/appinfo/v1/publicwebdav.php @@ -9,6 +9,7 @@ use OC\Files\Storage\Wrapper\DirPermissionsMask; 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(); + } $isReadable = $share->getPermissions() & Constants::PERMISSION_READ; $fileId = $share->getNodeId(); diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index f5861328d1a21..2e0ccff3e7002 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -265,6 +265,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 25b6fcefd976d..4b1b700efa17f 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -280,6 +280,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/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index 305f79cb0e082..1bd06234c97f2 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(); @@ -41,17 +45,27 @@ private function setupUserFs($userId) { #[\Override] 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) { return $this->setupUserFs($this->userSession->getUser()->getUID()); } 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 diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php new file mode 100644 index 0000000000000..3c2fc0e3edbcc --- /dev/null +++ b/apps/dav/lib/Controller/TokenController.php @@ -0,0 +1,191 @@ +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'); + parse_str($body, $data); + + $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( + 64, + 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 + IToken::OCM_ACCESS_TOKEN_NAME, + 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/apps/dav/tests/unit/Controller/TokenControllerTest.php b/apps/dav/tests/unit/Controller/TokenControllerTest.php new file mode 100644 index 0000000000000..17e4038955d53 --- /dev/null +++ b/apps/dav/tests/unit/Controller/TokenControllerTest.php @@ -0,0 +1,376 @@ +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 []; + } +} diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index cd72ec3e32cdb..1505c0b2eda85 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -7,8 +7,12 @@ */ namespace OCA\FederatedFileSharing; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; +use OCA\DAV\Db\OcmTokenMapMapper; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Federation\ICloudFederationProviderManager; @@ -22,6 +26,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 +176,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() ?? $uid; + $pass = $share->getPassword(); + + $dbToken = $provider->generateToken($token, $uid, $uid, $pass, $name, type: IToken::PERMANENT_TOKEN); $shareId = $this->addShareToDB( $share->getNodeId(), $share->getNodeType(), @@ -724,6 +738,25 @@ public function getShareByToken(string $token): IShare { $data = $cursor->fetchAssociative(); + if ($data === false) { + // 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 d276128958532..43f68e6b78160 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,14 +34,18 @@ use OCP\Files\ISetupManager; use OCP\Files\NotFoundException; use OCP\HintException; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroupManager; use OCP\IURLGenerator; use OCP\IUser; 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\Http\Client\IClientService; use OCP\Share\IManager; use OCP\Share\IProviderFactory; use OCP\Share\IShare; @@ -70,6 +75,11 @@ public function __construct( private readonly IProviderFactory $shareProviderFactory, private readonly ISetupManager $setupManager, private readonly ExternalShareMapper $externalShareMapper, + private readonly IOCMDiscoveryService $discoveryService, + private readonly IClientService $clientService, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, + private readonly IAppConfig $appConfig, ) { } @@ -106,6 +116,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 +181,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); @@ -687,4 +722,73 @@ 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, + ]; + + $options = [ + 'body' => http_build_query($payload), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + + 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); + + $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/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index da37617c121dd..e39f2e03eb152 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,23 @@ protected function setUp(): void { $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); + // Mock ISecureRandom to return predictable tokens (must be 32+ chars) + $secureRandom = $this->createMock(ISecureRandom::class); + $tokenCounter = 0; + $secureRandom->method('generate') + ->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 + $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, @@ -146,7 +166,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -184,7 +204,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate 'file_source' => 42, 'permissions' => 19, 'accepted' => 0, - 'token' => 'token', + 'token' => 'token1token1token1token1token1ab', 'expiration' => $expectedDataDate, ]; foreach (array_keys($expected) as $key) { @@ -199,7 +219,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('token1token1token1token1token1ab', $share->getToken()); $this->assertEquals($expirationDate, $share->getExpirationDate()); } @@ -229,7 +249,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(), @@ -283,7 +303,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(), @@ -373,7 +393,7 @@ public function testCreateAlreadyShared(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -445,7 +465,7 @@ public function testUpdate(string $owner, string $sharedBy, ?\DateTime $expirati $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -884,9 +904,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); @@ -927,11 +947,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); diff --git a/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php b/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php new file mode 100644 index 0000000000000..2593bf496aa0d --- /dev/null +++ b/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php @@ -0,0 +1,310 @@ +appManager = $this->createMock(IAppManager::class); + $this->federatedShareProvider = $this->createMock(FederatedShareProvider::class); + $this->addressHandler = $this->createMock(AddressHandler::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->shareManager = $this->createMock(IManager::class); + $this->cloudIdManager = $this->createMock(ICloudIdManager::class); + $this->activityManager = $this->createMock(IActivityManager::class); + $this->notificationManager = $this->createMock(INotificationManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class); + $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->config = $this->createMock(IConfig::class); + $this->externalShareManager = $this->createMock(Manager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->filenameValidator = $this->createMock(IFilenameValidator::class); + $this->shareProviderFactory = $this->createMock(IProviderFactory::class); + $this->setupManager = $this->createMock(ISetupManager::class); + $this->externalShareMapper = $this->createMock(ExternalShareMapper::class); + $this->discoveryService = $this->createMock(IOCMDiscoveryService::class); + $this->clientService = $this->createMock(IClientService::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + + $this->provider = new CloudFederationProviderFiles( + $this->appManager, + $this->federatedShareProvider, + $this->addressHandler, + $this->userManager, + $this->shareManager, + $this->cloudIdManager, + $this->activityManager, + $this->notificationManager, + $this->urlGenerator, + $this->cloudFederationFactory, + $this->cloudFederationProviderManager, + $this->groupManager, + $this->config, + $this->externalShareManager, + $this->logger, + $this->filenameValidator, + $this->shareProviderFactory, + $this->setupManager, + $this->externalShareMapper, + $this->discoveryService, + $this->clientService, + $this->signatureManager, + $this->signatoryManager, + $this->appConfig, + ); + } + + private function enableS2S(): void { + $this->appManager->method('isEnabledForUser') + ->with('files_sharing') + ->willReturn(true); + $this->federatedShareProvider->method('isIncomingServer2serverShareEnabled') + ->willReturn(true); + } + + private function buildShare(array $requirements = []): ICloudFederationShare&MockObject { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getProtocol')->willReturn([ + 'name' => 'webdav', + 'webdav' => ['requirements' => $requirements], + ]); + $share->method('getOwner')->willReturn('owner@example.com'); + $share->method('getOwnerDisplayName')->willReturn('Owner Name'); + $share->method('getShareSecret')->willReturn('refresh-token-abc'); + $share->method('getResourceName')->willReturn('/SharedFolder'); + $share->method('getShareWith')->willReturn('localuser'); + $share->method('getProviderId')->willReturn('42'); + $share->method('getSharedBy')->willReturn('owner@example.com'); + $share->method('getShareType')->willReturn('user'); + return $share; + } + + /** + * When must-exchange-token is required but the remote has no token endpoint, + * shareReceived must throw rather than silently accept the share. + */ + public function testShareReceivedMustExchangeTokenThrowsWhenExchangeFails(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(['must-exchange-token']); + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn(''); + + $this->discoveryService->method('discover') + ->willReturn($ocmProvider); + + $this->expectException(ProviderCouldNotAddShareException::class); + + $this->provider->shareReceived($share); + } + + /** + * When must-exchange-token is required and the token exchange succeeds, + * the access token is stored on the share (we drive through share creation + * up to the "user does not exist" guard to avoid a full integration setup). + */ + public function testShareReceivedMustExchangeTokenStoresAccessToken(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(['must-exchange-token']); + + $tokenEndpoint = 'https://example.com/index.php/ocm/token'; + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn($tokenEndpoint); + $ocmProvider->method('getCapabilities')->willReturn([]); + + $this->discoveryService->method('discover')->willReturn($ocmProvider); + + $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://local.example/'); + + $signedOptions = [ + 'body' => 'grant_type=authorization_code&client_id=local.example&code=refresh-token-abc', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded', 'Signature' => 'sig'], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + $this->signatureManager->method('signOutgoingRequestIClientPayload') + ->willReturn($signedOptions); + + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn(json_encode([ + 'access_token' => 'access-token-xyz', + 'token_type' => 'Bearer', + ])); + + $httpClient = $this->createMock(\OCP\Http\Client\IClient::class); + $httpClient->method('post')->willReturn($response); + $this->clientService->method('newClient')->willReturn($httpClient); + + // Exchange succeeds → share creation continues; we stop it at the user + // lookup stage to avoid a full integration setup. + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } + + /** + * When exchange-token capability is present but the discovery service throws, + * shareReceived must not propagate the exception — the token exchange is optional. + */ + public function testShareReceivedOptionalExchangeGracefulOnDiscoveryFailure(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + // Build a share with no must-exchange-token requirement + $share = $this->buildShare(); + + $this->discoveryService->method('discover') + ->willThrowException(new \Exception('network error')); + + // Discovery failure is caught and logged; share creation continues. + // We stop it at the user lookup stage. + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } + + /** + * When exchange-token capability is present and the exchange succeeds, + * the access token is set (we stop at user-not-found to avoid full setup). + */ + public function testShareReceivedOptionalExchangeStoresAccessTokenOnSuccess(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(); + + $tokenEndpoint = 'https://example.com/index.php/ocm/token'; + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn($tokenEndpoint); + $ocmProvider->method('getCapabilities')->willReturn(['exchange-token']); + + $this->discoveryService->method('discover')->willReturn($ocmProvider); + + $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://local.example/'); + + $signedOptions = [ + 'body' => 'grant_type=authorization_code&client_id=local.example&code=refresh-token-abc', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded', 'Signature' => 'sig'], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + $this->signatureManager->method('signOutgoingRequestIClientPayload') + ->willReturn($signedOptions); + + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn(json_encode([ + 'access_token' => 'access-token-xyz', + 'token_type' => 'Bearer', + ])); + + $httpClient = $this->createMock(\OCP\Http\Client\IClient::class); + $httpClient->method('post')->willReturn($response); + $this->clientService->method('newClient')->willReturn($httpClient); + + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } +} diff --git a/apps/files_sharing/lib/External/Manager.php b/apps/files_sharing/lib/External/Manager.php index 9693e52439b89..8b831fd72e71a 100644 --- a/apps/files_sharing/lib/External/Manager.php +++ b/apps/files_sharing/lib/External/Manager.php @@ -566,4 +566,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 f074d86d54fd4..1cabf5e1114a6 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -50,8 +50,9 @@ class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, private bool $updateChecked = false; private ExternalShareManager $manager; private IConfig $config; - private IAppConfig $appConfig; + protected IAppConfig $appConfig; private IShareManager $shareManager; + private bool $tokenRefreshed = false; /** * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, password: ?string}|array $options @@ -72,10 +73,22 @@ 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; + } + + // 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); @@ -98,12 +111,70 @@ 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, ] ); } + /** + * 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; + } + } + #[\Override] public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { @@ -170,7 +241,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(); @@ -185,7 +256,7 @@ public function hasUpdated(string $path, int $time): bool { #[\Override] 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/Authentication/Token/PublicKeyToken.php b/lib/private/Authentication/Token/PublicKeyToken.php index 11739507851e1..9de3e862b6a1e 100644 --- a/lib/private/Authentication/Token/PublicKeyToken.php +++ b/lib/private/Authentication/Token/PublicKeyToken.php @@ -204,6 +204,10 @@ public function getRemember(): int { return parent::getRemember(); } + public function getType(): int { + return $this->getter('type'); + } + #[\Override] public function setToken(string $token): void { parent::setToken($token); diff --git a/lib/private/Federation/CloudFederationFactory.php b/lib/private/Federation/CloudFederationFactory.php index f183fa3e87d77..d3193a10d39ad 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 * @@ -31,7 +41,52 @@ class CloudFederationFactory implements ICloudFederationFactory { */ #[\Override] 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 311a0ee7f5bc7..f60f7cee769ff 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); } @@ -351,7 +369,19 @@ public function getShareType() { */ #[\Override] 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/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 97cd0e427e3f9..068c32c79c976 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -12,6 +12,7 @@ use Icewind\Streams\IteratorDirectory; 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 +25,16 @@ 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\IURLGenerator; use OCP\Lock\LockedException; +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; @@ -38,7 +45,7 @@ use Sabre\HTTP\ClientHttpException; use Sabre\HTTP\RequestInterface; -/* +/** * Class BearerAuthAwareSabreClient * * This is an extension of the Sabre HTTP Client @@ -46,33 +53,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); + } + } } /** @@ -98,6 +103,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 */ @@ -109,6 +116,11 @@ 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; + protected IURLGenerator $urlGenerator; /** @var int */ private $timeout; @@ -131,6 +143,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() @@ -171,6 +188,10 @@ 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); + $this->urlGenerator = Server::get(IURLGenerator::class); } protected function init(): void { @@ -179,9 +200,21 @@ protected function init(): void { } $this->ready = true; + // 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)) { + // 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 = [ 'baseUri' => $this->createBaseUri(), - 'userName' => $this->user, + 'userName' => $userName, 'password' => $this->password, ]; if ($this->authType !== null) { @@ -193,7 +226,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) { @@ -222,6 +255,83 @@ 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 = ($this->secure ? 'https://' : 'http://') . $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 { + $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); + + $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 */ @@ -408,10 +518,17 @@ public function fopen(string $path, string $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), [ - 'auth' => [$this->user, $this->password], + 'headers' => $headers, + 'auth' => $auth, 'stream' => true, // set download timeout for users with slow connections or large files 'timeout' => $this->timeout, @@ -562,11 +679,18 @@ protected function uploadFile(string $path, string $target): void { $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]; + } $this->httpClientService ->newClient() ->put($this->createBaseUri() . $this->encodePath($target), [ 'body' => $source, - 'auth' => [$this->user, $this->password], + 'headers' => $headers, + 'auth' => $auth, // set upload timeout for users with slow connections or large files 'timeout' => $this->timeout, 'verify' => $this->verify, diff --git a/lib/private/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index 20d3fbf8360bd..f2a31c6c6e894 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; @@ -119,6 +120,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 */ @@ -268,6 +290,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'); @@ -308,6 +336,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 9459e9a03f043..d215e065f7bf5 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 = []; @@ -92,6 +92,7 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider } if (array_key_exists($remote, $this->remoteProviders)) { + return $this->remoteProviders[$remote]; } @@ -128,7 +129,6 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider $remote . '/ocm-provider', ]; - foreach ($urls as $url) { $exception = null; $body = null; @@ -193,6 +193,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'); @@ -202,7 +203,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); diff --git a/lib/private/Server.php b/lib/private/Server.php index a39d09fe3b040..a9f66ad44b126 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1206,7 +1206,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); diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 9d68aa23653c5..b403dee805021 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -8,6 +8,8 @@ namespace OC\Share20; use ArrayIterator; +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Core\AppInfo\ConfigLexicon; use OC\Files\Filesystem; use OC\KnownUser\KnownUserService; @@ -44,6 +46,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; @@ -1444,6 +1447,19 @@ 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|ShareNotFound|InvalidTokenException $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 8154178e455dd..1f8763ee473a7 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -617,14 +617,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]); + + // If we are in incognito mode, getUser (called in completeLogin) will return null + OC_User::setIncognitoMode(false); return $this->completeLogin( $user, [ @@ -830,6 +851,10 @@ public function tryTokenLogin(IRequest $request) { } else { return false; } + return $this->doTryTokenLogin($token); + } + + public function doTryTokenLogin($token) { try { $dbToken = $this->tokenProvider->getToken($token); @@ -838,11 +863,18 @@ public function tryTokenLogin(IRequest $request) { return false; } - if ($dbToken instanceof PublicKeyToken && $dbToken->getType() === IToken::TEMPORARY_TOKEN && !$tokenFromCookie) { + if ($dbToken instanceof PublicKeyToken + && $dbToken->getType() === IToken::TEMPORARY_TOKEN + && !$tokenFromCookie + && $dbToken->getName() !== IToken::OCM_ACCESS_TOKEN_NAME) { // Session token but from Bearer header, not allowed return false; } + return $this->doTryTokenLogin($token); + } + + private function doTryTokenLogin(string $token): bool { if (!$this->loginWithToken($token)) { return false; } @@ -855,6 +887,7 @@ public function tryTokenLogin(IRequest $request) { $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; } diff --git a/lib/public/Authentication/Token/IToken.php b/lib/public/Authentication/Token/IToken.php index 546e6a4225550..b99fedcd92963 100644 --- a/lib/public/Authentication/Token/IToken.php +++ b/lib/public/Authentication/Token/IToken.php @@ -47,6 +47,15 @@ interface IToken extends JsonSerializable { */ public const SCOPE_SKIP_PASSWORD_VALIDATION = 'password-unconfirmable'; + /** + * Token name used for OCM access tokens issued via the + * federated token endpoint. Marks a TEMPORARY_TOKEN as legitimate + * Bearer-header auth (vs. a browser session id leaked into a header). + * + * @since 33.0.0 + */ + public const OCM_ACCESS_TOKEN_NAME = 'OCM Access Token'; + /** * Get the token ID * @since 28.0.0 diff --git a/lib/public/OCM/IOCMProvider.php b/lib/public/OCM/IOCMProvider.php index 4896a050bd089..5baca6bd5be30 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 * diff --git a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php index 1c9dc02f8f17a..6773f134e3003 100644 --- a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php +++ b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php @@ -452,6 +452,7 @@ public function testRenewSessionTokenWithPassword(): void { public function testGetToken(): void { $token = new PublicKeyToken(); + $token->setType(IToken::TEMPORARY_TOKEN); $this->config->method('getSystemValue') ->with('secret') diff --git a/tests/lib/User/SessionTest.php b/tests/lib/User/SessionTest.php index d21522f0c2a1e..4131f6a492283 100644 --- a/tests/lib/User/SessionTest.php +++ b/tests/lib/User/SessionTest.php @@ -332,11 +332,17 @@ public function testPasswordlessLoginNoLastCheckUpdate(): void { ->getMock(); $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); - $session->expects($this->never()) - ->method('set'); + $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->once()) ->method('regenerateId'); $token = new PublicKeyToken(); + $token->setId(1); $token->setLoginName('foo'); $token->setLastCheck(0); // Never $token->setUid('foo'); @@ -370,11 +376,17 @@ public function testLoginLastCheckUpdate(): void { ->getMock(); $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); - $session->expects($this->never()) - ->method('set'); + $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->once()) ->method('regenerateId'); $token = new PublicKeyToken(); + $token->setId(1); $token->setLoginName('foo'); $token->setLastCheck(0); // Never $token->setUid('foo'); @@ -1325,4 +1337,5 @@ public function testLogClientInThrottlerEmail(): void { $this->assertFalse($userSession->logClientIn('john@foo.bar', 'I-AM-A-PASSWORD', $request, $this->throttler)); } + } From 58822b159c193b4af4c9a5b61a34ce4cf4ca1ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 6 Mar 2026 12:49:12 +0100 Subject: [PATCH 03/13] feat(dav): persist OCM refresh tokens in oc_ocm_token_map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `OcmTokenMap` mapping an `oc_authtoken` id to the refresh token it was issued for. Migration adds the `oc_ocm_token_map` table. Token-exchange responses are validated before being persisted. Signed-off-by: Enrique Pérez Arnaud --- .../Controller/RequestHandlerController.php | 5 +- .../composer/composer/autoload_classmap.php | 3 ++ .../dav/composer/composer/autoload_static.php | 3 ++ apps/dav/lib/Controller/TokenController.php | 11 +++- apps/dav/lib/Db/OcmTokenMap.php | 41 ++++++++++++++ apps/dav/lib/Db/OcmTokenMapMapper.php | 42 +++++++++++++++ .../Version1037Date20260306120000.php | 53 +++++++++++++++++++ .../unit/Controller/TokenControllerTest.php | 26 ++++++--- .../lib/OCM/CloudFederationProviderFiles.php | 35 ++++++++++-- lib/private/Files/Storage/DAV.php | 40 +++++++++++--- 10 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 apps/dav/lib/Db/OcmTokenMap.php create mode 100644 apps/dav/lib/Db/OcmTokenMapMapper.php create mode 100644 apps/dav/lib/Migration/Version1037Date20260306120000.php diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index ecbd6749c047e..e83359ba1e2e0 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -8,6 +8,7 @@ namespace OCA\CloudFederationAPI\Controller; use OC\Authentication\Token\PublicKeyTokenProvider; +use OCA\DAV\Db\OcmTokenMapMapper; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Db\FederatedInviteMapper; @@ -520,8 +521,8 @@ private function confirmNotificationIdentity( if ($identity === '') { $tokenProvider = Server::get(PublicKeyTokenProvider::class); $accessTokenDb = $tokenProvider->getToken($sharedSecret); - $refreshToken = $accessTokenDb->getUID(); - $identity = $provider->getFederationIdFromSharedSecret($refreshToken, $notification); + $mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId()); + $identity = $provider->getFederationIdFromSharedSecret($mapping->getRefreshToken(), $notification); } } else { $this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]); diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 2e0ccff3e7002..62e7b7953ef68 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -285,6 +285,8 @@ 'OCA\\DAV\\Db\\AbsenceMapper' => $baseDir . '/../lib/Db/AbsenceMapper.php', 'OCA\\DAV\\Db\\Direct' => $baseDir . '/../lib/Db/Direct.php', 'OCA\\DAV\\Db\\DirectMapper' => $baseDir . '/../lib/Db/DirectMapper.php', + 'OCA\\DAV\\Db\\OcmTokenMap' => $baseDir . '/../lib/Db/OcmTokenMap.php', + 'OCA\\DAV\\Db\\OcmTokenMapMapper' => $baseDir . '/../lib/Db/OcmTokenMapMapper.php', 'OCA\\DAV\\Db\\Property' => $baseDir . '/../lib/Db/Property.php', 'OCA\\DAV\\Db\\PropertyMapper' => $baseDir . '/../lib/Db/PropertyMapper.php', 'OCA\\DAV\\Direct\\DirectFile' => $baseDir . '/../lib/Direct/DirectFile.php', @@ -396,6 +398,7 @@ 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => $baseDir . '/../lib/Migration/Version1036Date20251202000000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php', + 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => $baseDir . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 4b1b700efa17f..177750eabe6cf 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -300,6 +300,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Db\\AbsenceMapper' => __DIR__ . '/..' . '/../lib/Db/AbsenceMapper.php', 'OCA\\DAV\\Db\\Direct' => __DIR__ . '/..' . '/../lib/Db/Direct.php', 'OCA\\DAV\\Db\\DirectMapper' => __DIR__ . '/..' . '/../lib/Db/DirectMapper.php', + 'OCA\\DAV\\Db\\OcmTokenMap' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMap.php', + 'OCA\\DAV\\Db\\OcmTokenMapMapper' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMapMapper.php', 'OCA\\DAV\\Db\\Property' => __DIR__ . '/..' . '/../lib/Db/Property.php', 'OCA\\DAV\\Db\\PropertyMapper' => __DIR__ . '/..' . '/../lib/Db/PropertyMapper.php', 'OCA\\DAV\\Direct\\DirectFile' => __DIR__ . '/..' . '/../lib/Direct/DirectFile.php', @@ -411,6 +413,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => __DIR__ . '/..' . '/../lib/Migration/Version1036Date20251202000000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php', + 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php index 3c2fc0e3edbcc..7096083ca7287 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -10,6 +10,8 @@ use OC\Authentication\Token\IProvider; use OC\OCM\OCMSignatoryManager; use OC\Security\Signature\Model\IncomingSignedRequest; +use OCA\DAV\Db\OcmTokenMap; +use OCA\DAV\Db\OcmTokenMapMapper; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; @@ -47,6 +49,7 @@ public function __construct( private readonly ISignatureManager $signatureManager, private readonly OCMSignatoryManager $signatoryManager, private readonly IAppConfig $appConfig, + private readonly OcmTokenMapMapper $ocmTokenMapMapper, ) { parent::__construct('dav', $request); } @@ -146,7 +149,7 @@ public function accessToken(): DataResponse { $accessToken = $this->tokenProvider->generateToken( $accessTokenString, - $refreshToken, // Keep refresh token with access token as UID + $token->getUID(), $token->getLoginName(), null, // No password for access tokens IToken::OCM_ACCESS_TOKEN_NAME, @@ -157,6 +160,12 @@ public function accessToken(): DataResponse { $accessToken->setExpires($expiresAt); $this->tokenProvider->updateToken($accessToken); + $mapping = new OcmTokenMap(); + $mapping->setAccessTokenId($accessToken->getId()); + $mapping->setRefreshToken($refreshToken); + $mapping->setExpires($expiresAt); + $this->ocmTokenMapMapper->insert($mapping); + return new DataResponse([ 'access_token' => $accessTokenString, 'token_type' => 'Bearer', diff --git a/apps/dav/lib/Db/OcmTokenMap.php b/apps/dav/lib/Db/OcmTokenMap.php new file mode 100644 index 0000000000000..afd91ffc225a3 --- /dev/null +++ b/apps/dav/lib/Db/OcmTokenMap.php @@ -0,0 +1,41 @@ +addType('accessTokenId', Types::INTEGER); + $this->addType('refreshToken', Types::STRING); + $this->addType('expires', Types::INTEGER); + } +} diff --git a/apps/dav/lib/Db/OcmTokenMapMapper.php b/apps/dav/lib/Db/OcmTokenMapMapper.php new file mode 100644 index 0000000000000..469353d8a935d --- /dev/null +++ b/apps/dav/lib/Db/OcmTokenMapMapper.php @@ -0,0 +1,42 @@ + + */ +class OcmTokenMapMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'dav_ocm_token_map', OcmTokenMap::class); + } + + /** + * @throws DoesNotExistException + */ + public function getByAccessTokenId(int $accessTokenId): OcmTokenMap { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('access_token_id', $qb->createNamedParameter($accessTokenId))); + + return $this->findEntity($qb); + } + + public function deleteExpired(int $time): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('expires', $qb->createNamedParameter($time))); + $qb->executeStatement(); + } +} diff --git a/apps/dav/lib/Migration/Version1037Date20260306120000.php b/apps/dav/lib/Migration/Version1037Date20260306120000.php new file mode 100644 index 0000000000000..04fe9000da0de --- /dev/null +++ b/apps/dav/lib/Migration/Version1037Date20260306120000.php @@ -0,0 +1,53 @@ +hasTable('dav_ocm_token_map')) { + return null; + } + + $table = $schema->createTable('dav_ocm_token_map'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('access_token_id', Types::INTEGER, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('refresh_token', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + ]); + $table->addColumn('expires', Types::INTEGER, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['access_token_id'], 'dav_ocm_tkmap_atid'); + $table->addIndex(['expires'], 'dav_ocm_tkmap_exp'); + + return $schema; + } +} diff --git a/apps/dav/tests/unit/Controller/TokenControllerTest.php b/apps/dav/tests/unit/Controller/TokenControllerTest.php index 17e4038955d53..50e04aac47ac8 100644 --- a/apps/dav/tests/unit/Controller/TokenControllerTest.php +++ b/apps/dav/tests/unit/Controller/TokenControllerTest.php @@ -12,6 +12,7 @@ use OC\Authentication\Token\IProvider; use OC\OCM\OCMSignatoryManager; use OCA\DAV\Controller\TokenController; +use OCA\DAV\Db\OcmTokenMapMapper; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Utility\ITimeFactory; @@ -39,6 +40,7 @@ class TokenControllerTest extends TestCase { private ISignatureManager&MockObject $signatureManager; private OCMSignatoryManager&MockObject $signatoryManager; private IAppConfig&MockObject $appConfig; + private OcmTokenMapMapper&MockObject $ocmTokenMapMapper; private TokenController $controller; @@ -53,6 +55,7 @@ protected function setUp(): void { $this->signatureManager = $this->createMock(ISignatureManager::class); $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); $this->appConfig = $this->createMock(IAppConfig::class); + $this->ocmTokenMapMapper = $this->createMock(OcmTokenMapMapper::class); $this->controller = new TokenController( $this->request, @@ -63,6 +66,7 @@ protected function setUp(): void { $this->signatureManager, $this->signatoryManager, $this->appConfig, + $this->ocmTokenMapMapper, ); } @@ -74,14 +78,15 @@ public function testAccessTokenSuccess(): void { ->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'); + $refreshTokenMock = $this->createMock(IToken::class); + $refreshTokenMock->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshTokenMock->method('getId')->willReturn(123); + $refreshTokenMock->method('getUID')->willReturn('testuser'); + $refreshTokenMock->method('getLoginName')->willReturn('testuser'); $this->tokenProvider->method('getToken') ->with('valid-refresh-token') - ->willReturn($refreshToken); + ->willReturn($refreshTokenMock); $this->random->method('generate') ->with(64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS) @@ -90,10 +95,11 @@ public function testAccessTokenSuccess(): void { $this->timeFactory->method('getTime')->willReturn(1000000); $accessToken = $this->createMock(IToken::class); + $accessToken->method('getId')->willReturn(456); $this->tokenProvider->method('generateToken') ->with( 'generated-access-token', - 'valid-refresh-token', + 'testuser', 'testuser', null, 'OCM Access Token', @@ -110,6 +116,14 @@ public function testAccessTokenSuccess(): void { ->method('updateToken') ->with($accessToken); + $this->ocmTokenMapMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(function ($mapping) { + return $mapping->getAccessTokenId() === 456 + && $mapping->getRefreshToken() === 'valid-refresh-token' + && $mapping->getExpires() === 1000000 + 3600; + })); + // Simulate POST body $this->simulatePostBody('grant_type=authorization_code&code=valid-refresh-token'); diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 43f68e6b78160..fb1ff028d1da8 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -777,15 +777,40 @@ private function exchangeToken(string $remote, #[SensitiveParameter] string $sha $response = $client->post($tokenEndpoint, $options); + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200) { + $this->logger->warning('Token exchange returned unexpected HTTP status', [ + 'remote' => $remote, + 'status' => $statusCode, + ]); + return null; + } + $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']; + if (!is_array($data)) { + $this->logger->warning('Token exchange response is not valid JSON', ['remote' => $remote]); + return null; } - $this->logger->warning('Token exchange response missing access_token', ['remote' => $remote, 'response' => $data]); - return null; + $accessToken = $data['access_token'] ?? null; + $tokenType = $data['token_type'] ?? null; + + if (!is_string($accessToken) || $accessToken === '') { + $this->logger->warning('Token exchange response missing or invalid access_token', ['remote' => $remote]); + return null; + } + + if (!is_string($tokenType) || strtolower($tokenType) !== 'bearer') { + $this->logger->warning('Token exchange response has unexpected token_type', [ + 'remote' => $remote, + 'token_type' => $tokenType, + ]); + return null; + } + + $this->logger->debug('Successfully exchanged token for access token', ['remote' => $remote]); + return $accessToken; } catch (\Exception $e) { $this->logger->warning('Failed to exchange token', ['remote' => $remote, 'exception' => $e]); return null; diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 068c32c79c976..3f4eee7b945d1 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -308,16 +308,40 @@ protected function exchangeRefreshToken(): string { $response = $client->post($tokenEndpoint, $options); - $body = $response->getBody(); - $data = json_decode($body, true); + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200) { + $this->logger->error('Token exchange returned unexpected HTTP status', [ + 'app' => 'dav', + 'status' => $statusCode, + ]); + throw new StorageNotAvailableException('Could not obtain access token: unexpected HTTP status ' . $statusCode); + } - 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'); + $data = json_decode($response->getBody(), true); + + if (!is_array($data)) { + $this->logger->error('Token exchange response is not valid JSON', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not obtain access token: invalid response format'); } + + $accessToken = $data['access_token'] ?? null; + $tokenType = $data['token_type'] ?? null; + + if (!is_string($accessToken) || $accessToken === '') { + $this->logger->error('Token exchange response missing or invalid access_token', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not obtain access token: missing access_token field'); + } + + if (!is_string($tokenType) || strtolower($tokenType) !== 'bearer') { + $this->logger->error('Token exchange response has unexpected token_type', [ + 'app' => 'dav', + 'token_type' => $tokenType, + ]); + throw new StorageNotAvailableException('Could not obtain access token: unexpected token_type'); + } + + $this->logger->debug('Successfully exchanged refresh token for access token', ['app' => 'dav']); + return $accessToken; } catch (OCMProviderException|OCMArgumentException $e) { $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); throw new StorageNotAvailableException('Could not discover token endpoint'); From 3e84a190b8325628d1fd586ed3f667b8f304138b Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Wed, 6 May 2026 12:29:15 +0200 Subject: [PATCH 04/13] feat(JWT): Switch the access_token to a JWT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By using a JWT that the receiver can verify against a public key, we make it easy for third party services to validate the token independently. This is relevant for example in the context of webapp shares that may defer to a separate service to deliver the actual webapp. NOTE: This requires https://github.com/nextcloud/3rdparty/pull/2413 to be merged before this. Signed-off-by: Micke Nordin Signed-off-by: Enrique Pérez Arnaud --- apps/dav/lib/Controller/TokenController.php | 66 +++++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php index 7096083ca7287..608c2443b428b 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -7,6 +7,7 @@ namespace OCA\DAV\Controller; +use Firebase\JWT\JWT; use OC\Authentication\Token\IProvider; use OC\OCM\OCMSignatoryManager; use OC\Security\Signature\Model\IncomingSignedRequest; @@ -31,6 +32,8 @@ use OCP\Security\Signature\Exceptions\SignatureNotFoundException; use OCP\Security\Signature\IIncomingSignedRequest; use OCP\Security\Signature\ISignatureManager; +use OCP\Security\Signature\Model\Signatory; +use OCP\Share\IManager as IShareManager; use Psr\Log\LoggerInterface; /** @@ -50,6 +53,7 @@ public function __construct( private readonly OCMSignatoryManager $signatoryManager, private readonly IAppConfig $appConfig, private readonly OcmTokenMapMapper $ocmTokenMapMapper, + private readonly IShareManager $shareManager, ) { parent::__construct('dav', $request); } @@ -81,6 +85,29 @@ private function verifySignedRequest(): ?IncomingSignedRequest { } } + /** + * @return array{0: string, 1: string} [JWS algorithm, key material accepted by firebase/php-jwt] + * @throws \RuntimeException if the key cannot be parsed or its type is unsupported + */ + private function resolveJwtSigningKey(string $privateKeyPem): array { + $key = openssl_pkey_get_private($privateKeyPem); + if ($key === false) { + throw new \RuntimeException('Cannot parse signatory private key'); + } + $details = openssl_pkey_get_details($key); + + if (isset($details['rsa'])) { + $algorithm = $details['bits'] >= 4096 ? 'RS512' : 'RS256'; + return [$algorithm, $privateKeyPem]; + } + if (isset($details['ed25519']['priv_key'])) { + $secretKey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($details['ed25519']['priv_key'])); + return ['EdDSA', base64_encode($secretKey)]; + } + + throw new \RuntimeException('Unsupported signatory key type for JWT access token'); + } + /** * Exchange a refresh token for a short-lived access token * @@ -139,13 +166,42 @@ public function accessToken(): DataResponse { ); } - $accessTokenString = $this->random->generate( - 64, - ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS - ); + // Revoke the previous access token for this refresh token, if any. + $existingMapping = $this->ocmTokenMapMapper->findByRefreshToken($refreshToken); + if ($existingMapping !== null) { + try { + $this->tokenProvider->invalidateTokenById( + $token->getUID(), + $existingMapping->getAccessTokenId() + ); + } catch (\Exception) { + // Token may already be gone; ignore. + } + $this->ocmTokenMapMapper->delete($existingMapping); + } + $share = $this->shareManager->getShareByToken($refreshToken); $expiresIn = 3600; // 1 hour in seconds - $expiresAt = $this->timeFactory->getTime() + $expiresIn; + $issuedAt = $this->timeFactory->getTime(); + $expiresAt = $issuedAt + $expiresIn; + + $signatory = $this->signatoryManager->getLocalSignatory(); + $keyId = $signatory->getKeyId(); + $issuer = parse_url($keyId, PHP_URL_SCHEME) . '://' . Signatory::extractIdentityFromUri($keyId); + + [$jwtAlgorithm, $jwtKey] = $this->resolveJwtSigningKey($signatory->getPrivateKey()); + + $payload = [ + 'iss' => $issuer, + 'sub' => $share->getShareOwner(), + 'aud' => $share->getSharedWith(), + 'client_id' => (string)$token->getId(), + 'iat' => $issuedAt, + 'exp' => $expiresAt, + 'jti' => $this->random->generate(16, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS), + ]; + + $accessTokenString = JWT::encode($payload, $jwtKey, $jwtAlgorithm, $keyId, ['typ' => 'at+jwt']); $accessToken = $this->tokenProvider->generateToken( $accessTokenString, From 25702ff2423f0384bc2305f4b5cd9aca718068ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 6 Mar 2026 13:25:43 +0100 Subject: [PATCH 05/13] feat(federatedfilesharing): persist outgoing refresh tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a refresh-token column to `federated_reshares` so refresh tokens issued to remotes survive across share-creation requests. The migration is backwards-compatible with shares created before the upgrade. Signed-off-by: Enrique Pérez Arnaud --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../lib/FederatedShareProvider.php | 12 +- .../Version1012Date20260306120000.php | 117 ++++++++++++++++++ 4 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php diff --git a/apps/federatedfilesharing/composer/composer/autoload_classmap.php b/apps/federatedfilesharing/composer/composer/autoload_classmap.php index a5ec2ecd82288..27a3710e120e0 100644 --- a/apps/federatedfilesharing/composer/composer/autoload_classmap.php +++ b/apps/federatedfilesharing/composer/composer/autoload_classmap.php @@ -17,6 +17,7 @@ 'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => $baseDir . '/../lib/Listeners/LoadAdditionalScriptsListener.php', 'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => $baseDir . '/../lib/Migration/Version1010Date20200630191755.php', 'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => $baseDir . '/../lib/Migration/Version1011Date20201120125158.php', + 'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => $baseDir . '/../lib/Migration/Version1012Date20260306120000.php', 'OCA\\FederatedFileSharing\\Notifications' => $baseDir . '/../lib/Notifications.php', 'OCA\\FederatedFileSharing\\Notifier' => $baseDir . '/../lib/Notifier.php', 'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => $baseDir . '/../lib/OCM/CloudFederationProviderFiles.php', diff --git a/apps/federatedfilesharing/composer/composer/autoload_static.php b/apps/federatedfilesharing/composer/composer/autoload_static.php index c415c51b5929f..77ce59fe0054f 100644 --- a/apps/federatedfilesharing/composer/composer/autoload_static.php +++ b/apps/federatedfilesharing/composer/composer/autoload_static.php @@ -32,6 +32,7 @@ class ComposerStaticInitFederatedFileSharing 'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScriptsListener.php', 'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630191755.php', 'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => __DIR__ . '/..' . '/../lib/Migration/Version1011Date20201120125158.php', + 'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1012Date20260306120000.php', 'OCA\\FederatedFileSharing\\Notifications' => __DIR__ . '/..' . '/../lib/Notifications.php', 'OCA\\FederatedFileSharing\\Notifier' => __DIR__ . '/..' . '/../lib/Notifier.php', 'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => __DIR__ . '/..' . '/../lib/OCM/CloudFederationProviderFiles.php', diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 1505c0b2eda85..093702f6a33f5 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -9,6 +9,7 @@ use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; +use OCA\DAV\Db\OcmTokenMapMapper; use OC\Share20\Share; use OCA\DAV\Db\OcmTokenMapMapper; use OCP\Authentication\Exceptions\InvalidTokenException; @@ -741,20 +742,19 @@ public function getShareByToken(string $token): IShare { if ($data === false) { // 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(); + $accessTokenDb = Server::get(PublicKeyTokenProvider::class)->getToken($token); + $mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId()); $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))) + ->andWhere($qb2->expr()->eq('token', $qb2->createNamedParameter($mapping->getRefreshToken()))) ->executeQuery(); $data = $cursor->fetch(); - } catch (InvalidTokenException) { - // Token is not a valid access token, share not found + } catch (InvalidTokenException|\OCP\AppFramework\Db\DoesNotExistException) { + // Token is not a valid access token or has no mapping, share not found } } if ($data === false) { diff --git a/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php new file mode 100644 index 0000000000000..78d42fb6e1a88 --- /dev/null +++ b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php @@ -0,0 +1,117 @@ +getQueryBuilder(); + $result = $qb->select('id', 'token', 'uid_initiator') + ->from('share') + ->where($qb->expr()->in( + 'share_type', + $qb->createNamedParameter( + [IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], + IQueryBuilder::PARAM_INT_ARRAY + ) + )) + ->executeQuery(); + + $registered = 0; + $skipped = 0; + + while ($row = $result->fetchAssociative()) { + $shareId = (int)$row['id']; + $token = (string)$row['token']; + $uid = (string)$row['uid_initiator']; + + if (strlen($token) < PublicKeyTokenProvider::TOKEN_MIN_LENGTH) { + // Old short token from TokenHandler — leave it as-is. + // Replacing it would invalidate the token stored on the receiving instance, + // breaking Basic-auth access to those shares. These shares keep working via + // Basic auth and are simply not eligible for the OCM token exchange flow. + $skipped++; + continue; + } + + // Long token — check if it's already in oc_authtoken. + try { + $tokenProvider->getToken($token); + $skipped++; + continue; + } catch (InvalidTokenException) { + // Not registered yet — fall through to create it. + } + + $user = $userManager->get($uid); + $name = $user?->getDisplayName() ?? $uid; + + try { + $tokenProvider->generateToken( + $token, + $uid, + $uid, + null, + $name, + IToken::PERMANENT_TOKEN, + ); + $registered++; + } catch (\Exception $e) { + $output->warning(sprintf( + 'Could not register auth token for share %d (uid=%s): %s', + $shareId, + $uid, + $e->getMessage() + )); + } + } + + $result->closeCursor(); + + $output->info(sprintf( + 'Federated share token migration: %d registered, %d skipped (already up-to-date or legacy short token).', + $registered, + $skipped + )); + } +} From d2ac11f5a0483f7e171792c2c0085eef4d3b2257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 6 Mar 2026 13:49:57 +0100 Subject: [PATCH 06/13] feat(files_sharing): store access tokens per external share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `access_token` and `access_token_expires` columns to `share_external`. The expires timestamp deduplicates concurrent refresh requests across processes. Signed-off-by: Enrique Pérez Arnaud --- .../composer/composer/autoload_classmap.php | 2 + .../composer/composer/autoload_static.php | 2 + .../lib/External/ExternalShare.php | 8 +++ apps/files_sharing/lib/External/Manager.php | 8 ++- apps/files_sharing/lib/External/Storage.php | 52 +++++++++++++++---- .../Version33000Date20260306120000.php | 41 +++++++++++++++ .../Version33000Date20260306130000.php | 40 ++++++++++++++ 7 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 apps/files_sharing/lib/Migration/Version33000Date20260306120000.php create mode 100644 apps/files_sharing/lib/Migration/Version33000Date20260306130000.php diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index 2d6b545a17a63..f1dfe4bb98995 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -88,6 +88,8 @@ 'OCA\\Files_Sharing\\Migration\\Version31000Date20240821142813' => $baseDir . '/../lib/Migration/Version31000Date20240821142813.php', 'OCA\\Files_Sharing\\Migration\\Version32000Date20251017081948' => $baseDir . '/../lib/Migration/Version32000Date20251017081948.php', 'OCA\\Files_Sharing\\Migration\\Version33000Date20251030081948' => $baseDir . '/../lib/Migration/Version33000Date20251030081948.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306120000' => $baseDir . '/../lib/Migration/Version33000Date20260306120000.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306130000' => $baseDir . '/../lib/Migration/Version33000Date20260306130000.php', 'OCA\\Files_Sharing\\MountProvider' => $baseDir . '/../lib/MountProvider.php', 'OCA\\Files_Sharing\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php', 'OCA\\Files_Sharing\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 1fc2ddd2dfd8a..b0ad698369c90 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -103,6 +103,8 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Migration\\Version31000Date20240821142813' => __DIR__ . '/..' . '/../lib/Migration/Version31000Date20240821142813.php', 'OCA\\Files_Sharing\\Migration\\Version32000Date20251017081948' => __DIR__ . '/..' . '/../lib/Migration/Version32000Date20251017081948.php', 'OCA\\Files_Sharing\\Migration\\Version33000Date20251030081948' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20251030081948.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20260306120000.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306130000' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20260306130000.php', 'OCA\\Files_Sharing\\MountProvider' => __DIR__ . '/..' . '/../lib/MountProvider.php', 'OCA\\Files_Sharing\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php', 'OCA\\Files_Sharing\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', diff --git a/apps/files_sharing/lib/External/ExternalShare.php b/apps/files_sharing/lib/External/ExternalShare.php index b452b8ce013a3..0f4031f10080b 100644 --- a/apps/files_sharing/lib/External/ExternalShare.php +++ b/apps/files_sharing/lib/External/ExternalShare.php @@ -30,6 +30,10 @@ * @method void setShareToken(string $shareToken) * @method string|null getPassword() * @method void setPassword(?string $password) + * @method string|null getAccessToken() + * @method void setAccessToken(?string $accessToken) + * @method int|null getAccessTokenExpires() + * @method void setAccessTokenExpires(?int $accessTokenExpires) * @method string getName() * @method string getOwner() * @method void setOwner(string $owner) @@ -50,6 +54,8 @@ class ExternalShare extends SnowflakeAwareEntity implements \JsonSerializable { protected ?string $remoteId = null; protected ?string $shareToken = null; protected ?string $password = null; + protected ?string $accessToken = null; + protected ?int $accessTokenExpires = null; protected ?string $name = null; protected ?string $owner = null; protected ?string $user = null; @@ -65,6 +71,8 @@ public function __construct() { $this->addType('remoteId', Types::STRING); $this->addType('shareToken', Types::STRING); $this->addType('password', Types::STRING); + $this->addType('accessToken', Types::STRING); + $this->addType('accessTokenExpires', Types::INTEGER); $this->addType('name', Types::STRING); $this->addType('owner', Types::STRING); $this->addType('user', Types::STRING); diff --git a/apps/files_sharing/lib/External/Manager.php b/apps/files_sharing/lib/External/Manager.php index 8b831fd72e71a..ee532defff023 100644 --- a/apps/files_sharing/lib/External/Manager.php +++ b/apps/files_sharing/lib/External/Manager.php @@ -113,6 +113,8 @@ public function addShare(ExternalShare $externalShare, IUser|IGroup|null $shareW 'remote' => $externalShare->getRemote(), 'token' => $externalShare->getShareToken(), 'password' => $externalShare->getPassword(), + 'access_token' => $externalShare->getAccessToken(), + 'access_token_expires' => $externalShare->getAccessTokenExpires(), 'mountpoint' => $externalShare->getMountpoint(), 'owner' => $externalShare->getOwner(), 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), @@ -190,6 +192,7 @@ private function updateSubShare(ExternalShare $externalShare, IUser $user, ?stri $subShare->generateId(); $subShare->setRemote($externalShare->getRemote()); $subShare->setPassword($externalShare->getPassword()); + $subShare->setAccessToken($externalShare->getAccessToken()); $subShare->setName($externalShare->getName()); $subShare->setOwner($externalShare->getOwner()); $subShare->setUser($user->getUID()); @@ -573,10 +576,11 @@ public function getAcceptedShares(): array { * @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 { + public function updateAccessToken(string $shareToken, string $accessToken, int $expiresAt): void { try { $share = $this->externalShareMapper->getShareByToken($shareToken); - $share->setPassword($accessToken); + $share->setAccessToken($accessToken); + $share->setAccessTokenExpires($expiresAt); $this->externalShareMapper->update($share); $this->logger->debug('Updated access token for share', ['shareToken' => substr($shareToken, 0, 8) . '...']); } catch (DoesNotExistException $e) { diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index 1cabf5e1114a6..a453e6615521a 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -53,9 +53,11 @@ class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, protected IAppConfig $appConfig; private IShareManager $shareManager; private bool $tokenRefreshed = false; + /** Unix timestamp until which the current access token is considered valid (0 = unknown/expired) */ + private int $tokenExpiresAt = 0; /** - * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, password: ?string}|array $options + * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, access_token: ?string, access_token_expires: ?int}|array $options */ public function __construct($options) { $this->memcacheFactory = Server::get(ICacheFactory::class); @@ -85,9 +87,9 @@ 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'])) { + // If we have a stored access token, use Bearer auth regardless of discovery. + // This handles the case where the share was created with must-exchange-token. + if (!empty($options['access_token'])) { $authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER; } @@ -103,6 +105,7 @@ public function __construct($options) { $this->mountPoint = $options['mountpoint']; $this->token = $options['token']; + $this->tokenExpiresAt = (int)($options['access_token_expires'] ?? 0); parent::__construct( [ @@ -112,7 +115,9 @@ public function __construct($options) { 'root' => $webDavEndpoint, 'user' => $options['token'], 'authType' => $authType, - 'password' => (string)$options['password'], + 'password' => $authType === \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER + ? (string)($options['access_token'] ?? '') + : (string)($options['password'] ?? ''), 'discoveryService' => $discoveryService, ] ); @@ -122,20 +127,45 @@ 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 + * Uses expiry timestamps instead of a boolean flag so that concurrent + * processes can detect that another process already obtained a fresh token + * and reuse it rather than performing a redundant exchange. + * + * @return bool True if token was refreshed (or reused from DB) successfully */ - protected function refreshAccessToken(): bool { - if ($this->tokenRefreshed) { - // only try to refresh once per request + protected function refreshBearerToken(): bool { + $now = time(); + + // Fast path: in-memory token is still valid (single-process guard). + if ($this->tokenExpiresAt > $now) { return false; } - $this->tokenRefreshed = true; + // Slow path: check DB — a concurrent process may have already refreshed. + $share = $this->manager->getShareByToken($this->token); + if ($share !== false) { + $dbExpiry = $share->getAccessTokenExpires(); + $dbToken = $share->getAccessToken(); + if ($dbExpiry !== null && $dbExpiry > $now && $dbToken !== null) { + // Another process already refreshed — reuse DB token. + $this->password = $dbToken; + $this->tokenExpiresAt = $dbExpiry; + $this->ready = false; + $this->client = null; + $this->init(); + $this->logger->debug('Reused access token refreshed by another process', ['app' => 'files_sharing']); + return true; + } + } + + // No valid token in DB — perform the exchange ourselves. try { + $expiresAt = $now + 3600; // access tokens are valid for 1 hour $newAccessToken = $this->exchangeRefreshToken(); $this->password = $newAccessToken; + $this->tokenExpiresAt = $expiresAt; - $this->manager->updateAccessToken($this->token, $newAccessToken); + $this->manager->updateAccessToken($this->token, $newAccessToken, $expiresAt); $this->ready = false; $this->client = null; diff --git a/apps/files_sharing/lib/Migration/Version33000Date20260306120000.php b/apps/files_sharing/lib/Migration/Version33000Date20260306120000.php new file mode 100644 index 0000000000000..86e753d41d486 --- /dev/null +++ b/apps/files_sharing/lib/Migration/Version33000Date20260306120000.php @@ -0,0 +1,41 @@ +getTable('share_external'); + + if ($table->hasColumn('access_token')) { + return null; + } + + $table->addColumn('access_token', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 4000, + ]); + + return $schema; + } +} diff --git a/apps/files_sharing/lib/Migration/Version33000Date20260306130000.php b/apps/files_sharing/lib/Migration/Version33000Date20260306130000.php new file mode 100644 index 0000000000000..ec1fe15d42f42 --- /dev/null +++ b/apps/files_sharing/lib/Migration/Version33000Date20260306130000.php @@ -0,0 +1,40 @@ +getTable('share_external'); + + if ($table->hasColumn('access_token_expires')) { + return null; + } + + $table->addColumn('access_token_expires', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + + return $schema; + } +} From f02c084151d8b7e3c9c9a8cb10a50667f7ab3b00 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 07/13] feat(dav): refresh expired access tokens transparently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a WebDAV request fails with 401 and the stored access token is expired, attempt one transparent refresh against the OCM token endpoint and retry. Limited to a single retry per request to avoid loops when the refresh itself fails. Signed-off-by: Enrique Pérez Arnaud --- apps/files_sharing/lib/External/Storage.php | 66 ++++---- lib/private/Files/Storage/DAV.php | 174 ++++++++++++++------ 2 files changed, 164 insertions(+), 76 deletions(-) diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index a453e6615521a..fbaa3d6886adc 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -55,6 +55,13 @@ class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, private bool $tokenRefreshed = false; /** Unix timestamp until which the current access token is considered valid (0 = unknown/expired) */ private int $tokenExpiresAt = 0; + /** Number of consecutive token exchange failures (resets on success or DB-reuse) */ + private int $refreshFailureCount = 0; + /** Unix timestamp before which the next exchange attempt must not be made (0 = no wait) */ + private int $refreshBackoffUntil = 0; + + private const REFRESH_MAX_ATTEMPTS = 3; + private const REFRESH_BACKOFF_SECONDS = 5; /** * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, access_token: ?string, access_token_expires: ?int}|array $options @@ -124,13 +131,17 @@ 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. * * Uses expiry timestamps instead of a boolean flag so that concurrent * processes can detect that another process already obtained a fresh token * and reuse it rather than performing a redundant exchange. * + * After a failed exchange, a 60-second backoff is applied so that + * subsequent file operations do not hammer the remote token endpoint. + * The DB is still consulted during backoff in case a concurrent process + * succeeded; only the outgoing exchange call is suppressed. + * * @return bool True if token was refreshed (or reused from DB) successfully */ protected function refreshBearerToken(): bool { @@ -147,9 +158,11 @@ protected function refreshBearerToken(): bool { $dbExpiry = $share->getAccessTokenExpires(); $dbToken = $share->getAccessToken(); if ($dbExpiry !== null && $dbExpiry > $now && $dbToken !== null) { - // Another process already refreshed — reuse DB token. + // Another process already refreshed — reuse DB token and reset failure state. $this->password = $dbToken; $this->tokenExpiresAt = $dbExpiry; + $this->refreshFailureCount = 0; + $this->refreshBackoffUntil = 0; $this->ready = false; $this->client = null; $this->init(); @@ -158,53 +171,46 @@ protected function refreshBearerToken(): bool { } } + // Gave up after max attempts: stop trying for the lifetime of this instance. + if ($this->refreshFailureCount >= self::REFRESH_MAX_ATTEMPTS) { + return false; + } + + // Still within the inter-attempt wait: don't hit the endpoint yet. + if ($this->refreshBackoffUntil > $now) { + return false; + } + // No valid token in DB — perform the exchange ourselves. try { $expiresAt = $now + 3600; // access tokens are valid for 1 hour $newAccessToken = $this->exchangeRefreshToken(); $this->password = $newAccessToken; $this->tokenExpiresAt = $expiresAt; + $this->refreshFailureCount = 0; + $this->refreshBackoffUntil = 0; $this->manager->updateAccessToken($this->token, $newAccessToken, $expiresAt); $this->ready = false; $this->client = null; + $this->init(); $this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']); return true; } catch (\Exception $e) { - $this->logger->warning('Failed to refresh access token', [ + $this->refreshFailureCount++; + $this->refreshBackoffUntil = $now + self::REFRESH_BACKOFF_SECONDS; + $this->logger->warning('Failed to refresh access token (attempt {attempt}/{max})', [ 'app' => 'files_sharing', + 'attempt' => $this->refreshFailureCount, + 'max' => self::REFRESH_MAX_ATTEMPTS, '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; - } - } - #[\Override] public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { @@ -271,7 +277,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(); @@ -286,7 +292,7 @@ public function hasUpdated(string $path, int $time): bool { #[\Override] 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 3f4eee7b945d1..0a7ef64d4fc40 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -356,6 +356,79 @@ protected function exchangeRefreshToken(): string { } } + /** + * Check if bearer authentication is being used + */ + protected function isBearerAuth(): bool { + return $this->authType !== null + && ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER); + } + + /** + * @var bool Flag to prevent infinite retry loops during token refresh + */ + 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; + } + /** * Clear the stat cache */ @@ -465,10 +538,10 @@ protected function propfind(string $path): array|false { $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) { @@ -542,29 +615,30 @@ public function fopen(string $path, string $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, - 'verify' => $this->verify, - ]); + $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, + 'verify' => $this->verify, + ]); + }); } catch (\GuzzleHttp\Exception\ClientException $e) { if ($e->getResponse() instanceof ResponseInterface && $e->getResponse()->getStatusCode() === 404) { return false; - } else { - throw $e; } + throw $e; } if ($response->getStatusCode() !== Http::STATUS_OK) { @@ -661,9 +735,9 @@ public function touch(string $path, ?int $mtime = null): bool { 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) { @@ -701,24 +775,26 @@ protected function uploadFile(string $path, string $target): void { // 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]; - } - $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, - 'verify' => $this->verify, - ]); + $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), [ + 'body' => $source, + 'headers' => $headers, + 'auth' => $auth, + // set upload timeout for users with slow connections or large files + 'timeout' => $this->timeout, + 'verify' => $this->verify, + ]); + }); $this->removeCachedFile($target); } @@ -734,14 +810,14 @@ public function rename(string $source, string $target): bool { // 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); @@ -749,6 +825,8 @@ public function rename(string $source, string $target): bool { $this->removeCachedFile($source); $this->removeCachedFile($target); return true; + } catch (ClientHttpException $e) { + $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); } @@ -766,18 +844,20 @@ public function copy(string $source, string $target): bool { // 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) { + $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); } @@ -889,7 +969,7 @@ protected function encodePath(string $path): string { 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') { @@ -1066,11 +1146,11 @@ 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)) { @@ -1084,6 +1164,8 @@ public function getDirectoryContent(string $directory): \Traversable { $this->statCache->set($file, $response); yield $this->getMetaFromPropfind($file, $response); } + } catch (ClientHttpException $e) { + $this->convertException($e, $directory); } catch (\Exception $e) { $this->convertException($e, $directory); } From 0d73649046d59581a0e013559c86fa0d9fcb6e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 6 Mar 2026 14:43:07 +0100 Subject: [PATCH 08/13] feat(dav): background job to purge expired OCM access tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleanupExpiredOcmTokensJob periodically deletes expired `oc_ocm_token_map` rows and their backing `oc_authtoken` entries. Signed-off-by: Enrique Pérez Arnaud --- apps/dav/appinfo/info.xml | 1 + .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + .../CleanupExpiredOcmTokensJob.php | 36 +++++++++++++++ apps/dav/lib/Db/OcmTokenMapMapper.php | 16 +++++++ .../CleanupExpiredOcmTokensJobTest.php | 44 +++++++++++++++++++ 6 files changed, 99 insertions(+) create mode 100644 apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php create mode 100644 apps/dav/tests/unit/BackgroundJob/CleanupExpiredOcmTokensJobTest.php diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index 75777adfecd99..00e3beb28126e 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -31,6 +31,7 @@ OCA\DAV\BackgroundJob\CalendarRetentionJob OCA\DAV\BackgroundJob\PruneOutdatedSyncTokensJob OCA\DAV\BackgroundJob\FederatedCalendarPeriodicSyncJob + OCA\DAV\BackgroundJob\CleanupExpiredOcmTokensJob diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 62e7b7953ef68..53b4c6389706e 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -15,6 +15,7 @@ 'OCA\\DAV\\BackgroundJob\\BuildReminderIndexBackgroundJob' => $baseDir . '/../lib/BackgroundJob/BuildReminderIndexBackgroundJob.php', 'OCA\\DAV\\BackgroundJob\\CalendarRetentionJob' => $baseDir . '/../lib/BackgroundJob/CalendarRetentionJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupDirectLinksJob' => $baseDir . '/../lib/BackgroundJob/CleanupDirectLinksJob.php', + 'OCA\\DAV\\BackgroundJob\\CleanupExpiredOcmTokensJob' => $baseDir . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupInvitationTokenJob' => $baseDir . '/../lib/BackgroundJob/CleanupInvitationTokenJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupOrphanedChildrenJob' => $baseDir . '/../lib/BackgroundJob/CleanupOrphanedChildrenJob.php', 'OCA\\DAV\\BackgroundJob\\DeleteOutdatedSchedulingObjects' => $baseDir . '/../lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 177750eabe6cf..abc5ef5e0b083 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -30,6 +30,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\BackgroundJob\\BuildReminderIndexBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/BuildReminderIndexBackgroundJob.php', 'OCA\\DAV\\BackgroundJob\\CalendarRetentionJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CalendarRetentionJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupDirectLinksJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupDirectLinksJob.php', + 'OCA\\DAV\\BackgroundJob\\CleanupExpiredOcmTokensJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupInvitationTokenJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupInvitationTokenJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupOrphanedChildrenJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupOrphanedChildrenJob.php', 'OCA\\DAV\\BackgroundJob\\DeleteOutdatedSchedulingObjects' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php', diff --git a/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php b/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php new file mode 100644 index 0000000000000..32f7c06c43738 --- /dev/null +++ b/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php @@ -0,0 +1,36 @@ +setInterval(6 * 60 * 60); // run every 6 hours + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + protected function run($argument): void { + $this->mapper->deleteExpired($this->time->getTime()); + } +} diff --git a/apps/dav/lib/Db/OcmTokenMapMapper.php b/apps/dav/lib/Db/OcmTokenMapMapper.php index 469353d8a935d..535e80b0b131a 100644 --- a/apps/dav/lib/Db/OcmTokenMapMapper.php +++ b/apps/dav/lib/Db/OcmTokenMapMapper.php @@ -33,6 +33,22 @@ public function getByAccessTokenId(int $accessTokenId): OcmTokenMap { return $this->findEntity($qb); } + /** + * Find the current mapping for a given refresh token, if any. + */ + public function findByRefreshToken(string $refreshToken): ?OcmTokenMap { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('refresh_token', $qb->createNamedParameter($refreshToken))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } + } + public function deleteExpired(int $time): void { $qb = $this->db->getQueryBuilder(); $qb->delete($this->getTableName()) diff --git a/apps/dav/tests/unit/BackgroundJob/CleanupExpiredOcmTokensJobTest.php b/apps/dav/tests/unit/BackgroundJob/CleanupExpiredOcmTokensJobTest.php new file mode 100644 index 0000000000000..aeda2171fb58e --- /dev/null +++ b/apps/dav/tests/unit/BackgroundJob/CleanupExpiredOcmTokensJobTest.php @@ -0,0 +1,44 @@ +timeFactory = $this->createMock(ITimeFactory::class); + $this->mapper = $this->createMock(OcmTokenMapMapper::class); + + $this->job = new CleanupExpiredOcmTokensJob($this->timeFactory, $this->mapper); + } + + public function testRunDeletesExpiredTokens(): void { + $now = 1700000000; + $this->timeFactory->expects($this->once()) + ->method('getTime') + ->willReturn($now); + + $this->mapper->expects($this->once()) + ->method('deleteExpired') + ->with($now); + + $method = new \ReflectionMethod(CleanupExpiredOcmTokensJob::class, 'run'); + $method->invoke($this->job, []); + } +} From 9bb0b9175f1a810286a2708647c10c4814bb6ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Fri, 20 Mar 2026 13:39:37 +0100 Subject: [PATCH 09/13] test(files_sharing): cover access-token storage and refresh paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- .../External/ManagerUpdateAccessTokenTest.php | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 apps/files_sharing/tests/External/ManagerUpdateAccessTokenTest.php diff --git a/apps/files_sharing/tests/External/ManagerUpdateAccessTokenTest.php b/apps/files_sharing/tests/External/ManagerUpdateAccessTokenTest.php new file mode 100644 index 0000000000000..2886c52fc5a6a --- /dev/null +++ b/apps/files_sharing/tests/External/ManagerUpdateAccessTokenTest.php @@ -0,0 +1,113 @@ +externalShareMapper = $this->createMock(ExternalShareMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn(null); + + $this->manager = new Manager( + $this->createMock(IDBConnection::class), + $this->createMock(\OC\Files\Mount\Manager::class), + $this->createMock(IStorageFactory::class), + $this->createMock(IClientService::class), + $this->createMock(INotificationManager::class), + $this->createMock(IDiscoveryService::class), + $this->createMock(ICloudFederationProviderManager::class), + $this->createMock(ICloudFederationFactory::class), + $this->createMock(IGroupManager::class), + $userSession, + $this->createMock(IEventDispatcher::class), + $this->logger, + $this->createMock(IRootFolder::class), + $this->createMock(ISetupManager::class), + $this->createMock(ICertificateManager::class), + $this->externalShareMapper, + $this->createMock(IConfig::class), + ); + } + + public function testUpdateAccessTokenUpdatesShareInDb(): void { + $share = new ExternalShare(); + $share->setShareToken('refresh-token'); + + $this->externalShareMapper->expects($this->once()) + ->method('getShareByToken') + ->with('refresh-token') + ->willReturn($share); + + $this->externalShareMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (ExternalShare $s) { + return $s->getAccessToken() === 'new-access-token' + && $s->getAccessTokenExpires() === 9999; + })); + + $this->manager->updateAccessToken('refresh-token', 'new-access-token', 9999); + } + + public function testUpdateAccessTokenLogsWarningWhenShareNotFound(): void { + $this->externalShareMapper->method('getShareByToken') + ->willThrowException(new DoesNotExistException('not found')); + + $this->externalShareMapper->expects($this->never())->method('update'); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Could not find share')); + + $this->manager->updateAccessToken('missing-token', 'access', 0); + } + + public function testUpdateAccessTokenLogsErrorOnDbException(): void { + $this->externalShareMapper->method('getShareByToken') + ->willThrowException(new Exception('db error')); + + $this->externalShareMapper->expects($this->never())->method('update'); + + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Failed to update access token')); + + $this->manager->updateAccessToken('some-token', 'access', 0); + } +} From ba63150c767c2e18c1d6a97c990046b059e65003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=A9rez=20Arnaud?= Date: Wed, 25 Mar 2026 16:17:32 +0100 Subject: [PATCH 10/13] fix: backwards compatibility for shares created before OCM bearer auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When mounting a federated share created on an instance that predates the bearer-auth upgrade, fall back to password-based authentication instead of attempting token exchange. Signed-off-by: Enrique Pérez Arnaud --- .../Controller/RequestHandlerController.php | 6 +++++- .../lib/OCM/CloudFederationProviderFiles.php | 2 +- .../lib/External/MountProvider.php | 4 ++-- apps/files_sharing/lib/External/Storage.php | 21 ++++++++++++------- lib/private/Files/Storage/DAV.php | 1 + 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index e83359ba1e2e0..8299aa642978d 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -176,6 +176,7 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ } $cloudId = $this->cloudIdManager->resolveCloudId($shareWith); + $shareWithCloudId = $shareWith; // preserve full cloud ID for factory capability discovery $shareWith = $cloudId->getUser(); if ($shareType === 'user') { @@ -220,7 +221,10 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ try { $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); - $share = $this->factory->getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType); + // Pass the original cloud ID so the factory can discover capabilities without warning. + // Then reset shareWith to the local username that shareReceived() needs for user lookup. + $share = $this->factory->getCloudFederationShare($shareWithCloudId, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType); + $share->setShareWith($shareWith); $share->setProtocol($protocol); $provider->shareReceived($share); } catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) { diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index fb1ff028d1da8..ae51f4480e492 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -182,7 +182,7 @@ public function shareReceived(ICloudFederationShare $share): string { $externalShare->setRemote($remote); $externalShare->setRemoteId($remoteId); $externalShare->setShareToken($token); // refresh token (sharedSecret) - $externalShare->setPassword($accessToken); // access token (empty if no token exchange) + $externalShare->setAccessToken($accessToken ?: null); $externalShare->setName($name); $externalShare->setOwner($owner); $externalShare->setShareType($shareType); diff --git a/apps/files_sharing/lib/External/MountProvider.php b/apps/files_sharing/lib/External/MountProvider.php index 06191927e0c59..e8a535abb9e21 100644 --- a/apps/files_sharing/lib/External/MountProvider.php +++ b/apps/files_sharing/lib/External/MountProvider.php @@ -60,7 +60,7 @@ private function getMount(IUser $user, array $data, IStorageFactory $storageFact #[\Override] public function getMountsForUser(IUser $user, IStorageFactory $loader): array { $qb = $this->connection->getQueryBuilder(); - $qb->select('id', 'remote', 'share_token', 'password', 'mountpoint', 'owner') + $qb->select('id', 'remote', 'share_token', 'password', 'access_token', 'access_token_expires', 'mountpoint', 'owner') ->from('share_external') ->where($qb->expr()->eq('user', $qb->createNamedParameter($user->getUID()))) ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT))); @@ -101,7 +101,7 @@ public function getMountsForPath( } $qb = $this->connection->getQueryBuilder(); - $qb->select('id', 'remote', 'share_token', 'password', 'mountpoint', 'owner') + $qb->select('id', 'remote', 'share_token', 'password', 'access_token', 'access_token_expires', 'mountpoint', 'owner') ->from('share_external') ->where($qb->expr()->eq('user', $qb->createNamedParameter($user->getUID()))) ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT))); diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index fbaa3d6886adc..b851b2f22e45b 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -83,10 +83,6 @@ public function __construct($options) { $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'; @@ -94,14 +90,22 @@ public function __construct($options) { $authType = \Sabre\DAV\Client::AUTH_BASIC; } - // If we have a stored access token, use Bearer auth regardless of discovery. - // This handles the case where the share was created with must-exchange-token. + // Only use Bearer auth when an access token is already stored. + // Shares created before the exchange-token capability was introduced have no + // stored token and must keep using basic auth for backwards compatibility. if (!empty($options['access_token'])) { $authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER; } $host = parse_url($remote, PHP_URL_HOST); + // If host extraction fails (e.g., endpoint has no scheme), fall back to cloudId's remote + if ($host === null) { + $host = parse_url($this->cloudId->getRemote(), PHP_URL_HOST); + } $port = parse_url($remote, PHP_URL_PORT); + if ($port === null) { + $port = parse_url($this->cloudId->getRemote(), PHP_URL_PORT); + } $host .= ($port === null) ? '' : ':' . $port; // we add port if available // in case remote NC is on a sub folder and using deprecated ocm provider @@ -114,9 +118,12 @@ public function __construct($options) { $this->token = $options['token']; $this->tokenExpiresAt = (int)($options['access_token_expires'] ?? 0); + // Determine scheme - fall back to cloudId's remote if $remote has no scheme + $scheme = parse_url($remote, PHP_URL_SCHEME) ?? parse_url($this->cloudId->getRemote(), PHP_URL_SCHEME) ?? 'https'; + parent::__construct( [ - 'secure' => ((parse_url($remote, PHP_URL_SCHEME) ?? 'https') === 'https'), + 'secure' => ($scheme === 'https'), 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false), 'host' => $host, 'root' => $webDavEndpoint, diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 0a7ef64d4fc40..52a5d62cb2f4e 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -131,6 +131,7 @@ class DAV extends Common { '{DAV:}getcontenttype', '{http://owncloud.org/ns}permissions', '{http://open-collaboration-services.org/ns}share-permissions', + '{http://open-cloud-mesh.org/ns}share-permissions', '{DAV:}resourcetype', '{DAV:}getetag', '{DAV:}quota-available-bytes', From 874c025b568d8c143ed6fcb61da63ad3f9d94b36 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Fri, 24 Apr 2026 17:44:57 +0200 Subject: [PATCH 11/13] chore: bump 3rdparty and update psalm baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Enrique Pérez Arnaud --- 3rdparty | 2 +- build/psalm-baseline.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/3rdparty b/3rdparty index 8f97d8cef37b3..5d09a7f56e2d0 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 8f97d8cef37b32d25e36c16fc2f7be36f1a46901 +Subproject commit 5d09a7f56e2d01b5f4083e65db77c4f7aa775252 diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index da8022aed5975..1eaa8493e506a 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -38,6 +38,7 @@ ['uid' => &$uid] )]]> + From 7f7f1ac45ac63fa1fe39627856f6bd95219aac87 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 12/13] chore: psalm, code style, openapi, integration tests, autoload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of tooling-level fixes accumulated during the PR: psalm fixes across affected files, code-style fixes, openapi spec regeneration, sqlite integration test fixes, missing `Override` attributes, and composer autoloader regeneration for new migrations and entities. Signed-off-by: Enrique Pérez Arnaud --- .../Controller/RequestHandlerController.php | 1 - apps/cloud_federation_api/openapi.json | 16 ++++++++------- .../composer/composer/autoload_classmap.php | 2 +- .../dav/composer/composer/autoload_static.php | 2 +- .../CleanupExpiredOcmTokensJob.php | 1 + apps/dav/lib/Connector/Sabre/BearerAuth.php | 1 - apps/dav/lib/Controller/TokenController.php | 8 ++++---- .../Version1037Date20260306120000.php | 1 + apps/dav/openapi.json | 7 ++++++- .../lib/FederatedShareProvider.php | 1 - .../Version1012Date20260306120000.php | 2 ++ .../lib/OCM/CloudFederationProviderFiles.php | 4 ++-- apps/files_sharing/lib/External/Storage.php | 1 + .../features/bootstrap/Sharing.php | 13 +++++++----- .../Authentication/Token/PublicKeyToken.php | 1 + .../Federation/CloudFederationFactory.php | 2 +- lib/private/Files/Storage/DAV.php | 3 ++- lib/private/OCM/Model/OCMProvider.php | 2 ++ lib/private/User/Session.php | 11 ++++++---- lib/public/Authentication/Token/IToken.php | 7 +++++++ .../OCM/ICapabilityAwareOCMProvider.php | 19 ++++++++++++++++++ openapi.json | 20 ++++++++++++------- 22 files changed, 88 insertions(+), 37 deletions(-) diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 8299aa642978d..7fbffeeb1053e 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -8,7 +8,6 @@ namespace OCA\CloudFederationAPI\Controller; use OC\Authentication\Token\PublicKeyTokenProvider; -use OCA\DAV\Db\OcmTokenMapMapper; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Db\FederatedInviteMapper; 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/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 53b4c6389706e..b7974c138a860 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -397,9 +397,9 @@ 'OCA\\DAV\\Migration\\Version1034Date20250605132605' => $baseDir . '/../lib/Migration/Version1034Date20250605132605.php', 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => $baseDir . '/../lib/Migration/Version1034Date20250813093701.php', 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => $baseDir . '/../lib/Migration/Version1036Date20251202000000.php', + 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => $baseDir . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php', - 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => $baseDir . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index abc5ef5e0b083..08171ffb9851f 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -412,9 +412,9 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Migration\\Version1034Date20250605132605' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250605132605.php', 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250813093701.php', 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => __DIR__ . '/..' . '/../lib/Migration/Version1036Date20251202000000.php', + 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php', - 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php b/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php index 32f7c06c43738..164727818ed18 100644 --- a/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php +++ b/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php @@ -30,6 +30,7 @@ public function __construct( $this->setTimeSensitivity(self::TIME_INSENSITIVE); } + #[\Override] protected function run($argument): void { $this->mapper->deleteExpired($this->time->getTime()); } diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index 1bd06234c97f2..539c9020c6b33 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -62,7 +62,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 608c2443b428b..04af9ae7bbbd6 100644 --- a/apps/dav/lib/Controller/TokenController.php +++ b/apps/dav/lib/Controller/TokenController.php @@ -10,7 +10,6 @@ use Firebase\JWT\JWT; use OC\Authentication\Token\IProvider; use OC\OCM\OCMSignatoryManager; -use OC\Security\Signature\Model\IncomingSignedRequest; use OCA\DAV\Db\OcmTokenMap; use OCA\DAV\Db\OcmTokenMapMapper; use OCP\AppFramework\ApiController; @@ -61,10 +60,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', [ @@ -111,11 +110,12 @@ private function resolveJwtSigningKey(string $privateKeyPem): array { /** * 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/dav/lib/Migration/Version1037Date20260306120000.php b/apps/dav/lib/Migration/Version1037Date20260306120000.php index 04fe9000da0de..ddea97bd508aa 100644 --- a/apps/dav/lib/Migration/Version1037Date20260306120000.php +++ b/apps/dav/lib/Migration/Version1037Date20260306120000.php @@ -18,6 +18,7 @@ #[CreateTable(table: 'dav_ocm_token_map', description: 'Maps OCM access tokens to their originating refresh tokens')] class Version1037Date20260306120000 extends SimpleMigrationStep { + #[\Override] public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index 344d37815318c..8e7f6b548e069 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1165,5 +1165,10 @@ } } }, - "tags": [] + "tags": [ + { + "name": "token", + "description": "Controller for the /token endpoint Exchanges long-lived refresh tokens for short-lived access tokens" + } + ] } diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 093702f6a33f5..06af846dc404d 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -9,7 +9,6 @@ use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; -use OCA\DAV\Db\OcmTokenMapMapper; use OC\Share20\Share; use OCA\DAV\Db\OcmTokenMapMapper; use OCP\Authentication\Exceptions\InvalidTokenException; diff --git a/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php index 78d42fb6e1a88..d34f05427ae04 100644 --- a/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php +++ b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php @@ -36,10 +36,12 @@ * oc_authtoken are silently repaired. */ class Version1012Date20260306120000 extends SimpleMigrationStep { + #[\Override] public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { return null; } + #[\Override] public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { $db = Server::get(IDBConnection::class); $tokenProvider = Server::get(PublicKeyTokenProvider::class); diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index ae51f4480e492..07602790bdc4c 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -7,8 +7,8 @@ namespace OCA\FederatedFileSharing\OCM; use OC\AppFramework\Http; -use OC\OCM\OCMSignatoryManager; use OC\Files\Filesystem; +use OC\OCM\OCMSignatoryManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\Federation\TrustedServers; @@ -34,6 +34,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; @@ -45,7 +46,6 @@ use OCP\Security\Signature\ISignatureManager; use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; -use OCP\Http\Client\IClientService; use OCP\Share\IManager; use OCP\Share\IProviderFactory; use OCP\Share\IShare; diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index b851b2f22e45b..66f63ab777127 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -151,6 +151,7 @@ public function __construct($options) { * * @return bool True if token was refreshed (or reused from DB) successfully */ + #[\Override] protected function refreshBearerToken(): bool { $now = time(); diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index 98ecce66832a8..bef2d031b5733 100644 --- a/build/integration/features/bootstrap/Sharing.php +++ b/build/integration/features/bootstrap/Sharing.php @@ -325,7 +325,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') { @@ -340,7 +341,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') { @@ -632,9 +634,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::assertMatchesRegularExpression('/^[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::assertMatchesRegularExpression('/^[a-zA-Z0-9]{15,32}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); } elseif (strpos($contentExpected, 'REGEXP ') === 0) { Assert::assertMatchesRegularExpression(substr($contentExpected, strlen('REGEXP ')), (string)$returnedShare->$field, "Field '$field' does not match"); } else { diff --git a/lib/private/Authentication/Token/PublicKeyToken.php b/lib/private/Authentication/Token/PublicKeyToken.php index 9de3e862b6a1e..2a91548f4e1ce 100644 --- a/lib/private/Authentication/Token/PublicKeyToken.php +++ b/lib/private/Authentication/Token/PublicKeyToken.php @@ -204,6 +204,7 @@ public function getRemember(): int { return parent::getRemember(); } + #[\Override] public function getType(): int { return $this->getter('type'); } diff --git a/lib/private/Federation/CloudFederationFactory.php b/lib/private/Federation/CloudFederationFactory.php index d3193a10d39ad..e60697e2fc37f 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 { diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 52a5d62cb2f4e..532b500687069 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/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index f2a31c6c6e894..02da57e7b0853 100644 --- a/lib/private/OCM/Model/OCMProvider.php +++ b/lib/private/OCM/Model/OCMProvider.php @@ -125,6 +125,7 @@ public function getEndPoint(): string { * * @return $this */ + #[\Override] public function setTokenEndPoint(string $endPoint): static { $this->tokenEndPoint = $endPoint; @@ -134,6 +135,7 @@ public function setTokenEndPoint(string $endPoint): static { /** * @return string */ + #[\Override] public function getTokenEndPoint(): string { if (in_array('exchange-token', $this->capabilities)) { return $this->tokenEndPoint; diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 1f8763ee473a7..cfe72b5389831 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -851,10 +851,6 @@ public function tryTokenLogin(IRequest $request) { } else { return false; } - return $this->doTryTokenLogin($token); - } - - public function doTryTokenLogin($token) { try { $dbToken = $this->tokenProvider->getToken($token); @@ -882,6 +878,13 @@ private function doTryTokenLogin(string $token): bool { return false; } + try { + $dbToken = $this->tokenProvider->getToken($token); + } catch (InvalidTokenException $e) { + // Can't really happen but better save than sorry + return true; + } + // Set the session variable so we know this is an app password if ($dbToken instanceof PublicKeyToken && $dbToken->getType() === IToken::PERMANENT_TOKEN) { $this->session->set('app_password', $token); diff --git a/lib/public/Authentication/Token/IToken.php b/lib/public/Authentication/Token/IToken.php index b99fedcd92963..26e210c11a392 100644 --- a/lib/public/Authentication/Token/IToken.php +++ b/lib/public/Authentication/Token/IToken.php @@ -139,4 +139,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/OCM/ICapabilityAwareOCMProvider.php b/lib/public/OCM/ICapabilityAwareOCMProvider.php index faf44067d1255..2535c9531bce6 100644 --- a/lib/public/OCM/ICapabilityAwareOCMProvider.php +++ b/lib/public/OCM/ICapabilityAwareOCMProvider.php @@ -15,4 +15,23 @@ * @deprecated 33.0.0 {@see IOCMProvider} */ interface ICapabilityAwareOCMProvider extends IOCMProvider { + /** + * get the token endpoint URL + * + * @return string + * @since 32.0.0 + */ + #[\Override] + public function getTokenEndPoint(): string; + + /** + * set the token endpoint URL + * + * @param string $endPoint + * + * @return $this + * @since 32.0.0 + */ + #[\Override] + public function setTokenEndPoint(string $endPoint): static; } diff --git a/openapi.json b/openapi.json index ba4ee767a4b16..6d35a9c66fc36 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" @@ -18127,23 +18131,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 3b089f4770c36d93db35deb9975c576954b23f9c Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Fri, 15 May 2026 11:41:07 +0200 Subject: [PATCH 13/13] feat(ocm): protocol validation in provider; add validateShare opt-in There are many resourceTypes possible, and we need to accept them all if we have a suitable cloudFederationProvider, so addShare() now enforces only the OCM-spec mandatory envelope fields instead of looking for a sharedSecret and options (which is deprecated in the OCM spec). Providers that can validate without side effects can implement the new IValidationAwareCloudFederationProvider. Existing providers are unaffected. The controller now also surfaces ProviderCouldNotAddShareException's code as the HTTP status (with 501 fallback) instead of flattening every failure to 501. federatedfilesharing and caldav implements the IValidationAwareCloudFederationProvider and when the PR lands, so can spreed and other cloudFederationProviders, until then things should work as before, only with better HTTP status codes. Signed-off-by: Micke Nordin --- .../Controller/RequestHandlerController.php | 45 ++-- .../tests/RequestHandlerControllerTest.php | 236 ++++++++++++++++++ .../Federation/CalendarFederationProvider.php | 77 +++--- .../lib/OCM/CloudFederationProviderFiles.php | 28 ++- ...ValidationAwareCloudFederationProvider.php | 32 +++ 5 files changed, 358 insertions(+), 60 deletions(-) create mode 100644 lib/public/Federation/IValidationAwareCloudFederationProvider.php diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 7fbffeeb1053e..7d3ae8dec5c0a 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -34,6 +34,7 @@ use OCP\Federation\ICloudFederationProviderManager; use OCP\Federation\ICloudIdManager; use OCP\Federation\ISignedCloudFederationProvider; +use OCP\Federation\IValidationAwareCloudFederationProvider; use OCP\IAppConfig; use OCP\IGroupManager; use OCP\IRequest; @@ -94,7 +95,9 @@ 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' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]] + * @param array $protocol OCM protocol envelope. The controller only + * enforces that `protocol.name` is set; the inner shape is the provider's + * responsibility (see {@see IValidationAwareCloudFederationProvider}). * @param string $shareType 'group' or 'user' share * @param string $resourceType 'file', 'calendar',... * @@ -139,33 +142,6 @@ 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']); - - // 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', - 'validationErrors' => [], - ], - Http::STATUS_BAD_REQUEST - ); - } - $supportedShareTypes = $this->config->getSupportedShareTypes($resourceType); if (!in_array($shareType, $supportedShareTypes)) { return new JSONResponse( @@ -225,12 +201,23 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ $share = $this->factory->getCloudFederationShare($shareWithCloudId, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType); $share->setShareWith($shareWith); $share->setProtocol($protocol); + if ($provider instanceof IValidationAwareCloudFederationProvider) { + $provider->validateShare($share); + } $provider->shareReceived($share); - } catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) { + } catch (BadRequestException $e) { + return new JSONResponse($e->getReturnMessage(), Http::STATUS_BAD_REQUEST); + } catch (ProviderDoesNotExistsException $e) { return new JSONResponse( ['message' => $e->getMessage()], Http::STATUS_NOT_IMPLEMENTED ); + } catch (ProviderCouldNotAddShareException $e) { + $status = $e->getCode() ?: Http::STATUS_NOT_IMPLEMENTED; + return new JSONResponse( + ['message' => $e->getMessage()], + $status + ); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new JSONResponse( diff --git a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php index 04cabbd234c25..ee1e35964cd98 100644 --- a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php +++ b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php @@ -9,6 +9,7 @@ namespace OCA\CloudFederationApi\Tests; +use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Controller\RequestHandlerController; use OCA\CloudFederationAPI\Db\FederatedInvite; @@ -18,9 +19,15 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\Exceptions\BadRequestException; +use OCP\Federation\Exceptions\ProviderCouldNotAddShareException; use OCP\Federation\ICloudFederationFactory; +use OCP\Federation\ICloudFederationProvider; use OCP\Federation\ICloudFederationProviderManager; +use OCP\Federation\ICloudFederationShare; +use OCP\Federation\ICloudId; use OCP\Federation\ICloudIdManager; +use OCP\Federation\IValidationAwareCloudFederationProvider; use OCP\IAppConfig; use OCP\IGroupManager; use OCP\IRequest; @@ -134,4 +141,233 @@ public function testInviteAccepted(): void { $this->assertEquals($json, $this->requestHandlerController->inviteAccepted($recipientProvider, $token, $recipientId, $recipientEmail, $recipientName)); } + + public static function addShareProtocolDataProvider(): array { + return [ + 'legacy single-protocol' => [ + ['name' => 'webdav', 'options' => ['sharedSecret' => 'secret-legacy']], + ], + 'multi envelope, one inner protocol' => [ + ['name' => 'multi', 'webdav' => ['sharedSecret' => 'secret-multi-single', 'uri' => 'https://sender/webdav/']], + ], + 'multi envelope, multiple inner protocols' => [ + [ + 'name' => 'multi', + 'webdav' => ['sharedSecret' => 'secret-webdav', 'uri' => 'https://sender/webdav/'], + 'webapp' => ['sharedSecret' => 'secret-webapp', 'uri' => 'https://sender/launch'], + ], + ], + 'multi envelope, sharedSecret only on non-webdav entry' => [ + [ + 'name' => 'multi', + 'webapp' => ['sharedSecret' => 'secret-webapp-only', 'uri' => 'https://sender/launch'], + ], + ], + 'multi envelope, no sharedSecret (provider-validated)' => [ + ['name' => 'multi', 'webdav' => ['uri' => 'https://sender/webdav/']], + ], + ]; + } + + /** + * @dataProvider addShareProtocolDataProvider + */ + public function testAddShareForwardsProtocolToProvider(array $protocol): void { + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, true) + ->willReturn(true); + + $cloudId = $this->createMock(ICloudId::class); + $cloudId->method('getUser')->willReturn('bob'); + $this->cloudIdManager->method('resolveCloudId')->willReturn($cloudId); + + $user = $this->createMock(IUser::class); + $user->method('getDisplayName')->willReturn('Bob'); + $user->method('getUID')->willReturn('bob'); + $this->userManager->method('userExists')->with('bob')->willReturn(true); + $this->userManager->method('get')->with('bob')->willReturn($user); + + $this->config->method('getSupportedShareTypes')->with('file')->willReturn(['user']); + + $capturedShare = null; + $share = $this->createMock(ICloudFederationShare::class); + $share->expects(self::once()) + ->method('setProtocol') + ->willReturnCallback(function (array $p) use (&$capturedShare): void { + $capturedShare = $p; + }); + $this->cloudFederationFactory->method('getCloudFederationShare')->willReturn($share); + + $provider = $this->createMock(ICloudFederationProvider::class); + $provider->expects(self::once())->method('shareReceived')->with($share); + $this->cloudFederationProviderManager->method('getCloudFederationProvider')->with('file')->willReturn($provider); + + $response = $this->requestHandlerController->addShare( + shareWith: 'bob@receiver.example.org', + name: 'doc.odt', + description: null, + providerId: 'abc', + owner: 'alice@sender.example.org', + ownerDisplayName: 'Alice', + sharedBy: 'alice@sender.example.org', + sharedByDisplayName: 'Alice', + protocol: $protocol, + shareType: 'user', + resourceType: 'file', + ); + + self::assertSame(Http::STATUS_CREATED, $response->getStatus()); + self::assertSame($protocol, $capturedShare); + } + + /** + * Returns the standard mock wiring (signing disabled, cloud-id, user, supported share types). + * Tests that exercise the controller's dispatch path call this and then plug in the share/provider. + * + * @return ICloudFederationShare&MockObject + */ + private function setUpHappyPathMocks(string $resourceType = 'file'): ICloudFederationShare { + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, true) + ->willReturn(true); + + $cloudId = $this->createMock(ICloudId::class); + $cloudId->method('getUser')->willReturn('bob'); + $this->cloudIdManager->method('resolveCloudId')->willReturn($cloudId); + + $user = $this->createMock(IUser::class); + $user->method('getDisplayName')->willReturn('Bob'); + $user->method('getUID')->willReturn('bob'); + $this->userManager->method('userExists')->with('bob')->willReturn(true); + $this->userManager->method('get')->with('bob')->willReturn($user); + + $this->config->method('getSupportedShareTypes')->with($resourceType)->willReturn(['user']); + + $share = $this->createMock(ICloudFederationShare::class); + $this->cloudFederationFactory->method('getCloudFederationShare')->willReturn($share); + return $share; + } + + public function testAddShareCallsValidateShareOnValidationAwareProvider(): void { + $share = $this->setUpHappyPathMocks(); + + $provider = $this->createMock(IValidationAwareCloudFederationProvider::class); + $order = []; + $provider->expects(self::once()) + ->method('validateShare') + ->with($share) + ->willReturnCallback(function () use (&$order): void { + $order[] = 'validate'; + }); + $provider->expects(self::once()) + ->method('shareReceived') + ->with($share) + ->willReturnCallback(function () use (&$order): string { + $order[] = 'receive'; + return ''; + }); + $this->cloudFederationProviderManager->method('getCloudFederationProvider')->with('file')->willReturn($provider); + + $response = $this->requestHandlerController->addShare( + shareWith: 'bob@receiver.example.org', + name: 'doc.odt', + description: null, + providerId: 'abc', + owner: 'alice@sender.example.org', + ownerDisplayName: 'Alice', + sharedBy: 'alice@sender.example.org', + sharedByDisplayName: 'Alice', + protocol: ['name' => 'webdav', 'options' => ['sharedSecret' => 's']], + shareType: 'user', + resourceType: 'file', + ); + + self::assertSame(Http::STATUS_CREATED, $response->getStatus()); + self::assertSame(['validate', 'receive'], $order); + } + + public function testAddShareMapsBadRequestExceptionToFourHundred(): void { + $share = $this->setUpHappyPathMocks(); + + $provider = $this->createMock(IValidationAwareCloudFederationProvider::class); + $provider->expects(self::once()) + ->method('validateShare') + ->willThrowException(new BadRequestException(['protocol.webdav.sharedSecret'])); + $provider->expects(self::never())->method('shareReceived'); + $this->cloudFederationProviderManager->method('getCloudFederationProvider')->with('file')->willReturn($provider); + + $response = $this->requestHandlerController->addShare( + shareWith: 'bob@receiver.example.org', + name: 'doc.odt', + description: null, + providerId: 'abc', + owner: 'alice@sender.example.org', + ownerDisplayName: 'Alice', + sharedBy: 'alice@sender.example.org', + sharedByDisplayName: 'Alice', + protocol: ['name' => 'webdav'], + shareType: 'user', + resourceType: 'file', + ); + + self::assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $body = $response->getData(); + self::assertSame('RESOURCE_NOT_FOUND', $body['message']); + self::assertSame([ + ['name' => 'protocol.webdav.sharedSecret', 'message' => 'NOT_FOUND'], + ], $body['validationErrors']); + } + + public function testAddShareSurfacesProviderExceptionCodeAsHttpStatus(): void { + $this->setUpHappyPathMocks(); + + $provider = $this->createMock(ICloudFederationProvider::class); + $provider->expects(self::once()) + ->method('shareReceived') + ->willThrowException(new ProviderCouldNotAddShareException('Server does not support federated cloud sharing', '', Http::STATUS_SERVICE_UNAVAILABLE)); + $this->cloudFederationProviderManager->method('getCloudFederationProvider')->with('file')->willReturn($provider); + + $response = $this->requestHandlerController->addShare( + shareWith: 'bob@receiver.example.org', + name: 'doc.odt', + description: null, + providerId: 'abc', + owner: 'alice@sender.example.org', + ownerDisplayName: 'Alice', + sharedBy: 'alice@sender.example.org', + sharedByDisplayName: 'Alice', + protocol: ['name' => 'webdav', 'options' => ['sharedSecret' => 's']], + shareType: 'user', + resourceType: 'file', + ); + + self::assertSame(Http::STATUS_SERVICE_UNAVAILABLE, $response->getStatus()); + self::assertSame('Server does not support federated cloud sharing', $response->getData()['message']); + } + + public function testAddShareRejectsProtocolMissingName(): void { + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, true) + ->willReturn(true); + + $this->cloudFederationProviderManager->expects(self::never()) + ->method('getCloudFederationProvider'); + + $response = $this->requestHandlerController->addShare( + shareWith: 'bob@receiver.example.org', + name: 'doc.odt', + description: null, + providerId: 'abc', + owner: 'alice@sender.example.org', + ownerDisplayName: 'Alice', + sharedBy: 'alice@sender.example.org', + sharedByDisplayName: 'Alice', + protocol: ['webdav' => ['sharedSecret' => 'secret', 'uri' => 'https://sender/webdav/']], + shareType: 'user', + resourceType: 'file', + ); + + self::assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + self::assertSame('Missing arguments', $response->getData()['message']); + } } diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php index 348852fb9310d..d1184137265ab 100644 --- a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php +++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php @@ -22,10 +22,11 @@ use OCP\Federation\ICloudFederationProvider; use OCP\Federation\ICloudFederationShare; use OCP\Federation\ICloudIdManager; +use OCP\Federation\IValidationAwareCloudFederationProvider; use OCP\Share\Exceptions\ShareNotFound; use Psr\Log\LoggerInterface; -class CalendarFederationProvider implements ICloudFederationProvider { +class CalendarFederationProvider implements ICloudFederationProvider, IValidationAwareCloudFederationProvider { public const PROVIDER_ID = 'calendar'; public const CALENDAR_RESOURCE = 'calendar'; public const USER_SHARE_TYPE = 'user'; @@ -45,7 +46,7 @@ public function getShareType(): string { } #[\Override] - public function shareReceived(ICloudFederationShare $share): string { + public function validateShare(ICloudFederationShare $share): void { if (!$this->calendarFederationConfig->isFederationEnabled()) { $this->logger->debug('Received a federation invite but federation is disabled'); throw new ProviderCouldNotAddShareException( @@ -65,39 +66,16 @@ public function shareReceived(ICloudFederationShare $share): string { // TODO: Implement group shares } - $rawProtocol = $share->getProtocol(); - if (!isset($rawProtocol[ICalendarFederationProtocol::PROP_VERSION])) { + $parsed = $this->parseShare($share); + if ($parsed === null) { throw new ProviderCouldNotAddShareException( - 'No protocol version', + 'Invalid or unsupported protocol payload', '', Http::STATUS_BAD_REQUEST, ); } - switch ($rawProtocol[ICalendarFederationProtocol::PROP_VERSION]) { - case CalendarFederationProtocolV1::VERSION: - try { - $protocol = CalendarFederationProtocolV1::parse($rawProtocol); - } catch (CalendarProtocolParseException $e) { - throw new ProviderCouldNotAddShareException( - 'Invalid protocol data (v1)', - '', - Http::STATUS_BAD_REQUEST, - ); - } - $calendarUrl = $protocol->getUrl(); - $displayName = $protocol->getDisplayName(); - $color = $protocol->getColor(); - $access = $protocol->getAccess(); - $components = $protocol->getComponents(); - break; - default: - throw new ProviderCouldNotAddShareException( - 'Unknown protocol version', - '', - Http::STATUS_BAD_REQUEST, - ); - } + [, $calendarUrl, $displayName, , $access, ] = $parsed; if (!$calendarUrl || !$displayName) { throw new ProviderCouldNotAddShareException( 'Incomplete protocol data', @@ -105,6 +83,19 @@ public function shareReceived(ICloudFederationShare $share): string { Http::STATUS_BAD_REQUEST, ); } + if (!in_array($access, [DavSharingBackend::ACCESS_READ, DavSharingBackend::ACCESS_READ_WRITE], true)) { + throw new ProviderCouldNotAddShareException( + "Unsupported access value: $access", + '', + Http::STATUS_BAD_REQUEST, + ); + } + } + + #[\Override] + public function shareReceived(ICloudFederationShare $share): string { + $this->validateShare($share); + [$protocol, $calendarUrl, $displayName, $color, $access, $components] = $this->parseShare($share); // convert access to permissions $permissions = match ($access) { @@ -220,4 +211,32 @@ private function handleSyncCalendarNotification(array $notification): array { return []; } + + /** + * @return array{0: CalendarFederationProtocolV1, 1: string, 2: string, 3: ?string, 4: int, 5: int}|null + * [parsed protocol, calendarUrl, displayName, color, access, components], or null when + * the envelope cannot be parsed (missing/unsupported version, parse error). + */ + private function parseShare(ICloudFederationShare $share): ?array { + $rawProtocol = $share->getProtocol(); + if (!isset($rawProtocol[ICalendarFederationProtocol::PROP_VERSION])) { + return null; + } + if ($rawProtocol[ICalendarFederationProtocol::PROP_VERSION] !== CalendarFederationProtocolV1::VERSION) { + return null; + } + try { + $protocol = CalendarFederationProtocolV1::parse($rawProtocol); + } catch (CalendarProtocolParseException $e) { + return null; + } + return [ + $protocol, + $protocol->getUrl(), + $protocol->getDisplayName(), + $protocol->getColor(), + $protocol->getAccess(), + $protocol->getComponents(), + ]; + } } diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 07602790bdc4c..ffe39ed5082a2 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -30,6 +30,7 @@ use OCP\Federation\ICloudFederationShare; use OCP\Federation\ICloudIdManager; use OCP\Federation\ISignedCloudFederationProvider; +use OCP\Federation\IValidationAwareCloudFederationProvider; use OCP\Files\IFilenameValidator; use OCP\Files\ISetupManager; use OCP\Files\NotFoundException; @@ -54,7 +55,7 @@ use Psr\Log\LoggerInterface; use SensitiveParameter; -class CloudFederationProviderFiles implements ISignedCloudFederationProvider { +class CloudFederationProviderFiles implements ISignedCloudFederationProvider, IValidationAwareCloudFederationProvider { public function __construct( private readonly IAppManager $appManager, private readonly FederatedShareProvider $federatedShareProvider, @@ -89,7 +90,7 @@ public function getShareType(): string { } #[Override] - public function shareReceived(ICloudFederationShare $share): string { + public function validateShare(ICloudFederationShare $share): void { if (!$this->isS2SEnabled(true)) { throw new ProviderCouldNotAddShareException('Server does not support federated cloud sharing', '', Http::STATUS_SERVICE_UNAVAILABLE); } @@ -101,6 +102,29 @@ public function shareReceived(ICloudFederationShare $share): string { [, $remote] = $this->addressHandler->splitUserRemote($share->getOwner()); + $token = $share->getShareSecret(); + $name = $share->getResourceName(); + $owner = $share->getOwnerDisplayName() ?: $share->getOwner(); + $shareWith = $share->getShareWith(); + $remoteId = $share->getProviderId(); + + if (!$remote || !$token || !$name || !$owner || !$remoteId || !$shareWith) { + throw new ProviderCouldNotAddShareException('server can not add remote share, missing parameter', '', Http::STATUS_BAD_REQUEST); + } + + if (!$this->filenameValidator->isFilenameValid($name)) { + throw new ProviderCouldNotAddShareException('The mountpoint name contains invalid characters.', '', Http::STATUS_BAD_REQUEST); + } + } + + #[Override] + public function shareReceived(ICloudFederationShare $share): string { + $this->validateShare($share); + + $protocol = $share->getProtocol(); + + [, $remote] = $this->addressHandler->splitUserRemote($share->getOwner()); + // for backward compatibility make sure that the remote url stored in the // database ends with a trailing slash if (!str_ends_with($remote, '/')) { diff --git a/lib/public/Federation/IValidationAwareCloudFederationProvider.php b/lib/public/Federation/IValidationAwareCloudFederationProvider.php new file mode 100644 index 0000000000000..4790bd94c4464 --- /dev/null +++ b/lib/public/Federation/IValidationAwareCloudFederationProvider.php @@ -0,0 +1,32 @@ +