diff --git a/third_party/astrolabe/appinfo/info.xml b/third_party/astrolabe/appinfo/info.xml index 1992b8f..31209a6 100644 --- a/third_party/astrolabe/appinfo/info.xml +++ b/third_party/astrolabe/appinfo/info.xml @@ -57,4 +57,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf link + + OCA\Astrolabe\BackgroundJob\RefreshUserTokens + diff --git a/third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php b/third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php new file mode 100644 index 0000000..c79add2 --- /dev/null +++ b/third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php @@ -0,0 +1,155 @@ +setInterval(self::JOB_INTERVAL_SECONDS); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + } + + protected function run(mixed $argument): void { + $this->logger->info('RefreshUserTokens: Starting background token refresh'); + + $userIds = $this->tokenStorage->getAllUsersWithTokens(); + $this->logger->debug('RefreshUserTokens: Found ' . count($userIds) . ' users with tokens'); + + $refreshed = 0; + $failed = 0; + $skipped = 0; + + foreach ($userIds as $userId) { + $result = $this->refreshUserTokenIfNeeded($userId); + match ($result) { + 'refreshed' => $refreshed++, + 'failed' => $failed++, + 'skipped' => $skipped++, + }; + } + + $this->logger->info("RefreshUserTokens: Complete - refreshed=$refreshed, failed=$failed, skipped=$skipped"); + } + + /** + * Refresh a user's token if it's nearing expiration. + * + * Calculates the refresh threshold based on the token's actual lifetime, + * refreshing when less than 50% of the lifetime remains. + * + * @return string 'refreshed', 'failed', or 'skipped' + */ + private function refreshUserTokenIfNeeded(string $userId): string { + $token = $this->tokenStorage->getUserToken($userId); + + if ($token === null) { + return 'skipped'; + } + + $expiresAt = (int)($token['expires_at'] ?? 0); + $issuedAt = isset($token['issued_at']) ? (int)$token['issued_at'] : null; + $timeRemaining = $expiresAt - time(); + + // Calculate token lifetime from stored data or use default + if ($issuedAt !== null) { + $tokenLifetime = $expiresAt - $issuedAt; + } else { + // Fallback: use default lifetime assumption + $tokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS; + } + + // Calculate threshold: refresh when 50% of lifetime remains + $threshold = max( + (int)($tokenLifetime * self::REFRESH_AT_REMAINING_PERCENT), + self::MIN_THRESHOLD_SECONDS + ); + + if ($timeRemaining > $threshold) { + // Token still has plenty of time, skip + return 'skipped'; + } + + // Token is expiring soon, attempt refresh + if (!isset($token['refresh_token'])) { + $this->logger->warning("RefreshUserTokens: User $userId has no refresh token"); + return 'failed'; + } + + $this->logger->debug("RefreshUserTokens: Refreshing token for user $userId (remaining={$timeRemaining}s, threshold={$threshold}s)"); + + try { + /** @var string $refreshToken */ + $refreshToken = $token['refresh_token']; + $newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken); + + if ($newTokenData === null) { + $this->logger->warning("RefreshUserTokens: Refresh returned null for user $userId"); + // Don't delete token here - let on-demand refresh handle cleanup + return 'failed'; + } + + // Calculate new expiration and store issued_at for future calculations + $expiresIn = (int)($newTokenData['expires_in'] ?? self::DEFAULT_TOKEN_LIFETIME_SECONDS); + $now = time(); + + /** @var string $accessToken */ + $accessToken = $newTokenData['access_token']; + /** @var string $newRefreshToken */ + $newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken; + + $this->tokenStorage->storeUserToken( + $userId, + $accessToken, + $newRefreshToken, + $now + $expiresIn, + $now // issued_at + ); + + $this->logger->debug("RefreshUserTokens: Successfully refreshed token for user $userId"); + return 'refreshed'; + + } catch (\Exception $e) { + $this->logger->error("RefreshUserTokens: Failed to refresh for user $userId: " . $e->getMessage()); + return 'failed'; + } + } +} diff --git a/third_party/astrolabe/lib/Search/SemanticSearchProvider.php b/third_party/astrolabe/lib/Search/SemanticSearchProvider.php index 8da0cb4..bdcb2f0 100644 --- a/third_party/astrolabe/lib/Search/SemanticSearchProvider.php +++ b/third_party/astrolabe/lib/Search/SemanticSearchProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace OCA\Astrolabe\Search; use OCA\Astrolabe\AppInfo\Application; +use OCA\Astrolabe\Service\IdpTokenRefresher; use OCA\Astrolabe\Service\McpServerClient; use OCA\Astrolabe\Service\McpTokenStorage; use OCA\Astrolabe\Settings\Admin as AdminSettings; @@ -35,6 +36,7 @@ class SemanticSearchProvider implements IProvider { public function __construct( private McpServerClient $client, private McpTokenStorage $tokenStorage, + private IdpTokenRefresher $tokenRefresher, private IConfig $config, private IL10N $l10n, private IURLGenerator $urlGenerator, @@ -85,12 +87,30 @@ class SemanticSearchProvider implements IProvider { return SearchResult::complete($this->getName(), []); } - // Get OAuth token for user - $accessToken = $this->tokenStorage->getAccessToken($user->getUID()); + $userId = $user->getUID(); + + // Create refresh callback matching ApiController pattern + /** @return array{access_token: string, refresh_token: string, expires_in: int}|null */ + $refreshCallback = function (string $refreshToken): ?array { + $newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken); + + if ($newTokenData === null) { + return null; + } + + return [ + 'access_token' => $newTokenData['access_token'], + 'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken, + 'expires_in' => $newTokenData['expires_in'] ?? 3600, + ]; + }; + + // Get OAuth token for user with automatic refresh + $accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback); if ($accessToken === null) { // User hasn't authorized the app yet - return empty results $this->logger->debug('No OAuth token for user in semantic search', [ - 'user_id' => $user->getUID(), + 'user_id' => $userId, ]); return SearchResult::complete($this->getName(), []); } diff --git a/third_party/astrolabe/lib/Service/McpTokenStorage.php b/third_party/astrolabe/lib/Service/McpTokenStorage.php index 5d499fa..8c36211 100644 --- a/third_party/astrolabe/lib/Service/McpTokenStorage.php +++ b/third_party/astrolabe/lib/Service/McpTokenStorage.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace OCA\Astrolabe\Service; use OCP\IConfig; +use OCP\IDBConnection; use OCP\Security\ICrypto; use Psr\Log\LoggerInterface; @@ -20,15 +21,18 @@ class McpTokenStorage { private $config; private $crypto; + private $db; private $logger; public function __construct( IConfig $config, ICrypto $crypto, + IDBConnection $db, LoggerInterface $logger, ) { $this->config = $config; $this->crypto = $crypto; + $this->db = $db; $this->logger = $logger; } @@ -41,18 +45,21 @@ class McpTokenStorage { * @param string $accessToken OAuth access token * @param string $refreshToken OAuth refresh token * @param int $expiresAt Unix timestamp when token expires + * @param int|null $issuedAt Unix timestamp when token was issued (for lifetime calculation) */ public function storeUserToken( string $userId, string $accessToken, string $refreshToken, int $expiresAt, + ?int $issuedAt = null, ): void { try { $tokenData = [ 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'expires_at' => $expiresAt, + 'issued_at' => $issuedAt ?? time(), ]; // Encrypt token data before storage @@ -153,6 +160,35 @@ class McpTokenStorage { } } + /** + * Get all user IDs that have OAuth tokens stored. + * + * Queries oc_preferences directly since IConfig doesn't support + * listing all users with a specific key set. + * + * @return list Array of user IDs + */ + public function getAllUsersWithTokens(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('userid') + ->from('preferences') + ->where($qb->expr()->eq('appid', $qb->createNamedParameter('astrolabe'))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('oauth_tokens'))); + + $result = $qb->executeQuery(); + /** @var list $userIds */ + $userIds = []; + /** @psalm-suppress MixedAssignment - IResult::fetch() returns mixed */ + while (($row = $result->fetch()) !== false) { + if (is_array($row) && isset($row['userid']) && is_string($row['userid'])) { + $userIds[] = $row['userid']; + } + } + $result->closeCursor(); + + return $userIds; + } + /** * Get the access token for a user, handling expiration and refresh. *