Frappe OIDC SSO 集成手册
TL;DR
将 gottenergy.getgrowth.app(Frappe v16)设为 OIDC IDP,Grafana 作为第一个外部 Client,实现了完整的 Authorization Code + PKCE SSO 流程。用户登录 Frappe 后打开 Grafana 无需再次输入密码。
现状:✅ 已跑通,已清理测试痕迹。后续任何支持 OIDC 的外部应用可复用此流程。
🎯 背景:为什么需要这个
- 需求:实现 Frappe OIDC 作为统一身份源(宪法第 3.1 节),让所有
*.getgrowth.app下的工具共享登录 - 卡点:Frappe v16.24.3 的 OAuth 模块函数名和本地源码不一致(
openid_configurationvsget_openid_configuration),Discovery Endpoint 不是走/api/method/...而是走/.well-known/... - 约束:不能动老师的生产环境,测试后必须可逆清理;测试 VPS 内存紧张
✅ 前置条件
- Frappe 站点(gottenergy.getgrowth.app, v16)
- 可 SSH 到 Press VPS (104.236.24.124)
- 外部应用支持 OIDC(Grafana Enterprise 12.4 已确认)
- Cloudflare Tunnel 已配置(grafana.gottenergy.com 可访问)
🏗️ 最终架构
用户浏览器
→ 打开 https://grafana.gottenergy.com
→ 点击 "Sign in with Frappe"
→ 302 跳转 https://gottenergy.getgrowth.app/.well-known/openid-configuration (Discovery)
→ Frappe 验证是否已登录
→ 未登录:弹出 Frappe 登录页 → 输入密码
→ 已登录:直接签发令牌
→ 回调 https://grafana.gottenergy.com/login/generic_oauth?code=xxx
→ Grafana 后端 POST token_endpoint 用 code 换 ID Token + Access Token
→ 解析 ID Token 拿到 email/name → 放行
为什么是这个架构:Frappe 原生支持 OIDC,不需要额外部署 Keycloak/Logto,减少一层依赖。
🛠️ 部署步骤
第 1 步:验证 OIDC Discovery Endpoint
curl https://gottenergy.getgrowth.app/.well-known/openid-configuration预期返回 JSON 包含 issuer、authorization_endpoint、token_endpoint、userinfo_endpoint。
这条命令在干嘛(点击展开)
Frappe v16 的 OIDC Discovery 不走
/api/method/frappe.integrations.oauth2.openid_configuration(这个函数在 v16.24.3 已改名且未 whitelist),而是通过handle_wellknown()路由在/.well-known/下暴露。
第 2 步:创建 Social Login Key
在 gottenergy console 中执行:
import frappe
key = frappe.new_doc("Social Login Key")
key.get_social_login_provider("Frappe", initialize=True)
key.base_url = "https://gottenergy.getgrowth.app"
key.enable_social_login = 0
key.insert(ignore_if_duplicate=True)
frappe.db.commit()这条命令在干嘛(点击展开)
告诉 Frappe 自己的公网地址(
base_url),否则签发的 ID Token 里issuer字段会是localhost,Grafana 验签会失败。enable_social_login=0是不需要第三方登录入口。
第 3 步:创建 OAuth Client
client = frappe.get_doc({
"doctype": "OAuth Client",
"app_name": "Grafana",
"grant_type": "Authorization Code",
"response_type": "Code",
"scopes": "openid email profile",
"redirect_uris": "https://grafana.gottenergy.com/login/generic_oauth",
"default_redirect_uri": "https://grafana.gottenergy.com/login/generic_oauth",
"skip_authorization": 1
})
client.insert()
frappe.db.commit()
print("Client ID:", client.name)
print("Client Secret:", client.client_secret)记住输出的 Client ID 和 Client Secret。
这条命令在干嘛(点击展开)
在 Frappe 里注册 Grafana 为信任的 OIDC Client。
redirect_uris必须精确匹配 Grafana 回调地址,差一个字符就报Mismatching redirect URI。skip_authorization=1跳过用户确认页面。
第 4 步:配置 OAuth Client 角色
client = frappe.get_doc("OAuth Client", "<CLIENT_ID>")
client.allowed_roles = []
client.append("allowed_roles", {"role": "Desk User"})
client.save()
frappe.db.commit()这条命令在干嘛(点击展开)
OAuth Client 默认要求
System User角色,但 gottenergy 站点没有这个角色(迁移站点的角色体系不同)。需要改成站点实际存在的角色(如Desk User),否则用户即使密码正确也会被拒。
第 5 步:配置 Grafana(Dokploy Compose)
在 Grafana 的 compose environment: 段添加:
GF_AUTH_GENERIC_OAUTH_ENABLED: "true"
GF_AUTH_GENERIC_OAUTH_NAME: Frappe
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${CLIENT_ID}
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ${CLIENT_SECRET}
GF_AUTH_GENERIC_OAUTH_AUTH_URL: https://gottenergy.getgrowth.app/api/method/frappe.integrations.oauth2.authorize
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: https://gottenergy.getgrowth.app/api/method/frappe.integrations.oauth2.get_token
GF_AUTH_GENERIC_OAUTH_API_URL: https://gottenergy.getgrowth.app/api/method/frappe.integrations.oauth2.openid_profile
GF_AUTH_GENERIC_OAUTH_SCOPES: openid email profile
GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP: "true"
GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_NAME: email
GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_NAME: email
GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_NAME: name
GF_SERVER_ROOT_URL: https://grafana.gottenergy.com这条命令在干嘛(点击展开)
GF_SERVER_ROOT_URL是关键——不设的话 Grafana 内部以为自己跑在localhost:3000,导致 OAuth 回调 redirect_uri 变成http://localhost:3000/login/generic_oauth,IDP 返回Mismatching redirect URI。EMAIL_ATTRIBUTE_NAME和LOGIN_ATTRIBUTE_NAME告诉 Grafana 从 Frappe 的 userinfo 返回 JSON 的哪个字段读用户标识。
↩️ 回滚 / 卸载
- Frappe 侧:
frappe.delete_doc("OAuth Client", "<CLIENT_ID>")+ 删掉测试用户 - Grafana 侧:compose 里删掉
GF_AUTH_GENERIC_OAUTH_*和GF_SERVER_ROOT_URL变量,Redeploy - Social Login Key:
frappe.delete_doc("Social Login Key", "frappe")(如果其他 app 不用的话) - 回滚后验证:无痕窗口打开 Grafana,确认登录页只有默认登录方式,没有 “Sign in with Frappe” 按钮
🔍 验证方法
- 无痕窗口打开
https://grafana.gottenergy.com - 点 “Sign in with Frappe” → 应跳转 gottenergy 登录页
- 输入测试账号密码 → 应自动回到 Grafana 仪表盘
- 关闭浏览器,再次无痕窗口打开 Grafana → 点按钮 → 如果之前 gottenergy session 未过期,直接进仪表盘
预期现象:Grafana 全程不弹自己的密码框,只在首次需要时弹 Frappe 登录页。
🧠 涉及到的代码逻辑
| 文件 | 关键方法/行号 | 做了什么 |
|---|---|---|
frappe/integrations/oauth2.py | get_openid_configuration() | 返回 OIDC Discovery JSON |
frappe/integrations/oauth2.py | handle_wellknown() | 路由 /.well-known/ 请求 |
frappe/integrations/oauth2.py | authorize() L123 | 处理授权请求,签发 code |
frappe/integrations/oauth2.py | get_token() L144 | code 换 token |
frappe/integrations/oauth2.py | openid_profile() L205 | 返回用户信息 |
frappe/oauth.py | validate_code_verifier() | PKCE S256 验证 |
💡 总结经验
- 这次最大的教训:Frappe v16 的 Discovery Endpoint 走
/.well-known/路由,不是/api/method/路径。先验证端点再配 Client - 如果再来一次,我会先验证 Discovery Endpoint 可用性,而不是上来就创建 OAuth Client
- 这个问题的本质其实是 不同 Frappe 版本/patch 的 OAuth 模块有细微差异,不能假设源码和运行环境一致
- 以后排查类似问题,第一时间应该看的三个东西:1)
/.well-known/openid-configuration2) console 里dir(oauth2)列出真实函数名 3) 容器里env | grep GF_AUTH确认变量生效
🕳️ 踩过的坑 & 被否决的方案
坑 1:Mismatching redirect URI(400 invalid_request)
现象:Grafana 跳转 Frappe 后页面显示
{"description":"Mismatching redirect URI.","status_code":400}根因:Grafana 没设GF_SERVER_ROOT_URL,内部认为自己是localhost:3000,OAuth 回调地址变成http://localhost:3000/login/generic_oauth,和 Frappe OAuth Client 里配的https://grafana.gottenergy.com/...不匹配 解决:加GF_SERVER_ROOT_URL: https://grafana.gottenergy.com
坑 2:Discovery Endpoint 403 / 417 / AttributeError
现象:访问
/api/method/frappe.integrations.oauth2.openid_configuration返回 417(函数不存在)或 403(未 whitelist) 根因:v16.24.3 的函数名是get_openid_configuration(多了get_前缀),且没有allow_guest=True。实际走了/.well-known/openid-configuration路由 解决:用/.well-known/openid-configuration而非/api/method/...
坑 3:用户创建后密码设不上
现象:
update_password(user, pwd)反复失败,SQL 语法错误或参数类型错误 根因:函数签名是update_password(user, pwd)不是update_password(pwd, user);且需要在user.insert()之后commit()再操作,否则事务回滚 解决:分步执行:insert → commit → reload → add role → save → update_password → commit
🩺 故障排查决策树
症状:Grafana 登录页没有 "Sign in with Frappe" 按钮
→ 查 GF_AUTH_GENERIC_OAUTH_ENABLED 是否为 true(docker exec env | grep GF_AUTH)
→ 如果是 false:compose 里 environment 没生效,查 Dokploy 是否 redeploy
→ 如果是 true:查 Grafana 日志 docker logs ...
症状:跳转 Frappe 后报 "invalid_request"
→ 看浏览器地址栏 redirect_uri 参数
→ 如果是 localhost:3000 → 没设 GF_SERVER_ROOT_URL
→ 如果域名是对的 → 查 OAuth Client 的 redirect_uris 是否精确匹配(包括 https 和路径)
症状:登录后跳回 Grafana 但显示未授权
→ 查 test 用户是否有 allowed_roles 里的角色(gottenergy 没有 System User 角色)
→ 删掉 OAuth Client 的 allowed_roles → 重新添加一个存在的角色(如 Desk User)
⚠️ 已知限制 / 副作用
- ID Token 签名算法是 HS256(共享密钥),不是 RS256(公私钥)。外部应用如果只支持 RS256 会无法验签
- Frappe 目前没有
jwks_uri,无法做公钥分发 redirect_uri必须精确匹配,不支持通配符- 测试用 Grafana,但同一流程适用于任何支持 Generic OAuth 的应用
🔒 安全 & 成本速查
- 密钥/Token 存放位置:client_id 和 client_secret 在 Dokploy Environment 标签(或 compose 里
${}引用),不要硬编码 - 成本:$0(纯粹 Frappe 已有功能的配置启用)
📌 待办 / 未来优化方向
- Grafana compose 改用
env_file或${VAR}引用,不硬编码 credentials - 决定长期 IDP 站点(gottenergy vs press vs 新建 auth.getgrowth.app)
- 测试 OIDC Session 过期后的 Refresh Token 续期行为
- 对接更多 Client:Outline、n8n
📋 速查命令合集(复制专用,无解释)
curl https://gottenergy.getgrowth.app/.well-known/openid-configuration📝 更新日志
| 日期 | 改动 | 原因 |
|---|---|---|
| 2026-07-09 | 首次创建,Grafana OIDC SSO 跑通 | 宪法 SSO 路线推进 |