From d414f00b772d616c9b9eeca076aa1ac760d1c78f Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 09:57:05 -0400 Subject: [PATCH 01/17] docs(config.sample.php): token_auth_enforced does not revoke existing sessions/tokens Signed-off-by: Josh --- config/config.sample.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/config/config.sample.php b/config/config.sample.php index b51558ea5cc37..967c869a37591 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -400,9 +400,16 @@ 'auto_logout' => false, /** - * Enforce token authentication for clients, which blocks requests using the user - * password for enhanced security. Users need to generate tokens in personal settings - * which can be used as passwords on their clients. + * Enforce token authentication for client logins. + * + * When enabled, new client authentication attempts using the user's account + * password are rejected and clients must use an app password or other valid + * token instead. + * + * This setting does not automatically revoke already existing browser sessions, + * remember-me logins, or previously issued tokens. To fully enforce the policy + * for existing authenticated sessions, those sessions/tokens must be invalidated + * separately. * * Defaults to ``false`` */ From 846cfba06f82697a6046fbcaaaadc954077d0112 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:00:46 -0400 Subject: [PATCH 02/17] feat(PublicKeyTokenMapper): bulk token invalidation methods Signed-off-by: Josh --- .../Token/PublicKeyTokenMapper.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lib/private/Authentication/Token/PublicKeyTokenMapper.php b/lib/private/Authentication/Token/PublicKeyTokenMapper.php index 9aabd69e57a12..7195ac9505b4c 100644 --- a/lib/private/Authentication/Token/PublicKeyTokenMapper.php +++ b/lib/private/Authentication/Token/PublicKeyTokenMapper.php @@ -61,6 +61,45 @@ public function invalidateLastUsedBefore(string $uid, int $before): int { return $qb->executeStatement(); } + /** + * Delete all tokens of a given type and remember value. + * + * @return int Number of deleted rows + */ + public function invalidateByTypeAndRemember(int $type, int $remember): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('remember', $qb->createNamedParameter($remember, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + + /** + * Delete all tokens except those of a given type. + * + * @return int Number of deleted rows + */ + public function invalidateAllExceptType(int $exceptType): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->neq('type', $qb->createNamedParameter($exceptType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + + /** + * Delete all tokens. + * + * @return int Number of deleted rows + */ + public function invalidateAllTokens(): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + /** * Get the user UID for the given token * From c4091e2a421eb337121df1c4fb93a89c0cde743f Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:03:08 -0400 Subject: [PATCH 03/17] feat(AuthTokens): New occ command to revoke tokens by class/type Particularly useful after enabling `token_auth_enforced` for existing sessions/tokens. Signed-off-by: Josh --- core/Command/User/AuthTokens/Revoke.php | 267 ++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 core/Command/User/AuthTokens/Revoke.php diff --git a/core/Command/User/AuthTokens/Revoke.php b/core/Command/User/AuthTokens/Revoke.php new file mode 100644 index 0000000000000..e3c5250327d5a --- /dev/null +++ b/core/Command/User/AuthTokens/Revoke.php @@ -0,0 +1,267 @@ +setName('user:auth-tokens:revoke') + ->setDescription('Revoke authentication tokens by class/type') + ->addArgument( + 'uid', + InputArgument::OPTIONAL, + 'ID of the user to revoke tokens for' + ) + ->addOption( + 'all-users', + null, + InputOption::VALUE_NONE, + 'Revoke tokens for all users' + ) + ->addOption( + 'browser-sessions', + null, + InputOption::VALUE_NONE, + 'Revoke browser session tokens (temporary, non-remembered)' + ) + ->addOption( + 'remembered-sessions', + null, + InputOption::VALUE_NONE, + 'Revoke remembered browser session tokens (temporary, remembered)' + ) + ->addOption( + 'non-app-passwords', + null, + InputOption::VALUE_NONE, + 'Revoke all tokens except permanent app passwords (includes session, remembered, one-time, and wipe tokens)' + ) + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'Revoke all tokens including app passwords' + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Show which tokens would be revoked without deleting them' + ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Skip confirmation prompt' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $uid = $input->getArgument('uid'); + $allUsers = (bool)$input->getOption('all-users'); + + $modes = [ + 'browser-sessions' => (bool)$input->getOption('browser-sessions'), + 'remembered-sessions' => (bool)$input->getOption('remembered-sessions'), + 'non-app-passwords' => (bool)$input->getOption('non-app-passwords'), + 'all' => (bool)$input->getOption('all'), + ]; + + $selectedModes = array_filter($modes); + if (count($selectedModes) !== 1) { + throw new RuntimeException('Specify exactly one of --browser-sessions, --remembered-sessions, --non-app-passwords, or --all.'); + } + + if ($allUsers && $uid !== null) { + throw new RuntimeException('Do not provide together with --all-users.'); + } + + if (!$allUsers && (!is_string($uid) || $uid === '')) { + throw new RuntimeException('Specify or use --all-users.'); + } + + $dryRun = (bool)$input->getOption('dry-run'); + $force = (bool)$input->getOption('force'); + + // Confirm destructive operations unless --force or --dry-run is set + if (!$dryRun && !$force && $input->isInteractive()) { + $modeName = array_key_first($selectedModes); + $scope = $allUsers ? 'ALL users' : "user '$uid'"; + $message = "This will revoke all --{$modeName} tokens for {$scope}. Continue? [y/N] "; + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion($message, false); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Aborted.'); + return Command::SUCCESS; + } + } + + $revoked = 0; + + if ($allUsers) { + if (!$dryRun) { + // Use bulk deletion for --all-users when possible to avoid + // iterating every user and their tokens individually + $bulkCount = $this->bulkRevoke($modes, $output); + if ($bulkCount !== null) { + $output->writeln("Revoked {$bulkCount} token(s)."); + return Command::SUCCESS; + } + } + + // Fall back to per-user iteration for dry-run (needs token details) + // or if bulk deletion is not applicable + $this->userManager->callForAllUsers(function (IUser $user) use ($output, $dryRun, $modes, &$revoked): void { + $revoked += $this->revokeForUser($user->getUID(), $modes, $output, $dryRun); + }); + } else { + $user = $this->userManager->get($uid); + if ($user === null) { + $output->writeln('user not found'); + return Command::FAILURE; + } + $revoked = $this->revokeForUser($user->getUID(), $modes, $output, $dryRun); + } + + if ($dryRun) { + $output->writeln("Dry run complete. {$revoked} token(s) would be revoked."); + } else { + $output->writeln("Revoked {$revoked} token(s)."); + } + + return Command::SUCCESS; + } + + /** + * Attempt a bulk DELETE query for --all-users instead of per-user iteration. + * + * @return int|null Number of deleted rows, or null if the mode doesn't + * support bulk deletion (caller should fall back to per-user) + */ + private function bulkRevoke(array $modes, OutputInterface $output): ?int { + // Build type/remember constraints for a single bulk query + if ($modes['browser-sessions']) { + return $this->mapper->invalidateByTypeAndRemember( + IToken::TEMPORARY_TOKEN, + IToken::DO_NOT_REMEMBER + ); + } + + if ($modes['remembered-sessions']) { + return $this->mapper->invalidateByTypeAndRemember( + IToken::TEMPORARY_TOKEN, + IToken::REMEMBER + ); + } + + if ($modes['non-app-passwords']) { + return $this->mapper->invalidateAllExceptType(IToken::PERMANENT_TOKEN); + } + + if ($modes['all']) { + return $this->mapper->invalidateAllTokens(); + } + + return null; + } + + private function revokeForUser(string $uid, array $modes, OutputInterface $output, bool $dryRun): int { + $tokens = $this->tokenProvider->getTokenByUser($uid); + $count = 0; + + foreach ($tokens as $token) { + if (!$this->matchesSelection($token, $modes)) { + continue; + } + + $count++; + + if ($output->isVerbose()) { + $output->writeln(sprintf( + '%s token %d for user %s (type=%s remember=%s name=%s)', + $dryRun ? 'Would revoke' : 'Revoking', + $token->getId(), + $uid, + self::formatTokenType($token->getType()), + (string)$token->getRemember(), + $token->getName() + )); + } + + if (!$dryRun) { + $this->tokenProvider->invalidateTokenById($uid, $token->getId()); + } + } + + return $count; + } + + private function matchesSelection(IToken $token, array $modes): bool { + if ($modes['all']) { + return true; + } + + $type = $token->getType(); + + if ($modes['browser-sessions']) { + return $type === IToken::TEMPORARY_TOKEN + && $token->getRemember() === IToken::DO_NOT_REMEMBER; + } + + if ($modes['remembered-sessions']) { + return $type === IToken::TEMPORARY_TOKEN + && $token->getRemember() === IToken::REMEMBER; + } + + if ($modes['non-app-passwords']) { + // Includes TEMPORARY_TOKEN, WIPE_TOKEN, and ONETIME_TOKEN + return $type !== IToken::PERMANENT_TOKEN; + } + + return false; + } + + private static function formatTokenType(int $type): string { + return match ($type) { + IToken::TEMPORARY_TOKEN => 'temporary', + IToken::PERMANENT_TOKEN => 'permanent', + IToken::WIPE_TOKEN => 'wipe', + IToken::ONETIME_TOKEN => 'onetime', + default => (string)$type, + }; + } +} From b2777f2a20c69e9fac3c8a4fe753b8423ed19ad9 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:05:17 -0400 Subject: [PATCH 04/17] chore(config.sample.php): mention new `occ user:auth-tokens:revoke` in `token_auth_enforced` Signed-off-by: Josh --- config/config.sample.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/config.sample.php b/config/config.sample.php index 967c869a37591..850d2ae48f320 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -409,7 +409,8 @@ * This setting does not automatically revoke already existing browser sessions, * remember-me logins, or previously issued tokens. To fully enforce the policy * for existing authenticated sessions, those sessions/tokens must be invalidated - * separately. + * separately. Use ``occ user:auth-tokens:revoke`` if you want to invalidate + * existing sessions after enabling this policy. * * Defaults to ``false`` */ From 9871a0862f1af9c5dccfc1f2cd870a39eaed0123 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:10:22 -0400 Subject: [PATCH 05/17] chore(AuthTokens): register new revoke command Signed-off-by: Josh --- core/register_command.php | 1 + 1 file changed, 1 insertion(+) diff --git a/core/register_command.php b/core/register_command.php index d28c1633c62bb..a7b8d41bb1ad6 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -226,6 +226,7 @@ $application->add(Server::get(Command\User\AuthTokens\Add::class)); $application->add(Server::get(Command\User\AuthTokens\ListCommand::class)); $application->add(Server::get(Command\User\AuthTokens\Delete::class)); + $application->add(Server::get(Command\User\AuthTokens\Revoke::class)); $application->add(Server::get(Verify::class)); $application->add(Server::get(Welcome::class)); From a5da44d6be8f02120d6e8e14cfbea2cae084cb2f Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:15:00 -0400 Subject: [PATCH 06/17] chore(AuthTokens): tighten up token_auth_enforced doc wording Signed-off-by: Josh --- config/config.sample.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/config/config.sample.php b/config/config.sample.php index 850d2ae48f320..995797440cd7f 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -403,14 +403,13 @@ * Enforce token authentication for client logins. * * When enabled, new client authentication attempts using the user's account - * password are rejected and clients must use an app password or other valid + * password are rejected and clients must use an app password or another valid * token instead. * - * This setting does not automatically revoke already existing browser sessions, - * remember-me logins, or previously issued tokens. To fully enforce the policy - * for existing authenticated sessions, those sessions/tokens must be invalidated - * separately. Use ``occ user:auth-tokens:revoke`` if you want to invalidate - * existing sessions after enabling this policy. + * This setting does not automatically revoke existing browser sessions, + * remember-me logins, or previously issued tokens. To fully enforce this + * policy for already authenticated sessions, invalidate those sessions or + * tokens separately, for example with ``occ user:auth-tokens:revoke``. * * Defaults to ``false`` */ From 3dd619c0e4e8c36abceef39a0d6f1d30de3bb1b3 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:39:54 -0400 Subject: [PATCH 07/17] chore(AuthTokens): streamline new revoke command Signed-off-by: Josh --- core/Command/User/AuthTokens/Revoke.php | 59 +++++++++++-------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/core/Command/User/AuthTokens/Revoke.php b/core/Command/User/AuthTokens/Revoke.php index e3c5250327d5a..bd11556be5b7a 100644 --- a/core/Command/User/AuthTokens/Revoke.php +++ b/core/Command/User/AuthTokens/Revoke.php @@ -51,22 +51,22 @@ protected function configure(): void { 'Revoke tokens for all users' ) ->addOption( - 'browser-sessions', + 'sessions', null, InputOption::VALUE_NONE, - 'Revoke browser session tokens (temporary, non-remembered)' + 'Revoke all session tokens, including remembered sessions' ) ->addOption( 'remembered-sessions', null, InputOption::VALUE_NONE, - 'Revoke remembered browser session tokens (temporary, remembered)' + 'Revoke remembered session tokens only' ) ->addOption( - 'non-app-passwords', + 'all-except-app-passwords', null, InputOption::VALUE_NONE, - 'Revoke all tokens except permanent app passwords (includes session, remembered, one-time, and wipe tokens)' + 'Revoke all tokens except permanent app passwords' ) ->addOption( 'all', @@ -93,15 +93,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $allUsers = (bool)$input->getOption('all-users'); $modes = [ - 'browser-sessions' => (bool)$input->getOption('browser-sessions'), + 'sessions' => (bool)$input->getOption('sessions'), 'remembered-sessions' => (bool)$input->getOption('remembered-sessions'), - 'non-app-passwords' => (bool)$input->getOption('non-app-passwords'), + 'all-except-app-passwords' => (bool)$input->getOption('all-except-app-passwords'), 'all' => (bool)$input->getOption('all'), ]; $selectedModes = array_filter($modes); if (count($selectedModes) !== 1) { - throw new RuntimeException('Specify exactly one of --browser-sessions, --remembered-sessions, --non-app-passwords, or --all.'); + throw new RuntimeException('Specify exactly one of --sessions, --remembered-sessions, --all-except-app-passwords, or --all.'); } if ($allUsers && $uid !== null) { @@ -115,11 +115,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $dryRun = (bool)$input->getOption('dry-run'); $force = (bool)$input->getOption('force'); - // Confirm destructive operations unless --force or --dry-run is set + // For bulk destructive operations, ask for confirmation unless this is + // a dry-run or the caller explicitly requested non-interactive behavior. if (!$dryRun && !$force && $input->isInteractive()) { $modeName = array_key_first($selectedModes); $scope = $allUsers ? 'ALL users' : "user '$uid'"; - $message = "This will revoke all --{$modeName} tokens for {$scope}. Continue? [y/N] "; + $message = "This will revoke all {$modeName} tokens for {$scope}. Continue? [y/N] "; /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); @@ -134,17 +135,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($allUsers) { if (!$dryRun) { - // Use bulk deletion for --all-users when possible to avoid - // iterating every user and their tokens individually - $bulkCount = $this->bulkRevoke($modes, $output); + // Prefer a single bulk DELETE for all-users operations where the + // selected revoke mode maps cleanly to SQL predicates. + $bulkCount = $this->bulkRevoke($modes); if ($bulkCount !== null) { $output->writeln("Revoked {$bulkCount} token(s)."); return Command::SUCCESS; } } - // Fall back to per-user iteration for dry-run (needs token details) - // or if bulk deletion is not applicable + // Dry-run needs to enumerate tokens to report matches, and any mode + // not handled by bulkRevoke() falls back to per-user evaluation. $this->userManager->callForAllUsers(function (IUser $user) use ($output, $dryRun, $modes, &$revoked): void { $revoked += $this->revokeForUser($user->getUID(), $modes, $output, $dryRun); }); @@ -167,18 +168,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * Attempt a bulk DELETE query for --all-users instead of per-user iteration. - * - * @return int|null Number of deleted rows, or null if the mode doesn't - * support bulk deletion (caller should fall back to per-user) + * @return int|null Number of deleted rows, or null if caller should fall back + * to per-user iteration */ - private function bulkRevoke(array $modes, OutputInterface $output): ?int { - // Build type/remember constraints for a single bulk query - if ($modes['browser-sessions']) { - return $this->mapper->invalidateByTypeAndRemember( - IToken::TEMPORARY_TOKEN, - IToken::DO_NOT_REMEMBER - ); + private function bulkRevoke(array $modes): ?int { + if ($modes['sessions']) { + return $this->mapper->invalidateByType(IToken::TEMPORARY_TOKEN); } if ($modes['remembered-sessions']) { @@ -188,7 +183,7 @@ private function bulkRevoke(array $modes, OutputInterface $output): ?int { ); } - if ($modes['non-app-passwords']) { + if ($modes['all-except-app-passwords']) { return $this->mapper->invalidateAllExceptType(IToken::PERMANENT_TOKEN); } @@ -237,9 +232,9 @@ private function matchesSelection(IToken $token, array $modes): bool { $type = $token->getType(); - if ($modes['browser-sessions']) { - return $type === IToken::TEMPORARY_TOKEN - && $token->getRemember() === IToken::DO_NOT_REMEMBER; + if ($modes['sessions']) { + // "sessions" means all temporary tokens, including remembered sessions. + return $type === IToken::TEMPORARY_TOKEN; } if ($modes['remembered-sessions']) { @@ -247,8 +242,8 @@ private function matchesSelection(IToken $token, array $modes): bool { && $token->getRemember() === IToken::REMEMBER; } - if ($modes['non-app-passwords']) { - // Includes TEMPORARY_TOKEN, WIPE_TOKEN, and ONETIME_TOKEN + if ($modes['all-except-app-passwords']) { + // Preserve permanent app passwords, revoke every other token type. return $type !== IToken::PERMANENT_TOKEN; } From 10906c49a89c94f1ab165aa453171e1f4bae42c9 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:43:00 -0400 Subject: [PATCH 08/17] feat(PublicKeyTokenMapper): add invalidateByType helper Signed-off-by: Josh --- .../Authentication/Token/PublicKeyTokenMapper.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/private/Authentication/Token/PublicKeyTokenMapper.php b/lib/private/Authentication/Token/PublicKeyTokenMapper.php index 7195ac9505b4c..cad2f79be9e23 100644 --- a/lib/private/Authentication/Token/PublicKeyTokenMapper.php +++ b/lib/private/Authentication/Token/PublicKeyTokenMapper.php @@ -61,6 +61,19 @@ public function invalidateLastUsedBefore(string $uid, int $before): int { return $qb->executeStatement(); } + /** + * Delete all tokens of a given type. + * + * @return int Number of deleted rows + */ + public function invalidateByType(int $type): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + /** * Delete all tokens of a given type and remember value. * From cd5122a8a04f141c31726a5c5d6de7491be7d298 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:44:22 -0400 Subject: [PATCH 09/17] docs(config.sample.php): make occ command in token_auth_enforced specific Signed-off-by: Josh --- config/config.sample.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/config.sample.php b/config/config.sample.php index 995797440cd7f..8fc2ed8dfd276 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -409,7 +409,8 @@ * This setting does not automatically revoke existing browser sessions, * remember-me logins, or previously issued tokens. To fully enforce this * policy for already authenticated sessions, invalidate those sessions or - * tokens separately, for example with ``occ user:auth-tokens:revoke``. + * tokens separately, for example with + * ``occ user:auth-tokens:revoke --sessions``. * * Defaults to ``false`` */ From b448ca8e165f30e7825f97c4d0c965b300f8ecd8 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 10:49:37 -0400 Subject: [PATCH 10/17] test(AuthTokens): add basic tests for new Revoke command Signed-off-by: Josh --- .../Command/User/AuthTokens/RevokeTest.php | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/Core/Command/User/AuthTokens/RevokeTest.php diff --git a/tests/Core/Command/User/AuthTokens/RevokeTest.php b/tests/Core/Command/User/AuthTokens/RevokeTest.php new file mode 100644 index 0000000000000..9178e7d3f260d --- /dev/null +++ b/tests/Core/Command/User/AuthTokens/RevokeTest.php @@ -0,0 +1,208 @@ +userManager = $this->createMock(IUserManager::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->mapper = $this->createMock(PublicKeyTokenMapper::class); + $this->input = $this->createMock(InputInterface::class); + $this->output = $this->createMock(OutputInterface::class); + + $this->command = new Revoke( + $this->userManager, + $this->tokenProvider, + $this->mapper, + ); + } + + public function testExecuteFailsWithoutMode(): void { + $this->input->method('getArgument') + ->with('uid') + ->willReturn('alice'); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users', + 'sessions', + 'remembered-sessions', + 'all-except-app-passwords', + 'all', + 'dry-run', + 'force' => false, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Specify exactly one of --sessions, --remembered-sessions, --all-except-app-passwords, or --all.'); + + self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + } + + public function testSessionsRevokesOnlyTemporaryTokens(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $tempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 101, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Firefox', + ]); + + $rememberedTempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 102, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::REMEMBER, + 'getName' => 'Remembered browser', + ]); + + $permanentToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 201, + 'getType' => IToken::PERMANENT_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Desktop client', + ]); + + $this->input->method('getArgument') + ->with('uid') + ->willReturn('alice'); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => false, + 'sessions' => true, + 'remembered-sessions' => false, + 'all-except-app-passwords' => false, + 'all' => false, + 'dry-run' => false, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($user); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenByUser') + ->with('alice') + ->willReturn([$tempToken, $rememberedTempToken, $permanentToken]); + + $this->tokenProvider->expects($this->exactly(2)) + ->method('invalidateTokenById') + ->willReturnCallback(function (string $uid, int $id): void { + self::assertSame('alice', $uid); + self::assertContains($id, [101, 102]); + }); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Revoked 2 token(s).'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } + + public function testAllExceptAppPasswordsDryRunDoesNotRevoke(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $tempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 101, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Firefox', + ]); + + $wipeToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 301, + 'getType' => IToken::WIPE_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Remote wipe', + ]); + + $permanentToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 201, + 'getType' => IToken::PERMANENT_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Desktop client', + ]); + + $this->input->method('getArgument') + ->with('uid') + ->willReturn('alice'); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => false, + 'sessions' => false, + 'remembered-sessions' => false, + 'all-except-app-passwords' => true, + 'all' => false, + 'dry-run' => true, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($user); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenByUser') + ->with('alice') + ->willReturn([$tempToken, $wipeToken, $permanentToken]); + + $this->tokenProvider->expects($this->never()) + ->method('invalidateTokenById'); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Dry run complete. 2 token(s) would be revoked.'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } +} From 145816f284a830cac96d9454ac3176bf824270b7 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 11:09:20 -0400 Subject: [PATCH 11/17] test(AuthTokens): add bulk / all-users path tests to for revoke cmd Signed-off-by: Josh --- .../Command/User/AuthTokens/RevokeTest.php | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/Core/Command/User/AuthTokens/RevokeTest.php b/tests/Core/Command/User/AuthTokens/RevokeTest.php index 9178e7d3f260d..00a3aaef69db0 100644 --- a/tests/Core/Command/User/AuthTokens/RevokeTest.php +++ b/tests/Core/Command/User/AuthTokens/RevokeTest.php @@ -205,4 +205,103 @@ public function testAllExceptAppPasswordsDryRunDoesNotRevoke(): void { self::assertSame(0, $result); } + + public function testAllUsersBulkRevokeSessionsUsesBulkPath(): void { + $this->input->method('getArgument') + ->with('uid') + ->willReturn(null); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => true, + 'sessions' => true, + 'remembered-sessions' => false, + 'all-except-app-passwords' => false, + 'all' => false, + 'dry-run' => false, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + // Bulk path should call the mapper directly, not iterate users + $this->mapper->expects($this->once()) + ->method('invalidateByType') + ->with(IToken::TEMPORARY_TOKEN) + ->willReturn(5); + + $this->userManager->expects($this->never()) + ->method('callForAllUsers'); + + $this->tokenProvider->expects($this->never()) + ->method('getTokenByUser'); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Revoked 5 token(s).'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } + + public function testAllUsersDryRunFallsBackToPerUserIteration(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $tempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 101, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Firefox', + ]); + + $this->input->method('getArgument') + ->with('uid') + ->willReturn(null); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => true, + 'sessions' => true, + 'remembered-sessions' => false, + 'all-except-app-passwords' => false, + 'all' => false, + 'dry-run' => true, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + // Bulk mapper methods should NOT be called in dry-run + $this->mapper->expects($this->never()) + ->method('invalidateByType'); + + // Should fall back to per-user iteration + $this->userManager->expects($this->once()) + ->method('callForAllUsers') + ->willReturnCallback(function (\Closure $callback) use ($user): void { + $callback($user); + }); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenByUser') + ->with('alice') + ->willReturn([$tempToken]); + + $this->tokenProvider->expects($this->never()) + ->method('invalidateTokenById'); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Dry run complete. 1 token(s) would be revoked.'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } } From 2a563fe45a5d6e70776eabc50618d64475abb330 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 11:12:07 -0400 Subject: [PATCH 12/17] docs(AuthTokens): add comment on bulkRevoke about skipped events Signed-off-by: Josh --- core/Command/User/AuthTokens/Revoke.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/core/Command/User/AuthTokens/Revoke.php b/core/Command/User/AuthTokens/Revoke.php index bd11556be5b7a..60a233ddaf70e 100644 --- a/core/Command/User/AuthTokens/Revoke.php +++ b/core/Command/User/AuthTokens/Revoke.php @@ -168,8 +168,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * @return int|null Number of deleted rows, or null if caller should fall back - * to per-user iteration + * Attempt a bulk DELETE for --all-users instead of per-user iteration. + * + * This operates directly on the mapper for performance (single SQL DELETE + * per mode). The trade-off is that TokenInvalidatedEvent is not dispatched + * for individual tokens. This is acceptable because: + * + * - The event is primarily consumed by the token cache layer, which uses + * a short TTL (TOKEN_CACHE_TTL = 10s) and will self-heal quickly. + * - Dispatching events per-token would require loading every row first, + * negating the performance benefit of the bulk path. + * - We already do this (presumably acceptably) elsewhere. + * + * @return int|null Number of deleted rows, or null if the caller should + * fall back to per-user iteration. */ private function bulkRevoke(array $modes): ?int { if ($modes['sessions']) { From 2718476093014b19d63364a1c63df90370710256 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 11:22:28 -0400 Subject: [PATCH 13/17] chore: lint Revoke Signed-off-by: Josh --- core/Command/User/AuthTokens/Revoke.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Command/User/AuthTokens/Revoke.php b/core/Command/User/AuthTokens/Revoke.php index 60a233ddaf70e..39894866fdacb 100644 --- a/core/Command/User/AuthTokens/Revoke.php +++ b/core/Command/User/AuthTokens/Revoke.php @@ -178,7 +178,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int * a short TTL (TOKEN_CACHE_TTL = 10s) and will self-heal quickly. * - Dispatching events per-token would require loading every row first, * negating the performance benefit of the bulk path. - * - We already do this (presumably acceptably) elsewhere. + * - We already do this (presumably acceptably) elsewhere. * * @return int|null Number of deleted rows, or null if the caller should * fall back to per-user iteration. From db2e825c8a492e15ef1d468138ff6bc445a7ab24 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 11:23:40 -0400 Subject: [PATCH 14/17] chore: tidy bulkRevoke comment Signed-off-by: Josh --- core/Command/User/AuthTokens/Revoke.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/Command/User/AuthTokens/Revoke.php b/core/Command/User/AuthTokens/Revoke.php index 39894866fdacb..a1f82ba183ee2 100644 --- a/core/Command/User/AuthTokens/Revoke.php +++ b/core/Command/User/AuthTokens/Revoke.php @@ -178,7 +178,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int * a short TTL (TOKEN_CACHE_TTL = 10s) and will self-heal quickly. * - Dispatching events per-token would require loading every row first, * negating the performance benefit of the bulk path. - * - We already do this (presumably acceptably) elsewhere. + * - Bulk token invalidation already follows this pattern elsewhere in + * the codebase. * * @return int|null Number of deleted rows, or null if the caller should * fall back to per-user iteration. From daa43500609d4d81dc945428ddefe952cde63b16 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 12:12:31 -0400 Subject: [PATCH 15/17] chore(AuthTokens): add revoke cmd to autoload_classmap.php Signed-off-by: Josh --- lib/composer/composer/autoload_classmap.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 303871feadc40..502142edac4c7 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1435,6 +1435,7 @@ 'OC\\Core\\Command\\User\\AuthTokens\\Add' => $baseDir . '/core/Command/User/AuthTokens/Add.php', 'OC\\Core\\Command\\User\\AuthTokens\\Delete' => $baseDir . '/core/Command/User/AuthTokens/Delete.php', 'OC\\Core\\Command\\User\\AuthTokens\\ListCommand' => $baseDir . '/core/Command/User/AuthTokens/ListCommand.php', + 'OC\\Core\\Command\\User\\AuthTokens\\ListCommand' => $baseDir . '/core/Command/User/AuthTokens/Revoke.php', 'OC\\Core\\Command\\User\\ClearGeneratedAvatarCacheCommand' => $baseDir . '/core/Command/User/ClearGeneratedAvatarCacheCommand.php', 'OC\\Core\\Command\\User\\Delete' => $baseDir . '/core/Command/User/Delete.php', 'OC\\Core\\Command\\User\\Disable' => $baseDir . '/core/Command/User/Disable.php', From 37fd1e6b90b611416868db219387c3b96bdbd452 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 12:13:32 -0400 Subject: [PATCH 16/17] chore(AuthTokens): add revoke cmd to autoload_static.php Signed-off-by: Josh --- lib/composer/composer/autoload_static.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 8dad797c6e962..c7c4f5d75efb3 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1476,6 +1476,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Command\\User\\AuthTokens\\Add' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/Add.php', 'OC\\Core\\Command\\User\\AuthTokens\\Delete' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/Delete.php', 'OC\\Core\\Command\\User\\AuthTokens\\ListCommand' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/ListCommand.php', + 'OC\\Core\\Command\\User\\AuthTokens\\Revoke' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/Revoke.php', 'OC\\Core\\Command\\User\\ClearGeneratedAvatarCacheCommand' => __DIR__ . '/../../..' . '/core/Command/User/ClearGeneratedAvatarCacheCommand.php', 'OC\\Core\\Command\\User\\Delete' => __DIR__ . '/../../..' . '/core/Command/User/Delete.php', 'OC\\Core\\Command\\User\\Disable' => __DIR__ . '/../../..' . '/core/Command/User/Disable.php', From f71b67a635898124130576b7f8dcd8395e21514a Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 10 Apr 2026 12:14:13 -0400 Subject: [PATCH 17/17] chore: fixup autoload_classmap.php Signed-off-by: Josh --- lib/composer/composer/autoload_classmap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 502142edac4c7..85af7174555fe 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1435,7 +1435,7 @@ 'OC\\Core\\Command\\User\\AuthTokens\\Add' => $baseDir . '/core/Command/User/AuthTokens/Add.php', 'OC\\Core\\Command\\User\\AuthTokens\\Delete' => $baseDir . '/core/Command/User/AuthTokens/Delete.php', 'OC\\Core\\Command\\User\\AuthTokens\\ListCommand' => $baseDir . '/core/Command/User/AuthTokens/ListCommand.php', - 'OC\\Core\\Command\\User\\AuthTokens\\ListCommand' => $baseDir . '/core/Command/User/AuthTokens/Revoke.php', + 'OC\\Core\\Command\\User\\AuthTokens\\Revoke' => $baseDir . '/core/Command/User/AuthTokens/Revoke.php', 'OC\\Core\\Command\\User\\ClearGeneratedAvatarCacheCommand' => $baseDir . '/core/Command/User/ClearGeneratedAvatarCacheCommand.php', 'OC\\Core\\Command\\User\\Delete' => $baseDir . '/core/Command/User/Delete.php', 'OC\\Core\\Command\\User\\Disable' => $baseDir . '/core/Command/User/Disable.php',