基于API实现Keycloak的Auth Code Flow with PKCE(无重定向URL)适配Flutter移动端
Keycloak Auth Code Flow with PKCE 纯API实现方案(适配Flutter移动端)
前置配置(Keycloak客户端)
- 创建Public类型的客户端(移动端属于Public Client,无法安全保存客户端密钥)
- 在客户端设置的
Advanced标签页,开启Proof Key for Code Exchange (PKCE),并将Code Challenge Method设为S256 - 在
Valid Redirect URIs中添加urn:ietf:wg:oauth:2.0:oob(OAuth2内置的无重定向URI,适配纯API场景) - 确保客户端的
Standard Flow Enabled选项处于开启状态
步骤1:生成PKCE挑战码
PKCE需要两个核心参数:code_verifier(原始随机串)和code_challenge(哈希后的编码串),Flutter中可通过以下代码实现:
import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; // 生成随机的code_verifier(长度32-128位) String generateCodeVerifier() { final random = Random.secure(); final bytes = List<int>.generate(64, (_) => random.nextInt(256)); return base64Url.encode(bytes).replaceAll('=', ''); } // 基于code_verifier生成code_challenge(SHA-256哈希后base64url编码) String generateCodeChallenge(String codeVerifier) { final bytes = utf8.encode(codeVerifier); final digest = sha256.convert(bytes); return base64Url.encode(digest.bytes).replaceAll('=', ''); }
步骤2:获取授权码(Authorization Code)
通过POST请求直接调用Keycloak的授权端点,传递用户名密码跳过UI登录:
import 'package:http/http.dart' as http; Future<String> getAuthorizationCode({ required String keycloakUrl, required String realm, required String clientId, required String username, required String password, required String codeChallenge, }) async { final url = Uri.parse('$keycloakUrl/realms/$realm/protocol/openid-connect/auth'); final response = await http.post( url, headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: { 'client_id': clientId, 'response_type': 'code', 'scope': 'openid email profile', // 根据业务需求调整 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', 'username': username, 'password': password, }, ); // 解析响应:oob模式下授权码直接在响应体中返回 if (response.statusCode == 200) { final codeMatch = RegExp(r'code=([^&]+)').firstMatch(response.body); return codeMatch?.group(1) ?? ''; } throw Exception('获取授权码失败: ${response.body}'); }
步骤3:用授权码换取Access Token和Refresh Token
拿到授权码后,调用Token端点换取最终令牌:
Future<Map<String, dynamic>> exchangeCodeForTokens({ required String keycloakUrl, required String realm, required String clientId, required String authorizationCode, required String codeVerifier, }) async { final url = Uri.parse('$keycloakUrl/realms/$realm/protocol/openid-connect/token'); final response = await http.post( url, headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: { 'client_id': clientId, 'grant_type': 'authorization_code', 'code': authorizationCode, 'code_verifier': codeVerifier, 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', }, ); if (response.statusCode == 200) { return jsonDecode(response.body); } throw Exception('令牌兑换失败: ${response.body}'); }
完整流程调用示例
void main() async { // 配置参数 const keycloakUrl = 'https://your-keycloak-domain.com'; const realm = 'your-realm-name'; const clientId = 'your-mobile-client-id'; const username = 'user@example.com'; const password = 'user-password'; // 步骤1:生成PKCE参数 final codeVerifier = generateCodeVerifier(); final codeChallenge = generateCodeChallenge(codeVerifier); // 步骤2:获取授权码 final authCode = await getAuthorizationCode( keycloakUrl: keycloakUrl, realm: realm, clientId: clientId, username: username, password: password, codeChallenge: codeChallenge, ); // 步骤3:换取令牌 final tokens = await exchangeCodeForTokens( keycloakUrl: keycloakUrl, realm: realm, clientId: clientId, authorizationCode: authCode, codeVerifier: codeVerifier, ); // 使用令牌(示例) print('Access Token: ${tokens['access_token']}'); print('Refresh Token: ${tokens['refresh_token']}'); }
注意事项
code_verifier必须是32-128位的随机字符串,仅由字母、数字、-、.、_、~组成- 生产环境禁止硬编码用户名密码,需通过
flutter_secure_storage等安全存储方案保存用户凭证 - 令牌过期后,可使用
refresh_token调用Token端点刷新,无需重复执行完整流程 - 确保Keycloak服务器的CORS配置允许Flutter应用的域名发起请求
内容的提问来源于stack exchange,提问作者AlwaysDeveloper
相关产品推荐
相关产品推荐

