Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.superun.ai/llms.txt

Use this file to discover all available pages before exploring further.

一、核心原理

问题

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