Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/oauth2/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

<commands>
<command>OCA\OAuth2\Command\ImportLegacyOcClient</command>
<command>OCA\OAuth2\Command\AddClient</command>
<command>OCA\OAuth2\Command\DeleteClient</command>
</commands>

<settings>
Expand Down
3 changes: 3 additions & 0 deletions apps/oauth2/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\OAuth2\\BackgroundJob\\CleanupExpiredAuthorizationCode' => $baseDir . '/../lib/BackgroundJob/CleanupExpiredAuthorizationCode.php',
'OCA\\OAuth2\\Command\\AddClient' => $baseDir . '/../lib/Command/AddClient.php',
'OCA\\OAuth2\\Command\\DeleteClient' => $baseDir . '/../lib/Command/DeleteClient.php',
'OCA\\OAuth2\\Command\\ImportLegacyOcClient' => $baseDir . '/../lib/Command/ImportLegacyOcClient.php',
'OCA\\OAuth2\\Controller\\LoginRedirectorController' => $baseDir . '/../lib/Controller/LoginRedirectorController.php',
'OCA\\OAuth2\\Controller\\OauthApiController' => $baseDir . '/../lib/Controller/OauthApiController.php',
Expand All @@ -25,5 +27,6 @@
'OCA\\OAuth2\\Migration\\Version011602Date20230613160650' => $baseDir . '/../lib/Migration/Version011602Date20230613160650.php',
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => $baseDir . '/../lib/Migration/Version011603Date20230620111039.php',
'OCA\\OAuth2\\Migration\\Version011901Date20240829164356' => $baseDir . '/../lib/Migration/Version011901Date20240829164356.php',
'OCA\\OAuth2\\Service\\ClientService' => $baseDir . '/../lib/Service/ClientService.php',
'OCA\\OAuth2\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
);
3 changes: 3 additions & 0 deletions apps/oauth2/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class ComposerStaticInitOAuth2
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\OAuth2\\BackgroundJob\\CleanupExpiredAuthorizationCode' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupExpiredAuthorizationCode.php',
'OCA\\OAuth2\\Command\\AddClient' => __DIR__ . '/..' . '/../lib/Command/AddClient.php',
'OCA\\OAuth2\\Command\\DeleteClient' => __DIR__ . '/..' . '/../lib/Command/DeleteClient.php',
'OCA\\OAuth2\\Command\\ImportLegacyOcClient' => __DIR__ . '/..' . '/../lib/Command/ImportLegacyOcClient.php',
'OCA\\OAuth2\\Controller\\LoginRedirectorController' => __DIR__ . '/..' . '/../lib/Controller/LoginRedirectorController.php',
'OCA\\OAuth2\\Controller\\OauthApiController' => __DIR__ . '/..' . '/../lib/Controller/OauthApiController.php',
Expand All @@ -40,6 +42,7 @@ class ComposerStaticInitOAuth2
'OCA\\OAuth2\\Migration\\Version011602Date20230613160650' => __DIR__ . '/..' . '/../lib/Migration/Version011602Date20230613160650.php',
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => __DIR__ . '/..' . '/../lib/Migration/Version011603Date20230620111039.php',
'OCA\\OAuth2\\Migration\\Version011901Date20240829164356' => __DIR__ . '/..' . '/../lib/Migration/Version011901Date20240829164356.php',
'OCA\\OAuth2\\Service\\ClientService' => __DIR__ . '/..' . '/../lib/Service/ClientService.php',
'OCA\\OAuth2\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
);

Expand Down
71 changes: 71 additions & 0 deletions apps/oauth2/lib/Command/AddClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\OAuth2\Command;

