Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => $baseDir . '/../lib/Db/OcmTokenMap.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => $baseDir . '/../lib/Db/OcmTokenMapMapper.php',
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => $baseDir . '/../lib/Events/FederatedInviteAcceptedEvent.php',
'OCA\\CloudFederationAPI\\Listener\\ShareDeletedListener' => $baseDir . '/../lib/Listener/ShareDeletedListener.php',
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => $baseDir . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\Migration\\Version1017Date20260306120000' => $baseDir . '/../lib/Migration/Version1017Date20260306120000.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\CloudFederationAPI\\Service\\OcmTokenService' => $baseDir . '/../lib/Service/OcmTokenService.php',
);
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ class ComposerStaticInitCloudFederationAPI
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMap.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMapMapper.php',
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => __DIR__ . '/..' . '/../lib/Events/FederatedInviteAcceptedEvent.php',
'OCA\\CloudFederationAPI\\Listener\\ShareDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/ShareDeletedListener.php',
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => __DIR__ . '/..' . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\Migration\\Version1017Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1017Date20260306120000.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\CloudFederationAPI\\Service\\OcmTokenService' => __DIR__ . '/..' . '/../lib/Service/OcmTokenService.php',
);

public static function getInitializer(ClassLoader $loader)
Expand Down
3 changes: 3 additions & 0 deletions apps/cloud_federation_api/lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
namespace OCA\CloudFederationAPI\AppInfo;

use OCA\CloudFederationAPI\Capabilities;
use OCA\CloudFederationAPI\Listener\ShareDeletedListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Share\Events\ShareDeletedEvent;

class Application extends App implements IBootstrap {
public const APP_ID = 'cloud_federation_api';
Expand All @@ -25,6 +27,7 @@ public function __construct() {
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
$context->registerEventListener(ShareDeletedEvent::class, ShareDeletedListener::class);
}

#[\Override]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@

namespace OCA\CloudFederationAPI\BackgroundJob;

use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
use OCA\CloudFederationAPI\Service\OcmTokenService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;

/**
* Periodically purge expired OCM access token mappings from ocm_token_map.
* Periodically purge expired OCM access tokens.
*
* The corresponding oc_authtoken entries (TEMPORARY_TOKEN with an expires
* timestamp) are cleaned up by Nextcloud's own token expiry jobs.
* Each expired mapping has its access token deleted from oc_authtoken before
* its ocm_token_map row is removed. Dropping the mapping first would orphan
* the access token, since nothing else records which oc_authtoken id belongs
* to a given refresh token.
*/
class CleanupExpiredOcmTokensJob extends TimedJob {
public function __construct(
ITimeFactory $timeFactory,
private readonly OcmTokenMapMapper $mapper,
private readonly OcmTokenService $tokenService,
) {
parent::__construct($timeFactory);

Expand All @@ -32,6 +34,6 @@ public function __construct(

#[\Override]
protected function run($argument): void {
$this->mapper->deleteExpired($this->time->getTime());
$this->tokenService->revokeExpired($this->time->getTime());
}
}
18 changes: 4 additions & 14 deletions apps/cloud_federation_api/lib/Controller/TokenController.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,10 @@ public function accessToken(string $grant_type = '', string $code = ''): DataRes
$this->tokenProvider->updateToken($token);
}

// 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);
}

// A refresh token may back several concurrent access tokens (e.g. the
// webdav mount and the webapp launcher exchange the same secret), so
// each exchange issues a fresh one and leaves the others in place;
// expiry and unshare cleanup revoke them.
$share = $this->shareManager->getShareByToken($refreshToken);
// access_token TTL from the refresh-token scope; default 3600, clamped 300..86400.
$ttl = (int)($token->getScopeAsArray()['ocm_access_token_ttl'] ?? 3600);
Expand Down
26 changes: 16 additions & 10 deletions apps/cloud_federation_api/lib/Db/OcmTokenMapMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,31 @@ public function getByAccessTokenId(int $accessTokenId): OcmTokenMap {
}

/**
* Find the current mapping for a given refresh token, if any.
* All mappings for a refresh token. Unlike findByRefreshToken this
* tolerates the duplicate rows a concurrent exchange can create.
*
* @return OcmTokenMap[]
*/
public function findByRefreshToken(string $refreshToken): ?OcmTokenMap {
public function findAllByRefreshToken(string $refreshToken): array {
$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;
}
return $this->findEntities($qb);
}

public function deleteExpired(int $time): void {
/**
* All mappings whose access token has expired before $time.
*
* @return OcmTokenMap[]
*/
public function findExpired(int $time): array {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->lt('expires', $qb->createNamedParameter($time)));
$qb->executeStatement();

return $this->findEntities($qb);
}
}
56 changes: 56 additions & 0 deletions apps/cloud_federation_api/lib/Listener/ShareDeletedListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

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

