feat(astrolabe): add background token refresh job

Prevents users from having to re-authorize Astrolabe after periods of
inactivity by proactively refreshing OAuth tokens before they expire.

Changes:
- Add RefreshUserTokens background job that runs every 15 minutes
- Add on-demand token refresh in SemanticSearchProvider (Unified Search)
- Store issued_at timestamp for accurate token lifetime calculation
- Add getAllUsersWithTokens() to query users needing refresh

The job dynamically calculates refresh threshold based on actual token
lifetime (50% remaining), working with any IdP (Nextcloud OIDC, Keycloak,
etc.) rather than relying on IdP-specific configuration.

Closes #510

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-01-27 10:30:22 +01:00
parent daaf460b0c
commit 28219e00e7
4 changed files with 217 additions and 3 deletions
+3
View File
@@ -57,4 +57,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
<type>link</type>
</navigation>
</navigations>
<background-jobs>
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
</background-jobs>
</info>
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\BackgroundJob;
use OCA\Astrolabe\Service\IdpTokenRefresher;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJob;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
/**
* Background job to proactively refresh OAuth tokens before expiration.
*
* Runs every 15 minutes and refreshes tokens based on their actual expiration
* time. Works with any IdP (Nextcloud OIDC, Keycloak, etc.) since it uses
* the real token expiration rather than IdP configuration.
*
* Refresh strategy: Refresh when less than 50% of token lifetime remains,
* ensuring tokens are refreshed well before expiration regardless of the
* IdP's configured token lifetime.
*
* @psalm-suppress UnusedClass - Background jobs are loaded dynamically by Nextcloud
*/
class RefreshUserTokens extends TimedJob {
/** Job runs every 15 minutes */
private const JOB_INTERVAL_SECONDS = 900;
/** Refresh when this percentage of token lifetime remains */
private const REFRESH_AT_REMAINING_PERCENT = 0.5;
/** Minimum threshold to avoid constant refresh (5 minutes) */
private const MIN_THRESHOLD_SECONDS = 300;
/** Default assumed token lifetime if we can't determine it (1 hour) */
private const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
public function __construct(
ITimeFactory $time,
private McpTokenStorage $tokenStorage,
private IdpTokenRefresher $tokenRefresher,
private LoggerInterface $logger,
) {
parent::__construct($time);
$this->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';
}
}
}
+23 -3
View File
@@ -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(), []);
}
+36
View File
@@ -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<string> 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<string> $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.
*