You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中google_sign_in静默刷新AccessToken失败问题排查

谷歌登录令牌刷新问题解答

问题1:为何静默刷新令牌时出现"No user signed in to refresh token"错误?

核心原因是google_sign_in插件的本地登录会话丢失,具体场景包括:

  • 应用被彻底杀死后,插件内部存储的用户会话(Android的SharedPreferences或iOS的Keychain)被系统或用户清理,导致signInSilently()无法找到已登录记录。
  • 首次登录时未正确获取并持久化refresh token:尽管设置了forceCodeForRefreshToken: true,但插件版本兼容问题、网络异常等可能导致refresh token未被存储。
  • 应用启动时未主动恢复会话:重启应用后未调用signInSilently()恢复登录状态,后续刷新时插件无有效会话可用。
  • 部分安卓机型的后台清理机制会强制清除应用SharedPreferences数据,直接丢失插件会话。

问题2:令牌存储与刷新方式的问题

当前实现存在几个关键缺陷:

  1. 未持久化refresh token:仅存储access token,完全依赖google_sign_in插件内部维护会话,一旦插件会话丢失,就无法触发刷新。
  2. 刷新逻辑单一依赖signInSilently():没有兜底机制,插件会话失效后直接抛出错误,无法手动用refresh token获取新的access token。
  3. 错误捕获范围过窄:withTokenRefresh仅判断错误信息包含"401",但谷歌API返回令牌过期的错误还可能是invalid_token或token_expired,会错过刷新时机。
  4. PrefsService缓存更新逻辑存在隐患:虽然是单例,但异步初始化过程中可能出现缓存与实际存储不一致的情况。

问题3:如何确保令牌刷新可靠,无需用户每小时重新登录?

核心优化方案

  1. 持久化存储refresh token:登录时获取并保存refresh token,作为刷新access token的兜底方案。
  2. 实现双刷新机制:优先用signInSilently()刷新,失败则用存储的refresh token手动请求新令牌。
  3. 应用启动时主动恢复会话:在初始化阶段尝试恢复登录状态,确保会话始终有效。
  4. 完善错误处理与重试逻辑:覆盖更多令牌失效场景,避免误判。

具体代码修改

1. 更新PrefsService,支持refresh token存储

class PrefsService {
  static final PrefsService _instance = PrefsService._internal();
  static SharedPreferences? _prefs;
  bool _isInitialized = false;
  String? _cachedAccessToken;
  String? _cachedRefreshToken;

  factory PrefsService() => _instance;
  PrefsService._internal();

  Future<void> ensureInitialized() async {
    if (!_isInitialized) {
      _prefs = await SharedPreferences.getInstance();
      _cachedAccessToken = _prefs?.getString('accessToken');
      _cachedRefreshToken = _prefs?.getString('refreshToken');
      _isInitialized = true;
    }
  }

  Future<String?> getAccessToken() async {
    await ensureInitialized();
    return _cachedAccessToken;
  }

  Future<void> setAccessToken(String? accessToken) async {
    await ensureInitialized();
    await _prefs?.setString('accessToken', accessToken ?? '');
    _cachedAccessToken = accessToken;
  }

  Future<String?> getRefreshToken() async {
    await ensureInitialized();
    return _cachedRefreshToken;
  }

  Future<void> setRefreshToken(String? refreshToken) async {
    await ensureInitialized();
    await _prefs?.setString('refreshToken', refreshToken ?? '');
    _cachedRefreshToken = refreshToken;
  }

  Future<void> clearSession() async {
    await ensureInitialized();
    await _prefs?.remove('accessToken');
    await _prefs?.remove('refreshToken');
    await _prefs?.remove('folderId');
    _cachedAccessToken = null;
    _cachedRefreshToken = null;
  }
}

2. 更新登录逻辑,保存refresh token

Future<AuthResponse> googleLogin() async {
  final _supabase = SupabaseManager.instance;
  final googleUser = await _googleSignIn.signIn();

  if (googleUser == null) {
    throw 'Google sign-in canceled or failed.';
  }

  final googleAuth = await googleUser.authentication;
  final accessToken = googleAuth.accessToken;
  final idToken = googleAuth.idToken;
  final refreshToken = googleAuth.refreshToken;

  if (accessToken == null || idToken == null || refreshToken == null) {
    throw 'Missing Google authentication tokens.';
  }

  final authResponse = await _supabase.auth.signInWithIdToken(
    provider: OAuthProvider.google,
    idToken: idToken,
    accessToken: accessToken,
  );

  final folderId = await ensureDriveDirectory(accessToken);
  await _saveSessionData(accessToken, refreshToken, folderId, googleUser.photoUrl);
  return authResponse;
}

