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'; } } }