Merge pull request #61889 from nextcloud/kano-fix-ocm-duplicate-sharedSecret

CloudFederationApi: access-token lifecycle fixes
pull/63239/merge
Micke Nordin 2 days ago committed by GitHub
commit 7dd9f40799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      apps/cloud_federation_api/composer/composer/autoload_classmap.php
  2. 2
      apps/cloud_federation_api/composer/composer/autoload_static.php
  3. 3
      apps/cloud_federation_api/lib/AppInfo/Application.php
  4. 14
      apps/cloud_federation_api/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php
  5. 18
      apps/cloud_federation_api/lib/Controller/TokenController.php
  6. 27
      apps/cloud_federation_api/lib/Db/OcmTokenMapMapper.php
  7. 56
      apps/cloud_federation_api/lib/Listener/ShareDeletedListener.php
  8. 70
      apps/cloud_federation_api/lib/Service/OcmTokenService.php
  9. 20
      apps/cloud_federation_api/tests/BackgroundJob/CleanupExpiredOcmTokensJobTest.php
  10. 24
      apps/cloud_federation_api/tests/Controller/TokenControllerTest.php
  11. 63
      apps/cloud_federation_api/tests/Listener/ShareDeletedListenerTest.php
  12. 98
      apps/cloud_federation_api/tests/Service/OcmTokenServiceTest.php

@ -15,8 +15,10 @@ return array(
'OCA\\CloudFederationAPI\\Controller\\TokenController' => $baseDir . '/../lib/Controller/TokenController.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => $baseDir . '/../lib/Db/OcmTokenMap.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => $baseDir . '/../lib/Db/OcmTokenMapMapper.php',
'OCA\\CloudFederationAPI\\Listener\\ShareDeletedListener' => $baseDir . '/../lib/Listener/ShareDeletedListener.php',
'OCA\\CloudFederationAPI\\Migration\\DropFederatedInvitesTable' => $baseDir . '/../lib/Migration/DropFederatedInvitesTable.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',
);

@ -30,10 +30,12 @@ class ComposerStaticInitCloudFederationAPI
'OCA\\CloudFederationAPI\\Controller\\TokenController' => __DIR__ . '/..' . '/../lib/Controller/TokenController.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMap.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMapMapper.php',
'OCA\\CloudFederationAPI\\Listener\\ShareDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/ShareDeletedListener.php',
'OCA\\CloudFederationAPI\\Migration\\DropFederatedInvitesTable' => __DIR__ . '/..' . '/../lib/Migration/DropFederatedInvitesTable.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)

@ -9,10 +9,12 @@ declare(strict_types=1);
namespace OCA\CloudFederationAPI\AppInfo;
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';
@ -23,6 +25,7 @@ class Application extends App implements IBootstrap {
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerEventListener(ShareDeletedEvent::class, ShareDeletedListener::class);
}
#[\Override]

@ -9,20 +9,22 @@ declare(strict_types=1);
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);
@ -32,6 +34,6 @@ class CleanupExpiredOcmTokensJob extends TimedJob {
#[\Override]
protected function run($argument): void {
$this->mapper->deleteExpired($this->time->getTime());
$this->tokenService->revokeExpired($this->time->getTime());
}
}

@ -226,20 +226,10 @@ class TokenController extends ApiController {
$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);