namespace OCA\CloudFederationAPI\Listener;

use OC\Authentication\Token\IProvider;
use OCA\CloudFederationAPI\Service\OcmTokenService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Share\Events\ShareDeletedEvent;
use OCP\Share\IShare;

/**
* When a federated share is removed, revoke the OCM access tokens it minted
* and invalidate its refresh token immediately, instead of waiting up to six
* hours for the expiry job — which cannot even find them once the share, and
* with it the mapping's context, is gone.
*
* @template-implements IEventListener<ShareDeletedEvent>
*/
class ShareDeletedListener implements IEventListener {
public function __construct(
private readonly OcmTokenService $tokenService,
private readonly IProvider $tokenProvider,
) {
}

#[\Override]
public function handle(Event $event): void {
if (!$event instanceof ShareDeletedEvent) {
return;
}

$share = $event->getShare();
if (!in_array($share->getShareType(), [IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], true)) {
return;
}

$refreshToken = $share->getToken();
if ($refreshToken === null || $refreshToken === '') {
return;
}

// Revoke the access tokens exchanged from this share's secret...
$this->tokenService->revokeByRefreshToken($refreshToken);
// ...and the refresh (permanent) token itself. invalidateToken is a
// no-op when the token is already gone.
$this->tokenProvider->invalidateToken($refreshToken);
}
}
70 changes: 70 additions & 0 deletions apps/cloud_federation_api/lib/Service/OcmTokenService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

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

namespace OCA\CloudFederationAPI\Service;

use OC\Authentication\Exceptions\ExpiredTokenException;
use OC\Authentication\Exceptions\InvalidTokenException;
use OC\Authentication\Exceptions\WipeTokenException;
use OC\Authentication\Token\IProvider;
use OCA\CloudFederationAPI\Db\OcmTokenMap;
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;

