📌 本模块的定位
OAuth 登录是整个飞书能力体系的基础模块。 它提供了飞书应用的创建和凭证配置(APP_ID / APP_SECRET),后续所有使用 tenant_access_token 鉴权的模块都复用这份凭证。
下游依赖关系
完成 OAuth 登录配置后,以下模块可直接复用已有的飞书凭证,无需重复配置:| 下游模块 | 复用内容 | 说明 |
|---|---|---|
| 通讯录 | APP_ID + APP_SECRET | 用于获取 tenant_access_token 查询组织架构 |
| 机器人消息 | APP_ID + APP_SECRET | 用于获取 tenant_access_token 发送消息和获取群列表 |
| 消息看板 | APP_ID + APP_SECRET + 机器人能力 | 除凭证外还需要机器人能力(来自「机器人消息」模块) |
| 审批流程 | APP_ID + APP_SECRET | 用于获取 tenant_access_token 管理审批 |
| 告警播报 | 间接依赖 | 通过「机器人消息」模块间接使用 |
💡 建议在配置 OAuth 登录时,一并完成应用凭证的后端配置(SUPERUN_FEISHU_APP_ID+SUPERUN_FEISHU_APP_SECRET),后续模块即可直接使用。
需要的飞书配置总览
OAuth 登录涉及三类配置,缺一不可:1. 飞书应用能力
| 能力 | 说明 | 必须 |
|---|---|---|
| 网页应用 | 在「添加应用能力」中启用 | ❌ 不需要(仅在飞书工作台内打开网页时才需要,OAuth 扫码登录不依赖此能力) |
⚠️ 常见误解澄清:「网页应用」能力的作用是让应用出现在飞书工作台的应用列表中(飞书内嵌网页场景),与「第三方网站用飞书扫码登录」是两个独立场景。OAuth 扫码登录只依赖重定向 URL 配置和应用发布,不需要网页应用能力。
2. 飞书 API 权限(按需)
| 权限标识 | 说明 | 必须 | 分类 |
|---|---|---|---|
contact:user.base:readonly | 获取扫码用户的完整信息(姓名、头像、open_id 等) | 推荐 | 用户身份权限 |
tenant:tenant:readonly | 获取企业信息(tenant_key、企业名称) | 仅白名单校验需要 | 应用身份权限 |
关于contact:user.base:readonly:基础 OAuth 流程通过user_access_token调用/authen/v1/user_info,基本字段(name、open_id、avatar_url、tenant_key)属于 OAuth 授权后的默认返回,不开此权限通常也能获取。但开通后可确保返回完整的用户数据(如 en_name 等附加字段),且其他模块(通讯录等)也需要此权限,建议一并开通。
3. 飞书安全设置(必须)
| 设置项 | 说明 | 必须 | 缺失后果 |
|---|---|---|---|
| 重定向 URL | 完整回调地址(域名 + /auth/callback/feishu) | ✅ 是 | 二维码显示 4401 错误 |
| H5 可信域名 | 你的应用域名 | ❌ 不需要 | 仅飞书 JSSDK 场景需要,OAuth 不依赖 |
H5 可信域名 是给飞书 JSSDK 用的——当你的网页在飞书内置浏览器中运行时,调用 tt.getSystemInfo() 等 API 才需要。OAuth 扫码登录不使用 JSSDK,因此不需要配置。
4. 发布版本(必须)
权限开通和能力添加后,必须创建新版本并发布。未发布的应用也会导致 4401 错误。背景与使用场景
这个能力做什么
飞书 OAuth 扫码登录让用户可以用飞书 App 扫码直接登录你的第三方应用,无需注册新账号或记忆额外密码。系统会自动获取用户的飞书身份信息(姓名、头像、所属企业等),并在你的应用中建立对应的会话。业界怎么用
- 企业内部系统统一入口:OA 系统、HR 平台、知识库、BI 看板等内部工具全部接入飞书扫码登录,员工用一个飞书账号即可访问所有系统,IT 部门只需维护一套身份体系。
- SaaS 产品企业客户对接:面向 B 端的 SaaS 产品(CRM、项目管理、客服系统等)提供「用飞书登录」选项,降低企业客户的采购门槛和员工学习成本。
- 企业白名单访问控制:结合 tenant_key 白名单机制,确保只有指定企业的员工可以登录,适用于定制化项目或特定客户的专属实例。
- 混合身份体系:在已有账号体系的基础上叠加飞书登录,作为第三方 OAuth Provider 之一(类似 Google/GitHub 登录),让用户自由选择登录方式。
为什么这么做
- 降低注册流失:每多一个注册步骤,就会流失 10-20% 的潜在用户。扫码登录将注册和登录合并为一步操作。
- 身份数据可信:飞书提供的用户信息已经过企业认证,不需要邮箱验证、手机验证等额外确认流程。
- 安全性高:OAuth 2.0 授权码模式是行业标准,令牌交换在服务端完成,敏感凭证不暴露到浏览器。
- 天然的组织关系:可以拿到用户的部门、职位、企业信息,为后续权限设计(RBAC)提供开箱即用的数据基础。
一、整体架构
本方案基于飞书 OAuth 2.0 授权码流程,通过飞书官方二维码 SDK 在网页内嵌入扫码区域,用户扫码后由后端 Edge Function 完成令牌交换和身份校验,最终实现”只有特定企业员工才能登录”的访问控制。二、数据流程
三、环境变量
plugin_secret_prefix 应为SUPERUN
| 变量名 | 位置 | 说明 |
|---|---|---|
FEISHU_APP_ID | Edge Function Secrets | 飞书 App ID,用于构造二维码 URL 和令牌交换 |
FEISHU_APP_SECRET | Edge Function Secrets | 飞书应用密钥,仅后端使用 |
ALLOWED_TENANT_KEYS | Edge Function Secrets | (可选)允许登录的企业 tenant_key,逗号分隔;留空则跳过白名单校验,允许所有企业登录 |
四、关键代码
4.1 飞书 QR SDK 加载 Hook
飞书二维码 SDK 只能通过 script 标签引入。这个 Hook 封装了脚本加载、通过 Edge Function 获取授权 URL、二维码初始化和扫码事件监听。 核心改动:appId 不再由前端传入,而是通过 Edge Function 在后端构造完整的 goto URL,前端只提供 redirectUri 和 state。
appId不在前端出现——通过 Edge Function 在后端拼接完整 URLstate仍在前端生成并存入sessionStorage,回调页对比后立即删除matchOrigin+matchData双重校验确保消息来源合法- 如果 Edge Function 调用失败,Hook 会进入
error状态并显示提示
4.2 OAuth 回调页
飞书 302 重定向回来后,回调页负责提取code,校验 state,调用后端换取用户信息:
useRef(hasProcessed)是必须的——React StrictMode 下useEffect会执行两次,第一次执行会清除sessionStorage中的state,第二次执行就会校验失败redirect_uri必须和登录页goto中传的完全一致
4.3 Edge Function:feishu-auth(action=“qr_url”)
前端不再持有FEISHU_APP_ID,改由 Edge Function 在后端构造完整的 goto URL:
4.4 Edge Function:feishu-auth(核心后端)
遵循第三方 OAuth 通用架构:用授权码换取用户信息 → 查user_identities → 创建/更新 Supabase 用户 → generateLink 签发 token_hash → 前端 verifyOtp 建立会话。
supabase/config.toml 中需要关闭 JWT 验证,因为这个接口在用户登录前调用:
4.5 Edge Function:feishu-tenant-info(获取企业 tenant_key)
用于在不需要用户扫码的情况下,直接通过应用凭证查询企业信息(获取tenant_key),拿到后配置到白名单环境变量中:
五、数据库
user_identities 表
on_auth_user_created — 当 auth.users 新增记录时自动在 user_identities 创建对应行。
虚拟邮箱模式: feishu_{open_id}@oauth.local,避免与真实邮箱冲突。
参考 第三方 OAuth 登录 + Supabase Auth 会话管理 获取 user_identities 表设计与触发器的完整实现。
六、飞书 QR SDK 内部机制
理解 SDK 内部行为可以帮助快速定位大部分二维码渲染问题。
6.1 SDK 做了什么
window.QRLogin({ id, goto, width, height }) 的内部流程:
- 解析 goto:
new URL(goto)提取 origin - 判断路径:检测 goto 是否包含
suite/passport - 构造 iframe URL:
- 包含
suite/passport→{origin}/suite/passport/sso/qr?goto={encodeURIComponent(goto)}&sdk_version=1.0.3 - 不包含 →
{origin}/accounts/auth_login/qr?goto={encodeURIComponent(goto)}&sdk_version=1.0.3
- 包含
- 创建 iframe:append 到
document.getElementById(id)容器中 - 返回工具对象:
{ matchOrigin(origin), matchData(data) }
6.2 goto 参数编码规则
| 位置 | 是否需要编码 |
|---|---|
Edge Function 构造 goto URL 时,redirect_uri 参数 | ✅ 需要 encodeURIComponent |
传入 window.QRLogin({ goto }) | ❌ 直接传原始 URL,SDK 内部会自动 encode |
扫码后跳转 goto + &tmp_code=xxx | ❌ 直接拼接即可 |
6.3 postMessage 数据格式
SDK 的matchData 函数检查:data.source === "qrcode" && data.tmp_code 存在。
⚠️ 关键:tmp_code 是 event.data 对象上的直接属性,不是 query string,不要用正则提取:
七、接口验证清单
本场景涉及以下飞书 API,可逐一验证是否在当前系统中调通。7.1 获取 tenant_access_token(应用凭证换令牌)
| 项目 | 值 |
|---|---|
| 方法 | POST |
| URL | https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal |
| 鉴权 | 无需 token,使用 app_id + app_secret 请求体鉴权 |
| 所需权限 | 无(仅需有效的应用凭证) |
| 用途 | 获取 tenant_access_token,用于后续企业信息查询(获取 tenant_key) |
| 请求体 | { "app_id": "cli_xxx", "app_secret": "xxx" } |
| 成功响应 | { "code": 0, "tenant_access_token": "t-xxx", "expire": 7200 } |
| 失败响应 | code 非 0,检查 app_id/app_secret 是否正确 |
| 官方文档 | 自建应用获取 tenant_access_token |
7.2 code 换 user_access_token
| 项目 | 值 |
|---|---|
| 方法 | POST |
| URL | https://open.feishu.cn/open-apis/authen/v2/oauth/token |
| 鉴权 | 无需 token,使用 client_id + client_secret 请求体鉴权 |
| 所需权限 | 无(OAuth 标准流程) |
| 用途 | 用授权码 code 换取用户的 user_access_token |
| 请求体 | { "grant_type": "authorization_code", "client_id": "cli_xxx", "client_secret": "xxx", "code": "xxx", "redirect_uri": "https://..." } |
| 成功响应 | { "code": 0, "access_token": "u-xxx", "token_type": "Bearer", "expires_in": 6900, "refresh_token": "ur-xxx" } |
| ⚠️ 注意 | v2 API 的 access_token 在响应顶层,不在 data 中 |
| 常见错误 | code 过期或已使用(code 只能用一次);redirect_uri 与授权时不一致 |
| 官方文档 | 获取 user_access_token |
7.3 获取用户信息
| 项目 | 值 |
|---|---|
| 方法 | GET |
| URL | https://open.feishu.cn/open-apis/authen/v1/user_info |
| 鉴权 | Authorization: Bearer {user_access_token} |
| 所需权限 | contact:user.base:readonly(获取用户基本信息) |
| 用途 | 获取扫码用户的详细信息(name、open_id、tenant_key 等) |
| 成功响应 | { "code": 0, "data": { "name": "张三", "open_id": "ou_xxx", "tenant_key": "xxx", ... } } |
| 常见错误 | token 无效或过期 → 需重新授权;权限不足 → 检查 contact:user.base:readonly |
| 官方文档 | 获取登录用户信息 |
7.4 获取企业信息(可选,白名单校验用)
| 项目 | 值 |
|---|---|
| 方法 | GET |
| URL | https://open.feishu.cn/open-apis/tenant/v2/tenant/query |
| 鉴权 | Authorization: Bearer {tenant_access_token} |
| 所需权限 | tenant:tenant:readonly(获取企业信息) |
| 用途 | 获取企业 tenant_key、企业名称等信息,用于白名单配置 |
| 成功响应 | { "code": 0, "data": { "tenant": { "name": "xxx公司", "tenant_key": "xxx", ... } } } |
| 常见错误 | 缺少 tenant:tenant:readonly 权限 → 去开发者后台申请 |
| 官方文档 | 获取企业信息 |
7.5 飞书 OAuth 授权页(浏览器跳转,非 API 调用)
| 项目 | 值 |
|---|---|
| 方法 | GET(浏览器重定向) |
| URL | https://passport.feishu.cn/suite/passport/oauth/authorize?client_id={app_id}&redirect_uri={encoded_uri}&response_type=code&state={state} |
| 鉴权 | 无(用户扫码授权) |
| 所需配置 | 飞书开发者后台 → 安全设置 → 重定向 URL 中配置 redirect_uri |
| 用途 | 生成二维码 SDK 的 goto URL,或直接重定向用户进行授权 |
| 常见错误 | 4401 错误 → redirect_uri 未在飞书后台配置;应用未发布;未添加网页能力 |
| 官方文档 | 请求身份验证 |
八、踩坑记录
8.1 飞书平台配置类
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 二维码区域显示 4401 错误 | SDK 加载成功,iframe 内显示 “Error code: 4401 The request is invalid” | redirect_uri 未在飞书开发者后台的「安全设置 → 重定向 URL」中注册,或应用未发布 | 将完整的回调地址(如 https://xxx.superun.yun/auth/callback/feishu)添加到飞书后台的重定向 URL 列表中 |
| 4401 排查要点 | 所有 redirect_uri 都报 4401 | 重定向 URL 未配置或应用未发布 | ①确认重定向 URL 已配置 ②确认已创建版本并发布 |
| redirect_uri 不匹配 | 扫码后 token 交换报错 | goto 中的、传给令牌接口的、飞书后台配置的三处 redirect_uri 不一致 | 统一用 window.location.origin + '/auth/callback/feishu' 生成,确保三处完全一致(含协议和路径) |
8.2 飞书 API 类
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| v2 token 接口响应格式 | tokenData.data?.access_token 始终为 undefined,登录永远失败 | 飞书 v2 OAuth token API (/authen/v2/oauth/token) 的 access_token 在响应顶层,不在 data 下 | 使用 tokenData.access_token,判断条件为 tokenData.code !== 0 || !tokenData.access_token |
8.3 QR SDK 类
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| SDK 脚本加载失败后重试卡死 | 第一次加载失败后,后续所有重试永远停在 loading 状态 | 失败的 <script> 标签残留在 DOM 中,后续 loadScript 找到已有标签后等待 load 事件,但事件永远不会再触发 | 每次加载前先 document.querySelector('script[src="..."]')?.remove() 清除旧标签,并加 5 秒超时 |
| QR SDK 不支持 iframe 环境 | 二维码区域空白或显示错误 | SDK 在容器内创建 iframe,如果宿主页面本身在 iframe 中(如 superun 预览),嵌套 iframe 可能被 CSP 或沙箱策略阻止 | 提供”直接跳转授权”作为降级方案:window.location.href = gotoUrl,走标准 302 重定向流程 |
| DOM 竞态:QRLogin 调用时容器不存在 | SDK 静默失败,二维码不渲染 | 同时调用 setState(true) 和 initQRCode(),React 批量更新导致容器 DOM 尚未渲染 | 用 waitForElement(id, 2000) 轮询 DOM(每 50ms 检查一次),确认容器存在后再调用 QRLogin |
| iframe 存在但显示错误(假阳性) | 代码检测到 iframe 存在就认为渲染成功,实际 iframe 内显示 4401 错误 | SDK 总会创建 iframe,无论服务端返回成功还是错误——iframe 的存在不等于二维码渲染成功 | 在 UI 中始终显示 redirect_uri 和配置提示,让用户能自行诊断 4401 等服务端错误 |
| tmp_code 提取方式错误 | 扫码后无反应 | event.data 是对象不是字符串,tmp_code 是直接属性 | 使用 event.data.tmp_code(直接属性访问),不要用正则匹配 |
8.4 React / 前端类
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| State 校验失败 | 回调页报 state 不匹配 | React StrictMode 下 useEffect 执行两次,第一次清除了 sessionStorage,第二次读到 null | 用 useRef(hasProcessed) 标记确保回调只处理一次 |
附录:Agent 权限与安全配置参考
Agent 权限配置
| scope | name | type |
|---|---|---|
contact:user.base:readonly | 获取用户基本信息(推荐) | tenant |
tenant:tenant:readonly | 获取企业信息(可选,白名单用) | tenant |
Agent 安全设置
| type | name | description |
|---|---|---|
redirect_url | 重定向 URL | https://{project_domain}/auth/callback/feishu |

