跳轉到主要內容

一、整體架構

本方案基於飛書 OAuth 2.0 授權碼流程,透過飛書官方二維碼 SDK 在網頁內嵌入掃碼區域,用戶掃碼後由後端 Edge Function 完成令牌交換和身份校驗,最終實現「只有特定企業員工才能登入」的存取控制。

二、飛書端配置(5 步)

步驟 1:建立應用並取得憑證

  1. 開啟 飛書開放平台,登入後進入「開發者後台
  2. 點擊「建立企業自建應用 建立企業自建應用
  3. 填寫應用名稱(如「數據工作台」)和描述,點擊建立
  4. 建立完成後進入應用詳情頁,左側選單選擇「憑證與基礎資訊
  5. 記錄 App IDApp Secret——分別對應環境變數 SUPERUN_FEISHU_APP_IDSUPERUN_FEISHU_APP_SECRET 取得應用憑證

步驟 2:新增網頁能力

  1. 在應用詳情頁,點擊「新增應用能力」,選擇「網頁應用
  2. 填寫桌面端主頁 URL 為 SUPERUN 發布後的位址,選擇瀏覽器開啟 新增網頁應用 網頁能力填寫

步驟 3:配置應用權限

  1. 進入「權限管理」,搜尋並開通以下權限:
權限標識說明必須分類
contact:user.base:readonly讀取用戶資訊用戶身份權限
tenant:tenant:readonly取得企業資訊僅白名單校驗時需要應用身份權限
配置應用權限 配置應用權限

步驟 4:配置安全設定(可信網域 & 重定向 URL)

  1. 進入「安全設定
  2. 新增 H5 可信網域——需要填寫以下兩個網域:
    • 開發網域(點擊演示 / 開啟新視窗預覽取得網域,去掉路徑部分)
    • 發布上線之後的正式網域
  3. 新增重定向 URL——在網域後拼接回調頁面路徑,例如:
    • 網域為 https://hello-world2446.superun.yun,回調路徑為 /auth/callback/feishu
    • 則重定向 URL 為 https://hello-world2446.superun.yun/auth/callback/feishu
    安全設定 取得superun開發網域 取得superun發布後的網域

步驟 5:建立版本並發布

  1. 左側選單 →「版本管理與發布
  2. 建立版本 → 填寫版本號和更新說明
  3. 選擇可用範圍——全體員工或部分成員(此處限制了哪些用戶可以掃碼登入)
  4. 提交發布(企業內部應用通常自動通過審核) 設定版本可用範圍
💡 提示:權限開通後必須建立版本並發布,權限才會生效。

三、數據流程

用戶開啟 /login


前端載入飛書 QR SDK(script 標籤動態注入)


前端產生 CSRF state 並存入 sessionStorage


呼叫 Edge Function feishu-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 會話建立,跳轉到受保護頁面

四、環境變數

  1. 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 不再由前端傳入,而是透過 feishu-qr-url 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;
  style?: string;
}
export function useFeishuQRCode({
  containerId, redirectUri,
  width = "300", height = "300", style,
}: UseFeishuQRCodeOptions) {
  const [status, setStatus] = useState<"loading" | "ready" | "scanned" | "error">("loading");
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const qrLoginRef = useRef(null);
  const gotoUrlRef = useRef("");
  // 產生隨機 state 存入 sessionStorage,防 CSRF
  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-qr-url", {
        body: { 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, style, generateState]);
  return { status, errorMessage };
}
要點
  • appId 不在前端出現——透過 feishu-qr-url 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?.user) {
        // 登入成功,儲存用戶資訊並跳轉
        navigate("/", { replace: true });
      }
    };
    processCallback();
  }, [searchParams, navigate]);
}
要點
  • useRef(hasProcessed) 是必須的——React StrictMode 下 useEffect 會執行兩次,第一次執行會清除 sessionStorage 中的 state,第二次執行就會校驗失敗
  • redirect_uri 必須和登入頁 goto 中傳的完全一致

