OAuth 2.0 API
OAuth 2.0 Authorization Code + PKCE 授权服务器端点。第三方应用通过此 API 获取用户授权的 access_token。
基础路径
/oauth认证方式
/oauth/authorize(GET):无需认证(参数校验后重定向)/oauth/authorize(POST):需要 JWT(用户确认授权)/oauth/token:无需认证(公开端点)/oauth/revoke:无需认证(公开端点)/oauth/userinfo:Bearer Token(OAuth access_token)/oauth/assertion:Bearer Token(插件类 App 的 OAuth access_token,须含profile:read)/.well-known/jwks.json:无需认证(公开 JWKS)/oauth/apps/:clientId/public:无需认证(公开信息)/oauth/apps/:clientId/development:需要 JWT(仅应用作者本人,开发自测元数据)
CORS 说明
/oauth/token、/oauth/revoke、/oauth/userinfo、/oauth/assertion、/.well-known/jwks.json 允许所有 origin 跨域访问,其他端点使用默认 CORS。
端点列表
GET /oauth/authorize
发起 OAuth 授权流程。校验参数后 302 重定向到前端 consent 页。
查询参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
response_type | string | 是 | 固定为 code |
client_id | string | 是 | OAuth App 的 client_id |
redirect_uri | string | 是 | 回调地址(必须在 App 注册的白名单中) |
scope | string | 否 | 请求的权限(空格分隔),默认使用 App 配置 |
state | string | 是 | 客户端随机值(CSRF 防护;长度 8-256,仅 unreserved 字符 A-Z / a-z / 0-9 / - / . / _ / ~) |
code_challenge | string | 是 | PKCE code_challenge(BASE64URL 格式,43-128 字符) |
code_challenge_method | string | 是 | 固定为 S256 |
prompt | string | 否 | OIDC prompt 参数(空格分隔)。支持:login(强制重新登录)、consent、select_account(同 login)、none(无交互) |
login_hint | string | 否 | 预填邮箱,传到前端登录表单 |
display | string | 否 | 显示模式,仅支持 popup |
development | string | 否 | 自助应用开发自测标记,仅接受字面量 1(其他值返回 invalid_request)。详见下方「开发自测模式」 |
开发自测模式(development=1,仅自助应用作者)
自助应用作者在上架前自测时,在标准授权 URL 上追加 development=1:
- 仅
management_mode = self_service的应用可用;官方应用返回invalid_request; scope必须显式非空(开发态没有「回落应用注册 scopes」的语义);- 不可与
prompt=none组合(开发自测必须交互,组合返回invalid_request); - 本步跳过按已注册白名单的
redirect_uri/scope校验(授权基准是作者的草稿版本, 强校验在 consent 页作者态元数据端点与POST /oauth/authorize完成),仅要求redirect_uri是合法绝对 URL; development=1会透传到 consent 页;consent 页要求登录且登录者必须是应用作者本人, 确认授权时自动签发一次性开发票据并随POST /oauth/authorize提交。
prompt 参数行为
| 值 | 行为 |
|---|---|
login / select_account | 即使已登录也强制重新登录 |
consent | 要求用户重新确认授权(当前每次都需确认,等效于默认行为) |
none | 不显示任何 UI。redirect 模式:302 到 redirect_uri 附带 error=interaction_required;popup 模式:由前端 postMessage 回传错误 |
注意:
none不可与其他值组合使用,否则返回invalid_request;未知值也返回invalid_request。
成功响应 302
重定向到前端 consent 页面:
{FRONTEND_BASE_URL}/oauth/consent?client_id=...&scope=...&state=...&prompt=...&login_hint=...错误响应 400
{
"error": "invalid_request",
"error_description": "Missing required parameter: client_id"
}GET /oauth/apps/:clientId/public
获取 OAuth 应用的公开信息(供 consent 页面展示)。
路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
clientId | string | OAuth App 的 client_id |
成功响应 200
{
"name": "Page Agent",
"description": "AI 网页操控助手",
"logo_url": "https://example.com/logo.png",
"homepage_url": "https://example.com",
"redirect_uris": ["http://localhost:4100/callback"],
"scopes": ["profile:read", "chat:completions"],
"app_definition_version": 4,
"redirect_uri_allowed": true,
"scope_descriptions": [
{
"scope": "profile:read",
"description": {
"zh-CN": "读取用户基本信息",
"en-US": "Read user profile information"
},
"display_names": {
"zh-CN": "基本资料",
"en-US": "Profile"
},
"descriptions": {
"zh-CN": "读取用户基本信息",
"en-US": "Read user profile information"
},
"definition_version": 1
}
]
}display_names、descriptions 与 definition_version 来自数据库实时 Scope registry;description 是兼容旧 consent 客户端的双语别名,新客户端应读取前两项。app_definition_version 是应用授权配置 (Scope 集合、回调白名单、capability 等)的单调版本。不存在、未发布、禁用或永久撤销的 Scope 不会作为可授权定义返回。Consent 页面必须把实际展示的 Scope 版本和 App 版本一起冻结,不能在登录、 切换账号或点击授权时静默重取。
Consent 请求应把原始 redirect_uri 作为同名 query 参数传给本端点,并只信 redirect_uri_allowed=true。该结论由服务端复用 authorize matcher 计算,覆盖 native_loopback App 的 RFC 8252 临时端口语义;前端不得用 redirect_uris.includes() 自行做精确匹配。
GET /oauth/apps/:clientId/development
获取自助应用的作者态授权元数据(开发自测专用,供 consent 页在 development=1 时渲染与 版本冻结)。需要 JWT 认证,且必须是应用作者本人。
与公开端点的分工:公开端点只暴露当前公众版本,对未过审应用一律 404;本端点返回的是 未冻结草稿版本的快照——未过审应用没有公众版本、已过审应用草稿新增的 Scope 公开端点 拿不到版本号,开发自测一律从这里取数。
路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
clientId | string | OAuth App 的 client_id |
查询参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
redirect_uri | string | 是 | 本次授权将使用的回调地址,服务端按草稿白名单给出 redirect_uri_allowed 结论 |
成功响应 200
响应形状与公开端点一致(name / redirect_uris / scopes / scope_descriptions / app_definition_version / redirect_uri_allowed,数据来自草稿版本),并附加:
| 字段 | 类型 | 说明 |
|---|---|---|
development | boolean | 恒为 true,标记作者态响应 |
app_id | string | 应用内部 id,consent 页用它调用开发票据签发端点 |
draft_revision_number | integer | 草稿版本号 |
错误响应
| 状态码 | error | 说明 |
|---|---|---|
| 404 | oauth_app_not_found | 应用不存在 / 非自助应用 / 已禁用 / 当前登录者不是作者(刻意同码,不泄漏存在性与归属) |
| 409 | oauth_app_draft_not_available | 没有可用草稿(不存在,或已冻结进审核) |
POST /oauth/authorize
用户确认授权,生成 authorization code。需要 JWT 认证。
请求体(JSON)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
client_id | string | 是 | OAuth App 的 client_id |
redirect_uri | string | 是 | 回调地址 |
scope | string | 否 | 请求的权限(省略时按 OAuth App 配置默认下发) |
state | string | 是 | 客户端随机值(CSRF 防护;长度 8-256,仅 unreserved 字符 A-Z / a-z / 0-9 / - / . / _ / ~) |
code_challenge | string | 是 | PKCE code_challenge(BASE64URL 格式,43-128 字符) |
code_challenge_method | string | 是 | 固定为 S256 |
expected_scope_versions | object | 是 | Consent 页面实际展示的全部 Scope 及其正整数 definition_version;键集合必须与本次请求的 Scope 完全一致 |
expected_app_definition_version | integer | 是 | Consent 页面加载 App 信息时冻结的正整数 app_definition_version |
development_ticket | string | 否 | 自助应用开发自测的一次性票据明文(仅应用作者本人;由 consent 页在开发态自动签发并提交)。带票据时授权码绑定草稿版本,回调地址与 Scope 也按草稿判定 |
两项版本字段始终必填。非滚动维护切换不提供旧 consent 兼容窗口;后端绝不以当前数据库值静默补齐 用户没有实际展示并确认的版本。oauth_scope_writes_enabled 只控制 Scope 管理写,也不会改变此规则。
成功响应 200
{
"redirectTo": "http://localhost:4100/callback?code=wno-code-...&state=..."
}前端收到后执行 window.location.assign(redirectTo) 完成跳转。
服务端在数据库事务内锁定并复核 Scope 生命周期与 definition_version 后创建授权码;管理员并发修改 API 集合时不会生成可兑换旧同意的新授权码。
定义并发变化 409
如果用户看到权限说明后,Scope 定义或 OAuth App 授权配置发生变化,服务端不会签发授权码。客户端应 刷新公开信息,并要求用户基于新内容重新确认:
{
"error": "scope_definition_changed",
"error_description": "Authorization scope definitions changed; reload and confirm the permissions again"
}App 配置并发变化时 error 为 app_definition_changed。两者 HTTP 状态都为 409,不得自动重试、 复用旧确认或把它们降级为普通 500。
非滚动维护切换合同
数据库 migration 本身可先在旧应用版本运行时 additive apply;但旧后端与本版本后端不能在 OAuth 流量下混跑。oauth_scope_writes_enabled=false 只阻断 Scope 生命周期/routes 管理,不是 OAuth 签发、refresh、App 或 peer 写入的维护开关,不能拿它冒充流量冻结。
- 在入口层冻结旧 revision 流量;本版本还会实时读取受保护的
oauth_runtime_hardening_writes_enabled,在值不是布尔true(包括缺行、读失败)时,对 POST authorize、 code exchange、direct grant、refresh,以及 OAuth App、peer key、Scope 的全部配置写稳定返回503+Retry-After: 30。GET authorize 等只读入口、运维探针、RFC 7009 与用户主动安全撤销入口保持可用。应用门禁不能替代 对旧 revision 的入口冻结,因为旧代码不知道该 setting。 - 等待入口和所有旧 backend 实例的在途请求归零,并等待默认 600 秒
OAUTH_CODE_TTL(若环境覆盖则 按实际值)或查询确认scope_definition_versions/app_definition_version IS NULL的未消费、未过期 authorization code 为 0;旧 code 不做静默升级,客户端需重新授权。随后确认 runtime-hardening migration 已 apply/verify; 若该环境尚未预先完成 additive schema 落地,只能在此冻结窗口内 apply/verify。 - 一次性替换全部 backend 实例;用实际部署清单逐实例核对目标 Git revision,旧 revision 数必须为 0。
- 通过 migration 提供的 service-only 单向 activation RPC 把
oauth_runtime_hardening_writes_enabled从false切到true。这是一次明确的破坏性重授权操作: RPC 在同一事务内消费全部未兑换 authorization code、撤销全部 OAuth token family/活跃 token,并 清理已经不满足 App/Scope/peer 合同的 gateway key;所有现有 OAuth 用户都必须重新授权。执行前先 调 service-only impact preview,记录 apps、codes、families、tokens、peer keys 的权威计数与有限样本, 由操作者显式确认;activation 自身把实际计数写入审计事件。随后才执行 authorize/exchange/direct/ refresh、App mutation、peer key、impact 与 verifier smoke。不得用通用site_settingsCRUD 改写或 反向关闭。 - 部署 consent 前端并核对公开信息含 Scope/App 两类版本、POST 原样回传;随后才解除旧 revision 的 OAuth 入口冻结。此时 runtime setting 已是不可逆的
true,新 revision 的 admission 正常放行。 - 所有前后端节点和静态资产都确认新合同后,才可把
oauth_scope_writes_enabled设为true;无论 该开关状态如何,缺任一版本字段始终返回400。
runtime activation 前,任一实例版本无法证明、在途请求或 legacy code 未清零时保持冻结并修复。activation 或首次受控写后,usage sequence 使 schema rollback 按设计 fail closed;此后 smoke 失败只能保持入口冻结 并 forward-fix,不能把 gate 改回 false 或恢复旧节点。Scope 永久撤权也不会因应用回退自动恢复。
POST /oauth/delegated/authorize
父子代签铸码端点(issue #1119,A 即主站模型):官方桌面壳 App 持自己的 access token(须含 oauth:delegate scope),替已过审的插件类 OAuth App 铸一条普通授权码。插件随后用 PKCE 走 POST /oauth/token(authorization_code)换码。插件类应用无法走浏览器 GET/POST /oauth/authorize(unauthorized_client)。
请求格式:application/json + Authorization: Bearer <壳的 access_token>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
target_client_id | string | 是 | 插件类 App 的 client_id(须 plugin + self_service + 已过审) |
scopes | string[] | 是 | 申请的 scope(四维交集:插件过审快照 ∩ 插件当前 ∩ 壳 token ∩ 壳当前;后端计算,超额即 400) |
redirect_uri | string | 是 | 插件注册的回调 URI 之一,换码时须传相同值 |
code_challenge | string | 是 | PKCE S256 challenge(由插件生成,经壳传入) |
code_challenge_method | string | 是 | 固定 S256 |
响应:{ "code": "...", "expires_in": 600, "scopes": [...], "scope_definition_versions": {...} }
壳白名单由 OAUTH_DELEGATION_HOST_CLIENT_IDS 控制(留空 = fail-closed 全拒)。详见 docs/features/oauth/delegated-authorization.md。
POST /oauth/assertion
受众断言签发端点(issue #1136):插件类 App 持 access token 换取 aud=目标站点 的短时 JWT,供第三方站点经 JWKS 验签建立登录身份。适用于 Host 内 Bearer 注入只覆盖同源 API、第三方站点收不到插件 token 的场景(受众断言桥)。
请求格式:application/json + Authorization: Bearer <插件的 access_token>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
aud | string | 是 | 目标受众,须为绝对 HTTPS URL;服务端规范化为 origin(拒绝 userinfo/query/fragment)后精确匹配 |
门禁(全部 fail-closed,认证优先——伪造/撤销 token 先于请求体解析被拒):
- Bearer 须为有效 OAuth access token 且含
profile:readscope - token 所属 App 须为
app_type='plugin'(查询失败/不存在一律拒绝) - 规范化 aud 须命中该 client 的受众绑定
OAUTH_ASSERTION_AUDIENCE_BINDINGS(未登记即拒) - 签发配置(iss / 签名 JWKS / 绑定)任一缺失或非法 → 503
temporarily_unavailable
响应:{ "assertion": "<JWT>" }(Cache-Control: no-store)
JWT(ES256)claims:iss(env 定值)、aud(规范化 origin)、sub(与 /oauth/userinfo 同值)、iat、exp = iat + 300s、jti(唯一标识,非防重放机制)、azp(调用方 client_id,归因用,消费方可忽略)、name/email(可选)。头部含 kid 供 JWKS 轮换定位。
限流:30 req/min/IP(本端点独立限流,豁免全局限流);429 返回 OAuth 错误契约(temporarily_unavailable + no-store + Retry-After,带 CORS 头)。
密钥轮换三阶段与配置说明详见 docs/features/oauth/audience-assertion.md。
GET /.well-known/jwks.json
受众断言验签公钥发布(匿名 JWKS,Cache-Control: public, max-age=300)。发布 OAUTH_ASSERTION_SIGNING_JWKS 中全部密钥的公钥部分(无 d);首个为当前签名钥。配置缺失/非法时返回 503 + no-store(防负缓存)。
POST /oauth/token
换取 access_token、刷新 token,或在已开通能力的 OAuth App 中用验证码直连换取 OAuth token。
请求格式:application/x-www-form-urlencoded
grant_type=authorization_code
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
grant_type | string | 是 | authorization_code |
code | string | 是 | 授权码 |
client_id | string | 是 | client_id |
redirect_uri | string | 是 | 与授权请求时一致 |
code_verifier | string | 是 | PKCE code_verifier(长度 43-128,仅 unreserved 字符 A-Z / a-z / 0-9 / - / . / _ / ~) |
grant_type=refresh_token
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
grant_type | string | 是 | refresh_token |
refresh_token | string | 是 | refresh_token |
client_id | string | 是 | client_id |
grant_type=urn:wainao:params:oauth:grant-type:otp
验证码直连 extension grant。仅当 OAuth App 显式开通 direct_otp_grant capability 时可用;未开通会返回 unauthorized_client。成功响应与标准授权码流程一致,返回 wno- access token 与 wno-rt- refresh token。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
grant_type | string | 是 | urn:wainao:params:oauth:grant-type:otp |
client_id | string | 是 | OAuth App 的 client_id |
otp_type | string | 是 | email 或 phone |
identifier | string | 是 | 邮箱地址或手机号 |
code | string | 是 | 6 位验证码 |
scope | string | 否 | 请求的权限,省略时按 App 配置默认下发 |
lang | string | 否 | 初始化默认 Team/Project 时使用的语言 |
警告
标准 OAuth 仍推荐走官网授权页。验证码直连不会暴露用户密码,但会把登录入口嵌入第三方 App,因此必须由平台为对应 App 单独开通 direct_otp_grant。
成功响应 200
{
"access_token": "wno-...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "wno-rt-...",
"scope": "chat:completions"
}响应头:Cache-Control: no-store、Pragma: no-cache
三种签发路径(授权码兑换、refresh、验证码直签)均以数据库 App-control RPC 返回的实际 scope 为准。 授权码兑换还会复核发码时冻结的 App 定义版本;验证码直签会在最终签发事务内重新复核 App capability; refresh 与 App 配置变更使用同一串行化边界。Scope 停止续签、临时禁用、永久撤销,或 Scope/App 定义 版本变化时均 fail closed,不会回显未真正写入 token 的 scope。
错误响应
{
"error": "invalid_grant",
"error_description": "Authorization code expired or already used"
}POST /oauth/revoke
撤销 token(RFC 7009)。始终返回 200。
以 refresh token 撤销时终止整个登录会话:同 token_family 的所有活跃 token(含 access token、宽限轮换刚签发的 token)一并撤销;以 access token 撤销时仅撤销对应 token 对。
请求格式:application/x-www-form-urlencoded
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
token | string | 是 | 要撤销的 token(access 或 refresh) |
token_type_hint | string | 否 | access_token 或 refresh_token |
client_id | string | 否 | client_id |
响应 200
{}GET /oauth/userinfo
获取当前 OAuth 用户的基本信息。
请求头
Authorization: Bearer wno-...成功响应 200
{
"sub": "3be0c712-480d-43bf-971b-77e82ebbc428",
"email": "user@example.com",
"name": "用户名",
"avatar_url": "https://example.com/avatar.jpg",
"scope": "profile:read",
"is_site_admin": false,
"is_real_team_admin": true
}is_real_team_admin 表示用户是否至少管理一个不少于 2 人的真实团队。个人默认 1 人 Team 不计入该判断;查询失败时后端会安全降级为 false。
错误响应 401
{
"error": "invalid_token",
"error_description": "Token expired or revoked"
}Admin OAuth Apps API
OAuth 应用管理端点。需要 JWT + site:admin 权限。
基础路径:/admin/oauth-apps
GET /admin/oauth-apps
列表(分页、搜索)。
查询参数
| 参数 | 类型 | 说明 |
|---|---|---|
page | number | 页码(默认 1) |
pageSize | number | 每页数量(默认 20) |
search | string | 搜索关键词(名称或 client_id) |
POST /admin/oauth-apps
创建应用。
请求体
{
"name": "My App",
"description": "描述",
"redirect_uris": ["https://example.com/callback"],
"scopes": ["chat:completions"],
"capabilities": [],
"homepage_url": "https://example.com",
"logo_url": "https://example.com/logo.png"
}PATCH /admin/oauth-apps/:id
更新应用。请求必须携带列表/详情响应中的 expected_app_definition_version 与 expected_updated_at。展示字段更新与安全字段更新都经同一原子 RPC;当 scopes、 redirect_uris、capabilities、禁用/删除状态或 peer contract 发生授权语义变化时, 数据库会在同一事务递增 App 版本、消费未兑换授权码并撤销所有 token family。旧版本返回 409,不会静默覆盖另一位管理员的新配置。
capabilities 当前支持:
| Capability | 说明 |
|---|---|
direct_otp_grant | 允许该 App 使用邮箱/手机号验证码直连 grant 获取 OAuth token |
PATCH /admin/oauth-apps/:id/app-type
应用分类(issue #1119)。请求体 { "app_type": "standard" | "plugin" }。标记为 plugin 时要求该 App 为 self_service 管理模式且已通过审核(存在过审公开版本),并在同一事务撤销其全部既有 token(插件只能持有代签凭证);数据库 guard 触发器保证插件类应用无法持有非代签 code/token,且 oauth:delegate scope 只能授予官方应用。另外 scopes 含 oauth:delegate 时仅 OAUTH_DELEGATION_HOST_CLIENT_IDS 白名单内的 client_id 可被授予。
DELETE /admin/oauth-apps/:id
请求体为 { expected_app_definition_version, expected_updated_at }。软删除、消费授权码、撤销 token 与该 App 签发的 peer gateway key 在同一事务完成。
POST /admin/oauth-apps/:id/disable
请求体为 { expected_app_definition_version, expected_updated_at }。禁用与全部安全清理原子完成。
POST /admin/oauth-apps/:id/enable
请求体为 { expected_app_definition_version, expected_updated_at }。启用同样走 CAS,避免覆盖并发变更。
Mobile / Native App 接入
ReAI Mobile 客户端走自定义 URL scheme + PKCE 接入:
| 项 | 值 |
|---|---|
| Redirect URI | reai-mobile://callback |
| Scope | mobile:full(vendor-locked,仅生产 / 沙箱 client_id 允许申请) |
| 浏览器组件 | iOS ASWebAuthenticationSession / Android Custom Tabs(不要使用 WebView) |
mobile:full scope 覆盖 profile、devices、个人默认项目、项目存储上传、ROMP、推送、模型网关与云端 ASR 等移动端业务接口,详细接入步骤见仓库内 docs/features/oauth/client-guide.md(注:该文档在仓库根 docs/ 目录,未纳入 docs-site 文档站;本页直接外链 GitHub 文件)。
Refresh Token Reuse Detection
后端按 RFC 6819 §5.2.2.3 实现 family-based reuse detection:
- 每次成功 rotate 后,旧 refresh token 立即失效(
revoked_at写入)。 - 同一登录会话所有 token 共享
token_family;rotation 时新 token 沿用 family。 - 旧 refresh 二次复用 → 整个 family 全部撤销,下次访问 access token 也 401。
- 客户端拿到
invalid_grantRefresh token reuse detected必须重新登录,禁止重试。 - 2 分钟宽限轮换(grace rotation):rotate 的响应可能在弱网 / 切网 / App 挂起时丢失,此时客户端手里仍是旧 refresh token。旧 token 在 rotate 后 2 分钟内的第一次重放会完成一次正常轮换并返回全新的 token 对(客户端无感续期,不掉登录);每个旧 token 的宽限资格仅一次(
grace_used_at标记)。宽限仅在标准丢响应形态下生效(轮换出的新 token 仍活跃):登出、管理员撤销或 family revoke 之后,旧 token 重放不会复活会话。宽限轮换会同时撤销 family 内其他活跃 token(即客户端从未收到的原新 token),保证 family 内始终只有一条活跃链——被撤销的 token 事后被重放(token 被盗场景)会立即触发 family revoke 自愈。同一旧 token 第二次重放、或超过 2 分钟后重放,走完整 family revoke 路径。客户端始终应使用最新一次响应返回的 refresh token。
Token 审计字段
oauth_tokens 表新增 last_used_at / last_used_ip / last_used_user_agent 字段,由 oauthAuth 中间件 fire-and-forget 异步写入。仅供审计,不参与认证决策。