Skip to content

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 URLOAuth App 数据
大陆服 / ReAIhttps://block2.wainao.chathttps://block2-api.wainao.chat与 CloudOS 共享(同一 selfhosted Supabase)
CloudOShttps://block.cloudos.comhttps://block-api.cloudos.com与 ReAI 共享(同一 selfhosted Supabase)
Overseas / 国际服https://g.wainao.aihttps://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

进入管理页面

  1. 登录外脑编辑器,确保你拥有站点管理员权限
  2. 进入 管理后台OAuth 应用(路径 /admin/oauth-apps

填写应用信息

点击创建应用,填写以下信息:

字段必填说明
名称应用名称,将显示在用户授权页面
描述应用的简短描述
回调地址授权成功后的回调 URL(支持多个)
权限范围至少勾选 chat:completions;调用 /oauth/userinfo 需同时勾选 profile:read;移动端接入需要 mobile:full(受限)
主页地址应用官网
Logo URL应用图标,显示在授权页面

回调地址规则

  • 生产环境必须使用 HTTPS
  • localhost127.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_verifiercode_challenge长度 43-128,且仅允许 unreserved 字符 A-Z / a-z / 0-9 / - / . / _ / ~。下方示例中的 charset 已合规,自行实现时不要混入其他字符。

javascript
// 生成加密安全的随机字符串(字符集与 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 发起授权请求

将用户重定向到外脑的授权端点:

javascript
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(强制重新登录)、consentselect_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

处理回调参数:

javascript
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:

javascript
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

javascript
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 工作流

javascript
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:

javascript
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——并返回:

json
{
  "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。

并发刷新去重

javascript
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 调用

javascript
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)

javascript
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:fullvendor-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 Codewno-code-10 分钟一次性,换取 access_token
Access Tokenwno-1 小时API 调用凭证
Refresh Tokenwno-rt-30 天换取新的 access_token

权限范围(Scope)

Scope描述可访问的 API申请方式
profile:read读取用户基本信息GET /oauth/userinfoOAuth App 配置勾选;调用 /oauth/userinfo 前 token 必须已授予此 scope
chat:completions调用 AI 对话与长跑工作流/api/released-app/*GET /runs/:runId/eventsPOST /runs/:runId/cancel任何已注册 OAuth App 都可申请
orders:checkout以当前授权用户身份走订单 checkout 流程/payment/*/billing/subscription*/public/hardware-sales/orders*/hardware-sales/orders*任何已注册 OAuth App 都可申请,但只能访问当前用户自己的订单或其有权限的团队订单
mobile:fullReAI 移动端全部业务接口profile / devices / account bindings / default project / storage upload / ROMP / push / WebSocket(详见移动端接入vendor-locked,仅生产 / 沙箱官方 client_id 可申请

在 authorize 请求里指定 scope

GET /oauth/authorizescope 参数可省略——省略时后端按 OAuth App 配置的 scopes 默认下发。如果你希望按本次授权动态收窄,显式传入空格分隔的 scope 列表即可,但所有列出的 scope 必须已在 OAuth App 配置里勾选

错误处理

Token 相关端点使用 RFC 6749 标准错误格式:

json
{
  "error": "invalid_grant",
  "error_description": "Authorization code expired or already used"
}
错误码HTTP场景建议处理
invalid_request400参数缺失或格式错误(含 state / code_verifier 字符集不合规、prompt=none 与其他值混用、未知 prompt检查请求参数
unsupported_response_type400response_type 不是 code当前仅支持 Authorization Code 流程
unsupported_grant_type400grant_type 不支持仅支持 authorization_coderefresh_token
invalid_scope400scope 不在 OAuth App 允许范围内检查 OAuth App 配置
invalid_grant400授权码过期/已使用,refresh_token 失效,或 refresh token reuse 触发 family 撤销检查 error_description;若是 reuse 必须重新登录、不要重试
access_denied302(回调附带)用户在 consent 页拒绝授权提示用户授权未完成,可重新发起
interaction_required302(回调附带)prompt=none 但无法静默完成授权(需要登录或 consent)改用普通 prompt 重新发起授权
invalid_token / unauthorized401access_token 缺失、格式错误、已撤销或过期;以及未携带 Bearer 前缀触发 refresh;失败则重新授权
insufficient_scope403访问了 scope 未覆盖的路由(如 token 没有 profile:read 却调 /oauth/userinfo检查 scope 配置,按需重新发起授权申请缺失 scope
server_error5xx后端内部错误(数据库不可用等)指数退避重试;持续失败请联系平台

安全最佳实践

必须做

  • 始终验证 state 参数:回调时比对 state,防止 CSRF 攻击
  • 使用 PKCE:每次授权生成新的 code_verifier / code_challenge,且字符集合规(unreserved chars)
  • HTTPS:生产环境的回调地址必须使用 HTTPS(localhost 例外,原生应用使用自定义 URL scheme)
  • 并发刷新去重:防止 Token Rotation 机制下并发 refresh 导致 reuse detection 误触发
  • 遇到 invalid_grant 不要重试:可能是 reuse 检测,必须重新发起授权流程

存储建议

环境推荐方式说明
SPA(浏览器)localStorageDemo 可接受,生产建议用 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 — 配置示例

相关链接

AI Workflow Editor