use OC\Core\Command\Base;
use OCA\OAuth2\Service\ClientService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class AddClient extends Base {
private const ARGUMENT_CLIENT_NAME = 'client-name';
private const ARGUMENT_CLIENT_REDIRECT_URI = 'client-redirect-uri';

public function __construct(
private readonly ClientService $clientService,
) {
parent::__construct();
}

#[\Override]
protected function configure(): void {
parent::configure();

$this->setName('oauth2:add-client');
$this->setDescription('This command adds a new oauth2 client.');
$this->addArgument(
self::ARGUMENT_CLIENT_NAME,
InputArgument::REQUIRED,
'Name of the oauth2 client',
);
$this->addArgument(
self::ARGUMENT_CLIENT_REDIRECT_URI,
InputArgument::REQUIRED,
'Redirection uri of the oauth2 client ',
);
}

#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
/** @var string $name */
$name = $input->getArgument(self::ARGUMENT_CLIENT_NAME);

/** @var string $redirectUri */
$redirectUri = $input->getArgument(self::ARGUMENT_CLIENT_REDIRECT_URI);

// Should not happen but just to be sure
if (empty($redirectUri) || empty($name)) {
$output->writeln('<error>Redirect uri or name is empty</error>');
return Command::FAILURE;
}

if (filter_var($redirectUri, FILTER_VALIDATE_URL) === false) {
$output->writeln('<error>Your redirect URL needs to be a full URL for example: https://yourdomain.com/path</error>');
return Command::FAILURE;
}

$result = $this->clientService->addClient($name, $redirectUri);
$this->writeArrayInOutputFormat($input, $output, $result);
return Command::SUCCESS;
}
}
58 changes: 58 additions & 0 deletions apps/oauth2/lib/Command/DeleteClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\OAuth2\Command;

use OC\Core\Command\Base;
use OCA\OAuth2\Service\ClientService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class DeleteClient extends Base {
private const ARGUMENT_CLIENT_ID = 'client-id';

public function __construct(
private readonly ClientService $clientService,
) {
parent::__construct();
}

#[\Override]
protected function configure(): void {
Comment thread
CarlSchwan marked this conversation as resolved.
parent::configure();

$this->setName('oauth2:delete-client');
$this->setDescription('This command removes an existing oauth2 client.');
$this->addArgument(
self::ARGUMENT_CLIENT_ID,
InputArgument::REQUIRED,
'Id of the oauth2 client',
);
}

#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$id = (int)$input->getArgument(self::ARGUMENT_CLIENT_ID);
Comment thread
CarlSchwan marked this conversation as resolved.
if ($id === 0) {
$output->writeln('<error>The given id is not a valid positive integer.</error>');
return Command::FAILURE;
}

try {
$this->clientService->deleteClient($id);
} catch (\Exception $exception) {
$output->writeln('<error>' . $exception->getMessage() . '</error>');
return Command::FAILURE;
}
return Command::SUCCESS;
}
}
68 changes: 4 additions & 64 deletions apps/oauth2/lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,39 +9,20 @@

namespace OCA\OAuth2\Controller;

use OC\Authentication\Token\IProvider as IAuthTokenProvider;
use OCA\OAuth2\Db\AccessTokenMapper;
use OCA\OAuth2\Db\Client;
use OCA\OAuth2\Db\ClientMapper;
use OCA\OAuth2\Service\ClientService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Exceptions\WipeTokenException;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;

class SettingsController extends Controller {

public const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

public function __construct(
string $appName,
IRequest $request,
private ClientMapper $clientMapper,
private ISecureRandom $secureRandom,
private AccessTokenMapper $accessTokenMapper,
private IL10N $l,
private IAuthTokenProvider $tokenProvider,
private IUserManager $userManager,
private ICrypto $crypto,
private LoggerInterface $logger,
private readonly ClientService $clientService,
) {
parent::__construct($appName, $request);
}
Expand All @@ -53,55 +34,14 @@ public function addClient(string $name,
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);
}

$client = new Client();
$client->setName($name);
$client->setRedirectUri($redirectUri);
$secret = $this->secureRandom->generate(64, self::validChars);
$hashedSecret = bin2hex($this->crypto->calculateHMAC($secret));
$client->setSecret($hashedSecret);
$client->setClientIdentifier($this->secureRandom->generate(64, self::validChars));
$client = $this->clientMapper->insert($client);

$result = [
'id' => $client->getId(),
'name' => $client->getName(),
'redirectUri' => $client->getRedirectUri(),
'clientId' => $client->getClientIdentifier(),
'clientSecret' => $secret,
];
$result = $this->clientService->addClient($name, $redirectUri);

