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:34:39 +01:00
co-authored by Claude Opus 4.5
parent daaf460b0c
commit 28219e00e7
4 changed files with 217 additions and 3 deletions
+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(), []);
}