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_configuration vs get_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 包含 issuerauthorization_endpointtoken_endpointuserinfo_endpoint

第 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()

第 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 IDClient Secret

第 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()

第 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

↩️ 回滚 / 卸载

  • Frappe 侧frappe.delete_doc("OAuth Client", "<CLIENT_ID>") + 删掉测试用户
  • Grafana 侧:compose 里删掉 GF_AUTH_GENERIC_OAUTH_*GF_SERVER_ROOT_URL 变量,Redeploy
  • Social Login Keyfrappe.delete_doc("Social Login Key", "frappe")(如果其他 app 不用的话)
  • 回滚后验证:无痕窗口打开 Grafana,确认登录页只有默认登录方式,没有 “Sign in with Frappe” 按钮

🔍 验证方法

  1. 无痕窗口打开 https://grafana.gottenergy.com
  2. 点 “Sign in with Frappe” → 应跳转 gottenergy 登录页
  3. 输入测试账号密码 → 应自动回到 Grafana 仪表盘
  4. 关闭浏览器,再次无痕窗口打开 Grafana → 点按钮 → 如果之前 gottenergy session 未过期,直接进仪表盘

预期现象:Grafana 全程不弹自己的密码框,只在首次需要时弹 Frappe 登录页。


🧠 涉及到的代码逻辑

文件关键方法/行号做了什么
frappe/integrations/oauth2.pyget_openid_configuration()返回 OIDC Discovery JSON
frappe/integrations/oauth2.pyhandle_wellknown()路由 /.well-known/ 请求
frappe/integrations/oauth2.pyauthorize() L123处理授权请求,签发 code
frappe/integrations/oauth2.pyget_token() L144code 换 token
frappe/integrations/oauth2.pyopenid_profile() L205返回用户信息
frappe/oauth.pyvalidate_code_verifier()PKCE S256 验证

💡 总结经验

  • 这次最大的教训:Frappe v16 的 Discovery Endpoint 走 /.well-known/ 路由,不是 /api/method/ 路径。先验证端点再配 Client
  • 如果再来一次,我会先验证 Discovery Endpoint 可用性,而不是上来就创建 OAuth Client
  • 这个问题的本质其实是 不同 Frappe 版本/patch 的 OAuth 模块有细微差异,不能假设源码和运行环境一致
  • 以后排查类似问题,第一时间应该看的三个东西:1) /.well-known/openid-configuration 2) console 里 dir(oauth2) 列出真实函数名 3) 容器里 env | grep GF_AUTH 确认变量生效

🕳️ 踩过的坑 & 被否决的方案


🩺 故障排查决策树

症状: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 路线推进