跳轉到主要內容

一、核心原理

問題

Supabase Auth 原生不支援飛書、微信等國內 OAuth 平台。我們需要用這些第三方平台做身份驗證,同時複用 Supabase 的會話管理能力(JWT、refresh token、RLS)。

解決思路

把第三方 OAuth 當作「門禁系統」——只負責驗證身份,不負責管理通行證。驗證通過後,由 Supabase Auth 簽發真正的會話令牌。 數據流
用戶掃碼 / 授權


第三方平台返回授權碼 (code)


Edge Function 用 code 換取用戶信息


查 profiles 表:該 OAuth 用戶是否已註冊?

    ├── 已註冊 → 更新用戶信息

    └── 未註冊 → auth.admin.createUser() 創建 Supabase 用戶

                  觸發器自動在 profiles 表插入記錄


auth.admin.generateLink({ type: 'magiclink', email })


返回 hashed_token 給前端


前端調用 supabase.auth.verifyOtp({ token_hash, type: 'magiclink' })


Supabase 會話建立(access_token + refresh_token 自動持久化)

二、user_identities 表設計

表結構

CREATE TABLE public.user_identities (
  id              UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  oauth_provider  TEXT,            -- 'feishu', 'wechat', 'google' 等,非 OAuth 用戶為 NULL
  oauth_open_id   TEXT,            -- 該平台下的用戶唯一標識,非 OAuth 用戶為 NULL
  raw_metadata    JSONB DEFAULT '{}'::jsonb,  -- 保留各平台原始欄位
  created_at      TIMESTAMPTZ DEFAULT now(),
  updated_at      TIMESTAMPTZ DEFAULT now()
);

關鍵設計決策

1. oauth_provideroauth_open_id 是可空欄位 這是最重要的設計決策。原因:
觸發器對所有 auth.users 插入都會執行。如果用戶是通過郵箱/密碼、手機號等非 OAuth 方式註冊的,user_metadata 裡不會有 oauth_provideroauth_open_id。如果這兩個欄位是 NOT NULL,觸發器執行時會因為 NULL 值違反非空約束而報錯,導致用戶註冊失敗。 所以必須將這兩個欄位設為可空,讓觸發器能正常為所有類型的用戶創建 identity 記錄——OAuth 用戶填入具體值,非 OAuth 用戶留 NULL
2. 用部分唯一索引替代複合唯一約束
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;
為什麼不用 UNIQUE(oauth_provider, oauth_open_id)
  • PostgreSQL 的 UNIQUE 約束中,NULL 不等於 NULL,所以多個 (NULL, NULL) 的行不會衝突
  • 但語義上不夠清晰,用部分唯一索引更明確地表達意圖:僅在 OAuth 欄位非空時才強制唯一
  • 這保證了同一平台的同一用戶不會被創建兩次,同時允許任意數量的非 OAuth 用戶存在
3. id 直接引用 auth.users(id)ON DELETE CASCADE
  • user_identities.idauth.users.id 是同一個 UUID
  • Supabase 管理後台刪除用戶時,profile 記錄自動清理
  • RLS 策略可以直接用 auth.uid() = id 做權限控制
4. raw_metadata 存各平台特有欄位 不同 OAuth 平台返回的用戶信息欄位不同(飛書有 tenant_key、微信有 unionid),與其給每個平台加專屬列,不如統一放進 JSONB,表結構永遠不需要為新平台改動。

RLS 策略

ALTER TABLE public.user_identities ENABLE ROW LEVEL SECURITY;

-- 用戶只能讀自己的 identity
CREATE POLICY "Users can view own identity"
  ON public.user_identities FOR SELECT
  USING (auth.uid() = id);

-- 用戶只能改自己的 identity
CREATE POLICY "Users can update own identity"
  ON public.user_identities FOR UPDATE
  USING (auth.uid() = id);

三、觸發器

觸發器函數

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
  provider TEXT := NEW.raw_user_meta_data->>'oauth_provider';
  open_id  TEXT := NEW.raw_user_meta_data->>'oauth_open_id';
BEGIN
  INSERT INTO public.user_identities (id, oauth_provider, oauth_open_id, raw_metadata)
  VALUES (
    NEW.id,
    provider,    -- OAuth 用戶有值,非 OAuth 用戶為 NULL
    open_id,     -- OAuth 用戶有值,非 OAuth 用戶為 NULL
    COALESCE(NEW.raw_user_meta_data, '{}'::jsonb)
  );
  RETURN NEW;
END;
$$;

綁定觸發器

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW
  EXECUTE FUNCTION public.handle_new_user();

觸發器行為說明

註冊方式oauth_provideroauth_open_idraw_metadata
飛書 OAuth'feishu''ou_xxxxx'包含 name、avatar_url、tenant_key 等
微信 OAuth'wechat''oXXXX'包含 nickname、headimgurl、unionid 等
郵箱密碼註冊NULLNULL{} 或包含註冊時傳入的 metadata
手機號註冊NULLNULL{}
SECURITY DEFINER 確保觸發器以創建者權限執行,繞過 RLS 寫入 profiles 表。NEW.raw_user_meta_data 是 Supabase Auth 在 createUser 時傳入的 user_metadata 對象。

四、Edge Function 關鍵代碼(以飛書為例)

用戶查找與創建

import { createClient } from '@supabase/supabase-js';

// 用 Service Role Key 創建管理員客戶端,繞過 RLS
const supabaseAdmin = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  { auth: { autoRefreshToken: false, persistSession: false } }
);

