首页
/ 用 Supabase Edge Functions 实现 OAuth2 + PKCE 授权:connect-supabase 集成实战

用 Supabase Edge Functions 实现 OAuth2 + PKCE 授权:connect-supabase 集成实战

2026-09-06 18:08:13作者:蔡怀权

本教程围绕 Supabase 官方示例仓库中的 connect-supabase Edge Function,讲解如何构建一款“连接用户托管 Supabase 项目”的第三方集成(Marketplace Integration)。读者将掌握基于 OAuth2 Authorization Code + PKCE 的完整授权链路的实现方法:生成授权地址、交换令牌、借助 Management API 拉取用户项目列表,并学会本地运行与线上部署该函数。

背景:为什么需要 OAuth2 连接流程

Supabase 是 Postgres 开发平台,除了提供托管数据库,还为第三方应用开放了两类关键能力:

  1. OAuth2 连接流程(Connection Flow):允许第三方应用代表用户发起授权,让用户将其托管在 Supabase 上的项目授权给第三方应用访问与操作;
  2. Management API:授权通过后,第三方应用可携带 access_token 调用该 API,以编程方式在用户账户下创建、查询和管理 Supabase 项目。

两者结合即是“构建可编程后端”的快捷路径:无需用户手动复制粘贴项目密钥,第三方工具即可获得用户明确授权的项目访问能力。官方示例仓库 examples/edge-functions 中的 connect-supabase 正是这一场景的最小完整示范。

整个示例由两部分构成:

  • README.md:OAuth App 创建、环境变量、本地运行与部署的命令说明;
  • index.ts:用 Deno + Oak + OAuth2 客户端库实现的端到端授权服务。

下文先梳理整体流程,再逐段拆解源码,最后给出本地调试与生产部署的完整步骤。

授权流程一览

该示例实现的是带 PKCE(Proof Key for Code Exchange,代码交换证明密钥)的 OAuth2 Authorization Code 流程,README 明确了四个环节:

  1. 生成携带 PKCE codeVerifier 的授权 URL;
  2. 将用户重定向到 Supabase,授权该应用连接其 Supabase 账户;
  3. Supabase 授权完成后将用户重定向回应用的 callback 路由,在这里用 URL 中的 code 换取 access_tokenrefresh_token
  4. 用拿到的 access_token 通过 supabase-management-js 拉取用户的项目列表。

通俗地说:步骤 1~2 解决“让用户去 Supabase 官方页面点‘同意授权’”的问题;步骤 3~4 解决“拿到令牌后替用户调用 Management API”的问题。

前置准备

1. 创建 OAuth App

参考 Supabase 官方 OAuth Apps 文档完成应用注册,你会获得一对凭证:

  • Client ID
  • Client Secret

在注册时需配置授权回调地址(Redirect URI),该地址必须与下方源码中 config.redirectUri 的取值逐字符一致,否则授权回调会失败。

2. 配置环境变量

在仓库的 .env.local.example 中找到与 connect-supabase 对应的两行(第 52~54 行),并把它们填入你自己的 .env.local

# connect-supabase
SUPA_CONNECT_CLIENT_ID=
SUPA_CONNECT_CLIENT_SECRET=

这两个变量与授权端点地址的对应关系如下表:

环境变量 含义 在源码中的用途
SUPA_CONNECT_CLIENT_ID OAuth App 的客户端 ID 生成授权 URL、换取令牌时的 Basic Auth 用户标识
SUPA_CONNECT_CLIENT_SECRET OAuth App 的客户端密钥 换取令牌时与 Client ID 拼接做 Basic Auth

源码拆解:OAuth2 + PKCE 全流程

示例核心逻辑全部位于 index.ts,它用 Deno 直接运行,没有使用 Supabase 客户端,而是组合了 Oak(Web 框架)、oak_sessions(会话存储)与 oauth2_client(OAuth2 辅助库)。

依赖与常量配置

文件开头的远程导入锁定了三个库的版本:

import { Application, Router } from 'https://deno.land/x/oak@v11.1.0/mod.ts'
import { CookieStore, Session } from 'https://deno.land/x/oak_sessions@v4.1.9/mod.ts'
import { OAuth2Client } from 'https://deno.land/x/oauth2_client@v1.0.2/mod.ts'
import { SupabaseManagementAPI } from 'https://esm.sh/supabase-management-js@0.1.2'

随后是 OAuth2 客户端配置(index.ts 第 6~13 行):

const config = {
  clientId: Deno.env.get('SUPA_CONNECT_CLIENT_ID')!,
  clientSecret: Deno.env.get('SUPA_CONNECT_CLIENT_SECRET')!,
  authorizationEndpointUri: 'https://api.supabase.com/v1/oauth/authorize',
  tokenUri: 'https://api.supabase.com/v1/oauth/token',
  redirectUri: 'http://localhost:54321/functions/v1/connect-supabase/oauth2/callback',
}
const oauth2Client = new OAuth2Client(config)

