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 立即失效
Scope 白名单OAuth token 只能访问 scope 授权的路由

整体流程

你的应用                         外脑后端                       外脑前端
  │                               │                             │
  ├─ 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 应用,获取 client_id

进入管理页面

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

填写应用信息

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

字段必填说明
名称应用名称,将显示在用户授权页面
描述应用的简短描述
回调地址授权成功后的回调 URL(支持多个)
权限范围勾选 chat:completions
主页地址应用官网
Logo URL应用图标,显示在授权页面

回调地址规则

  • 生产环境必须使用 HTTPS
  • localhost127.0.0.1 允许使用 HTTP(方便本地开发)
  • 精确匹配,不支持通配符
  • 可以添加多个回调地址

获取 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 哈希(发送给服务器)
javascript
// 生成加密安全的随机字符串
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
async function startAuth() {
  const { codeVerifier, codeChallenge } = await generatePKCE()

  // 生成随机 state 防止 CSRF
  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: 'chat:completions',
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  })

  // 跳转到外脑授权页面
  window.location.assign(
    `https://block2-api.wainao.chat/oauth/authorize?${params}`
  )
}

用户会看到外脑的授权确认页面,显示你的应用名称、Logo 和请求的权限。用户点击「同意」后,浏览器会跳转到你的回调地址。

2.3 处理回调

授权成功后,用户被重定向到:

https://your-app.com/callback?code=wno-code-xxx&state=xxx

如果用户拒绝授权:

https://your-app.com/callback?error=access_denied&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.4 换取 Token

用授权码和 code_verifier 换取 access_token:

javascript
async function exchangeToken(code, codeVerifier) {
  const response = await fetch('https://block2-api.wainao.chat/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:

查询用户信息

javascript
const response = await fetch('https://block2-api.wainao.chat/oauth/userinfo', {
  headers: {
    'Authorization': `Bearer ${accessToken}`,
  },
})

const user = await response.json()
// { sub: "用户ID", email: "user@example.com", name: "用户名", avatar_url: "..." }

调用已发布的 AI 工作流

javascript
const response = await fetch(
  'https://block2-api.wainao.chat/api/released-app/r/my-agent/v1/chat',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      inputs: {
        message: '你好,请帮我分析这段数据',
      },
    }),
  }
)

URL 格式说明:

端点格式说明
/api/released-app/r/{alias}/{version}/{path}通过 Release 别名 + 版本调用
/api/released-app/{deploymentId}/run通过 Deployment ID 直接调用
/api/released-app/d/{documentId}/run通过文档 ID 直接调用

第四步:Token 管理

自动刷新 Token

access_token 有效期为 1 小时。过期后使用 refresh_token 获取新 token:

javascript
async function refreshToken() {
  const stored = JSON.parse(localStorage.getItem('wainao_token'))

  const response = await fetch('https://block2-api.wainao.chat/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。如果你的应用有多个并发请求可能同时触发刷新,需要做去重处理。

并发刷新去重

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('https://block2-api.wainao.chat/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')
}

Token 体系

Token 类型前缀有效期用途
Authorization Codewno-code-10 分钟一次性,换取 access_token
Access Tokenwno-1 小时API 调用凭证
Refresh Tokenwno-rt-30 天换取新的 access_token

权限范围(Scope)

Scope描述可访问的 API
chat:completions调用 AI 对话接口Release API 端点

信息

目前仅支持 chat:completions scope,后续会扩展更多权限范围。

错误处理

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

json
{
  "error": "invalid_grant",
  "error_description": "Authorization code expired or already used"
}
错误码场景建议处理
invalid_request参数缺失或格式错误检查请求参数
invalid_grant授权码过期/已使用,refresh_token 失效重新发起授权
invalid_scopescope 不在允许范围内检查 OAuth App 配置
unsupported_grant_typegrant_type 不支持仅支持 authorization_coderefresh_token
insufficient_scope访问了 scope 未覆盖的路由返回 403,检查 scope 配置

安全最佳实践

必须做

  • 始终验证 state 参数:回调时比对 state,防止 CSRF 攻击
  • 使用 PKCE:每次授权生成新的 code_verifier/code_challenge
  • HTTPS:生产环境的回调地址必须使用 HTTPS
  • 并发刷新去重:防止 Token Rotation 机制下并发 refresh 导致的竞态问题

存储建议

环境推荐方式说明
SPA(浏览器)localStorageDemo 可接受,生产建议用 BFF 模式
原生应用Keychain / Keystore系统级安全存储
服务端应用加密数据库或内存不暴露到客户端

禁止做

  • 不要将 token 放在 URL 参数中
  • 不要在日志中输出完整 token
  • 不要在多个 tab 间共享 sessionStorage 中的 state/verifier

CORS 说明

以下端点允许所有 origin 跨域访问,第三方 SPA 可直接调用:

  • POST /oauth/token
  • POST /oauth/revoke
  • GET /oauth/userinfo

其他端点(如 /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