Skip to content

Commit 34fbf27

Browse files
committed
feat(oauth2): Add commands for adding and deleting clients
Refactor the code for doing that from the controller to a seperate service. Signed-off-by: Carl Schwan <carlschwan@kde.org>
1 parent 0302d60 commit 34fbf27

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-id';
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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
$this->setName('oauth2:delete-client');
32+
$this->setDescription('This command removes an existing oauth2 client.');
33+
$this->addArgument(
34+
self::ARGUMENT_CLIENT_ID,
35+
InputArgument::REQUIRED,
36+
'Id of the oauth2 client',
37+
);
38+
}
39+
40+
#[\Override]
41+
protected function execute(InputInterface $input, OutputInterface $output): int {
42+
$id = (int)$input->getArgument(self::ARGUMENT_CLIENT_ID);
43+
44+
try {
45+
$this->clientService->deleteClient($id);
46+
} catch (\Exception $exception) {
47+
$output->writeln('<error>' . $exception->getMessage() . '</error>');
48+
return Command::FAILURE;
49+
}
50+
return Command::SUCCESS;
51+
}
52+
}

apps/oauth2/lib/Controller/SettingsController.php

Lines changed: 4 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -9,39 +9,20 @@
99

1010
namespace OCA\OAuth2\Controller;
1111

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

3020
class SettingsController extends Controller {
31-
32-
public const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
33-
3421
public function __construct(
3522
string $appName,
3623
IRequest $request,
37-
private ClientMapper $clientMapper,
38-
private ISecureRandom $secureRandom,
39-
private AccessTokenMapper $accessTokenMapper,
4024
private IL10N $l,
41-
private IAuthTokenProvider $tokenProvider,
42-
private IUserManager $userManager,
43-
private ICrypto $crypto,
44-
private LoggerInterface $logger,
25+
private readonly ClientService $clientService,
4526
) {
4627
parent::__construct($appName, $request);
4728
}
@@ -53,55 +34,14 @@ public function addClient(string $name,
5334
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);
5435
}
5536

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

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

7642
#[PasswordConfirmationRequired]
7743
public function deleteClient(int $id): JSONResponse {
78-
$client = $this->clientMapper->getByUid($id);
79-
80-
$this->userManager->callForSeenUsers(function (IUser $user) use ($client): void {
81-
// Skip tokens that are marked for remote wipe so revoking the
82-
// OAuth2 client does not silently cancel a pending wipe.
83-
$tokens = $this->tokenProvider->getTokenByUser($user->getUID());
84-
foreach ($tokens as $token) {
85-
if ($token->getName() !== $client->getName()) {
86-
continue;
87-
}
88-
try {
89-
$this->tokenProvider->getTokenById($token->getId());
90-
} catch (WipeTokenException $e) {
91-
$this->logger->info('Preserving token {tokenId} of user {uid}: marked for remote wipe, OAuth2 client revoke would cancel the wipe.', [
92-
'tokenId' => $token->getId(),
93-
'uid' => $user->getUID(),
94-
]);
95-
continue;
96-
} catch (InvalidTokenException $e) {
97-
// Token already invalid; let invalidateTokenById handle it.
98-
}
99-
$this->tokenProvider->invalidateTokenById($user->getUID(), $token->getId());
100-
}
101-
});
102-
103-
$this->accessTokenMapper->deleteByClientId($id);
104-
$this->clientMapper->delete($client);
44+
$this->clientService->deleteClient($id);
10545
return new JSONResponse([]);
10646
}
10747
}
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)