各字段的作用:

配置项 说明
clientId 来自 SUPA_CONNECT_CLIENT_ID OAuth App 客户端 ID
clientSecret 来自 SUPA_CONNECT_CLIENT_SECRET OAuth App 客户端密钥
authorizationEndpointUri https://api.supabase.com/v1/oauth/authorize 用户授权页地址,用于重定向用户
tokenUri https://api.supabase.com/v1/oauth/token 用授权码换令牌的端点
redirectUri 本地开发地址 回调路由,部署后需替换为线上函数地址

值得注意的是 redirectUri 被硬编码为本地地址 http://localhost:54321/functions/v1/connect-supabase/oauth2/callback。本地开发没问题,但线上部署前必须改写成你的生产函数域名(形如 https://<project-ref>.functions.supabase.co/connect-supabase/oauth2/callback),这一点会在后文部署章节再次强调。

启动 HTTP 服务与会话中间件

const app = new Application<AppState>()
// cookie name for the store is configurable, default is: {sessionDataCookieName: 'session_data'}
const store = new CookieStore('very-secret-key')
// @ts-ignore TODO: open issue at https://github.com/jcs224/oak_sessions
app.use(Session.initMiddleware(store))
app.use(router.routes())
app.use(router.allowedMethods())
await app.listen({ port: 8000 })

示例用一个简单的 CookieStore 保存会话(Cookie 默认名为 session_data)。会话在这里扮演关键角色:PKCE 的 codeVerifier 必须跨越两次 HTTP 请求(发起授权的那一次与回调的那一次)而保存下来,因此它被写入会话而不是停留在客户端。代码注释提示 very-secret-key 仅是示例,生产环境应替换为高强度的随机密钥。

/login 路由:生成授权地址与 codeVerifier

router.get('/connect-supabase/login', async (ctx) => {
  // Construct the URL for the authorization redirect and get a PKCE codeVerifier.
  const { uri, codeVerifier } = await oauth2Client.code.getAuthorizationUri()
  console.log(uri.toString())

  // Store both the state and codeVerifier in the user session.
  ctx.state.session.flash('codeVerifier', codeVerifier)

  // Redirect the user to the authorization endpoint.
  ctx.response.redirect(uri)
})

这一段的要点:

  • code.getAuthorizationUri()oauth2_client 内部完成 PKCE 挑战码/验证码(challenge/verifier)的配对生成,并返回可直接重定向的授权 URL
  • uri 指向 Supabase 的 authorizationEndpointUri,用户在 Supabase 页面完成登录授权;
  • codeVerifier 通过 session.flash(...) 存入会话。flash 语义是“写入后下一次读取即失效”,正好契合“回调时读一次”的使用方式,降低重复使用带来的风险。

/oauth2/callback 路由:用授权码交换令牌

用户授权完毕后,Supabase 会带着 ?code=... 重定向到回调路由:

router.get('/connect-supabase/oauth2/callback', async (ctx) => {
  // Make sure the codeVerifier is present for the user's session.
  const codeVerifier = ctx.state.session.get('codeVerifier') as string
  console.log('codeVerifier', codeVerifier)
  if (!codeVerifier) throw new Error('No codeVerifier!')

  // Exchange the authorization code for an access token.
  const tokens = await fetch(config.tokenUri, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
      Authorization: `Basic ${btoa(`${config.clientId}:${config.clientSecret}`)}`,
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: ctx.request.url.searchParams.get('code') || '',
      redirect_uri: config.redirectUri,
      code_verifier: codeVerifier,
    }),
  }).then((res) => res.json())
  console.log('tokens', tokens)
  // TODO: Make sure to store the tokens in your DB for future use.
  ...
})

这里体现了完整的“授权码换令牌”四要素:

请求参数 说明
grant_type authorization_code 声明使用授权码模式
code 回调 URL 查询串中的 code 一次性授权码
redirect_uri 与注册一致的回调地址 OAuth2 服务端会校验与授权请求时一致
code_verifier 会话中取出的 PKCE 验证码 与授权请求时的 challenge 配对校验

请求头使用 HTTP Basic Auth(Authorization: Basic base64(clientId:clientSecret))来标识应用身份。需要说明:

  • 示例中回调前先校验会话中存在 codeVerifier,否则抛出 No codeVerifier!,避免在用户会话丢失时继续无意义的交换;
  • 注释中的 TODO 提醒开发者:换来的 access_tokenrefresh_token 应当持久化到自己的数据库,供后续任务(例如定时同步、后台调用)使用,而不是仅仅在内存中处理完就丢弃。

用 Management API 拉取项目列表