return new JSONResponse($result);
}

#[PasswordConfirmationRequired]
public function deleteClient(int $id): JSONResponse {
$client = $this->clientMapper->getByUid($id);

$this->userManager->callForSeenUsers(function (IUser $user) use ($client): void {
// Skip tokens that are marked for remote wipe so revoking the
// OAuth2 client does not silently cancel a pending wipe.
$tokens = $this->tokenProvider->getTokenByUser($user->getUID());
foreach ($tokens as $token) {
if ($token->getName() !== $client->getName()) {
continue;
}
try {
$this->tokenProvider->getTokenById($token->getId());
} catch (WipeTokenException $e) {
$this->logger->info('Preserving token {tokenId} of user {uid}: marked for remote wipe, OAuth2 client revoke would cancel the wipe.', [
'tokenId' => $token->getId(),
'uid' => $user->getUID(),
]);
continue;
} catch (InvalidTokenException $e) {
// Token already invalid; let invalidateTokenById handle it.
}
$this->tokenProvider->invalidateTokenById($user->getUID(), $token->getId());
}
});

$this->accessTokenMapper->deleteByClientId($id);
$this->clientMapper->delete($client);
$this->clientService->deleteClient($id);
return new JSONResponse([]);
}
}
98 changes: 98 additions & 0 deletions apps/oauth2/lib/Service/ClientService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\OAuth2\Service;

use OC\Authentication\Token\IProvider as IAuthTokenProvider;
use OCA\OAuth2\Db\AccessTokenMapper;
use OCA\OAuth2\Db\Client;
use OCA\OAuth2\Db\ClientMapper;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Exceptions\WipeTokenException;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;

class ClientService {
public const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

public function __construct(
private readonly ISecureRandom $secureRandom,
private readonly ICrypto $crypto,
private readonly ClientMapper $clientMapper,
private readonly IUserManager $userManager,
private readonly IAuthTokenProvider $tokenProvider,
private readonly LoggerInterface $logger,
private readonly AccessTokenMapper $accessTokenMapper,
) {
}

/**
* @param non-empty-string $name
* @param non-empty-string $redirectUri
* @return array{
* id: int,
* name: string,
* redirectUri: string,
* clientId: string,
* clientSecret: string,
* }
*/
public function addClient(string $name, string $redirectUri): array {
$client = new Client();
$client->setName($name);
$client->setRedirectUri($redirectUri);
$secret = $this->secureRandom->generate(64, self::validChars);
$hashedSecret = bin2hex($this->crypto->calculateHMAC($secret));
$client->setSecret($hashedSecret);
$client->setClientIdentifier($this->secureRandom->generate(64, self::validChars));
$client = $this->clientMapper->insert($client);

return [
'id' => $client->getId(),
'name' => $client->getName(),
'redirectUri' => $client->getRedirectUri(),
'clientId' => $client->getClientIdentifier(),
'clientSecret' => $secret,
];
}

public function deleteClient(int $id): void {
$client = $this->clientMapper->getByUid($id);

$this->userManager->callForSeenUsers(function (IUser $user) use ($client): void {
// Skip tokens that are marked for remote wipe so revoking the
// OAuth2 client does not silently cancel a pending wipe.
$tokens = $this->tokenProvider->getTokenByUser($user->getUID());
foreach ($tokens as $token) {
if ($token->getName() !== $client->getName()) {
continue;
}
try {
$this->tokenProvider->getTokenById($token->getId());
} catch (WipeTokenException) {
$this->logger->info('Preserving token {tokenId} of user {uid}: marked for remote wipe, OAuth2 client revoke would cancel the wipe.', [
'tokenId' => $token->getId(),
'uid' => $user->getUID(),
]);
continue;
} catch (InvalidTokenException) {
// Token already invalid; let invalidateTokenById handle it.
}
$this->tokenProvider->invalidateTokenById($user->getUID(), $token->getId());
}
});

$this->accessTokenMapper->deleteByClientId($id);
$this->clientMapper->delete($client);
}
}
Loading
Loading