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_provider 和 oauth_open_id 是可空字段
这是最重要的设计决策。原因:
触发器对所有 auth.users 插入都会执行。如果用户是通过邮箱/密码、手机号等非 OAuth 方式注册的,user_metadata 里不会有 oauth_provider 和 oauth_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.id 和 auth.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_provider | oauth_open_id | raw_metadata |
|---|
| 飞书 OAuth | 'feishu' | 'ou_xxxxx' | 包含 name、avatar_url、tenant_key 等 |
| 微信 OAuth | 'wechat' | 'oXXXX' | 包含 nickname、headimgurl、unionid 等 |
| 邮箱密码注册 | NULL | NULL | {} 或包含注册时传入的 metadata |
| 手机号注册 | NULL | NULL | {} |
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 平台
以接入微信为例,只需要:
- 新建 Edge Function
wechat-auth:实现微信 OAuth 授权码交换、用户信息获取
- 同样的 Supabase 逻辑:查
user_identities → 创建/更新用户 → generateLink → 返回 token_hash
- 查找时换 provider:
.eq('oauth_provider', 'wechat').eq('oauth_open_id', wechatOpenId)
不需要改的部分:user_identities 表结构、触发器、前端 useAuth、verifyOtp 逻辑、RLS 策略——全部复用。
七、踩坑记录
| 问题 | 原因 | 解决方案 |
|---|
| 非 OAuth 用户注册时触发器报错 | oauth_provider 和 oauth_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 中 createUser 的 email 必填 | Supabase Auth 要求每个用户有 email | 固定使用虚拟邮箱 {provider}_{openid}@oauth.local,不使用 OAuth 平台返回的真实邮箱,避免与普通注册账号冲突 |