Supabase 自定义第三方认证
Supabase 本身支持 OAuth 和基于 SAML2.0 的 SSO 登录,但有时我们需要集成一些自定义的第三方认证服务,比如公司内部的认证系统,此时就需要自己实现认证流程并接入到 Supabase 中。本文将介绍如何在 Nuxt 项目中集成自定义第三方认证,并将认证结果同步到 Supabase 的用户系统中。
准备工作
这里我们使用了一个由 Nuxt 和 Supabase 组成的项目,假设你已经完成了 Supabase 项目的创建,并在 Nuxt 中集成了 @nuxtjs/supabase。如果还没有,可以参考上一篇文章进行配置。
要接入的认证系统是标准的 SSO CAS 系统,拥有以下 API 端点:
/cas/login?service=:用户登录页面/cas/validate?service=&ticket=:验证登录票据,返回用户名
为了集中统一配置,可以将以上内容写入到 nuxt.config.ts 的 appConfig 中:
export default defineNuxtConfig({
appConfig: {
casBaseUrl: process.env.CAS_BASE_URL ?? "https://example.com/cas",
casServiceUrl:
process.env.CAS_SERVICE_URL ??
encodeURIComponent("http://localhost:3000/api/cas"),
},
});
@nuxtjs/supabase 默认会自动跳转所有调用了 useSupabaseUser() 的页面到 /login,在某些只是需要判断用户是否登录的页面,这个行为并不适合,所以我们需要在 nuxt.config.ts 中准确指定哪些页面需要登录:
export default defineNuxtConfig({
supabase: {
redirectOptions: {
include: ["/admin(/*)?"],
login: "/login",
callback: "/",
},
},
});
实现认证流程
接下来我们需要实现认证流程,主要原理是先手动处理 CAS 流程,然后通过 Supabase 生成魔法链接登录用户。
首先创建一个 API 路由 server/api/cas.ts 来处理 CAS 的回调:
import { serverSupabaseServiceRole } from "#supabase/server";
export default defineEventHandler(async (event) => {
const { casBaseUrl, casServiceUrl } = useAppConfig();
const { ticket } = getQuery(event);
const supabase = serverSupabaseServiceRole(event);
const rawXml = await $fetch<string>(
`${casBaseUrl}/serviceValidate?service=${casServiceUrl}&ticket=${ticket}`,
);
const name = rawXml.match(/<cas:user>(\d+)<\/cas:user>/)?.[1];
if (!name) {
throw createError({ statusCode: 400, statusMessage: "Invalid CAS ticket" });
}
// 如果 CAS 系统返回的字段有邮箱可以直接使用,否则需要构造一个内部邮箱
const email = `${name}@id.internal`;
// TODO
});
然后我们需要判断 Supabase 中是否已经存在该用户, Supabase 内部没有直接通过 email 查询用户是否存在的接口,可以通过查询 public.profiles 表来实现:
let { data: user } = await supabase
.from("profiles")
.select("id")
.eq("name", name)
.single();
或者你也可以创建一个数据库函数:
CREATE OR REPLACE FUNCTION get_user_id_by_email(email TEXT)
RETURNS TABLE (id uuid)
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY SELECT id FROM auth.users WHERE email = $1;
END;
$$ LANGUAGE plpgsql;
然后调用:
let { data: user } = await supabase.rpc("get_user_id_by_email", { email });
之后我们就可以根据用户是否存在来创建用户,并生成魔法链接发送给用户:
if (!user) {
const { data } = await supabase.auth.admin.createUser({
email,
});
user = data.user!;
await supabase.from("profiles").insert({ id: user.id, name });
}
const { data } = await supabase.auth.admin.generateLink({
type: "magiclink",
email,
});
const action_link = data.properties?.action_link;
if (action_link) {
sendRedirect(event, action_link);
}
throw createError({
statusCode: 500,
statusMessage: "Failed to generate magic link",
});
如果你启用了 SSR
显然 Nuxt 和 @nuxtjs/supabase 都是默认开启 SSR 的,所以你很有可能会遇到在前端成功跳转认证,并获得 access_token 后,但用户仍然没有登录的情况。这是因为在 SSR 模式下,Supabase 使用的是 PKCE 流,而不是传统 implicit 流。如果你在前端尝试手动初始化:
const supabase = useSupabaseClient();
console.log(supabase.auth.initialize());
你会在控制台看到如下问题:
AuthPKCEGrantCodeExchangeError: Not a valid PKCE flow url.
这个问题在 Supabase 文档的 Signing in with Magic Link 部分有具体写到。但是文档只给出了在邮件模板中插入 hashToken 供 PKCE 使用的方法,后端 API 并没有直接生成带 hashToken 的魔法链接的接口。
所以我们需要自己实现这个功能,在上文我们配置了 callback 为 /,所以我们可以在首页处理这个逻辑,你也可以将其写到 app.vue 中:
watchEffect(() => {
if (route.hash) {
const url = new URLSearchParams(route.hash.slice(1));
const accessToken = url.get("access_token") || "";
supabase.auth.setSession({
access_token: accessToken,
refresh_token: accessToken,
});
}
});
现在你就可以愉快地使用 Supabase 自定义第三方认证了!