/**
* Revokes OCM access tokens together with their ocm_token_map rows, so a
* removed or expired mapping never leaves an orphaned oc_authtoken entry.
*/
class OcmTokenService {
public function __construct(
private readonly OcmTokenMapMapper $mapper,
private readonly IProvider $tokenProvider,
) {
}

/**
* Revoke every access token whose mapping expired before $time.
*/
public function revokeExpired(int $time): void {
foreach ($this->mapper->findExpired($time) as $mapping) {
$this->revokeMapping($mapping);
}
}

/**
* Revoke every access token issued for the given refresh token. Tolerates
* the duplicate rows a concurrent exchange can leave behind.
*/
public function revokeByRefreshToken(string $refreshToken): void {
foreach ($this->mapper->findAllByRefreshToken($refreshToken) as $mapping) {
$this->revokeMapping($mapping);
}
}

private function revokeMapping(OcmTokenMap $mapping): void {
$this->revokeAccessToken($mapping->getAccessTokenId());
$this->mapper->delete($mapping);
}

/**
* Delete the access token from oc_authtoken. getTokenById throws for an
* expired token but still carries it, so the owner uid required by
* invalidateTokenById is recoverable.
*/
private function revokeAccessToken(int $accessTokenId): void {
try {
$token = $this->tokenProvider->getTokenById($accessTokenId);
} catch (ExpiredTokenException|WipeTokenException $e) {
$token = $e->getToken();
} catch (InvalidTokenException) {
// Access token already gone; nothing left to revoke.
return;
}
$this->tokenProvider->invalidateTokenById($token->getUID(), $accessTokenId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,30 @@
namespace OCA\CloudFederationAPI\Tests\BackgroundJob;

use OCA\CloudFederationAPI\BackgroundJob\CleanupExpiredOcmTokensJob;
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
use OCA\CloudFederationAPI\Service\OcmTokenService;
use OCP\AppFramework\Utility\ITimeFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class CleanupExpiredOcmTokensJobTest extends TestCase {
private ITimeFactory&MockObject $timeFactory;
private OcmTokenMapMapper&MockObject $mapper;
private OcmTokenService&MockObject $tokenService;
private CleanupExpiredOcmTokensJob $job;

protected function setUp(): void {
parent::setUp();

$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->mapper = $this->createMock(OcmTokenMapMapper::class);
$this->tokenService = $this->createMock(OcmTokenService::class);

$this->job = new CleanupExpiredOcmTokensJob($this->timeFactory, $this->mapper);
$this->job = new CleanupExpiredOcmTokensJob($this->timeFactory, $this->tokenService);
}

public function testRunDeletesExpiredTokens(): void {
public function testRunRevokesExpiredAtCurrentTime(): void {
$now = 1700000000;
$this->timeFactory->expects($this->once())
->method('getTime')
->willReturn($now);

$this->mapper->expects($this->once())
->method('deleteExpired')
->with($now);
$this->timeFactory->method('getTime')->willReturn($now);
$this->tokenService->expects($this->once())
->method('revokeExpired')->with($now);

$method = new \ReflectionMethod(CleanupExpiredOcmTokensJob::class, 'run');
$method->invoke($this->job, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,6 @@ private function configureHappyPath(
->with($refreshToken)
->willReturn($refreshTokenMock);

$this->ocmTokenMapMapper->method('findByRefreshToken')
->with($refreshToken)
->willReturn(null);

$share = $this->createMock(IShare::class);
$share->method('getShareOwner')->willReturn($shareOwner);
$share->method('getSharedWith')->willReturn($sharedWith);
Expand Down Expand Up @@ -182,6 +178,26 @@ public function testAccessTokenSuccess(): void {
$this->assertSame(1000000 + 3600, $decoded->exp);
}

public function testAccessTokenDoesNotRevokeExistingTokens(): void {
$signedRequest = $this->createMock(IIncomingSignedRequest::class);
$signedRequest->method('getOrigin')->willReturn('remote.example.com');
$this->signatureManager->method('getIncomingSignedRequest')
->with($this->signatoryManager)
->willReturn($signedRequest);

$this->configureHappyPath('valid-refresh-token', 123, 'testuser', 'owner', 'sharee@remote.example.com', 'fixedjtivalue00');

// A refresh token may back multiple concurrent access tokens, so an
// exchange only adds one and never revokes or deletes an existing one.
$this->tokenProvider->expects($this->never())->method('invalidateTokenById');
$this->ocmTokenMapMapper->expects($this->never())->method('delete');
$this->ocmTokenMapMapper->expects($this->once())->method('insert');

$result = $this->controller->accessToken('authorization_code', 'valid-refresh-token');

$this->assertEquals(Http::STATUS_OK, $result->getStatus());
}

public function testAccessTokenLocksRefreshTokenToExchangeOnly(): void {
$signedRequest = $this->createMock(IIncomingSignedRequest::class);
$signedRequest->method('getOrigin')->willReturn('remote.example.com');
Expand Down
Loading
Loading