// ... 飛書 OAuth 驗證 + 白名單校驗完成後 ...

const oauthProvider = 'feishu';
const oauthOpenId = feishuUser.open_id;
// 固定使用虛擬郵箱,不用 OAuth 平台返回的真實郵箱
// 原因:用戶可能已用同一郵箱註冊了普通賬號,用真實郵箱會導致 createUser 衝突
const userEmail = `feishu_${oauthOpenId}@oauth.local`;

const userMetadata = {
  oauth_provider: oauthProvider,
  oauth_open_id: oauthOpenId,
  name: feishuUser.name,
  avatar_url: feishuUser.avatar_url,
  tenant_key: feishuUser.tenant_key,
  ...feishuUser,  // 保留所有原始欄位
};

// 通過 user_identities 表查找用戶(Service Role 無視 RLS)
const { data: existingIdentity } = await supabaseAdmin
  .from('user_identities')
  .select('id')
  .eq('oauth_provider', oauthProvider)
  .eq('oauth_open_id', oauthOpenId)
  .single();

if (existingIdentity) {
  // 老用戶:更新 auth 用戶的 metadata + 同步 user_identities 表
  await supabaseAdmin.auth.admin.updateUserById(existingIdentity.id, {
    user_metadata: userMetadata,
  });
  await supabaseAdmin.from('user_identities').update({
    raw_metadata: userMetadata,
    updated_at: new Date().toISOString(),
  }).eq('id', existingIdentity.id);
} else {
  // 新用戶:createUser 後觸發器自動創建 profile 記錄
  const { error } = await supabaseAdmin.auth.admin.createUser({
    email: userEmail,         // 使用虛擬郵箱,不使用 OAuth 平台返回的真實郵箱
    email_confirm: true,      // 跳過郵箱驗證
    user_metadata: userMetadata,
  });
  if (error) throw error;
}

簽發會話令牌

// generateLink 生成一次性令牌
const { data: linkData, error: linkError } =
  await supabaseAdmin.auth.admin.generateLink({
    type: 'magiclink',
    email: userEmail,
  });

if (linkError || !linkData) throw linkError;

const hashedToken = linkData.properties?.hashed_token;

// 返回給前端
return new Response(JSON.stringify({
  token_hash: hashedToken,
  user: feishuUser,
}));

前端建立會話

// 回調頁收到 token_hash 後
const { data, error } = await supabase.auth.verifyOtp({
  token_hash: tokenHash,
  type: 'magiclink',
});

// data.session 包含 access_token + refresh_token
// Supabase 客戶端自動持久化,後續請求自動帶上 JWT

五、前端認證狀態讀取

不需要自定義 Context 或 localStorage,直接用 Supabase Auth 內建的會話管理:
import { supabase } from '@/integrations/supabase/client';

export function useAuth() {
  const [session, setSession] = useState(null);
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 讀取當前會話
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setUser(session?.user ? extractUser(session.user) : null);
      setIsLoading(false);
    });

    // 監聽會話變化(登入、登出、token 刷新)
    const { data: { subscription } } =
      supabase.auth.onAuthStateChange((_event, session) => {
        setSession(session);
        setUser(session?.user ? extractUser(session.user) : null);
        setIsLoading(false);
      });

    return () => subscription.unsubscribe();
  }, []);

  const logout = useCallback(async () => {
    await supabase.auth.signOut();
  }, []);

  return { session, user, isLoading, logout };
}

// 從 Supabase user.user_metadata 提取業務欄位
function extractUser(user) {
  const metadata = user.user_metadata || {};
  return {
    id: user.id,
    email: user.email,
    name: metadata.name || '',
    avatarUrl: metadata.avatar_url || '',
    oauthProvider: metadata.oauth_provider || '',
    oauthOpenId: metadata.oauth_open_id || '',
    tenantKey: metadata.tenant_key || '',
  };
}
用戶信息存在 session.user.user_metadata 裡,就是 createUser 時傳入的那個 userMetadata 對象。

六、擴展新的 OAuth 平台

以接入微信為例,只需要:
  1. 新建 Edge Function wechat-auth:實現微信 OAuth 授權碼交換、用戶信息獲取
  2. 同樣的 Supabase 邏輯:查 user_identities → 創建/更新用戶 → generateLink → 返回 token_hash
  3. 查找時換 provider.eq('oauth_provider', 'wechat').eq('oauth_open_id', wechatOpenId)
不需要改的部分user_identities 表結構、觸發器、前端 useAuthverifyOtp 邏輯、RLS 策略——全部複用。

七、踩坑記錄

問題原因解決方案
非 OAuth 用戶註冊時觸發器報錯oauth_provideroauth_open_id 設為 NOT NULL,但非 OAuth 用戶的 metadata 裡沒有這兩個欄位改為可空欄位,觸發器統一處理:有 OAuth 信息就填,沒有就留 NULL
部分唯一索引 vs 複合唯一約束UNIQUE(a, b) 在 PostgreSQL 中允許多個 (NULL, NULL) 行(因為 NULL != NULL),語義不夠明確CREATE UNIQUE INDEX ... WHERE ... IS NOT NULL 部分索引,明確只約束非空值
generateLink 返回的 hashed_token 一次性使用verifyOtp 成功後 token 失效前端必須在回調頁立即調用 verifyOtp,不能延遲或重試
Edge Function 中 createUseremail 必填Supabase Auth 要求每個用戶有 email固定使用虛擬郵箱 {provider}_{openid}@oauth.local,不使用 OAuth 平台返回的真實郵箱,避免與普通註冊賬號衝突