Skip to content

Commit 1be09ac

Browse files
Merge pull request #61362 from nextcloud/backport/61289/stable33
[stable33] feat(oauth2): Add commands for adding and deleting clients
2 parents dd4969b + bdb4071 commit 1be09ac

9 files changed

Lines changed: 486 additions & 332 deletions

File tree

apps/oauth2/appinfo/info.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535

3636
<commands>
3737
<command>OCA\OAuth2\Command\ImportLegacyOcClient</command>
38+
<command>OCA\OAuth2\Command\AddClient</command>
39+
<command>OCA\OAuth2\Command\DeleteClient</command>
3840
</commands>
3941

4042
<settings>

apps/oauth2/composer/composer/autoload_classmap.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
return array(
99
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
1010
'OCA\\OAuth2\\BackgroundJob\\CleanupExpiredAuthorizationCode' => $baseDir . '/../lib/BackgroundJob/CleanupExpiredAuthorizationCode.php',
11+
'OCA\\OAuth2\\Command\\AddClient' => $baseDir . '/../lib/Command/AddClient.php',
12+
'OCA\\OAuth2\\Command\\DeleteClient' => $baseDir . '/../lib/Command/DeleteClient.php',
1113
'OCA\\OAuth2\\Command\\ImportLegacyOcClient' => $baseDir . '/../lib/Command/ImportLegacyOcClient.php',
1214
'OCA\\OAuth2\\Controller\\LoginRedirectorController' => $baseDir . '/../lib/Controller/LoginRedirectorController.php',
1315
'OCA\\OAuth2\\Controller\\OauthApiController' => $baseDir . '/../lib/Controller/OauthApiController.php',
@@ -25,5 +27,6 @@
2527
'OCA\\OAuth2\\Migration\\Version011602Date20230613160650' => $baseDir . '/../lib/Migration/Version011602Date20230613160650.php',
2628
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => $baseDir . '/../lib/Migration/Version011603Date20230620111039.php',
2729
'OCA\\OAuth2\\Migration\\Version011901Date20240829164356' => $baseDir . '/../lib/Migration/Version011901Date20240829164356.php',
30+
'OCA\\OAuth2\\Service\\ClientService' => $baseDir . '/../lib/Service/ClientService.php',
2831
'OCA\\OAuth2\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
2932
);

