OAuth 2.0 第三方接入
让你的应用通过 OAuth 2.0 获取用户授权,以用户身份安全调用外脑 API。
概述
外脑实现了标准的 OAuth 2.0 Authorization Code + PKCE 授权流程。第三方应用(网站、原生应用、浏览器扩展等)可以通过此协议获取 access_token,调用外脑已发布的 AI 工作流 API。
核心特性:
| 特性 | 说明 |
|---|---|
| PKCE 强制 | 仅支持 S256,防降级攻击 |
| Public Client | 不需要 client_secret,适合 SPA 和原生应用 |
| Token Rotation | 刷新 token 后旧 token 立即失效 |
| Refresh Reuse Detection | 旧 refresh_token 被复用 → 整个 token family 撤销(RFC 6819 §5.2.2.3) |
| Scope 白名单 | OAuth token 只能访问 scope 授权的路由 |
| 移动端接入 | iOS / Android 走 RFC 8252(详见专章) |
整体流程
你的应用 外脑后端 外脑前端
│ │ │
├─ 1. GET /oauth/authorize ────►│ │
│ (client_id, PKCE, state) │ │
│ ├─ 2. 302 重定向 ───────────►│
│ │ /oauth/consent
│ │ │
│ │◄─ 3. 用户登录 + 同意 ──────┤
│ │ POST /oauth/authorize │
│ │ │
│◄── 4. 302 回调 ──────────────┤ │
│ redirect_uri?code=... │ │
│ │ │
├─ 5. POST /oauth/token ──────►│ │
│ (code, code_verifier) │ │
│ │ │
│◄── 6. access_token ─────────┤ │
│ + refresh_token │ │
│ │ │
├─ 7. 调用 API ──────────────►│ │
│ Authorization: Bearer ... │ │后端地址
外脑的 OAuth 服务器部署在多个节点。请按你 OAuth App 注册所在节点选用对应 base URL:
| 节点 | 前端域名 | 后端 API base URL | OAuth App 数据 |
|---|---|---|---|
| 大陆服 / ReAI | https://block2.wainao.chat | https://block2-api.wainao.chat | 与 CloudOS 共享(同一 selfhosted Supabase) |
| CloudOS | https://block.cloudos.com | https://block-api.cloudos.com | 与 ReAI 共享(同一 selfhosted Supabase) |
| Overseas / 国际服 | https://g.wainao.ai | https://g-api.wainao.ai | 独立 Supabase 云端实例 |
OAuth App 不跨边界
ReAI 与 CloudOS 共用一个 selfhosted Supabase 实例,所以在管理后台创建的 OAuth App 对这两个节点都可用;但 Overseas 是独立 Supabase 云端实例,OAuth App 数据不与大陆侧共享。如果你的用户需要在国际服登录,必须在国际服后台另外注册一份 OAuth App。
下文示例代码统一使用 JS 变量 BACKEND_URL,请按部署节点替换为上表中的实际地址。
第一步:创建 OAuth 应用
在外脑管理后台创建一个 OAuth 应用,获取 client_id。
进入管理页面
- 登录外脑编辑器,确保你拥有站点管理员权限
- 进入 管理后台 → OAuth 应用(路径
/admin/oauth-apps)
填写应用信息
点击创建应用,填写以下信息:
| 字段 | 必填 | 说明 |
|---|---|---|
| 名称 | 是 | 应用名称,将显示在用户授权页面 |
| 描述 | 否 | 应用的简短描述 |
| 回调地址 | 是 | 授权成功后的回调 URL(支持多个) |
| 权限范围 | 是 | 至少勾选 chat:completions;调用 /oauth/userinfo 需同时勾选 profile:read;移动端接入需要 mobile:full(受限) |
| 主页地址 | 否 | 应用官网 |
| Logo URL | 否 | 应用图标,显示在授权页面 |
回调地址规则
- 生产环境必须使用 HTTPS
localhost和127.0.0.1允许使用 HTTP(方便本地开发)- 原生应用可使用自定义 URL scheme(例如
reai-mobile://callback,需平台预先加入白名单) - 精确匹配,不支持通配符
- 可以添加多个回调地址
获取 client_id
创建成功后,系统会生成一个 32 字符的 client_id。这是一个公开标识符,可以安全地写入前端代码。
警告
外脑 OAuth 采用 Public Client 模式,不会生成 client_secret。安全性通过 PKCE 机制保障。
第二步:实现授权流程
2.1 生成 PKCE 参数
每次发起授权前,需要生成一对 PKCE 参数:
- code_verifier:43-128 字符的随机字符串(你持有,不发送给服务器)
- code_challenge:code_verifier 的 SHA-256 哈希,BASE64URL 编码(发送给服务器)
字符集约束
后端按 RFC 7636 §4.1 强校验 code_verifier 和 code_challenge:长度 43-128,且仅允许 unreserved 字符 A-Z / a-z / 0-9 / - / . / _ / ~。下方示例中的 charset 已合规,自行实现时不要混入其他字符。
// 生成加密安全的随机字符串(字符集与 RFC 7636 unreserved 一致)
function randomString(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
const values = crypto.getRandomValues(new Uint8Array(length))
return Array.from(values, v => charset[v % charset.length]).join('')
}
// BASE64URL 编码
function base64url(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// 生成 PKCE 参数对
async function generatePKCE() {
const codeVerifier = randomString(128)
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(codeVerifier)
)
const codeChallenge = base64url(digest)
return { codeVerifier, codeChallenge }
}2.2 发起授权请求
将用户重定向到外脑的授权端点:
const BACKEND_URL = 'https://block2-api.wainao.chat' // 按你的节点替换
async function startAuth() {
const { codeVerifier, codeChallenge } = await generatePKCE()
// 生成随机 state 防止 CSRF
// 注意:state 长度必须 ≥ 8,仅允许 unreserved 字符;randomString(32) 已合规
const state = randomString(32)
// 保存 state 和 codeVerifier,回调时需要验证
sessionStorage.setItem('oauth_state', state)
sessionStorage.setItem('oauth_verifier', codeVerifier)
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your_client_id',
redirect_uri: 'https://your-app.com/callback',
scope: 'profile:read chat:completions', // 多个 scope 用空格分隔
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
// 跳转到外脑授权页面
window.location.assign(`${BACKEND_URL}/oauth/authorize?${params}`)
}state 强校验
后端按 RFC 6749 §10.12 强校验 state:长度 8-256,仅 unreserved 字符。缺失或不合规直接返回 invalid_request,授权流程无法继续。
用户会看到外脑的授权确认页面,显示你的应用名称、Logo 和请求的权限。用户点击「同意」后,浏览器会跳转到你的回调地址。
2.3 可选授权参数
/oauth/authorize 还支持以下 OIDC 标准参数:
| 参数 | 说明 |
|---|---|
prompt | 空格分隔的 token set。支持 login(强制重新登录)、consent、select_account(同 login)、none(无交互) |
login_hint | 预填登录邮箱(传到前端登录表单的 initialEmail) |
display | 显示模式,仅支持 popup(弹窗回调走 postMessage) |
prompt 行为细节:
prompt=login/prompt=select_account:即使已登录也清除 session 强制重新登录prompt=none:不显示任何 UI。redirect 模式 302 到redirect_uri附带error=interaction_required;popup 模式由前端postMessage回传错误prompt=none不能与其他值组合使用,未知值同样返回invalid_request
2.4 处理回调
授权成功后,用户被重定向到:
https://your-app.com/callback?code=wno-code-xxx&state=xxx如果用户拒绝授权,或 prompt=none 无法静默授权:
https://your-app.com/callback?error=access_denied&state=xxx
https://your-app.com/callback?error=interaction_required&state=xxx处理回调参数:
async function handleCallback() {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
// 检查是否被拒绝
if (error) {
console.error('授权未完成:', error)
return
}
// 验证 state(防 CSRF)
const savedState = sessionStorage.getItem('oauth_state')
if (state !== savedState) {
throw new Error('State 不匹配,可能遭受 CSRF 攻击')
}
// 取出 code_verifier
const codeVerifier = sessionStorage.getItem('oauth_verifier')
sessionStorage.removeItem('oauth_state')
sessionStorage.removeItem('oauth_verifier')
// 用 code + code_verifier 换取 token
await exchangeToken(code, codeVerifier)
}2.5 换取 Token
用授权码和 code_verifier 换取 access_token:
async function exchangeToken(code, codeVerifier) {
const response = await fetch(`${BACKEND_URL}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded', // 必须!不是 JSON
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: 'your_client_id',
redirect_uri: 'https://your-app.com/callback',
code_verifier: codeVerifier,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(`Token 交换失败: ${data.error_description}`)
}
// 保存 token
localStorage.setItem('wainao_token', JSON.stringify({
access_token: data.access_token, // wno-xxx,1 小时有效
refresh_token: data.refresh_token, // wno-rt-xxx,30 天有效
expires_at: Date.now() + (data.expires_in - 60) * 1000, // 提前 60 秒刷新
scope: data.scope,
}))
}注意请求格式
/oauth/token 和 /oauth/revoke 端点必须使用 application/x-www-form-urlencoded 格式,不接受 JSON。这是 OAuth 2.0 规范要求。
第三步:调用 API
拿到 access_token 后,你可以调用外脑的 Release API:
查询用户信息
调用 /oauth/userinfo 需要 token 携带 profile:read scope。若 OAuth App 未配置或 authorize 时未授予此 scope,会返回 403 insufficient_scope。
const response = await fetch(`${BACKEND_URL}/oauth/userinfo`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
const user = await response.json()
// {
// sub: "用户ID", // 用户 UUID(OIDC subject)
// email: "user@example.com",
// name: "用户名", // 取自 user_metadata.full_name / name / email 前缀
// avatar_url: "...", // 可能为 null
// scope: "profile:read chat:completions", // 当前 token 已授予的全部 scope
// is_site_admin: false, // 是否为站点管理员
// is_real_team_admin: true // 是否管理至少一个 ≥2 人的真实团队(查询失败时安全降级为 false)
// }调用已发布的 AI 工作流
const response = await fetch(
`${BACKEND_URL}/api/released-app/r/my-agent/v1/chat`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
inputs: {
message: '你好,请帮我分析这段数据',
},
}),
}
)chat:completions scope 覆盖以下端点:
| 端点 | 说明 |
|---|---|
POST /api/released-app/{deploymentId}/run | 通过 Deployment ID 直接调用 |
POST /api/released-app/d/{documentId}/run | 通过文档 ID 直接调用 |
ALL /api/released-app/r/{alias}/{version}/{path} | 通过 Release 别名 + 版本调用 |
GET /runs/:runId/events | 长跑工作流:SSE 订阅 run 事件流 |
POST /runs/:runId/cancel | 长跑工作流:取消运行中的 run |
/runs/* 的 CORS 与 Release API 不同
/api/released-app/* 走开放 CORS(所有 origin 都可直接 fetch),但 /runs/* 走受限的 jwtCors,浏览器 SPA 跨域调用需要走代理或 BFF。详见下方 CORS 说明。
第四步:Token 管理
自动刷新 Token
access_token 有效期为 1 小时。过期后使用 refresh_token 获取新 token:
async function refreshToken() {
const stored = JSON.parse(localStorage.getItem('wainao_token'))
const response = await fetch(`${BACKEND_URL}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: stored.refresh_token,
client_id: 'your_client_id',
}),
})
const data = await response.json()
if (!response.ok) {
// refresh_token 失效,需要重新授权
localStorage.removeItem('wainao_token')
startAuth()
return null
}
// 保存新 token(旧 refresh_token 立即失效)
localStorage.setItem('wainao_token', JSON.stringify({
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires_in - 60) * 1000,
scope: data.scope,
}))
return data.access_token
}Token Rotation
每次刷新后,旧的 refresh_token 立即失效,必须使用新返回的 refresh_token。如果你的应用有多个并发请求可能同时触发刷新,需要做去重处理(见下方)。
Refresh Token Reuse Detection(RFC 6819 §5.2.2.3)
同一登录会话的所有 token 共享一个 token_family。当某个旧 refresh_token 被二次复用时,服务端会撤销同 family 的所有 token——包括尚未过期的 access_token——并返回:
{
"error": "invalid_grant",
"error_description": "Refresh token reuse detected; all related sessions have been revoked"
}客户端遇到此错误必须清空本地 token 并强制用户重新登录,禁止重试。重试会再次触发 reuse 检测、陷入死循环。
2 分钟宽限轮换(grace rotation):刷新响应可能在弱网 / 切网 / App 挂起时丢失,此时客户端手里仍是旧 refresh_token。rotate 后 2 分钟内对同一旧 refresh_token 的第一次重放会完成一次正常轮换并返回全新的 token 对(用户无感续期,不掉登录);每个旧 token 的宽限资格仅一次,且宽限轮换会同时撤销 family 内其他所有活跃 token(family 内始终只有一条活跃链)。同一旧 token 第二次重放、或超过 2 分钟后重放,才触发完整 family revoke。客户端应始终使用最新一次响应返回的 refresh_token。
并发刷新去重
let inflightRefresh = null
async function getAccessToken() {
const stored = JSON.parse(localStorage.getItem('wainao_token'))
if (!stored) return null
// 未过期,直接返回
if (Date.now() < stored.expires_at) {
return stored.access_token
}
// 正在刷新中,等待结果
if (inflightRefresh) return inflightRefresh
// 发起刷新
inflightRefresh = refreshToken().finally(() => {
inflightRefresh = null
})
return inflightRefresh
}带自动刷新的 API 调用
async function callAPI(url, options = {}) {
let token = await getAccessToken()
if (!token) throw new Error('未登录')
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
})
// 401 时尝试强制刷新一次
if (response.status === 401) {
token = await refreshToken()
if (!token) throw new Error('登录已过期,请重新授权')
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
})
}
return response
}登出(撤销 Token)
async function logout() {
const stored = JSON.parse(localStorage.getItem('wainao_token'))
if (stored?.refresh_token) {
// 撤销 refresh_token,所有关联 token 失效
await fetch(`${BACKEND_URL}/oauth/revoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: stored.refresh_token,
token_type_hint: 'refresh_token',
client_id: 'your_client_id',
}),
})
}
localStorage.removeItem('wainao_token')
}移动端 / 原生 App 接入
iOS / Android / 桌面原生应用按 RFC 8252 推荐的"系统浏览器 + 自定义 URL scheme"路径接入:
| 项 | 推荐值 |
|---|---|
| Redirect URI | 自定义 URL scheme(例如 reai-mobile://callback) |
| 浏览器组件 | iOS ASWebAuthenticationSession / Android Custom Tabs |
| Scope | 业务接口使用 mobile:full(vendor-locked,仅 ReAI 官方移动端的生产 / 沙箱 client_id 允许申请) |
| Token 存储 | iOS Keychain / Android Keystore |
不要使用 WebView
RFC 8252 §8.12 明确禁止使用应用内嵌 WebView 发起 OAuth 授权——App 可监听用户键盘输入,违反信任边界。必须使用系统浏览器组件。
mobile:full 是平台为官方 ReAI Mobile 客户端预留的受限 scope:即使你在 OAuth App 配置里写入 mobile:full,/oauth/authorize 也会拒绝非白名单 client_id 的请求。如果你在开发自己的第三方移动应用,请使用 profile:read + chat:completions 的标准 scope 组合。
mobile:full 覆盖的接口范围(profile、devices、个人默认项目、项目存储上传、ROMP 会话/消息/文件/推送、/services/passthrough/*、WebSocket 等)以及完整接入步骤参见仓库内的移动端开发指南(该文档未纳入文档站,直接外链 GitHub)。
Token 体系
| Token 类型 | 前缀 | 有效期 | 用途 |
|---|---|---|---|
| Authorization Code | wno-code- | 10 分钟 | 一次性,换取 access_token |
| Access Token | wno- | 1 小时 | API 调用凭证 |
| Refresh Token | wno-rt- | 30 天 | 换取新的 access_token |
权限范围(Scope)
| Scope | 描述 | 可访问的 API | 申请方式 |
|---|---|---|---|
profile:read | 读取用户基本信息 | GET /oauth/userinfo | OAuth App 配置勾选;调用 /oauth/userinfo 前 token 必须已授予此 scope |
chat:completions | 调用 AI 对话与长跑工作流 | /api/released-app/*、GET /runs/:runId/events、POST /runs/:runId/cancel | 任何已注册 OAuth App 都可申请 |
orders:checkout | 以当前授权用户身份走订单 checkout 流程 | /payment/*、/billing/subscription*、/public/hardware-sales/orders*、/hardware-sales/orders* | 任何已注册 OAuth App 都可申请,但只能访问当前用户自己的订单或其有权限的团队订单 |
mobile:full | ReAI 移动端全部业务接口 | profile / devices / account bindings / default project / storage upload / ROMP / push / WebSocket(详见移动端接入) | vendor-locked,仅生产 / 沙箱官方 client_id 可申请 |
在 authorize 请求里指定 scope
GET /oauth/authorize 的 scope 参数可省略——省略时后端按 OAuth App 配置的 scopes 默认下发。如果你希望按本次授权动态收窄,显式传入空格分隔的 scope 列表即可,但所有列出的 scope 必须已在 OAuth App 配置里勾选。
错误处理
Token 相关端点使用 RFC 6749 标准错误格式:
{
"error": "invalid_grant",
"error_description": "Authorization code expired or already used"
}| 错误码 | HTTP | 场景 | 建议处理 |
|---|---|---|---|
invalid_request | 400 | 参数缺失或格式错误(含 state / code_verifier 字符集不合规、prompt=none 与其他值混用、未知 prompt) | 检查请求参数 |
unsupported_response_type | 400 | response_type 不是 code | 当前仅支持 Authorization Code 流程 |
unsupported_grant_type | 400 | grant_type 不支持 | 仅支持 authorization_code 和 refresh_token |
invalid_scope | 400 | scope 不在 OAuth App 允许范围内 | 检查 OAuth App 配置 |
invalid_grant | 400 | 授权码过期/已使用,refresh_token 失效,或 refresh token reuse 触发 family 撤销 | 检查 error_description;若是 reuse 必须重新登录、不要重试 |
access_denied | 302(回调附带) | 用户在 consent 页拒绝授权 | 提示用户授权未完成,可重新发起 |
interaction_required | 302(回调附带) | prompt=none 但无法静默完成授权(需要登录或 consent) | 改用普通 prompt 重新发起授权 |
invalid_token / unauthorized | 401 | access_token 缺失、格式错误、已撤销或过期;以及未携带 Bearer 前缀 | 触发 refresh;失败则重新授权 |
insufficient_scope | 403 | 访问了 scope 未覆盖的路由(如 token 没有 profile:read 却调 /oauth/userinfo) | 检查 scope 配置,按需重新发起授权申请缺失 scope |
server_error | 5xx | 后端内部错误(数据库不可用等) | 指数退避重试;持续失败请联系平台 |
安全最佳实践
必须做
- 始终验证 state 参数:回调时比对 state,防止 CSRF 攻击
- 使用 PKCE:每次授权生成新的
code_verifier/code_challenge,且字符集合规(unreserved chars) - HTTPS:生产环境的回调地址必须使用 HTTPS(localhost 例外,原生应用使用自定义 URL scheme)
- 并发刷新去重:防止 Token Rotation 机制下并发 refresh 导致 reuse detection 误触发
- 遇到
invalid_grant不要重试:可能是 reuse 检测,必须重新发起授权流程
存储建议
| 环境 | 推荐方式 | 说明 |
|---|---|---|
| SPA(浏览器) | localStorage | Demo 可接受,生产建议用 BFF 模式 |
| 原生应用 | Keychain / Keystore | 系统级安全存储 |
| 服务端应用 | 加密数据库或内存 | 不暴露到客户端 |
禁止做
- 不要将 token 放在 URL 参数中
- 不要在日志中输出完整 token
- 不要在多个 tab 间共享 sessionStorage 中的 state/verifier
- 不要在原生应用中用 WebView 发起授权(违反 RFC 8252)
CORS 说明
不同端点的 CORS 策略不一致,浏览器跨域调用时请留意:
| 端点 | CORS 策略 | 跨域可直接调用 |
|---|---|---|
POST /oauth/token | 全 origin 开放 | ✅ |
POST /oauth/revoke | 全 origin 开放 | ✅ |
GET /oauth/userinfo | 全 origin 开放 | ✅ |
/api/released-app/* | 全 origin 开放(apiKeyCors) | ✅ |
GET /runs/:runId/events | 受限 origin(jwtCors) | ⚠️ 浏览器 SPA 需走代理或 BFF |
POST /runs/:runId/cancel | 受限 origin(jwtCors) | ⚠️ 浏览器 SPA 需走代理或 BFF |
GET /oauth/authorize 通过浏览器重定向访问,不涉及 CORS。
参考实现
完整的 OAuth 2.0 PKCE 客户端参考代码见 Page Agent:
src/oauth.ts— 完整的 PKCE 客户端(Popup + Redirect 两种模式)src/api.ts— API 调用封装(含 401 自动刷新)src/config.ts— 配置示例
相关链接
- OAuth 2.0 API 参考 — 完整的端点文档
- 应用发布 — 了解如何发布 AI 工作流为 API
- 部署管理 — 了解部署和 API 端点