Future<void> _saveSessionData(String accessToken, String refreshToken, String folderId, String? photoUrl) async {
  final prefs = PrefsService();
  await prefs.setAccessToken(accessToken);
  await prefs.setRefreshToken(refreshToken);
  await prefs.setFolderId(folderId);
  // 其他存储逻辑(如头像URL)
}

3. 优化refreshToken函数,添加手动刷新逻辑

Future<void> refreshToken() async {
  try {
    // 优先尝试静默登录刷新
    final googleUser = await _googleSignIn.signInSilently(suppressErrors: false);
    if (googleUser != null) {
      final googleAuth = await googleUser.authentication;
      final newAccessToken = googleAuth.accessToken;
      final newRefreshToken = googleAuth.refreshToken;

      if (newAccessToken != null) {
        String? folderId = await PrefsService().getFolderId();
        folderId ??= await ensureDriveDirectory(newAccessToken);
        await _saveSessionData(
          newAccessToken, 
          newRefreshToken ?? await PrefsService().getRefreshToken() ?? '', 
          folderId, 
          googleUser.photoUrl
        );
        debugPrint('Token refreshed via signInSilently.');
        return;
      }
    }

    // 静默登录失败,用refresh token手动请求
    final prefs = PrefsService();
    final refreshToken = await prefs.getRefreshToken();
    if (refreshToken == null) {
      throw Exception('No refresh token available.');
    }

    final response = await http.post(
      Uri.parse('https://oauth2.googleapis.com/token'),
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: {
        'client_id': _webClientId,
        'refresh_token': refreshToken,
        'grant_type': 'refresh_token',
      },
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      final newAccessToken = data['access_token'] as String;
      final newRefreshToken = data['refresh_token'] as String? ?? refreshToken;

      String? folderId = await prefs.getFolderId();
      folderId ??= await ensureDriveDirectory(newAccessToken);
      await _saveSessionData(newAccessToken, newRefreshToken, folderId, null);
      debugPrint('Token refreshed via refresh token.');
    } else {
      throw Exception('Failed to refresh token manually: ${response.statusCode}');
    }
  } catch (e) {
    debugPrint('Token refresh error: $e');
    // 刷新失败,清除本地会话并登出
    await PrefsService().clearSession();
    await _googleSignIn.signOut();
    rethrow;
  }
}

4. 优化withTokenRefresh的错误判断与重试逻辑

Future<T> withTokenRefresh<T>(
    Future<T> Function(String accessToken) operation) async {
  final prefs = PrefsService();
  String? accessToken = await prefs.getAccessToken();

  if (accessToken == null) {
    throw Exception('No access token available');
  }

  int retryCount = 0;
  const maxRetries = 1;

  while (retryCount <= maxRetries) {
    try {
      return await operation(accessToken);
    } catch (e) {
      final errorMsg = e.toString();
      if ((errorMsg.contains('401') || errorMsg.contains('invalid_token') || errorMsg.contains('token_expired')) && retryCount < maxRetries) {
        debugPrint('Access token invalid/expired. Refreshing...');
        await refreshToken();
        accessToken = await prefs.getAccessToken();
        if (accessToken == null) {
          throw Exception('Token refresh failed. User must sign in again.');
        }
        retryCount++;
      } else {
        rethrow;
      }
    }
  }
  throw Exception('Max retries reached.');
}

5. 应用启动时主动恢复会话

在main函数添加初始化逻辑:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = PrefsService();
  await prefs.ensureInitialized();
  
  // 尝试恢复登录会话
  try {
    final googleUser = await _googleSignIn.signInSilently();
    if (googleUser != null) {
      final googleAuth = await googleUser.authentication;
      if (googleAuth.accessToken != null) {
        String? folderId = await prefs.getFolderId();
        folderId ??= await ensureDriveDirectory(googleAuth.accessToken!);
        await _saveSessionData(
          googleAuth.accessToken!, 
          googleAuth.refreshToken ?? await prefs.getRefreshToken() ?? '', 
          folderId, 
          googleUser.photoUrl
        );
      }
    }
  } catch (e) {
    debugPrint('Session recovery failed: $e');
  }

  runApp(const MyApp());
}

额外注意事项

  • 确保_webClientId使用正确的Web客户端ID(非Android/iOS客户端ID),否则手动刷新token会失败。
  • forceCodeForRefreshToken: true仅在首次登录时生效,如需强制获取新的refresh token,可在退出登录后重新登录时添加prompt: 'consent'参数。
  • 定期更新google_sign_in插件到最新稳定版,避免已知的会话持久化bug。

内容的提问来源于stack exchange,提问作者ussrback

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 01:18:10