跳转到主要内容

📌 本模块的定位

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 登录),让用户自由选择登录方式。

为什么这么做

  1. 降低注册流失:每多一个注册步骤,就会流失 10-20% 的潜在用户。扫码登录将注册和登录合并为一步操作。
  2. 身份数据可信:飞书提供的用户信息已经过企业认证,不需要邮箱验证、手机验证等额外确认流程。
  3. 安全性高:OAuth 2.0 授权码模式是行业标准,令牌交换在服务端完成,敏感凭证不暴露到浏览器。
  4. 天然的组织关系:可以拿到用户的部门、职位、企业信息,为后续权限设计(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_IDEdge Function Secrets飞书 App ID,用于构造二维码 URL 和令牌交换
FEISHU_APP_SECRETEdge Function Secrets飞书应用密钥,仅后端使用
ALLOWED_TENANT_KEYSEdge Function Secrets(可选)允许登录的企业 tenant_key,逗号分隔;留空则跳过白名单校验,允许所有企业登录

四、关键代码

4.1 飞书 QR SDK 加载 Hook

飞书二维码 SDK 只能通过 script 标签引入。这个 Hook 封装了脚本加载、通过 Edge Function 获取授权 URL、二维码初始化和扫码事件监听。 核心改动appId 不再由前端传入,而是通过 Edge Function 在后端构造完整的 goto URL,前端只提供 redirectUristate
// 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 }) 的内部流程:
  1. 解析 gotonew URL(goto) 提取 origin
  2. 判断路径:检测 goto 是否包含 suite/passport
  3. 构造 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
  4. 创建 iframe:append 到 document.getElementById(id) 容器中
  5. 返回工具对象{ 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_codeevent.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
URLhttps://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
URLhttps://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
URLhttps://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
URLhttps://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(浏览器重定向)
URLhttps://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,第二次读到 nulluseRef(hasProcessed) 标记确保回调只处理一次

附录:Agent 权限与安全配置参考

Agent 权限配置

scopenametype
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 安全设置

typenamedescription
redirect_url重定向 URLhttps://{project_domain}/auth/callback/feishu
settings
[
  { "type": "redirect_url", "name": "重定向 URL", "description": "https://{project_domain}/auth/callback/feishu" }
]