apps/oauth2/composer/composer/autoload_static.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class ComposerStaticInitOAuth2
2323
public static $classMap = array (
2424
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
2525
'OCA\\OAuth2\\BackgroundJob\\CleanupExpiredAuthorizationCode' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupExpiredAuthorizationCode.php',
26+
'OCA\\OAuth2\\Command\\AddClient' => __DIR__ . '/..' . '/../lib/Command/AddClient.php',
27+
'OCA\\OAuth2\\Command\\DeleteClient' => __DIR__ . '/..' . '/../lib/Command/DeleteClient.php',
2628
'OCA\\OAuth2\\Command\\ImportLegacyOcClient' => __DIR__ . '/..' . '/../lib/Command/ImportLegacyOcClient.php',
2729
'OCA\\OAuth2\\Controller\\LoginRedirectorController' => __DIR__ . '/..' . '/../lib/Controller/LoginRedirectorController.php',
2830
'OCA\\OAuth2\\Controller\\OauthApiController' => __DIR__ . '/..' . '/../lib/Controller/OauthApiController.php',
@@ -40,6 +42,7 @@ class ComposerStaticInitOAuth2
4042
'OCA\\OAuth2\\Migration\\Version011602Date20230613160650' => __DIR__ . '/..' . '/../lib/Migration/Version011602Date20230613160650.php',
4143
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => __DIR__ . '/..' . '/../lib/Migration/Version011603Date20230620111039.php',
4244
'OCA\\OAuth2\\Migration\\Version011901Date20240829164356' => __DIR__ . '/..' . '/../lib/Migration/Version011901Date20240829164356.php',
45+
'OCA\\OAuth2\\Service\\ClientService' => __DIR__ . '/..' . '/../lib/Service/ClientService.php',
4346
'OCA\\OAuth2\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
4447
);
4548

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
7+
* SPDX-FileContributor: Carl Schwan
8+
* SPDX-License-Identifier: AGPL-3.0-or-later
9+
*/
10+
11+
namespace OCA\OAuth2\Command;
12+
13+
use OC\Core\Command\Base;
14+
use OCA\OAuth2\Service\ClientService;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputArgument;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
class AddClient extends Base {
21+
private const ARGUMENT_CLIENT_NAME = 'client-name';
22+
private const ARGUMENT_CLIENT_REDIRECT_URI = 'client-redirect-uri';
23+
24+
public function __construct(
25+
private readonly ClientService $clientService,
26+
) {
27+
parent::__construct();
28+
}
29+
30+
#[\Override]
31+
protected function configure(): void {
32+
parent::configure();
33+
34+
$this->setName('oauth2:add-client');
35+
$this->setDescription('This command adds a new oauth2 client.');
36+
$this->addArgument(
37+
self::ARGUMENT_CLIENT_NAME,
38+
InputArgument::REQUIRED,
39+
'Name of the oauth2 client',
40+
);
41+
$this->addArgument(
42+
self::ARGUMENT_CLIENT_REDIRECT_URI,
43+
InputArgument::REQUIRED,
44+
'Redirection uri of the oauth2 client ',
45+
);
46+
}
47+
48+
#[\Override]
49+
protected function execute(InputInterface $input, OutputInterface $output): int {
50+
/** @var string $name */
51+
$name = $input->getArgument(self::ARGUMENT_CLIENT_NAME);
52+
53+
/** @var string $redirectUri */
54+
$redirectUri = $input->getArgument(self::ARGUMENT_CLIENT_REDIRECT_URI);
55+
56+
// Should not happen but just to be sure
57+
if (empty($redirectUri) || empty($name)) {
58+
$output->writeln('<error>Redirect uri or name is empty</error>');
59+
return Command::FAILURE;
60+
}
61+
62+
if (filter_var($redirectUri, FILTER_VALIDATE_URL) === false) {
63+
$output->writeln('<error>Your redirect URL needs to be a full URL for example: https://yourdomain.com/path</error>');
64+
return Command::FAILURE;
65+
}
66+
67+
$result = $this->clientService->addClient($name, $redirectUri);
68+
$this->writeArrayInOutputFormat($input, $output, $result);
69+
return Command::SUCCESS;
70+
}
71+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
7+
* SPDX-FileContributor: Carl Schwan
8+
* SPDX-License-Identifier: AGPL-3.0-or-later
9+
*/
10+
11+
namespace OCA\OAuth2\Command;
12+
13+
use OC\Core\Command\Base;
14+
use OCA\OAuth2\Service\ClientService;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputArgument;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
class DeleteClient extends Base {
21+
private const ARGUMENT_CLIENT_ID = 'client-id';
22+
23+
public function __construct(
24+
private readonly ClientService $clientService,
25+
) {
26+
parent::__construct();
27+
}
28+
29+
#[\Override]
30+
protected function configure(): void {
31+
parent::configure();
32+
33+
$this->setName('oauth2:delete-client');
34+
$this->setDescription('This command removes an existing oauth2 client.');
35+
$this->addArgument(
36+
self::ARGUMENT_CLIENT_ID,
37+
InputArgument::REQUIRED,
38+
'Id of the oauth2 client',
39+
);
40+
}
41+
42+
#[\Override]
43+
protected function execute(InputInterface $input, OutputInterface $output): int {
44+
$id = (int)$input->getArgument(self::ARGUMENT_CLIENT_ID);
45+
if ($id === 0) {
46+
$output->writeln('<error>The given id is not a valid positive integer.</error>');
47+
return Command::FAILURE;
48+
}
49+
50+
try {
51+
$this->clientService->deleteClient($id);
52+
} catch (\Exception $exception) {
53+
$output->writeln('<error>' . $exception->getMessage() . '</error>');
54+
return Command::FAILURE;
55+
}
56+
return Command::SUCCESS;
57+
}
58+
}

apps/oauth2/lib/Controller/SettingsController.php

Lines changed: 4 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,20 @@
88
*/
99
namespace OCA\OAuth2\Controller;
1010

11-
use OC\Authentication\Token\IProvider as IAuthTokenProvider;
12-
use OCA\OAuth2\Db\AccessTokenMapper;
13-
use OCA\OAuth2\Db\Client;
14-
use OCA\OAuth2\Db\ClientMapper;
11+
use OCA\OAuth2\Service\ClientService;
1512
use OCP\AppFramework\Controller;
1613
use OCP\AppFramework\Http;
1714
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
1815
use OCP\AppFramework\Http\JSONResponse;
19-
use OCP\Authentication\Exceptions\InvalidTokenException;
20-
use OCP\Authentication\Exceptions\WipeTokenException;
2116
use OCP\IL10N;
2217
use OCP\IRequest;
23-
use OCP\IUser;
24-
use OCP\IUserManager;
25-
use OCP\Security\ICrypto;
26-
use OCP\Security\ISecureRandom;
27-
use Psr\Log\LoggerInterface;
2818

2919
class SettingsController extends Controller {
30-
31-
public const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
32-
3320
public function __construct(
3421
string $appName,
3522
IRequest $request,
36-
private ClientMapper $clientMapper,
37-
private ISecureRandom $secureRandom,
38-
private AccessTokenMapper $accessTokenMapper,
3923
private IL10N $l,
40-
private IAuthTokenProvider $tokenProvider,
41-
private IUserManager $userManager,
42-
private ICrypto $crypto,
43-
private LoggerInterface $logger,
24+
private readonly ClientService $clientService,
4425
) {
4526
parent::__construct($appName, $request);
4627
}
@@ -52,55 +33,14 @@ public function addClient(string $name,
5233
return new JSONResponse(['message' => $this->l->t('Your redirect URL needs to be a full URL for example: https://yourdomain.com/path')], Http::STATUS_BAD_REQUEST);
5334
}
5435

55-
$client = new Client();
56-
$client->setName($name);
57-
$client->setRedirectUri($redirectUri);
58-
$secret = $this->secureRandom->generate(64, self::validChars);
59-
$hashedSecret = bin2hex($this->crypto->calculateHMAC($secret));
60-
$client->setSecret($hashedSecret);
61-
$client->setClientIdentifier($this->secureRandom->generate(64, self::validChars));
62-
$client = $this->clientMapper->insert($client);
63-
64-
$result = [
65-
'id' => $client->getId(),
66-
'name' => $client->getName(),
67-
'redirectUri' => $client->getRedirectUri(),
68-
'clientId' => $client->getClientIdentifier(),
69-
'clientSecret' => $secret,
70-
];
36+
$result = $this->clientService->addClient($name, $redirectUri);
7137

7238
return new JSONResponse($result);
7339
}
7440

7541
#[PasswordConfirmationRequired]
7642
public function deleteClient(int $id): JSONResponse {
77-
$client = $this->clientMapper->getByUid($id);
78-
79-
$this->userManager->callForSeenUsers(function (IUser $user) use ($client): void {
80-
// Skip tokens that are marked for remote wipe so revoking the
81-
// OAuth2 client does not silently cancel a pending wipe.
82-
$tokens = $this->tokenProvider->getTokenByUser($user->getUID());
83-
foreach ($tokens as $token) {
84-
if ($token->getName() !== $client->getName()) {
85-
continue;
86-
}
87-
try {
88-
$this->tokenProvider->getTokenById($token->getId());
89-
} catch (WipeTokenException $e) {
90-
$this->logger->info('Preserving token {tokenId} of user {uid}: marked for remote wipe, OAuth2 client revoke would cancel the wipe.', [
91-
'tokenId' => $token->getId(),
92-
'uid' => $user->getUID(),
93-
]);
94-
continue;
95-
} catch (InvalidTokenException $e) {
96-
// Token already invalid; let invalidateTokenById handle it.
97-
}
98-
$this->tokenProvider->invalidateTokenById($user->getUID(), $token->getId());
99-
}
100-
});
101-
102-
$this->accessTokenMapper->deleteByClientId($id);
103-
$this->clientMapper->delete($client);
43+
$this->clientService->deleteClient($id);
10444
return new JSONResponse([]);
10545
}
10646
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
7+
* SPDX-FileContributor: Carl Schwan
8+
* SPDX-License-Identifier: AGPL-3.0-or-later
9+
*/
10+
11+
namespace OCA\OAuth2\Service;
12+
13+
use OC\Authentication\Token\IProvider as IAuthTokenProvider;
14+
use OCA\OAuth2\Db\AccessTokenMapper;
15+
use OCA\OAuth2\Db\Client;
16+
use OCA\OAuth2\Db\ClientMapper;
17+
use OCP\Authentication\Exceptions\InvalidTokenException;
18+
use OCP\Authentication\Exceptions\WipeTokenException;
19+
use OCP\IUser;
20+
use OCP\IUserManager;
21+
use OCP\Security\ICrypto;
22+
use OCP\Security\ISecureRandom;
23+
use Psr\Log\LoggerInterface;
24+
25+
class ClientService {
26+
public const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
27+
28+
public function __construct(
29+
private readonly ISecureRandom $secureRandom,
30+
private readonly ICrypto $crypto,
31+
private readonly ClientMapper $clientMapper,
32+
private readonly IUserManager $userManager,
33+
private readonly IAuthTokenProvider $tokenProvider,
34+
private readonly LoggerInterface $logger,
35+
private readonly AccessTokenMapper $accessTokenMapper,
36+
) {
37+
}
38+
39+
/**
40+
* @param non-empty-string $name
41+
* @param non-empty-string $redirectUri
42+
* @return array{
43+
* id: int,
44+
* name: string,
45+
* redirectUri: string,
46+
* clientId: string,
47+
* clientSecret: string,
48+
* }
49+
*/
50+
public function addClient(string $name, string $redirectUri): array {
51+
$client = new Client();
52+
$client->setName($name);
53+
$client->setRedirectUri($redirectUri);
54+
$secret = $this->secureRandom->generate(64, self::validChars);
55+
$hashedSecret = bin2hex($this->crypto->calculateHMAC($secret));
56+
$client->setSecret($hashedSecret);
57+
$client->setClientIdentifier($this->secureRandom->generate(64, self::validChars));
58+
$client = $this->clientMapper->insert($client);
59+
60+
return [
61+
'id' => $client->getId(),
62+
'name' => $client->getName(),
63+
'redirectUri' => $client->getRedirectUri(),
64+
'clientId' => $client->getClientIdentifier(),
65+
'clientSecret' => $secret,
66+
];
67+
}
68+
69+
public function deleteClient(int $id): void {
70+
$client = $this->clientMapper->getByUid($id);
71+
72+
$this->userManager->callForSeenUsers(function (IUser $user) use ($client): void {
73+
// Skip tokens that are marked for remote wipe so revoking the
74+
// OAuth2 client does not silently cancel a pending wipe.
75+
$tokens = $this->tokenProvider->getTokenByUser($user->getUID());
76+
foreach ($tokens as $token) {
77+
if ($token->getName() !== $client->getName()) {
78+
continue;
79+
}
80+
try {
81+
$this->tokenProvider->getTokenById($token->getId());
82+
} catch (WipeTokenException) {
83+
$this->logger->info('Preserving token {tokenId} of user {uid}: marked for remote wipe, OAuth2 client revoke would cancel the wipe.', [
84+
'tokenId' => $token->getId(),
85+
'uid' => $user->getUID(),
86+
]);
87+
continue;
88+
} catch (InvalidTokenException) {
89+
// Token already invalid; let invalidateTokenById handle it.
90+
}
91+
$this->tokenProvider->invalidateTokenById($user->getUID(), $token->getId());
92+
}
93+
});
94+
95+
$this->accessTokenMapper->deleteByClientId($id);
96+
$this->clientMapper->delete($client);
97+
}
98+
}

0 commit comments

Comments
 (0)