Flutter中基于Custom Tabs的OAuth2认证流程:如何获取会话Cookie
首先得说,你这套BFF+Keycloak的架构思路非常靠谱——把敏感的access token存在后端Redis,移动端只持有会话Cookie,确实比直接在App里存token安全太多,完全不用怀疑这个架构选择。
回到你的问题:用Custom Tabs/Safari View Controller完成认证后,怎么拿到网关返回的会话Cookie并在后续请求中复用?这里分两种可行方案,结合平台特性给你拆解:
方案一:让网关在重定向时附带Session ID到回调URI(最简单,需后端配合)
如果你的Spring Cloud Gateway可以调整重定向逻辑,这是最省心的办法:
- 当网关完成授权码交换、生成会话Cookie后,重定向到自定义URI时,把会话ID作为查询参数附加进去,比如:
yourapp://auth/callback?sessionId=xxx-xxx-xxx - Flutter应用在处理这个回调URI时,直接从
queryParameters里提取sessionId,然后手动构造Cookie并存到HTTP客户端的CookieJar中。
示例代码(Flutter层):
void handleAuthCallback(Uri callbackUri) { final sessionId = callbackUri.queryParameters['sessionId']; if (sessionId == null) return; // 把Session ID存到dio的CookieJar里 final cookieJar = CookieJar(); cookieJar.saveFromResponse( Uri.parse('https://your-gateway-domain'), [Cookie('SESSIONID', sessionId)..domain = 'your-gateway-domain'], ); // 后续用dio请求时会自动带上Cookie final dio = Dio()..interceptors.add(CookieManager(cookieJar)); }
这个方案的好处是不用写原生代码,全Flutter层面就能搞定,而且自定义URI的回调是本地处理的,Session ID不会经过公开网络,安全性没问题。
方案二:通过原生代码同步系统浏览器的Cookie(无需后端修改)
如果不想改动后端,就需要利用Android/iOS的原生API,从Custom Tabs/Safari View Controller的Cookie存储中拿到网关设置的会话Cookie,再同步到Flutter的HTTP客户端。
步骤1:配置自定义URI回调
先确保你的App已经配置了自定义URI scheme,让认证完成后能回到Flutter:
- Android:在
AndroidManifest.xml的主Activity里添加intent-filter:
<intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="yourapp" android:host="auth" android:path="/callback" /> </intent-filter>
- iOS:在
Info.plist里添加URL Types配置:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>yourapp</string> </array> </dict> </array>
步骤2:用Custom Tabs/Safari View Controller启动认证
推荐用flutter_custom_tabs插件,它同时支持Android和iOS的浏览器组件:
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; Future<void> startAuthFlow() async { final authUrl = Uri.parse('https://your-gateway-domain/auth/redirect-to-keycloak'); try { await launchUrl( authUrl, customTabsOptions: CustomTabsOptions( urlBarHidingEnabled: true, closeButton: CustomTabsCloseButton(icon: CustomTabsCloseButtonIcon.back), ), safariVCOptions: SafariViewControllerOptions( preferredBarTintColor: Colors.blue, preferredControlTintColor: Colors.white, ), ); } catch (e) { print('启动认证流程失败: $e'); } }
步骤3:原生代码获取Cookie并传递给Flutter
Android端(Kotlin)
在MainActivity里注册MethodChannel,调用系统CookieManager获取指定域名的Cookie:
import android.webkit.CookieManager import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val CHANNEL = "com.your.app/cookie" override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> if (call.method == "getSessionCookie") { val domain = call.argument<String>("domain") ?: "" val cookieManager = CookieManager.getInstance() val allCookies = cookieManager.getCookie(domain) // 提取你的会话Cookie(比如名字是SESSIONID) val sessionCookie = allCookies?.split(";")?.firstOrNull { it.trim().startsWith("SESSIONID=") } result.success(sessionCookie) } else { result.notImplemented() } } } }
iOS端(Swift)
在AppDelegate里注册MethodChannel,用WKHTTPCookieStore获取Safari的Cookie:
import UIKit import Flutter import WebKit @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { private let CHANNEL = "com.your.app/cookie" override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let controller = window?.rootViewController as! FlutterViewController let channel = FlutterMethodChannel(name: CHANNEL, binaryMessenger: controller.binaryMessenger) channel.setMethodCallHandler { [weak self] call, result in guard let self = self else { return } if call.method == "getSessionCookie" { guard let domain = call.arguments as? String else { result.success(nil) return } // 去掉https://前缀,匹配Cookie的domain let cookieDomain = domain.replacingOccurrences(of: "https://", with: "") WKHTTPCookieStore.default().getAllCookies { cookies in let sessionCookie = cookies.first { $0.name == "SESSIONID" && $0.domain.contains(cookieDomain) } result.success(sessionCookie?.description.split(separator: ";").first) } } else { result.notImplemented() } } GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } }
步骤4:Flutter层接收Cookie并存储
通过MethodChannel调用原生方法获取Cookie,然后存到HTTP客户端的CookieJar:
import 'package:flutter/services.dart'; import 'package:dio/dio.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:cookie_jar/cookie_jar.dart'; Future<String?> _getSessionCookie(String domain) async { const platform = MethodChannel('com.your.app/cookie'); try { return await platform.invokeMethod('getSessionCookie', {'domain': domain}); } on PlatformException catch (e) { print('获取Cookie失败: ${e.message}'); return null; } } void handleAuthCallback(Uri callbackUri) async { final gatewayDomain = 'https://your-gateway-domain'; final sessionCookie = await _getSessionCookie(gatewayDomain); if (sessionCookie == null) return; // 存储到CookieJar final cookieJar = CookieJar(); await cookieJar.saveFromResponse( Uri.parse(gatewayDomain), [Cookie.fromSetCookieValue(sessionCookie)], ); // 配置dio自动携带Cookie final dio = Dio()..interceptors.add(CookieManager(cookieJar)); // 测试请求受保护的API final response = await dio.get('$gatewayDomain/api/protected'); print('受保护API响应: ${response.data}'); }
关键注意事项
- HttpOnly Cookie:网关设置的Cookie如果是HttpOnly的,不用担心——原生的CookieManager/WKHTTPCookieStore可以正常获取,HttpOnly只是限制JavaScript访问,不影响原生API。
- Cookie Domain/Path:确保网关设置的Cookie的domain和path正确,否则原生代码可能找不到对应的Cookie。
- 会话有效期:和后端约定好会话Cookie的有效期,以及失效后的处理逻辑(比如跳转重新认证)。
备注:内容来源于stack exchange,提问作者Riccardo Bellini

