微信小程序|在 Supabase 中集成微信登录

在微信小程序中使用 Supabase 作为后端时,无法直接使用 Supabase 内置的 OAuth 登录流程。我们可以通过 Supabase Edge Functions 实现微信原生登录,让用户可以在小程序中无缝注册和登录。

在 Supabase 完成完整登录流程,无需引入格外的后端 API(例如 Vercel)。

架构与原理概述

┌─────────────────┐      ┌─────────────────┐      ┌─────────────────┐
│   微信小程序     │ ───▶ │  Edge Function  │ ───▶ │   Supabase      │
│   wx.login()    │      │  wechat-login   │      │   Auth + DB     │
└─────────────────┘      └─────────────────┘      └─────────────────┘
         │                        │                        │
    获取 code              换取 openid              创建/登录用户
                          (微信 API)              返回 JWT Token

调用链路:

  1. 小程序调用 wx.login() → 获取 code
  2. 小程序 POST 请求 → Edge Function
  3. Edge Function 调用微信 API → 获取 opened
  4. Edge Function 检查用户是否存在
  5. 如果不存在 → 调用 supabaseAdmin.auth.admin.createUser() 创建用户
  6. 调用 signInWithPassword() 获取 Session(Session 由 Supabase 提供
  7. 返回 Token 给小程序

登录有两种方式:

  • wx.login() - 无需用户授权,可以静默登录
  • xxx - 可以获取用户手机号,需要用户授权,需要企业身份

Supabase 端的配置

了解 Edge Functions 的作用

微信登录需要调用外部 HTTP API(微信的 jscode2session),在高徒项目中,我们通过创建一个后端 API 来处理微信请求 —— 可以在 Vercel 中托管一个 API。

Supabase 提供了 Edge Functions 来接收外部请求,通过使用它,我们可以实现类似的功能,这样就可以简化架构,无需引入格外的 API。

  1. 接收小程序的请求
  2. 调用微信 API 获取 
  3. 创建/登录 Supabase 用户
  4. 返回 Session

编写函数代码

编辑 supabase/functions/wechat-login/index.ts

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
};

const WECHAT_API = "https://api.weixin.qq.com/sns/jscode2session";

// 生成确定性密码
async function generatePassword(openid: string, secret: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(openid + secret);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const WECHAT_APP_ID = Deno.env.get("WECHAT_APP_ID");
    const WECHAT_APP_SECRET = Deno.env.get("WECHAT_APP_SECRET");
    const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
    const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
    const PASSWORD_SECRET = Deno.env.get("PASSWORD_SECRET") ?? "your-secret";

    const { code } = await req.json();

    // 1. 调用微信 API 换取 openid
    const wxUrl = `${WECHAT_API}?appid=${WECHAT_APP_ID}&secret=${WECHAT_APP_SECRET}&js_code=${code}&grant_type=authorization_code`;
    const wxResponse = await fetch(wxUrl);
    const wxData = await wxResponse.json();

    if (wxData.errcode || !wxData.openid) {
      return new Response(
        JSON.stringify({ error: "WeChat login failed", details: wxData }),
        { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    const openid = wxData.openid;

    // 2. 创建 Supabase Admin 客户端
    const supabaseAdmin = createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!, {
      auth: { autoRefreshToken: false, persistSession: false },
    });

    // 3. 生成 Shadow Account 凭据
    const emailPrefix = openid.replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
    const email = `${emailPrefix}@example.com`;
    const password = await generatePassword(openid, PASSWORD_SECRET);

    // 4. 查找用户是否存在
    const { data: existingUsers } = await supabaseAdmin.auth.admin.listUsers();
    const existingUser = existingUsers?.users?.find((u: any) => 
      u.user_metadata?.openid === openid
    );

    let session;

    if (existingUser) {
      // 用户已存在,登录
      const { data, error } = await supabaseAdmin.auth.signInWithPassword({
        email: existingUser.email!,
        password,
      });
      if (error) throw error;
      session = data.session;
    } else {
      // 用户不存在,创建(使用 Admin API 跳过邮箱验证)
      const { data: newUser, error: createError } = await supabaseAdmin.auth.admin.createUser({
        email,
        password,
        email_confirm: true,
        user_metadata: { openid, provider: "wechat" },
      });
      if (createError) throw createError;

      // 创建后登录
      const { data, error } = await supabaseAdmin.auth.signInWithPassword({
        email,
        password,
      });
      if (error) throw error;
      session = data.session;
    }

    return new Response(
      JSON.stringify({
        access_token: session!.access_token,
        refresh_token: session!.refresh_token,
        expires_in: session!.expires_in,
        user: { id: session!.user.id, openid },
      }),
      { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );

  } catch (error: any) {
    return new Response(
      JSON.stringify({ error: "Internal server error", details: error.message }),
      { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }
});

注意:需要使用 admin.createUser 而不是 signUp

signUp 会验证邮箱域名是否真实存在,而 admin.createUser 可以跳过验证。我们使用虚拟邮箱,所以必须用 Admin API。

由于 Supabase Auth 必须使用邮箱+密码登录,我们采用以下方案:

  • 用微信的 openid 生成一个确定性的虚拟邮箱
  • 用 openid + 密钥的哈希值作为密码
  • 这样同一用户每次登录都会匹配到同一账户

配置 AppID 和 AppSecret

在微信公众号管理端获取 AppID 和 AppSecret,

supabase secrets set WECHAT_APP_ID=your_app_id
supabase secrets set WECHAT_APP_SECRET=your_app_secret

部署函数

supabase functions deploy wechat-login --no-verify-jwt
--no-verify-jwt 允许未登录用户调用此函数(登录前用户还没有 Token)

小程序端配置

小程序端调用

创建 services/auth.js

const EDGE_FUNCTION_URL = 'https://your-project-ref.supabase.co/functions/v1/wechat-login';
const SUPABASE_KEY = 'your-anon-key';

export function loginWithWeChat() {
    return new Promise((resolve, reject) => {
        wx.login({
            success: (loginRes) => {
                wx.request({
                    url: EDGE_FUNCTION_URL,
                    method: 'POST',
                    header: {
                        'Content-Type': 'application/json',
                        'apikey': SUPABASE_KEY,
                    },
                    data: { code: loginRes.code },
                    success: (res) => {
                        if (res.statusCode === 200) {
                            // 存储 Token
                            wx.setStorageSync('access_token', res.data.access_token);
                            wx.setStorageSync('user', JSON.stringify(res.data.user));
                            resolve(res.data);
                        } else {
                            reject(new Error(res.data.error));
                        }
                    },
                    fail: reject,
                });
            },
            fail: reject,
        });
    });
}

如何关联用户数据?

在 RLS 策略中使用 auth.uid() 即可:

CREATE POLICY "用户只能访问自己的收藏"
ON favorites FOR ALL
USING (auth.uid() = user_id);

如何获取用户的微信头像和昵称?

需要用户授权 wx.getUserProfile,获取后可以更新 user_metadata

const { data } = await supabase.auth.updateUser({
  data: { nickname, avatar_url }
});