Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
use NCU\Security\Signature\IIncomingSignedRequest;
use NCU\Security\Signature\ISignatureManager;
use OC\Authentication\Token\PublicKeyTokenProvider;
use OC\OCM\OCMSignatoryManager;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
Expand Down Expand Up @@ -44,6 +45,7 @@
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\Server;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Util;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -518,6 +520,12 @@ private function confirmNotificationIdentity(
$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
if ($provider instanceof ISignedCloudFederationProvider) {
$identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification);
if ($identity === "") {
$tokenProvider = Server::get(PublicKeyTokenProvider::class);
$accessTokenDb = $tokenProvider->getToken($sharedSecret);
$refreshToken = $accessTokenDb->getUID();
$identity = $provider->getFederationIdFromSharedSecret($refreshToken, $notification);
}
} else {
$this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
return;
Expand Down
16 changes: 14 additions & 2 deletions apps/dav/appinfo/v1/publicwebdav.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use OC\Files\Storage\Wrapper\PermissionsMask;
use OC\Files\View;
use OCA\DAV\Connector\LegacyPublicAuth;
use OCA\DAV\Connector\Sabre\BearerAuth;
use OCA\DAV\Connector\Sabre\ServerFactory;
use OCA\DAV\Files\Sharing\FilesDropPlugin;
use OCA\DAV\Files\Sharing\PublicLinkCheckPlugin;
Expand Down Expand Up @@ -45,7 +46,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);
Expand Down Expand Up @@ -75,6 +83,7 @@
$authPlugin,
function (\Sabre\DAV\Server $server) use (
$authBackend,
$bearerAuthBackend,
$linkCheckPlugin,
$filesDropPlugin
) {
Expand All @@ -85,8 +94,11 @@ function (\Sabre\DAV\Server $server) use (
// this is what is thrown when trying to access a non-existing share
throw new \Sabre\DAV\Exception\NotAuthenticated();
}

$share = $authBackend->getShare();
try {
$share = $authBackend->getShare();
} catch (AssertionError $e) {
$share = $bearerAuthBackend->getShare();
}
$owner = $share->getShareOwner();
$isReadable = $share->getPermissions() & Constants::PERMISSION_READ;
$fileId = $share->getNodeId();
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,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',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,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',
Expand Down
15 changes: 15 additions & 0 deletions apps/dav/lib/Connector/Sabre/BearerAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -40,17 +44,28 @@ private function setupUserFs($userId) {
*/
public function validateBearerToken($bearerToken) {
\OC_Util::setupFS();
$this->token = $bearerToken;

if (!$this->userSession->isLoggedIn()) {
$this->userSession->tryTokenLogin($this->request);
}
if (!$this->userSession->isLoggedIn()) {
$this->userSession->doTryTokenLogin($bearerToken);
}
if ($this->userSession->isLoggedIn()) {
return $this->setupUserFs($this->userSession->getUser()->getUID());
}

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
Expand Down
198 changes: 198 additions & 0 deletions apps/dav/lib/Controller/TokenController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Controller;

use OC\Authentication\Token\IProvider;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Authentication\Exceptions\ExpiredTokenException;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Token\IToken;
use OCP\IRequest;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;
use OCP\IAppConfig;
use OC\OCM\OCMSignatoryManager;
use NCU\Security\Signature\ISignatureManager;
use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
use NCU\Security\Signature\Exceptions\SignatureException;
use NCU\Security\Signature\Exceptions\SignatoryNotFoundException;
use NCU\Security\Signature\Model\IIncomingSignedRequest;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use OC\Security\Signature\Model\IncomingSignedRequest;

/**
* Controller for the /token endpoint
* Exchanges long-lived refresh tokens for short-lived access tokens
*
* @since 32.0.0
*/
class TokenController extends ApiController {
public function __construct(
IRequest $request,
private readonly IProvider $tokenProvider,
private readonly ISecureRandom $random,
private readonly ITimeFactory $timeFactory,
private readonly LoggerInterface $logger,
private readonly ISignatureManager $signatureManager,
private readonly OCMSignatoryManager $signatoryManager,
private readonly IAppConfig $appConfig,
) {
parent::__construct('dav', $request);
}

/**
* Verify the signature of incoming request if available
*
* @return IncomingSignedRequest|null null if remote does not support signed requests
* @throws IncomingRequestException if signature is required but invalid
*/
private function verifySignedRequest(): ?IncomingSignedRequest {
try {
$signedRequest = $this->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<Http::STATUS_OK, array{access_token: string, token_type: string, expires_in: int}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Access token successfully generated
* 400: Bad request - missing refresh token or invalid request format
* 401: Unauthorized - invalid or expired refresh token, or invalid signature
*/
#[PublicPage]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')]
public function accessToken(): DataResponse {
try {
$signedRequest = $this->verifySignedRequest();
} catch (IncomingRequestException $e) {
$this->logger->warning('Token request signature verification failed', [
'exception' => $e
]);
return new DataResponse(
['error' => 'invalid_request'],
Http::STATUS_UNAUTHORIZED
);
}

$body = file_get_contents('php://input');
$data = json_decode($body, true);

if (!is_array($data)) {
return new DataResponse(
['error' => 'invalid_request'],
Http::STATUS_BAD_REQUEST
);
}

$refreshToken = $data['code'] ?? '';
$grantType = $data['grant_type'] ?? '';

if ($grantType !== 'authorization_code') {
return new DataResponse(
['error' => 'unsupported_grant_type'],
Http::STATUS_BAD_REQUEST
);
}

if (empty($refreshToken)) {
return new DataResponse(
['error' => 'refresh_token is required'],
Http::STATUS_BAD_REQUEST
);
}

try {
$token = $this->tokenProvider->getToken($refreshToken);

if ($token->getType() !== IToken::PERMANENT_TOKEN) {
$this->logger->warning('Attempted to use non-permanent token as refresh token', [
'tokenId' => $token->getId(),
]);
return new DataResponse(
['error' => 'invalid_grant'],
Http::STATUS_UNAUTHORIZED
);
}

$accessTokenString = $this->random->generate(
72,
ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS
);

$expiresIn = 3600; // 1 hour in seconds
$expiresAt = $this->timeFactory->getTime() + $expiresIn;

$accessToken = $this->tokenProvider->generateToken(
$accessTokenString,
$refreshToken, // Keep refresh token with access token as UID
$token->getLoginName(),
null, // No password for access tokens
'OCM Access Token',
IToken::TEMPORARY_TOKEN,
IToken::DO_NOT_REMEMBER
);

$accessToken->setExpires($expiresAt);
$this->tokenProvider->updateToken($accessToken);

return new DataResponse([
'access_token' => $accessTokenString,
'token_type' => 'Bearer',
'expires_in' => $expiresIn,
], Http::STATUS_OK);
} catch (InvalidTokenException $e) {
$this->logger->info('Invalid refresh token provided', [
'exception' => $e,
]);
return new DataResponse(
['error' => 'invalid_grant'],
Http::STATUS_UNAUTHORIZED
);
} catch (ExpiredTokenException $e) {
$this->logger->info('Expired refresh token provided', [
'exception' => $e,
]);
return new DataResponse(
['error' => 'invalid_grant'],
Http::STATUS_UNAUTHORIZED
);
} catch (\Exception $e) {
$this->logger->error('Error generating access token', [
'exception' => $e,
]);
return new DataResponse(
['error' => 'server_error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
}
28 changes: 27 additions & 1 deletion apps/federatedfilesharing/lib/FederatedShareProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/
namespace OCA\FederatedFileSharing;

use OC\Authentication\Token\IToken;
use OC\Authentication\Token\PublicKeyTokenProvider;
use OC\Share20\Exception\InvalidShare;
use OC\Share20\Share;
use OCP\Constants;
Expand All @@ -22,6 +24,8 @@
use OCP\IDBConnection;
use OCP\IL10N;
use OCP\IUserManager;
use OCP\Security\ISecureRandom;
use OCP\Server;
use OCP\Share\Exceptions\GenericShareException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IShare;
Expand Down Expand Up @@ -170,7 +174,15 @@ public function create(IShare $share): IShare {
* @throws \Exception
*/
protected function createFederatedShare(IShare $share): string {
$token = $this->tokenHandler->generateToken();

$provider = Server::get(PublicKeyTokenProvider::class);
$token = Server::get(ISecureRandom::class)->generate(32, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS);
$uid = $share->getSharedBy();
$user = $this->userManager->get($uid);
$name = $user->getDisplayName();
$pass = $share->getPassword();

$dbToken = $provider->generateToken($token, $uid, $uid, $pass, $name, type: IToken::PERMANENT_TOKEN);
$shareId = $this->addShareToDB(
$share->getNodeId(),
$share->getNodeType(),
Expand Down Expand Up @@ -739,6 +751,20 @@ public function getShareByToken(string $token): IShare {

$data = $cursor->fetchAssociative();

if ($data === false) {

$provider = Server::get(PublicKeyTokenProvider::class);
$accessTokenDb = $provider->getToken($token);
$refreshToken = $accessTokenDb->getUID();

$cursor = $qb->select('*')
->from('share')
->where($qb->expr()->in('share_type', $qb->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($refreshToken)))
->executeQuery();

$data = $cursor->fetch();
}
if ($data === false) {
throw new ShareNotFound('Share not found', $this->l->t('Could not find share'));
}
Expand Down
Loading