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。
进入管理页面
- 登录外脑编辑器,确保你拥有站点管理员权限
- 进入 管理后台 → OAuth 应用(路径
/admin/oauth-apps)
填写应用信息
点击创建应用,填写以下信息:
| 字段 | 必填 | 说明 |
|---|---|---|
| 名称 | 是 | 应用名称,将显示在用户授权页面 |
| 描述 | 否 | 应用的简短描述 |
| 回调地址 | 是 | 授权成功后的回调 URL(支持多个) |
| 权限范围 | 是 | 勾选 chat:completions |
| 主页地址 | 否 | 应用官网 |
| Logo URL | 否 | 应用图标,显示在授权页面 |
回调地址规则
- 生产环境必须使用 HTTPS
localhost和127.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 哈希(发送给服务器)
// 生成加密安全的随机字符串
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 发起授权请求
将用户重定向到外脑的授权端点:
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处理回调参数:
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:
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:
查询用户信息
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 工作流
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:
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。如果你的应用有多个并发请求可能同时触发刷新,需要做去重处理。
并发刷新去重
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('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 Code | wno-code- | 10 分钟 | 一次性,换取 access_token |
| Access Token | wno- | 1 小时 | API 调用凭证 |
| Refresh Token | wno-rt- | 30 天 | 换取新的 access_token |
权限范围(Scope)
| Scope | 描述 | 可访问的 API |
|---|---|---|
chat:completions | 调用 AI 对话接口 | Release API 端点 |
信息
目前仅支持 chat:completions scope,后续会扩展更多权限范围。
错误处理
Token 相关端点使用 RFC 6749 标准错误格式:
{
"error": "invalid_grant",
"error_description": "Authorization code expired or already used"
}| 错误码 | 场景 | 建议处理 |
|---|---|---|
invalid_request | 参数缺失或格式错误 | 检查请求参数 |
invalid_grant | 授权码过期/已使用,refresh_token 失效 | 重新发起授权 |
invalid_scope | scope 不在允许范围内 | 检查 OAuth App 配置 |
unsupported_grant_type | grant_type 不支持 | 仅支持 authorization_code 和 refresh_token |
insufficient_scope | 访问了 scope 未覆盖的路由 | 返回 403,检查 scope 配置 |
安全最佳实践
必须做
- 始终验证 state 参数:回调时比对 state,防止 CSRF 攻击
- 使用 PKCE:每次授权生成新的 code_verifier/code_challenge
- HTTPS:生产环境的回调地址必须使用 HTTPS
- 并发刷新去重:防止 Token Rotation 机制下并发 refresh 导致的竞态问题
存储建议
| 环境 | 推荐方式 | 说明 |
|---|---|---|
| SPA(浏览器) | localStorage | Demo 可接受,生产建议用 BFF 模式 |
| 原生应用 | Keychain / Keystore | 系统级安全存储 |
| 服务端应用 | 加密数据库或内存 | 不暴露到客户端 |
禁止做
- 不要将 token 放在 URL 参数中
- 不要在日志中输出完整 token
- 不要在多个 tab 间共享 sessionStorage 中的 state/verifier
CORS 说明
以下端点允许所有 origin 跨域访问,第三方 SPA 可直接调用:
POST /oauth/tokenPOST /oauth/revokeGET /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— 配置示例
相关链接
- OAuth 2.0 API 参考 — 完整的端点文档
- 应用发布 — 了解如何发布 AI 工作流为 API
- 部署管理 — 了解部署和 API 端点