跳转到主要内容

一、核心原理

问题

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 平台返回的真实邮箱,避免与普通注册账号冲突