用 Supabase Edge Functions 实现 OAuth2 + PKCE 授权:connect-supabase 集成实战
本教程围绕 Supabase 官方示例仓库中的 connect-supabase Edge Function,讲解如何构建一款“连接用户托管 Supabase 项目”的第三方集成(Marketplace Integration)。读者将掌握基于 OAuth2 Authorization Code + PKCE 的完整授权链路的实现方法:生成授权地址、交换令牌、借助 Management API 拉取用户项目列表,并学会本地运行与线上部署该函数。
背景:为什么需要 OAuth2 连接流程
Supabase 是 Postgres 开发平台,除了提供托管数据库,还为第三方应用开放了两类关键能力:
- OAuth2 连接流程(Connection Flow):允许第三方应用代表用户发起授权,让用户将其托管在 Supabase 上的项目授权给第三方应用访问与操作;
- Management API:授权通过后,第三方应用可携带
access_token调用该 API,以编程方式在用户账户下创建、查询和管理 Supabase 项目。
两者结合即是“构建可编程后端”的快捷路径:无需用户手动复制粘贴项目密钥,第三方工具即可获得用户明确授权的项目访问能力。官方示例仓库 examples/edge-functions 中的 connect-supabase 正是这一场景的最小完整示范。
整个示例由两部分构成:
下文先梳理整体流程,再逐段拆解源码,最后给出本地调试与生产部署的完整步骤。
授权流程一览
该示例实现的是带 PKCE(Proof Key for Code Exchange,代码交换证明密钥)的 OAuth2 Authorization Code 流程,README 明确了四个环节:
- 生成携带 PKCE
codeVerifier的授权 URL; - 将用户重定向到 Supabase,授权该应用连接其 Supabase 账户;
- Supabase 授权完成后将用户重定向回应用的 callback 路由,在这里用 URL 中的
code换取access_token与refresh_token; - 用拿到的
access_token通过supabase-management-js拉取用户的项目列表。
通俗地说:步骤 1~2 解决“让用户去 Supabase 官方页面点‘同意授权’”的问题;步骤 3~4 解决“拿到令牌后替用户调用 Management API”的问题。
前置准备
1. 创建 OAuth App
参考 Supabase 官方 OAuth Apps 文档完成应用注册,你会获得一对凭证:
Client IDClient 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_token与refresh_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-js的getProjects()返回用户账号下项目数组,示例只抽取id与name两个字段回显到页面;- 该 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 列表。若看不到结果,可查看函数本地日志中的 uri、codeVerifier、tokens 等调试输出辅助排查。
如果只想在本地用命令行触发(不经过完整浏览器授权),可结合 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_ID、SUPA_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 实践,从示例走向生产还需补齐以下几点:
- 令牌持久化:把
access_token、refresh_token、过期时间与用户/项目关联后写入自己的数据库,避免每次调用都要求用户重新授权; - 令牌刷新:示例只演示了换取令牌,未实现刷新逻辑。应在
access_token临近过期时用refresh_token调用tokenUri以grant_type=refresh_token续期; - 保护回调与会话:将
CookieStore的very-secret-key换成随机高熵密钥,并建议为授权状态增加state参数防 CSRF; - 分离本地与生产密钥:官方建议生产环境使用独立的 env 文件存放 secrets,而不是直接复用本地
.env.local; - 遵循最小权限:Management API 令牌能操作用户账户下的资源,务必按业务实际需求只保留必要权限,并在用户侧清晰说明授权范围。
小结
connect-supabase 是理解 Supabase Marketplace Integration 的极佳最小范例:它在单个 Edge Function 内串联起 OAuth2 Authorization Code + PKCE 的发起、回调和换令牌三个环节,并示范了用 supabase-management-js 消费 Management API。参照 README.md 中的命令即可在 示例仓库 中直接复现,而本文对 index.ts 的逐段拆解,则为在此基础上扩展令牌存储、刷新与更多 Management API 调用提供了起点。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00