Documentation Index
Fetch the complete documentation index at: https://docs.superun.com/llms.txt
Use this file to discover all available pages before exploring further.
📌 本模块的定位
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 完成令牌交换和身份校验,最终实现”只有特定企业员工才能登录”的访问控制。
用户浏览器 Edge Function 飞书开放平台
│ │ │
├─ 1. 请求 goto_url ──────────▶│ │
│◀─ 2. 返回拼接好的授权 URL ──│ │
│ │ │
├─ 3. QR SDK 渲染二维码 │ │
├─ 4. 用户扫码 ───────────────────────────────────────────────▶│
│◀─ 5. postMessage(tmp_code) │ │
├─ 6. 跳转飞书授权页 ────────────────────────────────────────▶│
│◀─ 7. 302 回调 /auth/callback/feishu?code=xxx&state=xxx │
│ │ │
├─ 8. 发送 code ──────────────▶│ │
│ ├─ 9. code 换 token ──────────▶│
│ │◀─ 10. user_access_token ─────│
│ ├─ 11. 获取用户信息 ───────────▶│
│ │◀─ 12. 用户数据 ──────────────│
│ │ │
│ ├─ 13. 创建/查找 Supabase 用户 │
│ ├─ 14. 生成 magiclink token │
│◀─ 15. 返回 token_hash ──────│ │
│ │ │
├─ 16. verifyOtp 建立会话 │ │
└─ 17. 登录完成,显示用户信息 │ │
二、数据流程
用户打开 /login
│
▼
前端加载飞书 QR SDK(script 标签动态注入)
│
▼
前端生成 CSRF state 并存入 sessionStorage
│
▼
调用 Edge Function feishu-auth(action="qr_url")(传入 redirect_uri + state)
→ 后端用 FEISHU_APP_ID 构造 goto URL 返回前端
│
▼
调用 window.QRLogin({ goto: gotoUrl }) 渲染二维码
│
▼
用户用飞书 App 扫码确认
│
▼
SDK 通过 postMessage 返回 tmp_code
│
▼
前端跳转到 goto + &tmp_code=xxx
│
▼
飞书服务端验证,302 重定向到 redirect_uri?code=xxx&state=xxx
│
▼
/auth/callback/feishu 页面提取 code,校验 state
│
▼
前端调用 Edge Function feishu-auth,传入 code 和 redirect_uri
│
▼
Edge Function 内部:
① POST /authen/v2/oauth/token → 用 code 换 user_access_token
② GET /authen/v1/user_info → 用 token 获取用户信息(含 tenant_key)
③ (可选)校验 tenant_key 是否在白名单中
④ 查询/创建 Supabase 用户 + user_identities 记录
⑤ 生成 magiclink token_hash 返回前端
│
▼
前端调用 supabase.auth.verifyOtp({ token_hash, type: 'magiclink' })
│
▼
Supabase 会话建立,跳转到受保护页面
三、环境变量
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。
// hooks/useFeishuQRCode.ts
import { supabase } from "@/integrations/supabase/client";
const FEISHU_SDK_URL =
"https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js";
interface UseFeishuQRCodeOptions {
containerId: string; // 二维码渲染容器的 DOM id
redirectUri: string; // OAuth 回调地址
width?: string;
height?: string;
}
export function useFeishuQRCode({
containerId, redirectUri,
width = "300", height = "300",
}: UseFeishuQRCodeOptions) {
const [status, setStatus] = useState<"loading" | "ready" | "scanned" | "error">("loading");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const qrLoginRef = useRef(null);
const gotoUrlRef = useRef("");
const generateState = useCallback(() => {
const randomState = crypto.randomUUID();
sessionStorage.setItem("feishu_oauth_state", randomState);
return randomState;
}, []);
useEffect(() => {
let isCleanedUp = false;
const loadSdkAndInit = async () => {
// 1. 动态注入 SDK 脚本
if (!window.QRLogin) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = FEISHU_SDK_URL;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("飞书 SDK 加载失败"));
document.head.appendChild(script);
});
}
if (isCleanedUp) return;
// 2. 生成 CSRF state,调用 Edge Function 获取 goto URL
const state = generateState();
const { data, error } = await supabase.functions.invoke("feishu-auth", {
body: { action: "qr_url", redirect_uri: redirectUri, state },
});
if (isCleanedUp) return;
if (error || !data?.goto_url) {
setStatus("error");
setErrorMessage("获取登录链接失败");
return;
}
const gotoUrl = data.goto_url;
gotoUrlRef.current = gotoUrl;
// 3. 渲染二维码
const qrLoginInstance = window.QRLogin({
id: containerId,
goto: gotoUrl,
width, height,
});
qrLoginRef.current = qrLoginInstance;
setStatus("ready");
// 4. 监听扫码事件
const handleMessage = (event: MessageEvent) => {
if (
qrLoginInstance.matchOrigin(event.origin) &&
qrLoginInstance.matchData(event.data)
) {
setStatus("scanned");
const tmpCode = event.data.tmp_code;
// 跳转到飞书授权页,飞书验证后会 302 到 redirect_uri
window.location.href = \`\${gotoUrl}&tmp_code=\${tmpCode}\`;
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
};
loadSdkAndInit().catch((err) => {
if (!isCleanedUp) {
setStatus("error");
setErrorMessage(err.message);
}
});
return () => { isCleanedUp = true; };
}, [redirectUri, containerId, width, height, generateState]);
return { status, errorMessage };
}
要点:
appId 不在前端出现——通过 Edge Function 在后端拼接完整 URL
state 仍在前端生成并存入 sessionStorage,回调页对比后立即删除
matchOrigin + matchData 双重校验确保消息来源合法
- 如果 Edge Function 调用失败,Hook 会进入
error 状态并显示提示
4.2 OAuth 回调页
飞书 302 重定向回来后,回调页负责提取 code,校验 state,调用后端换取用户信息:
// pages/AuthCallback.tsx
export default function AuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const hasProcessed = useRef(false); // 防止 StrictMode 双重执行
useEffect(() => {
if (hasProcessed.current) return;
hasProcessed.current = true;
const processCallback = async () => {
const code = searchParams.get("code");
const returnedState = searchParams.get("state");
// 1. 校验 state(防 CSRF)
const storedState = sessionStorage.getItem("feishu_oauth_state");
if (!returnedState || returnedState !== storedState) {
// 安全校验失败,终止流程
return;
}
sessionStorage.removeItem("feishu_oauth_state");
if (!code) return; // 授权码缺失
// 2. 调用后端 Edge Function
const redirectUri = \`\${window.location.origin}/auth/callback/feishu\`;
const { data, error } = await supabase.functions.invoke("feishu-auth", {
body: { code, redirect_uri: redirectUri },
});
// 3. 处理结果
if (data?.error === "access_denied") {
// 企业不在白名单中
return;
}
if (data?.token_hash) {
// 建立 Supabase 会话
await supabase.auth.verifyOtp({
token_hash: data.token_hash,
type: "magiclink",
});
navigate("/", { replace: true });
}
};
processCallback();
}, [searchParams, navigate]);
}
要点:
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:
// action === "qr_url" 分支
function handleQRUrl(body) {
const appId = Deno.env.get("SUPERUN_FEISHU_APP_ID");
const { redirect_uri, state } = body;
const gotoUrl =
\`https://passport.feishu.cn/suite/passport/oauth/authorize\` +
\`?client_id=\${appId}\` +
\`&redirect_uri=\${encodeURIComponent(redirect_uri)}\` +
\`&response_type=code\` +
\`&state=\${state}\`;
return new Response(JSON.stringify({ goto_url: gotoUrl }));
}
4.4 Edge Function:feishu-auth(核心后端)
遵循第三方 OAuth 通用架构:用授权码换取用户信息 → 查 user_identities → 创建/更新 Supabase 用户 → generateLink 签发 token_hash → 前端 verifyOtp 建立会话。
// handleAuth 核心流程
async function handleAuth(body) {
const appId = Deno.env.get("SUPERUN_FEISHU_APP_ID");
const appSecret = Deno.env.get("SUPERUN_FEISHU_APP_SECRET");
const allowedTenantKeys = Deno.env.get("SUPERUN_FEISHU_ALLOWED_TENANT_KEYS") || "";
const { code, redirect_uri } = body;
// ── Step 1: code 换 user_access_token (v2 API) ──
// 注意: v2 API 返回的 access_token 在响应顶层,不在 data 中
const tokenResponse = await fetch(
"https://open.feishu.cn/open-apis/authen/v2/oauth/token",
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: appId,
client_secret: appSecret,
code,
redirect_uri,
}),
}
);
const tokenData = await tokenResponse.json();
if (tokenData.code !== 0 || !tokenData.access_token) {
// 错误: tokenData.error_description 包含详细信息
return errorResponse(401, tokenData);
}
// ── Step 2: 获取用户信息 ──
const userInfoResponse = await fetch(
"https://open.feishu.cn/open-apis/authen/v1/user_info",
{
method: "GET",
headers: {
Authorization: \`Bearer \${tokenData.access_token}\`,
"Content-Type": "application/json; charset=utf-8",
},
}
);
const userInfoData = await userInfoResponse.json();
const feishuUser = userInfoData.data;
// ── Step 3: 企业白名单校验(可选)──
const allowedList = allowedTenantKeys.split(",").map(k => k.trim()).filter(Boolean);
if (allowedList.length > 0 && !allowedList.includes(feishuUser.tenant_key)) {
return new Response(
JSON.stringify({ error: "access_denied", message: "您的企业未被授权访问此应用" }),
{ status: 403 }
);
}
// ── Step 4: 查找或创建 Supabase 用户 ──
const virtualEmail = \`feishu_\${feishuUser.open_id}@oauth.local\`;
const userMetadata = {
oauth_provider: "feishu",
oauth_open_id: feishuUser.open_id,
name: feishuUser.name,
...feishuUser,
};
// 查 user_identities 表(service_role 绕过 RLS)
const { data: existingIdentity } = await supabaseAdmin
.from("user_identities")
.select("id")
.eq("oauth_provider", "feishu")
.eq("oauth_open_id", feishuUser.open_id)
.maybeSingle();
if (existingIdentity) {
// 更新已有用户的 metadata
await supabaseAdmin.auth.admin.updateUserById(existingIdentity.id, { user_metadata: userMetadata });
} else {
// 创建新用户(触发器自动创建 user_identities 行)
await supabaseAdmin.auth.admin.createUser({
email: virtualEmail,
email_confirm: true,
user_metadata: userMetadata,
});
}
// ── Step 5: 生成 magic link token ──
const { data: linkData } = await supabaseAdmin.auth.admin.generateLink({
type: "magiclink",
email: virtualEmail,
});
return new Response(
JSON.stringify({
token_hash: linkData.properties?.hashed_token,
user: feishuUser,
}),
{ status: 200 }
);
}
注意:supabase/config.toml 中需要关闭 JWT 验证,因为这个接口在用户登录前调用:
[functions.feishu-auth]
verify_jwt = false
4.5 Edge Function:feishu-tenant-info(获取企业 tenant_key)
用于在不需要用户扫码的情况下,直接通过应用凭证查询企业信息(获取 tenant_key),拿到后配置到白名单环境变量中:
// supabase/functions/feishu-tenant-info/index.ts
serve(async (req) => {
const appId = Deno.env.get("SUPERUN_FEISHU_APP_ID");
const appSecret = Deno.env.get("SUPERUN_FEISHU_APP_SECRET");
// Step 1: 获取 tenant_access_token
const tokenResponse = await fetch(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
}
);
const tokenData = await tokenResponse.json();
// Step 2: 查询企业信息
const tenantResponse = await fetch(
"https://open.feishu.cn/open-apis/tenant/v2/tenant/query",
{
method: "GET",
headers: {
Authorization: \`Bearer \${tokenData.tenant_access_token}\`,
"Content-Type": "application/json; charset=utf-8",
},
}
);
const tenantData = await tenantResponse.json();
// tenantData.data.tenant 包含 name, tenant_key, display_id 等
return new Response(JSON.stringify(tenantData.data));
});
五、数据库
user_identities 表
CREATE TABLE public.user_identities (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
oauth_provider TEXT, -- 'feishu' | null
oauth_open_id TEXT, -- 飞书 open_id
raw_metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 同一 provider + open_id 不能重复
CREATE UNIQUE INDEX unique_oauth_identity
ON public.user_identities(oauth_provider, oauth_open_id)
WHERE oauth_provider IS NOT NULL AND oauth_open_id IS NOT NULL;
触发器: 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,不要用正则提取:
// ✅ 正确
const tmpCode = event.data.tmp_code;
// ❌ 错误 — 把 event.data 当字符串解析
const tmpCode = (event.data as string)?.match(/tmp_code=([^&]+)/)?.[1];
七、接口验证清单
本场景涉及以下飞书 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 |
permissions:
[
{ "scope": "contact:user.base:readonly", "name": "获取用户基本信息", "type": "tenant" },
{ "scope": "tenant:tenant:readonly", "name": "获取企业信息(白名单校验用)", "type": "tenant" }
]
batch_import_json:
{
"scopes": {
"tenant": ["contact:user.base:readonly", "tenant:tenant:readonly"],
"user": []
}
}
Agent 安全设置
| type | name | description |
|---|
redirect_url | 重定向 URL | https://{project_domain}/auth/callback/feishu |
settings:
[
{ "type": "redirect_url", "name": "重定向 URL", "description": "https://{project_domain}/auth/callback/feishu" }
]