Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,5 @@
'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => $baseDir . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php',
'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceMetadata.php',
'OCA\\Files\\Sharing\\Source\\NodeShareSourceType' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceType.php',
'OCA\\Files\\TokenScopes' => $baseDir . '/../lib/TokenScopes.php',
);
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => __DIR__ . '/..' . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php',
'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceMetadata.php',
'OCA\\Files\\Sharing\\Source\\NodeShareSourceType' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceType.php',
'OCA\\Files\\TokenScopes' => __DIR__ . '/..' . '/../lib/TokenScopes.php',
);

public static function getInitializer(ClassLoader $loader)
Expand Down
19 changes: 19 additions & 0 deletions apps/files/lib/TokenScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files;

/**
* App password scopes for files
*/
class TokenScopes {
public const FILES_READ = 'files:read';
public const FILES_WRITE = 'files:write';
public const FILES_SHARE = 'files:share';
}
4 changes: 4 additions & 0 deletions apps/settings/lib/Activity/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Provider implements IProvider {
public const APP_TOKEN_RENAMED = 'app_token_renamed';
public const APP_TOKEN_FILESYSTEM_GRANTED = 'app_token_filesystem_granted';
public const APP_TOKEN_FILESYSTEM_REVOKED = 'app_token_filesystem_revoked';
public const APP_TOKEN_SCOPES_CHANGED = 'app_token_scopes_changed';

/** @var IL10N */
protected $l;
Expand Down Expand Up @@ -104,6 +105,8 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null):
$subject = $this->l->t('You granted filesystem access to app password "{token}"');
} elseif ($event->getSubject() === self::APP_TOKEN_FILESYSTEM_REVOKED) {
$subject = $this->l->t('You revoked filesystem access from app password "{token}"');
} elseif ($event->getSubject() === self::APP_TOKEN_SCOPES_CHANGED) {
$subject = $this->l->t('You changed the access of app password "{token}"');
} else {
throw new UnknownActivityException('Unknown subject');
}
Expand Down Expand Up @@ -141,6 +144,7 @@ protected function getParameters(IEvent $event): array {
case self::APP_TOKEN_DELETED_WIPE_CANCELLED:
case self::APP_TOKEN_FILESYSTEM_GRANTED:
case self::APP_TOKEN_FILESYSTEM_REVOKED:
case self::APP_TOKEN_SCOPES_CHANGED:
return [
'token' => [
'type' => 'highlight',
Expand Down
20 changes: 17 additions & 3 deletions apps/settings/lib/Controller/AuthSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OC\Authentication\Token\INamedToken;
use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\RemoteWipe;
use OC\Authentication\Token\TokenScopes;
use OCA\Settings\Activity\Provider;
use OCA\Settings\ConfigLexicon;
use OCP\Activity\IManager;
Expand Down Expand Up @@ -52,6 +53,7 @@ public function __construct(
private LoggerInterface $logger,
private IConfig $serverConfig,
private IL10N $l,
private TokenScopes $tokenScopes,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -245,10 +247,22 @@ public function update(int $id, array $scope, string $name): JSONResponse {
}

$currentName = $token->getName();
$currentScope = $token->getScopeAsArray();

if ($scope !== $token->getScopeAsArray()) {
$token->setScope([IToken::SCOPE_FILESYSTEM => $scope[IToken::SCOPE_FILESYSTEM]]);
$this->publishActivity($scope[IToken::SCOPE_FILESYSTEM] ? Provider::APP_TOKEN_FILESYSTEM_GRANTED : Provider::APP_TOKEN_FILESYSTEM_REVOKED, $token->getId(), ['name' => $currentName]);
try {
$newScope = $this->tokenScopes->updateScope($currentScope, $scope);
} catch (\InvalidArgumentException) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}

if ($newScope !== $currentScope) {
$token->setScope($newScope);
if ($this->tokenScopes->fromBlob($newScope) !== $this->tokenScopes->fromBlob($currentScope)) {
$this->publishActivity(Provider::APP_TOKEN_SCOPES_CHANGED, $token->getId(), ['name' => $currentName]);
} elseif ($newScope[IToken::SCOPE_FILESYSTEM] !== ($currentScope[IToken::SCOPE_FILESYSTEM] ?? false)) {
Comment thread
come-nc marked this conversation as resolved.
// Only legacy tokens without scopes flip filesystem on its own
$this->publishActivity($newScope[IToken::SCOPE_FILESYSTEM] ? Provider::APP_TOKEN_FILESYSTEM_GRANTED : Provider::APP_TOKEN_FILESYSTEM_REVOKED, $token->getId(), ['name' => $currentName]);
}
}

if (mb_strlen($name) > 128) {
Expand Down
19 changes: 19 additions & 0 deletions apps/settings/tests/Activity/ProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,25 @@ public function testParseRevokedAllUsesPluralForm(int $count): void {
$this->provider->parse('en', $event);
}

public function testParseScopesChanged(): void {
$event = $this->createMock(IEvent::class);
$event->method('getApp')->willReturn('settings');
$event->method('getSubject')->willReturn(Provider::APP_TOKEN_SCOPES_CHANGED);
$event->method('getSubjectParameters')->willReturn(['name' => 'Thunderbird']);
$event->method('getObjectId')->willReturn(42);

$this->l->expects($this->once())
->method('t')
->with('You changed the access of app password "{token}"')
->willReturn('parsed subject');

$event->expects($this->once())
->method('setRichSubject')
->with('parsed subject', ['token' => ['type' => 'highlight', 'id' => '42', 'name' => 'Thunderbird']]);

$this->provider->parse('en', $event);
}

public function testParseRevokedAllWithoutCountParameter(): void {
$event = $this->createMock(IEvent::class);
$event->method('getApp')->willReturn('settings');
Expand Down
113 changes: 111 additions & 2 deletions apps/settings/tests/Controller/AuthSettingsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
use OC\Authentication\Token\IWipeableToken;
use OC\Authentication\Token\PublicKeyToken;
use OC\Authentication\Token\RemoteWipe;
use OC\Authentication\Token\TokenScopes;
use OCA\Files\TokenScopes as FilesTokenScopes;
use OCA\Settings\Activity\Provider;
use OCA\Settings\Controller\AuthSettingsController;
use OCP\Activity\IEvent;
use OCP\Activity\IManager;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Authentication\Exceptions\WipeTokenException;
Expand Down Expand Up @@ -78,6 +81,7 @@ protected function setUp(): void {
$logger,
$this->serverConfig,
$this->l,
new TokenScopes(),
);
}

Expand Down Expand Up @@ -528,6 +532,100 @@ public function testUpdateNoChange(): void {
$this->assertSame([], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => true], 'App password')->getData());
}

public static function dataUpdateScopedToken(): array {
$stored = [IToken::SCOPE_FILESYSTEM => false, TokenScopes::KEY => ['calendar:read']];
return [
'scopes omitted' => [$stored, [IToken::SCOPE_FILESYSTEM => false]],
'scopes sent back unchanged' => [$stored, $stored],
'legacy filesystem toggle' => [$stored, [IToken::SCOPE_FILESYSTEM => true, TokenScopes::KEY => ['calendar:read']]],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataUpdateScopedToken')]
public function testUpdateScopedTokenKeepsScopes(array $stored, array $scope): void {
$tokenId = 42;
$token = $this->createMock(PublicKeyToken::class);

$this->mockGetTokenById($tokenId, $token);
$this->mockActivityManager();

$token->method('getUID')->willReturn('jane');
$token->method('getName')->willReturn('App password');
$token->method('getScopeAsArray')->willReturn($stored);

$token->expects($this->never())
->method('setScope');
$token->expects($this->once())
->method('setName')
->with('Renamed');

$this->tokenProvider->expects($this->once())
->method('updateToken')
->with($token);

$this->assertSame([], $this->controller->update($tokenId, $scope, 'Renamed')->getData());
}

public static function dataUpdateScopes(): array {
$calendar = [IToken::SCOPE_FILESYSTEM => false, TokenScopes::KEY => ['calendar:read']];
return [
'scopes set, filesystem flips too' => [[IToken::SCOPE_FILESYSTEM => true], [TokenScopes::KEY => ['calendar:read']], $calendar],
'scopes cleared' => [$calendar, [IToken::SCOPE_FILESYSTEM => false, TokenScopes::KEY => null], [IToken::SCOPE_FILESYSTEM => true]],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataUpdateScopes')]
public function testUpdateScopesPublishOneActivity(array $stored, array $scope, array $expected): void {
$tokenId = 42;
$token = $this->createMock(PublicKeyToken::class);

$this->mockGetTokenById($tokenId, $token);
$this->mockActivityManager(Provider::APP_TOKEN_SCOPES_CHANGED);

$token->method('getUID')->willReturn('jane');
$token->method('getName')->willReturn('App password');
$token->method('getScopeAsArray')->willReturn($stored);

$token->expects($this->once())
->method('setScope')
->with($expected);

$this->tokenProvider->expects($this->once())
->method('updateToken')
->with($token);

$this->assertSame([], $this->controller->update($tokenId, $scope, 'App password')->getData());
}

public function testUpdateInvalidScopes(): void {
$tokenId = 42;
$token = $this->createMock(PublicKeyToken::class);

$this->mockGetTokenById($tokenId, $token);

$token->method('getUID')->willReturn('jane');
$token->method('getName')->willReturn('App password');
$token->method('getScopeAsArray')->willReturn([IToken::SCOPE_FILESYSTEM => true]);

$token->expects($this->never())
->method('setScope');
$token->expects($this->never())
->method('setName');
$this->tokenProvider->expects($this->never())
->method('updateToken');

$response = $this->controller->update($tokenId, [TokenScopes::KEY => [FilesTokenScopes::FILES_READ]], 'Renamed');
$this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
}

public function testUpdateRequiresStrictPasswordConfirmation(): void {
$attributes = (new \ReflectionMethod(AuthSettingsController::class, 'update'))
->getAttributes(PasswordConfirmationRequired::class);

$this->assertCount(1, $attributes);
$this->assertTrue($attributes[0]->newInstance()->getStrict());
}

public function testUpdateExpired(): void {
$tokenId = 42;
$token = $this->createMock(PublicKeyToken::class);
Expand Down Expand Up @@ -589,10 +687,21 @@ private function mockAuthToken(int $id, int $type = IToken::PERMANENT_TOKEN): Pu
return $token;
}

private function mockActivityManager(): void {
private function mockActivityManager(?string $subject = null): void {
$event = $this->createMock(IEvent::class);
if ($subject !== null) {
foreach (['setApp', 'setType', 'setAffectedUser', 'setAuthor', 'setObject'] as $method) {
$event->method($method)->willReturnSelf();
}
$event->expects($this->once())
->method('setSubject')
->with($subject, $this->anything())
->willReturnSelf();
}

$this->activityManager->expects($this->once())
->method('generateEvent')
->willReturn($this->createMock(IEvent::class));
->willReturn($event);
$this->activityManager->expects($this->once())
->method('publish');
}
Expand Down
11 changes: 10 additions & 1 deletion core/Command/User/AuthTokens/ListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\IToken;
use OC\Authentication\Token\TokenScopes;
use OC\Core\Command\Base;
use OCP\IUserManager;
use Symfony\Component\Console\Input\InputArgument;
Expand All @@ -19,6 +20,7 @@ class ListCommand extends Base {
public function __construct(
protected IUserManager $userManager,
protected IProvider $tokenProvider,
private TokenScopes $tokenScopes,
) {
parent::__construct();
}
Expand Down Expand Up @@ -71,7 +73,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

public function formatTokenForPlainOutput(array $token): array {
$token['scope'] = implode(', ', array_keys(array_filter($token['scope'] ?? [])));
$scope = $token['scope'] ?? [];
$scopes = $this->tokenScopes->fromBlob($scope);
unset($scope[TokenScopes::KEY]);
$parts = array_keys(array_filter($scope));
if ($scopes !== null) {
$parts[] = 'scopes: ' . ($scopes === [] ? 'none' : implode(' ', $scopes));
}
$token['scope'] = implode(', ', $parts);

$token['lastActivity'] = date(DATE_ATOM, $token['lastActivity']);

Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,7 @@
'OC\\Authentication\\Token\\PublicKeyTokenProvider' => $baseDir . '/lib/private/Authentication/Token/PublicKeyTokenProvider.php',
'OC\\Authentication\\Token\\RemoteWipe' => $baseDir . '/lib/private/Authentication/Token/RemoteWipe.php',
'OC\\Authentication\\Token\\TokenCleanupJob' => $baseDir . '/lib/private/Authentication/Token/TokenCleanupJob.php',
'OC\\Authentication\\Token\\TokenScopes' => $baseDir . '/lib/private/Authentication/Token/TokenScopes.php',
'OC\\Authentication\\TwoFactorAuth\\Db\\ProviderUserAssignmentDao' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php',
'OC\\Authentication\\TwoFactorAuth\\EnforcementState' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/EnforcementState.php',
'OC\\Authentication\\TwoFactorAuth\\Manager' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/Manager.php',
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Authentication\\Token\\PublicKeyTokenProvider' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/PublicKeyTokenProvider.php',
'OC\\Authentication\\Token\\RemoteWipe' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/RemoteWipe.php',
'OC\\Authentication\\Token\\TokenCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/TokenCleanupJob.php',
'OC\\Authentication\\Token\\TokenScopes' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/TokenScopes.php',
'OC\\Authentication\\TwoFactorAuth\\Db\\ProviderUserAssignmentDao' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php',
'OC\\Authentication\\TwoFactorAuth\\EnforcementState' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/EnforcementState.php',
'OC\\Authentication\\TwoFactorAuth\\Manager' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/Manager.php',
Expand Down
Loading
Loading