4.3 Edge Function:feishu-qr-url(產生二維碼授權 URL)

前端不再持有 FEISHU_APP_ID,改由這個輕量 Edge Function 在後端構造完整的 goto URL:
// supabase/functions/feishu-qr-url/index.ts
serve(async (req) => {
  const appId = Deno.env.get("SUPERUN_FEISHU_APP_ID");
  if (!appId) {
    return new Response(
      JSON.stringify({ error: "FEISHU_APP_ID not configured" }),
      { status: 500 }
    );
  }
  const { redirect_uri, state } = await req.json();
  if (!redirect_uri || !state) {
    return new Response(
      JSON.stringify({ error: "redirect_uri and state are required" }),
      { status: 400 }
    );
  }
  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 }), { status: 200 });
});
配置
[functions.feishu-qr-url]
verify_jwt = false

4.4 Edge Function:feishu-auth(核心後端)

// supabase/functions/feishu-auth/index.ts
serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }
  const feishuAppId = Deno.env.get("SUPERUN_FEISHU_APP_ID");
  const feishuAppSecret = Deno.env.get("SUPERUN_FEISHU_APP_SECRET");
  const allowedTenantKeys = Deno.env.get("SUPERUN_FEISHU_ALLOWED_TENANT_KEYS") || "";
  const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
  const supabaseServiceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
  if (!feishuAppId || !feishuAppSecret) {
    logStep("ERROR", { message: "Missing FEISHU_APP_ID or FEISHU_APP_SECRET" });
    return new Response(
      JSON.stringify({ error: "Server configuration error" }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }
    );
  }
  // Supabase Admin client (bypasses RLS)
  const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, {
    auth: { autoRefreshToken: false, persistSession: false },
  });
  const { code, redirect_uri } = await req.json();
  if (!code) {
    return new Response(
      JSON.stringify({ error: "Authorization code is required" }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400 }
    );
  }
  // ── Step 1: Exchange code for user_access_token (Feishu v2 API) ──
  logStep("Step 1: Exchanging code for user_access_token");
  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: feishuAppId,
        client_secret: feishuAppSecret,
        code,
        redirect_uri,
      }),
    }
  );
  const tokenData = await tokenResponse.json();
  if (tokenData.code !== 0 || !tokenData.access_token) {
    logStep("ERROR: Token exchange failed", {
      code: tokenData.code,
      error: tokenData.error,
      description: tokenData.error_description,
    });
    return new Response(
      JSON.stringify({
        error: "Token exchange failed",
        detail: tokenData.error_description || tokenData.error || "Unknown error",
      }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 401 }
    );
  }
  logStep("Step 1 completed", { expires_in: tokenData.expires_in });
  // ── Step 2: Get user info from Feishu ──
  logStep("Step 2: Fetching user info");
  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();
  if (userInfoData.code !== 0) {
    logStep("ERROR: Failed to fetch user info", { code: userInfoData.code, msg: userInfoData.msg });
    return new Response(
      JSON.stringify({ error: "Failed to get user info", detail: userInfoData.msg }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 401 }
    );
  }
  const feishuUser = userInfoData.data;
  const tenantKey = feishuUser.tenant_key;
  logStep("Step 2 completed", {
    name: feishuUser.name,
    tenant_key: tenantKey,
    open_id: feishuUser.open_id,
  });
  // ── Step 3: Enterprise whitelist validation (optional) ──
  // 如果 SUPERUN_FEISHU_ALLOWED_TENANT_KEYS 未配置或為空,則跳過白名單校驗,允許所有企業登入
  logStep("Step 3: Validating tenant_key (optional)");
  const allowedList = allowedTenantKeys
    .split(",")
    .map((key: string) => key.trim())
    .filter(Boolean);
  if (allowedList.length > 0 && !allowedList.includes(tenantKey)) {
    logStep("REJECTED: Tenant not in whitelist", {
      tenant_key: tenantKey,
      allowed: allowedList,
    });
    return new Response(
      JSON.stringify({
        error: "access_denied",
        message: "您的企業未被授權存取此應用",
        tenant_key: tenantKey,
      }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 403 }
    );
  }
  logStep("Step 3 completed: Tenant validated (or whitelist disabled)");
  // 遵循第三方 OAuth 通用架構
  // ── Step 4: Find or create Supabase user via user_identities table ──
  logStep("Step 4: Finding or creating Supabase user");
  const oauthProvider = "feishu";
  const oauthOpenId = feishuUser.open_id;
  // 固定使用虛擬信箱,避免與用戶用真實信箱註冊的帳號衝突
  const userEmail = `feishu_${oauthOpenId}@oauth.local`;
  const userMetadata = {
    oauth_provider: oauthProvider,
    oauth_open_id: oauthOpenId,
    name: feishuUser.name,
    en_name: feishuUser.en_name,
    avatar_url: feishuUser.avatar_url,
    tenant_key: tenantKey,
    ...feishuUser,
  };
  // Query user_identities table (service_role bypasses RLS)
  const { data: existingIdentity } = await supabaseAdmin
    .from("user_identities")
    .select("id")
    .eq("oauth_provider", oauthProvider)
    .eq("oauth_open_id", oauthOpenId)
    .single();
  if (existingIdentity) {
    logStep("User exists, updating metadata", { userId: existingIdentity.id });
    // Update auth user metadata
    await supabaseAdmin.auth.admin.updateUserById(existingIdentity.id, {
      user_metadata: userMetadata,
    });
    // Sync user_identities table
    await supabaseAdmin
      .from("user_identities")
      .update({
        raw_metadata: userMetadata,
        updated_at: new Date().toISOString(),
      })
      .eq("id", existingIdentity.id);
  } else {
    logStep("New user, creating Supabase account", { email: userEmail });
    const { error: createError } = await supabaseAdmin.auth.admin.createUser({
      email: userEmail,
      email_confirm: true,
      user_metadata: userMetadata,
    });
    if (createError) {
      logStep("ERROR: Failed to create user", { message: createError.message });
      return new Response(
        JSON.stringify({ error: "Failed to create user account", detail: createError.message }),
        { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }
      );
    }
    // Trigger automatically creates user_identities row
  }
  // ── Step 5: Generate magic link token for session establishment ──
  logStep("Step 5: Generating magic link token");
  const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
    type: "magiclink",
    email: userEmail,
  });
  if (linkError || !linkData) {
    logStep("ERROR: Failed to generate link", { message: linkError?.message });
    return new Response(
      JSON.stringify({ error: "Failed to generate session token", detail: linkError?.message }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }
    );
  }
  const hashedToken = linkData.properties?.hashed_token;
  if (!hashedToken) {
    logStep("ERROR: No hashed_token in response", { linkData });
    return new Response(
      JSON.stringify({ error: "Failed to generate session token" }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }
    );
  }
  logStep("Step 5 completed: Token generated");
  return new Response(
    JSON.stringify({
      token_hash: hashedToken,
      user: feishuUser,
    }),
    {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
      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("FEISHU_APP_ID");
  const appSecret = Deno.env.get("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();
  // tokenData.tenant_access_token
  // 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));
});

六、踩坑記錄

問題原因解決方案
State 校驗失敗(值明明一致)React StrictMode 下 useEffect 執行兩次,第一次清除了 sessionStorage,第二次讀到 nulluseRef 標記確保回調只處理一次
redirect_uri 不匹配goto 中的、傳給令牌介面的、飛書後台配置的三處必須完全一致統一用 window.location.origin + '/auth/callback/feishu' 產生
二維碼無法正常載入或掃碼後無回應飛書 QR SDK 不支援在 iframe 中執行(跨域限制 & postMessage 來源校驗)測試時需點擊「開啟新視窗」預覽,或發佈上線後在獨立頁面中測試