diff --git a/apps/oauth2/appinfo/routes.php b/apps/oauth2/appinfo/routes.php index eadac04f47f4b..14f5f2c33f5ff 100644 --- a/apps/oauth2/appinfo/routes.php +++ b/apps/oauth2/appinfo/routes.php @@ -28,5 +28,11 @@ 'url' => '/api/v1/token', 'verb' => 'POST' ], + [ + /** @see \OCA\OAuth2\Controller\OauthApiController::pushToken() */ + 'name' => 'OauthApi#pushToken', + 'url' => '/api/v1/pushtoken', + 'verb' => 'POST' + ], ], ]; diff --git a/apps/oauth2/lib/Controller/LoginRedirectorController.php b/apps/oauth2/lib/Controller/LoginRedirectorController.php index 69d65ea2dbf5f..fb64febd908e8 100644 --- a/apps/oauth2/lib/Controller/LoginRedirectorController.php +++ b/apps/oauth2/lib/Controller/LoginRedirectorController.php @@ -30,24 +30,16 @@ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] class LoginRedirectorController extends Controller { - /** - * @param string $appName - * @param IRequest $request - * @param IURLGenerator $urlGenerator - * @param ClientMapper $clientMapper - * @param ISession $session - * @param IL10N $l - */ public function __construct( string $appName, IRequest $request, - private IURLGenerator $urlGenerator, - private ClientMapper $clientMapper, - private ISession $session, - private IL10N $l, - private ISecureRandom $random, - private IAppConfig $appConfig, - private IConfig $config, + private readonly IURLGenerator $urlGenerator, + private readonly ClientMapper $clientMapper, + private readonly ISession $session, + private readonly IL10N $l, + private readonly ISecureRandom $random, + private readonly IAppConfig $appConfig, + private readonly IConfig $config, ) { parent::__construct($appName, $request); } @@ -62,15 +54,14 @@ public function __construct( * @return TemplateResponse|RedirectResponse * * 200: Client not found - * 303: Redirect to login URL + * 303: Redirect to the login URL */ #[PublicPage] #[NoCSRFRequired] #[UseSession] - public function authorize($client_id, - $state, - $response_type, - string $redirect_uri = ''): TemplateResponse|RedirectResponse { + public function authorize( + string $client_id, string $state, string $response_type, string $redirect_uri = '', + ): TemplateResponse|RedirectResponse { try { $client = $this->clientMapper->getByIdentifier($client_id); } catch (ClientNotFoundException $e) { diff --git a/apps/oauth2/lib/Controller/OauthApiController.php b/apps/oauth2/lib/Controller/OauthApiController.php index 719f74b5b151f..db87f5ad9ae42 100644 --- a/apps/oauth2/lib/Controller/OauthApiController.php +++ b/apps/oauth2/lib/Controller/OauthApiController.php @@ -24,12 +24,19 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\Exceptions\ExpiredTokenException; use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\DB\Exception; +use OCP\GlobalScale\IConfig as GlobalScaleConfig; +use OCP\GlobalScale\IGlobalScaleService; use OCP\IDBConnection; use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserManager; use OCP\Security\Bruteforce\IThrottler; use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] @@ -40,16 +47,20 @@ class OauthApiController extends Controller { public function __construct( string $appName, IRequest $request, - private ICrypto $crypto, - private AccessTokenMapper $accessTokenMapper, - private ClientMapper $clientMapper, - private TokenProvider $tokenProvider, - private ISecureRandom $secureRandom, - private ITimeFactory $time, - private LoggerInterface $logger, - private IThrottler $throttler, - private ITimeFactory $timeFactory, - private IDBConnection $db, + private readonly ICrypto $crypto, + private readonly AccessTokenMapper $accessTokenMapper, + private readonly ClientMapper $clientMapper, + private readonly TokenProvider $tokenProvider, + private readonly ISecureRandom $secureRandom, + private readonly ITimeFactory $time, + private readonly LoggerInterface $logger, + private readonly IThrottler $throttler, + private readonly ITimeFactory $timeFactory, + private readonly IDBConnection $db, + private readonly GlobalScaleConfig $globalScaleConfig, + private readonly IUserManager $userManager, + private readonly IURLGenerator $urlGenerator, + private readonly ContainerInterface $container, ) { parent::__construct($appName, $request); } @@ -212,7 +223,8 @@ public function getToken( ); // Expiration is in 1 hour again - $appToken->setExpires($this->time->getTime() + 3600); + $expires = $this->time->getTime() + 3600; + $appToken->setExpires($expires); $this->tokenProvider->updateToken($appToken); $this->db->commit(); @@ -229,6 +241,11 @@ public function getToken( $this->throttler->resetDelay($this->request->getRemoteAddress(), 'login', ['user' => $appToken->getUID()]); + if ($this->globalScaleConfig->isGlobalScaleEnabled() && $this->globalScaleConfig->isPrimary()) { + // Also make sure the access token is available on the secondary instance + $this->pushTokenToSecondary($appToken, $newToken, $expires); + } + return new JSONResponse( [ 'access_token' => $newToken, @@ -239,4 +256,93 @@ public function getToken( ] ); } + + /** + * Push the freshly issued app token to the secondary instance holding the + * user's account, so the OAuth client can use it there directly. + */ + private function pushTokenToSecondary(IToken $appToken, string $newToken, ?int $expires): void { + $user = $this->userManager->get($appToken->getUID()); + if ($user === null) { + $this->logger->warning('could not push oauth token to secondary: unknown user', ['uid' => $appToken->getUID()]); + return; + } + + try { + /** @var IGlobalScaleService $globalScaleService */ + $globalScaleService = $this->container->get(IGlobalScaleService::class); + } catch (ContainerExceptionInterface $e) { + $this->logger->warning('could not push oauth token to secondary: globalsiteselector is not available', ['exception' => $e]); + return; + } + + try { + $globalScaleService->sendToSecondary($user, $this->urlGenerator->linkToRoute('oauth2.OauthApi.pushToken'), [ + 'uid' => $appToken->getUID(), + 'loginName' => $appToken->getLoginName(), + 'name' => $appToken->getName(), + 'type' => $appToken->getType(), + 'remember' => $appToken->getRemember(), + 'scope' => $appToken->getScopeAsArray(), + 'expires' => $expires, + 'token' => $newToken, + ]); + } catch (\Exception $e) { + $this->logger->warning('could not push oauth token to secondary', ['exception' => $e]); + } + } + + /** + * Receive an app token pushed from the primary instance, so it can be used + * directly against this (secondary) instance. + */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + #[BruteForceProtection(action: 'oauth2PushToken')] + public function pushToken(string $jwt): JSONResponse { + if (!$this->globalScaleConfig->isGlobalScaleEnabled() || !$this->globalScaleConfig->isSecondary() || $jwt === '') { + $response = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $response->throttle(); + return $response; + } + + try { + /** @var IGlobalScaleService $globalScaleService */ + $globalScaleService = $this->container->get(IGlobalScaleService::class); + } catch (ContainerExceptionInterface $e) { + $this->logger->warning('could not receive oauth token from primary: globalsiteselector is not available', ['exception' => $e]); + $response = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $response->throttle(); + return $response; + } + + try { + $decoded = $globalScaleService->decodePayload($jwt); + + $uid = (string)$decoded['uid']; + if (!$this->userManager->userExists($uid)) { + throw new \InvalidArgumentException('unknown user: ' . $uid); + } + + $this->tokenProvider->generateToken( + (string)$decoded['token'], + $uid, + (string)$decoded['loginName'], + null, + (string)$decoded['name'], + (int)$decoded['type'], + (int)$decoded['remember'], + (array)$decoded['scope'], + $decoded['expires'] !== null ? (int)$decoded['expires'] : null, + ); + } catch (\Exception $e) { + $this->logger->warning('could not create pushed oauth token', ['exception' => $e]); + $response = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $response->throttle(); + return $response; + } + + return new JSONResponse([]); + } } diff --git a/apps/oauth2/tests/Controller/OauthApiControllerTest.php b/apps/oauth2/tests/Controller/OauthApiControllerTest.php index bc4275960d9ae..b8adb18684b0d 100644 --- a/apps/oauth2/tests/Controller/OauthApiControllerTest.php +++ b/apps/oauth2/tests/Controller/OauthApiControllerTest.php @@ -21,11 +21,20 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Token\IToken; +use OCP\GlobalScale\IConfig as GlobalScaleConfig; +use OCP\GlobalScale\IGlobalScaleService; use OCP\IDBConnection; use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; use OCP\Security\Bruteforce\IThrottler; use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Test\TestCase; @@ -35,30 +44,22 @@ abstract class RequestMock implements IRequest { } class OauthApiControllerTest extends TestCase { - /** @var IRequest|\PHPUnit\Framework\MockObject\MockObject */ - private $request; - /** @var ICrypto|\PHPUnit\Framework\MockObject\MockObject */ - private $crypto; - /** @var AccessTokenMapper|\PHPUnit\Framework\MockObject\MockObject */ - private $accessTokenMapper; - /** @var ClientMapper|\PHPUnit\Framework\MockObject\MockObject */ - private $clientMapper; - /** @var TokenProvider|\PHPUnit\Framework\MockObject\MockObject */ - private $tokenProvider; - /** @var ISecureRandom|\PHPUnit\Framework\MockObject\MockObject */ - private $secureRandom; - /** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */ - private $time; - /** @var IThrottler|\PHPUnit\Framework\MockObject\MockObject */ - private $throttler; - /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ - private $logger; - /** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */ - private $timeFactory; - /** @var IDBConnection|\PHPUnit\Framework\MockObject\MockObject */ - private $db; - /** @var OauthApiController */ - private $oauthApiController; + private IRequest&MockObject $request; + private ICrypto&MockObject $crypto; + private AccessTokenMapper&MockObject $accessTokenMapper; + private ClientMapper&MockObject $clientMapper; + private TokenProvider&MockObject $tokenProvider; + private ISecureRandom&MockObject $secureRandom; + private ITimeFactory&MockObject $time; + private IThrottler&MockObject $throttler; + private LoggerInterface&MockObject $logger; + private ITimeFactory&MockObject $timeFactory; + private IDBConnection&MockObject $db; + private GlobalScaleConfig&MockObject $globalScaleConfig; + private IUserManager&MockObject $userManager; + private IURLGenerator&MockObject $urlGenerator; + private ContainerInterface&MockObject $container; + private OauthApiController $oauthApiController; protected function setUp(): void { parent::setUp(); @@ -74,6 +75,10 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->db = $this->createMock(IDBConnection::class); + $this->globalScaleConfig = $this->createMock(GlobalScaleConfig::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->container = $this->createMock(ContainerInterface::class); $this->oauthApiController = new OauthApiController( 'oauth2', @@ -88,6 +93,10 @@ protected function setUp(): void { $this->throttler, $this->timeFactory, $this->db, + $this->globalScaleConfig, + $this->userManager, + $this->urlGenerator, + $this->container, ); } @@ -737,4 +746,345 @@ public function testRefreshTokenRedeemedConcurrently(): void { $this->assertEquals($expected, $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret')); } + + /** + * arrange a successful "refresh_token" exchange, shared by the secondary-push tests below. + * returns the resulting app token, so tests can further configure push-related mocks. + */ + private function arrangeSuccessfulTokenExchange(): PublicKeyToken { + $accessToken = new AccessToken(); + $accessToken->setId(21); + $accessToken->setClientId(42); + $accessToken->setTokenId(1337); + $accessToken->setEncryptedToken('encryptedToken'); + + $this->accessTokenMapper->method('getByCode') + ->with('validrefresh') + ->willReturn($accessToken); + + $client = new Client(); + $client->setClientIdentifier('clientId'); + $client->setSecret(bin2hex('hashedClientSecret')); + $this->clientMapper->method('getByUid') + ->with(42) + ->willReturn($client); + + $this->crypto->method('decrypt') + ->with('encryptedToken') + ->willReturn('decryptedToken'); + $this->crypto->method('calculateHMAC') + ->with('clientSecret') + ->willReturn('hashedClientSecret'); + $this->crypto->method('encrypt') + ->with('random72', 'random128') + ->willReturn('newEncryptedToken'); + + $appToken = new PublicKeyToken(); + $appToken->setUid('userId'); + $appToken->setLoginName('userId'); + $appToken->setName('token name'); + $appToken->setType(IToken::PERMANENT_TOKEN); + $appToken->setRemember(IToken::DO_NOT_REMEMBER); + $this->tokenProvider->method('getTokenById') + ->with(1337) + ->willReturn($appToken); + $this->tokenProvider->method('rotate') + ->willReturn($appToken); + + $this->secureRandom->method('generate') + ->willReturnCallback(function ($len) { + return 'random' . $len; + }); + $this->time->method('getTime')->willReturn(1000); + $this->accessTokenMapper->method('rotateToken')->willReturn(1); + $this->request->method('getRemoteAddress')->willReturn('1.2.3.4'); + + return $appToken; + } + + private function expectedSuccessfulTokenResponse(): JSONResponse { + return new JSONResponse([ + 'access_token' => 'random72', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token' => 'random128', + 'user_id' => 'userId', + ]); + } + + public function testGetTokenSkipsSecondaryPushWhenNotGlobalScale(): void { + $this->arrangeSuccessfulTokenExchange(); + + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(false); + + $this->userManager->expects($this->never())->method('get'); + $this->container->expects($this->never())->method('get'); + + $this->assertEquals( + $this->expectedSuccessfulTokenResponse(), + $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret') + ); + } + + public function testGetTokenSkipsSecondaryPushWhenNotPrimary(): void { + $this->arrangeSuccessfulTokenExchange(); + + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isPrimary')->willReturn(false); + + $this->userManager->expects($this->never())->method('get'); + $this->container->expects($this->never())->method('get'); + + $this->assertEquals( + $this->expectedSuccessfulTokenResponse(), + $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret') + ); + } + + public function testGetTokenSkipsSecondaryPushWhenUserUnknown(): void { + $this->arrangeSuccessfulTokenExchange(); + + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isPrimary')->willReturn(true); + $this->userManager->method('get')->with('userId')->willReturn(null); + + $this->container->expects($this->never())->method('get'); + + $this->assertEquals( + $this->expectedSuccessfulTokenResponse(), + $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret') + ); + } + + public function testGetTokenSkipsSecondaryPushWhenGlobalScaleServiceUnavailable(): void { + $this->arrangeSuccessfulTokenExchange(); + + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isPrimary')->willReturn(true); + + $user = $this->createMock(IUser::class); + $this->userManager->method('get')->with('userId')->willReturn($user); + + $this->container->method('get') + ->with(IGlobalScaleService::class) + ->willThrowException($this->createMock(ContainerExceptionInterface::class)); + + $this->assertEquals( + $this->expectedSuccessfulTokenResponse(), + $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret') + ); + } + + public function testGetTokenPushesTokenToSecondaryWhenPrimary(): void { + $this->arrangeSuccessfulTokenExchange(); + + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isPrimary')->willReturn(true); + + $user = $this->createMock(IUser::class); + $this->userManager->method('get')->with('userId')->willReturn($user); + + $this->urlGenerator->method('linkToRoute') + ->with('oauth2.OauthApi.pushToken') + ->willReturn('/apps/oauth2/api/v1/pushtoken'); + + $globalScaleService = $this->createMock(IGlobalScaleService::class); + $this->container->method('get') + ->with(IGlobalScaleService::class) + ->willReturn($globalScaleService); + + $globalScaleService->expects($this->once())->method('sendToSecondary') + ->with( + $user, + '/apps/oauth2/api/v1/pushtoken', + [ + 'uid' => 'userId', + 'loginName' => 'userId', + 'name' => 'token name', + 'type' => IToken::PERMANENT_TOKEN, + 'remember' => IToken::DO_NOT_REMEMBER, + 'scope' => [IToken::SCOPE_FILESYSTEM => true], + 'expires' => 4600, + 'token' => 'random72', + ] + ); + + $this->assertEquals( + $this->expectedSuccessfulTokenResponse(), + $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret') + ); + } + + public function testGetTokenSucceedsEvenIfPushToSecondaryFails(): void { + $this->arrangeSuccessfulTokenExchange(); + + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isPrimary')->willReturn(true); + + $user = $this->createMock(IUser::class); + $this->userManager->method('get')->with('userId')->willReturn($user); + + $globalScaleService = $this->createMock(IGlobalScaleService::class); + $this->container->method('get') + ->with(IGlobalScaleService::class) + ->willReturn($globalScaleService); + + $globalScaleService->method('sendToSecondary') + ->willThrowException(new \Exception('could not reach secondary')); + + $this->assertEquals( + $this->expectedSuccessfulTokenResponse(), + $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret') + ); + } + + public function testPushTokenRejectsWhenGlobalScaleDisabled(): void { + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(false); + + $expected = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $expected->throttle(); + + $this->assertEquals($expected, $this->oauthApiController->pushToken('some.jwt.token')); + } + + public function testPushTokenRejectsWhenNotSecondary(): void { + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isSecondary')->willReturn(false); + + $expected = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $expected->throttle(); + + $this->assertEquals($expected, $this->oauthApiController->pushToken('some.jwt.token')); + } + + public function testPushTokenRejectsEmptyJwt(): void { + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isSecondary')->willReturn(true); + + $expected = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $expected->throttle(); + + $this->assertEquals($expected, $this->oauthApiController->pushToken('')); + } + + public function testPushTokenRejectsWhenGlobalScaleServiceUnavailable(): void { + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isSecondary')->willReturn(true); + + $this->container->method('get') + ->with(IGlobalScaleService::class) + ->willThrowException($this->createMock(ContainerExceptionInterface::class)); + + $this->tokenProvider->expects($this->never())->method('generateToken'); + + $expected = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $expected->throttle(); + + $this->assertEquals($expected, $this->oauthApiController->pushToken('some.jwt.token')); + } + + public function testPushTokenRejectsUnknownUser(): void { + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isSecondary')->willReturn(true); + + $globalScaleService = $this->createMock(IGlobalScaleService::class); + $this->container->method('get') + ->with(IGlobalScaleService::class) + ->willReturn($globalScaleService); + + $globalScaleService->method('decodePayload') + ->with('some.jwt.token') + ->willReturn([ + 'uid' => 'userId', + 'loginName' => 'userId', + 'name' => 'token name', + 'type' => IToken::PERMANENT_TOKEN, + 'remember' => IToken::DO_NOT_REMEMBER, + 'scope' => [IToken::SCOPE_FILESYSTEM => true], + 'expires' => null, + 'token' => 'sometoken', + ]); + + $this->userManager->method('userExists')->with('userId')->willReturn(false); + + $this->tokenProvider->expects($this->never())->method('generateToken'); + + $expected = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $expected->throttle(); + + $this->assertEquals($expected, $this->oauthApiController->pushToken('some.jwt.token')); + } + + public function testPushTokenCreatesLocalToken(): void { + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isSecondary')->willReturn(true); + + $globalScaleService = $this->createMock(IGlobalScaleService::class); + $this->container->method('get') + ->with(IGlobalScaleService::class) + ->willReturn($globalScaleService); + + $globalScaleService->method('decodePayload') + ->with('some.jwt.token') + ->willReturn([ + 'uid' => 'userId', + 'loginName' => 'userId', + 'name' => 'token name', + 'type' => IToken::PERMANENT_TOKEN, + 'remember' => IToken::DO_NOT_REMEMBER, + 'scope' => [IToken::SCOPE_FILESYSTEM => true], + 'expires' => 4600, + 'token' => 'sometoken', + ]); + + $this->userManager->method('userExists')->with('userId')->willReturn(true); + + $this->tokenProvider->expects($this->once())->method('generateToken') + ->with( + 'sometoken', + 'userId', + 'userId', + null, + 'token name', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER, + [IToken::SCOPE_FILESYSTEM => true], + 4600, + ); + + $this->assertEquals(new JSONResponse([]), $this->oauthApiController->pushToken('some.jwt.token')); + } + + public function testPushTokenRejectsWhenGenerateTokenThrows(): void { + $this->globalScaleConfig->method('isGlobalScaleEnabled')->willReturn(true); + $this->globalScaleConfig->method('isSecondary')->willReturn(true); + + $globalScaleService = $this->createMock(IGlobalScaleService::class); + $this->container->method('get') + ->with(IGlobalScaleService::class) + ->willReturn($globalScaleService); + + $globalScaleService->method('decodePayload') + ->with('some.jwt.token') + ->willReturn([ + 'uid' => 'userId', + 'loginName' => 'userId', + 'name' => 'token name', + 'type' => IToken::PERMANENT_TOKEN, + 'remember' => IToken::DO_NOT_REMEMBER, + 'scope' => [IToken::SCOPE_FILESYSTEM => true], + 'expires' => null, + 'token' => 'sometoken', + ]); + + $this->userManager->method('userExists')->with('userId')->willReturn(true); + + $this->tokenProvider->method('generateToken') + ->willThrowException(new \RuntimeException('could not generate token')); + + $expected = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $expected->throttle(); + + $this->assertEquals($expected, $this->oauthApiController->pushToken('some.jwt.token')); + } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index beafb4700c69d..f898759462cf6 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -578,6 +578,7 @@ 'OCP\\FullTextSearch\\Service\\IProviderService' => $baseDir . '/lib/public/FullTextSearch/Service/IProviderService.php', 'OCP\\FullTextSearch\\Service\\ISearchService' => $baseDir . '/lib/public/FullTextSearch/Service/ISearchService.php', 'OCP\\GlobalScale\\IConfig' => $baseDir . '/lib/public/GlobalScale/IConfig.php', + 'OCP\\GlobalScale\\IGlobalScaleService' => $baseDir . '/lib/public/GlobalScale/IGlobalScaleService.php', 'OCP\\GroupInterface' => $baseDir . '/lib/public/GroupInterface.php', 'OCP\\Group\\Backend\\ABackend' => $baseDir . '/lib/public/Group/Backend/ABackend.php', 'OCP\\Group\\Backend\\IAddToGroupBackend' => $baseDir . '/lib/public/Group/Backend/IAddToGroupBackend.php', @@ -1082,6 +1083,7 @@ 'OCP\\User\\Backend\\IGetDisplayNameBackend' => $baseDir . '/lib/public/User/Backend/IGetDisplayNameBackend.php', 'OCP\\User\\Backend\\IGetHomeBackend' => $baseDir . '/lib/public/User/Backend/IGetHomeBackend.php', 'OCP\\User\\Backend\\IGetRealUIDBackend' => $baseDir . '/lib/public/User/Backend/IGetRealUIDBackend.php', + 'OCP\\User\\Backend\\IGlobalScaleExtraDataBackend' => $baseDir . '/lib/public/User/Backend/IGlobalScaleExtraDataBackend.php', 'OCP\\User\\Backend\\ILimitAwareCountUsersBackend' => $baseDir . '/lib/public/User/Backend/ILimitAwareCountUsersBackend.php', 'OCP\\User\\Backend\\IPasswordConfirmationBackend' => $baseDir . '/lib/public/User/Backend/IPasswordConfirmationBackend.php', 'OCP\\User\\Backend\\IPasswordHashBackend' => $baseDir . '/lib/public/User/Backend/IPasswordHashBackend.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index e024c1fb44b9f..92b5ed3e1baf8 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -619,6 +619,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\FullTextSearch\\Service\\IProviderService' => __DIR__ . '/../../..' . '/lib/public/FullTextSearch/Service/IProviderService.php', 'OCP\\FullTextSearch\\Service\\ISearchService' => __DIR__ . '/../../..' . '/lib/public/FullTextSearch/Service/ISearchService.php', 'OCP\\GlobalScale\\IConfig' => __DIR__ . '/../../..' . '/lib/public/GlobalScale/IConfig.php', + 'OCP\\GlobalScale\\IGlobalScaleService' => __DIR__ . '/../../..' . '/lib/public/GlobalScale/IGlobalScaleService.php', 'OCP\\GroupInterface' => __DIR__ . '/../../..' . '/lib/public/GroupInterface.php', 'OCP\\Group\\Backend\\ABackend' => __DIR__ . '/../../..' . '/lib/public/Group/Backend/ABackend.php', 'OCP\\Group\\Backend\\IAddToGroupBackend' => __DIR__ . '/../../..' . '/lib/public/Group/Backend/IAddToGroupBackend.php', @@ -1123,6 +1124,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\User\\Backend\\IGetDisplayNameBackend' => __DIR__ . '/../../..' . '/lib/public/User/Backend/IGetDisplayNameBackend.php', 'OCP\\User\\Backend\\IGetHomeBackend' => __DIR__ . '/../../..' . '/lib/public/User/Backend/IGetHomeBackend.php', 'OCP\\User\\Backend\\IGetRealUIDBackend' => __DIR__ . '/../../..' . '/lib/public/User/Backend/IGetRealUIDBackend.php', + 'OCP\\User\\Backend\\IGlobalScaleExtraDataBackend' => __DIR__ . '/../../..' . '/lib/public/User/Backend/IGlobalScaleExtraDataBackend.php', 'OCP\\User\\Backend\\ILimitAwareCountUsersBackend' => __DIR__ . '/../../..' . '/lib/public/User/Backend/ILimitAwareCountUsersBackend.php', 'OCP\\User\\Backend\\IPasswordConfirmationBackend' => __DIR__ . '/../../..' . '/lib/public/User/Backend/IPasswordConfirmationBackend.php', 'OCP\\User\\Backend\\IPasswordHashBackend' => __DIR__ . '/../../..' . '/lib/public/User/Backend/IPasswordHashBackend.php', diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index 8a6f6bb3e5c51..9b1e1efab24b9 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -29,6 +29,7 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Conversion\IConversionProvider; use OCP\Files\Template\ICustomTemplateProvider; +use OCP\GlobalScale\IGlobalScaleService; use OCP\Http\WellKnown\IHandler; use OCP\Mail\Provider\IProvider as IMailProvider; use OCP\Notification\INotifier; @@ -167,6 +168,9 @@ class RegistrationContext { /** @var ServiceRegistration[] */ private $mailProviders = []; + /** @var class-string|null */ + private ?string $globalScaleService = null; + public function __construct( private LoggerInterface $logger, ) { @@ -491,6 +495,13 @@ public function registerConfigLexicon(string $configLexiconClass): void { $configLexiconClass ); } + + #[\Override] + public function registerGlobalScaleService(string $globalScaleServiceClass): void { + $this->context->registerGlobalScaleService( + $globalScaleServiceClass + ); + } }; } @@ -709,6 +720,13 @@ public function registerConfigLexicon(string $appId, string $configLexiconClass) $this->configLexiconClasses[$appId] = $configLexiconClass; } + /** + * @param class-string $class + */ + public function registerGlobalScaleService(string $class): void { + $this->globalScaleService = $class; + } + /** * @param App[] $apps */ @@ -1094,4 +1112,11 @@ public function getConfigLexicon(string $appId): ?ILexicon { return Server::get($this->configLexiconClasses[$appId]); } + + /** + * @return ?class-string + */ + public function getGlobalScaleService(): ?string { + return $this->globalScaleService; + } } diff --git a/lib/private/Authentication/Token/IProvider.php b/lib/private/Authentication/Token/IProvider.php index 39209709c1581..a95105cce7332 100644 --- a/lib/private/Authentication/Token/IProvider.php +++ b/lib/private/Authentication/Token/IProvider.php @@ -146,7 +146,7 @@ public function getPassword(OCPIToken $savedToken, string $tokenId): string; public function setPassword(OCPIToken $token, string $tokenId, string $password); /** - * Rotate the token. Useful for for example oauth tokens + * Rotate the token. Useful for example oauth tokens * * @param OCPIToken $token * @param string $oldTokenId diff --git a/lib/private/Server.php b/lib/private/Server.php index a1caf17da3984..a10d10a950496 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -12,6 +12,7 @@ use OC\Accounts\AccountManager; use OC\Activity\EventMerger; use OC\App\AppManager; +use OC\AppFramework\Bootstrap\Coordinator; use OC\AppFramework\Http\Request; use OC\AppFramework\Http\RequestId; use OC\AppFramework\Services\AppConfig; @@ -204,6 +205,7 @@ use OCP\Files\Template\ITemplateManager; use OCP\FilesMetadata\IFilesMetadataManager; use OCP\FullTextSearch\IFullTextSearchManager; +use OCP\GlobalScale\IGlobalScaleService; use OCP\Group\ISubAdmin; use OCP\Http\Client\IClientService; use OCP\IAppConfig; @@ -1154,6 +1156,17 @@ function () use ($c) { $this->registerAlias(\OCP\Sharing\ISharingRegistry::class, \OC\Sharing\SharingRegistry::class); $this->registerAlias(\OCP\Sharing\ISharingManager::class, \OC\Sharing\SharingManager::class); + $this->registerService(IGlobalScaleService::class, function (ContainerInterface $c): IGlobalScaleService { + /** @var Coordinator $coordinator */ + $coordinator = $c->get(Coordinator::class); + $registrationContext = $coordinator->getRegistrationContext(); + $globalScaleServiceClass = $registrationContext->getGlobalScaleService(); + if ($globalScaleServiceClass === null) { + throw new ServiceUnavailableException('The app providing the global scale service is not enabled'); + } + return $c->get($globalScaleServiceClass); + }); + $this->connectDispatcher(); } diff --git a/lib/private/ServiceUnavailableException.php b/lib/private/ServiceUnavailableException.php index f1afc0f216ef1..99027c0efcc60 100644 --- a/lib/private/ServiceUnavailableException.php +++ b/lib/private/ServiceUnavailableException.php @@ -10,5 +10,7 @@ namespace OC; -class ServiceUnavailableException extends \Exception { +use Psr\Container\ContainerExceptionInterface; + +class ServiceUnavailableException extends \Exception implements ContainerExceptionInterface { } diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index 15d89371be21b..98952c8fb31b9 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -16,6 +16,7 @@ use OCP\Collaboration\Reference\IReferenceProvider; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Template\ICustomTemplateProvider; +use OCP\GlobalScale\IGlobalScaleService; use OCP\IContainer; use OCP\Mail\Provider\IProvider as IMailProvider; use OCP\Notification\INotifier; @@ -458,4 +459,12 @@ public function registerMailProvider(string $class): void; * @since 31.0.0 */ public function registerConfigLexicon(string $configLexiconClass): void; + + /** + * Register an implementation of {@see IGlobalScaleService}. + * + * @param class-string $globalScaleServiceClass + * @since 34.0.3 + */ + public function registerGlobalScaleService(string $globalScaleServiceClass): void; } diff --git a/lib/public/GlobalScale/IGlobalScaleService.php b/lib/public/GlobalScale/IGlobalScaleService.php new file mode 100644 index 0000000000000..e6d4b86ab150c --- /dev/null +++ b/lib/public/GlobalScale/IGlobalScaleService.php @@ -0,0 +1,44 @@ +