@ -34,25 +34,32 @@ class OcmTokenMapMapper extends QBMapper {
}
/**
* Find the current mapping for a given refresh token, if any.
* All mappings for a refresh token. Returns every match, since a refresh
* token can back several access tokens and concurrent exchanges may add
* duplicate rows.
*
* @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);
}
}

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

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

@ -10,14 +10,14 @@ declare(strict_types=1);
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;
#[\Override]
@ -25,20 +25,16 @@ class CleanupExpiredOcmTokensJobTest extends TestCase {
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, []);

@ -125,10 +125,6 @@ class TokenControllerTest extends TestCase {
->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);
@ -196,6 +192,26 @@ class TokenControllerTest extends TestCase {
$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');

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Tests\Listener;
use OC\Authentication\Token\IProvider;
use OCA\CloudFederationAPI\Listener\ShareDeletedListener;
use OCA\CloudFederationAPI\Service\OcmTokenService;
use OCP\Share\Events\ShareDeletedEvent;
use OCP\Share\IShare;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class ShareDeletedListenerTest extends TestCase {
private OcmTokenService&MockObject $tokenService;
private IProvider&MockObject $tokenProvider;
private ShareDeletedListener $listener;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->tokenService = $this->createMock(OcmTokenService::class);
$this->tokenProvider = $this->createMock(IProvider::class);
$this->listener = new ShareDeletedListener($this->tokenService, $this->tokenProvider);
}
private function event(int $shareType, ?string $token): ShareDeletedEvent {
$share = $this->createMock(IShare::class);
$share->method('getShareType')->willReturn($shareType);
$share->method('getToken')->willReturn($token);
return new ShareDeletedEvent($share);
}
public function testHandleFederatedShareRevokesTokens(): void {
$this->tokenService->expects($this->once())
->method('revokeByRefreshToken')->with('secret');
$this->tokenProvider->expects($this->once())
->method('invalidateToken')->with('secret');
$this->listener->handle($this->event(IShare::TYPE_REMOTE, 'secret'));
}
public function testHandleIgnoresNonFederatedShare(): void {
$this->tokenService->expects($this->never())->method('revokeByRefreshToken');
$this->tokenProvider->expects($this->never())->method('invalidateToken');
$this->listener->handle($this->event(IShare::TYPE_USER, 'secret'));
}
public function testHandleIgnoresEmptyToken(): void {
$this->tokenService->expects($this->never())->method('revokeByRefreshToken');
$this->tokenProvider->expects($this->never())->method('invalidateToken');
$this->listener->handle($this->event(IShare::TYPE_REMOTE, ''));
}
}

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Tests\Service;
use OC\Authentication\Exceptions\ExpiredTokenException;
use OC\Authentication\Exceptions\InvalidTokenException;
use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\IToken;
use OCA\CloudFederationAPI\Db\OcmTokenMap;
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
use OCA\CloudFederationAPI\Service\OcmTokenService;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class OcmTokenServiceTest extends TestCase {
private OcmTokenMapMapper&MockObject $mapper;
private IProvider&MockObject $tokenProvider;
private OcmTokenService $service;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->mapper = $this->createMock(OcmTokenMapMapper::class);
$this->tokenProvider = $this->createMock(IProvider::class);
$this->service = new OcmTokenService($this->mapper, $this->tokenProvider);
}
private function mapping(int $accessTokenId): OcmTokenMap {
$mapping = new OcmTokenMap();
$mapping->setAccessTokenId($accessTokenId);
return $mapping;
}
private function token(string $uid): IToken&MockObject {
$token = $this->createMock(IToken::class);
$token->method('getUID')->willReturn($uid);
return $token;
}
public function testRevokeExpiredRevokesTokenThenDeletesMapping(): void {
$now = 1700000000;
$mapping = $this->mapping(42);
$this->mapper->expects($this->once())
->method('findExpired')->with($now)->willReturn([$mapping]);
$this->tokenProvider->method('getTokenById')->with(42)
->willReturn($this->token('alice'));
$this->tokenProvider->expects($this->once())
->method('invalidateTokenById')->with('alice', 42);
$this->mapper->expects($this->once())->method('delete')->with($mapping);
$this->service->revokeExpired($now);
}
public function testRevokeHandlesExpiredAccessToken(): void {
$mapping = $this->mapping(7);
$this->mapper->method('findExpired')->willReturn([$mapping]);
// getTokenById throws for the expired token but still carries it.
$this->tokenProvider->method('getTokenById')->with(7)
->willThrowException(new ExpiredTokenException($this->token('bob')));
$this->tokenProvider->expects($this->once())
->method('invalidateTokenById')->with('bob', 7);
$this->mapper->expects($this->once())->method('delete')->with($mapping);
$this->service->revokeExpired(1700000000);
}
public function testRevokeSkipsWhenAccessTokenAlreadyGone(): void {
$mapping = $this->mapping(9);
$this->mapper->method('findExpired')->willReturn([$mapping]);
$this->tokenProvider->method('getTokenById')->with(9)
->willThrowException(new InvalidTokenException());
$this->tokenProvider->expects($this->never())->method('invalidateTokenById');
$this->mapper->expects($this->once())->method('delete')->with($mapping);
$this->service->revokeExpired(1700000000);
}
public function testRevokeByRefreshTokenRevokesMapping(): void {
$mapping = $this->mapping(5);
$this->mapper->expects($this->once())
->method('findAllByRefreshToken')->with('secret')->willReturn([$mapping]);
$this->tokenProvider->method('getTokenById')->with(5)
->willReturn($this->token('alice'));
$this->tokenProvider->expects($this->once())
->method('invalidateTokenById')->with('alice', 5);
$this->mapper->expects($this->once())->method('delete')->with($mapping);
$this->service->revokeByRefreshToken('secret');
}
}
Loading…
Cancel
Save