多容器共用Keycloak客户端遇invalid_grant错误求助
我们的架构包含Keycloak、NGINX代理、四个运行单体遗留Java应用的容器,以及用于共享节点会话的Redis。所有容器主机名相同,应用使用KeycloakOIDCFilter做认证,NGINX配置为轮询分发请求至各应用实例,因会话存储在Redis中,此前运行正常。
将认证方式改为Keycloak授权码模式后出现问题:访问应用时Keycloak正常显示登录界面,但提交POST登录请求后返回400错误。日志显示,当Keycloak调用auth/realms/mycompany/protocol/openid-connect/token接口时,应用收到响应:{"error":"invalid_grant","error_description":"Code not valid"}。
仅保留单个节点时一切正常;启用粘性会话后,四节点也能正常运行。
现有NGINX配置
app-location.conf
location /diarias { allow all; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host:$server_port; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Port 443; proxy_set_header X-Forwarded-Server $host; proxy_pass http://diarias/diarias; }
app-upstream.conf
upstream diarias { server dairias:8081; # 注意此处拼写错误:dairias 应为 diarias server diarias:8082; server diarias:8083; server diarias:8084; }
- 授权码模式的核心逻辑是:应用生成授权码并临时存储,跳转Keycloak完成登录后,Keycloak回调应用,应用用授权码交换token。
- 当前使用的KeycloakOIDCFilter默认将授权码存储在实例本地内存中,即便Redis共享了用户会话,但授权码并未纳入共享存储。
- NGINX轮询分发时,登录回调请求可能被分配到未生成该授权码的实例,导致该实例找不到匹配的授权码,返回
invalid_grant错误。 - 单个节点或粘性会话能正常运行,是因为回调请求始终打到生成授权码的实例,可匹配到存储的授权码。
1. 修正NGINX上游的拼写错误
先把upstream diarias里的server dairias:8081改为server diarias:8081,消除实例无法访问的潜在问题。
2. 共享授权码存储(推荐方案)
修改KeycloakOIDCFilter的配置,将授权码存储迁移到Redis,实现多实例共享:
- 若为Spring Boot应用,检查是否配置了
org.springframework.security.oauth2.client.web.AuthorizationRequestRepository,将其实现替换为基于Redis的RedisAuthorizationRequestRepository。 - 若使用原生KeycloakOIDCFilter,需自定义授权码存储逻辑,把授权码的生成、存储、读取操作绑定到Redis,确保所有实例能访问同一份授权码数据。
3. 针对回调路径配置粘性会话(临时兼容方案)
如果无法修改应用代码,可在NGINX中仅对Keycloak的回调路径配置粘性会话,其他业务路径保持轮询:
假设应用的回调路径为/diarias/login/oauth2/code/keycloak,修改NGINX配置如下:
# 回调路径单独配置IP哈希粘性 location /diarias/login/oauth2/code/keycloak { allow all; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host:$server_port; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Port 443; proxy_set_header X-Forwarded-Server $host; proxy_pass http://diarias/diarias/login/oauth2/code/keycloak; ip_hash; } # 业务路径保持轮询 location /diarias { allow all; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host:$server_port; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Port 443; proxy_set_header X-Forwarded-Server $host; proxy_pass http://diarias/diarias; # 这里保留默认轮询策略,无需额外配置 }
说明:ip_hash基于客户端IP绑定实例,若客户端处于NAT环境下可能导致实例负载不均;也可使用cookie粘性(需NGINX启用sticky模块),配置示例:sticky cookie srv_id expires=1h domain=$host httponly path=/;。
内容的提问来源于stack exchange,提问作者Walter do Valle