拿到令牌后,示例立刻调用 Management API 验证令牌有效性:

const supaManagementClient = new SupabaseManagementAPI({
  accessToken: tokens.accessToken ?? tokens.access_token,
})
const projects = await supaManagementClient.getProjects()

ctx.response.body = `Hello, these are your projects: \n ${JSON.stringify(
  projects?.map((p) => ({ id: p.id, name: p.name })),
  null,
  2
)}!`

几个值得注意的工程细节:

  • tokens.accessToken ?? tokens.access_token 同时兼容了返回体中的驼峰(camelCase)与下划线(snake_case)两种字段命名,降低对上游响应格式变化的敏感度;
  • supabase-management-jsgetProjects() 返回用户账号下项目数组,示例只抽取 idname 两个字段回显到页面;
  • 该 API 请求全程在服务端完成,令牌不会暴露给浏览器端。

本地运行与验证

进入包含 supabase/ 目录的仓库子目录(即 examples/edge-functions),按以下命令启动:

supabase functions serve connect-supabase --no-verify-jwt --env-file ./supabase/.env.local

命令参数拆解:

参数 作用
connect-supabase 指定要本地托管的函数名
--no-verify-jwt 本地调试时不校验 JWT,方便用浏览器直接触发流程
--env-file ./supabase/.env.local 把环境变量注入函数运行时

启动后打开浏览器:

http://localhost:54321/functions/v1/connect-supabase

会看到一段路由提示文案。随后访问登录入口发起授权:

http://localhost:54321/functions/v1/connect-supabase/login

浏览器将被 302 重定向到 Supabase 的授权页面;完成授权后自动跳回 http://localhost:54321/functions/v1/connect-supabase/oauth2/callback,页面最终打印当前授权账户的项目 JSON 列表。若看不到结果,可查看函数本地日志中的 uricodeVerifiertokens 等调试输出辅助排查。

如果只想在本地用命令行触发(不经过完整浏览器授权),可结合 app 测试客户端或 curl 调用上面的 URL。

部署到 Supabase Edge Functions

按顺序执行两条命令即可完成上线与密钥注入:

supabase functions deploy connect-supabase --no-verify-jwt
supabase secrets set --env-file ./supabase/.env.local
  • supabase functions deploy 将函数部署到云端。--no-verify-jwt 意味着该函数端点不做网关层 JWT 校验(示例没有集成 Supabase Auth 场景),实际使用时可根据需要去掉,或在应用层自行实现保护;
  • supabase secrets set --env-file ./supabase/.env.local 把本地 env 文件中的 SUPA_CONNECT_CLIENT_IDSUPA_CONNECT_CLIENT_SECRET 以密文形式写入云端,供线上函数通过 Deno.env.get(...) 读取。

部署完成后,请在 OAuth App 管理后台与 index.ts 中将 redirectUri 一并更新为线上地址:

https://<your-project-ref>.functions.supabase.co/connect-supabase/oauth2/callback

其中 <your-project-ref> 是你的 Supabase 项目引用标识。只有注册回调、源码回调、token 交换请求三者完全一致,授权闭环才能成立。另外,config.toml 中也登记了 [functions.connect-supabase] 函数段,若希望部署后默认关闭 JWT 校验,可在该段内显式添加 verify_jwt = false(当前示例依赖部署命令的 --no-verify-jwt 参数)。

工程化实践建议

结合源码中的注释与通用 OAuth2 实践,从示例走向生产还需补齐以下几点:

  1. 令牌持久化:把 access_tokenrefresh_token、过期时间与用户/项目关联后写入自己的数据库,避免每次调用都要求用户重新授权;
  2. 令牌刷新:示例只演示了换取令牌,未实现刷新逻辑。应在 access_token 临近过期时用 refresh_token 调用 tokenUrigrant_type=refresh_token 续期;
  3. 保护回调与会话:将 CookieStorevery-secret-key 换成随机高熵密钥,并建议为授权状态增加 state 参数防 CSRF;
  4. 分离本地与生产密钥:官方建议生产环境使用独立的 env 文件存放 secrets,而不是直接复用本地 .env.local
  5. 遵循最小权限:Management API 令牌能操作用户账户下的资源,务必按业务实际需求只保留必要权限,并在用户侧清晰说明授权范围。

小结

connect-supabase 是理解 Supabase Marketplace Integration 的极佳最小范例:它在单个 Edge Function 内串联起 OAuth2 Authorization Code + PKCE 的发起、回调和换令牌三个环节,并示范了用 supabase-management-js 消费 Management API。参照 README.md 中的命令即可在 示例仓库 中直接复现,而本文对 index.ts 的逐段拆解,则为在此基础上扩展令牌存储、刷新与更多 Management API 调用提供了起点。

登录后查看全文
热门项目推荐
相关项目推荐