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.
*