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
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
18 changes: 18 additions & 0 deletions apps/files/lib/TokenScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?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:files:read';
public const FILES_WRITE = 'files:files:write';
}
1 change: 1 addition & 0 deletions apps/files_sharing/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
'OCA\\Files_Sharing\\SharedMount' => $baseDir . '/../lib/SharedMount.php',
'OCA\\Files_Sharing\\SharedStorage' => $baseDir . '/../lib/SharedStorage.php',
'OCA\\Files_Sharing\\SharesReminderJob' => $baseDir . '/../lib/SharesReminderJob.php',
'OCA\\Files_Sharing\\TokenScopes' => $baseDir . '/../lib/TokenScopes.php',
'OCA\\Files_Sharing\\Updater' => $baseDir . '/../lib/Updater.php',
'OCA\\Files_Sharing\\ViewOnly' => $baseDir . '/../lib/ViewOnly.php',
);
1 change: 1 addition & 0 deletions apps/files_sharing/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ class ComposerStaticInitFiles_Sharing
'OCA\\Files_Sharing\\SharedMount' => __DIR__ . '/..' . '/../lib/SharedMount.php',
'OCA\\Files_Sharing\\SharedStorage' => __DIR__ . '/..' . '/../lib/SharedStorage.php',
'OCA\\Files_Sharing\\SharesReminderJob' => __DIR__ . '/..' . '/../lib/SharesReminderJob.php',
'OCA\\Files_Sharing\\TokenScopes' => __DIR__ . '/..' . '/../lib/TokenScopes.php',
'OCA\\Files_Sharing\\Updater' => __DIR__ . '/..' . '/../lib/Updater.php',
'OCA\\Files_Sharing\\ViewOnly' => __DIR__ . '/..' . '/../lib/ViewOnly.php',
);
Expand Down
17 changes: 17 additions & 0 deletions apps/files_sharing/lib/TokenScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

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

namespace OCA\Files_Sharing;

/**
* App password scopes for sharing
*/
class TokenScopes {
public const SHARES_WRITE = 'files_sharing:shares:write';
}
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 => ['dav: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 => ['dav: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 => ['dav:calendar:read']];
return [
'scopes set, filesystem flips too' => [[IToken::SCOPE_FILESYSTEM => true], [TokenScopes::KEY => ['dav: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