简聊应用接入:OAuth 登录与钱包互转

从用简聊账号登录,到应用余额与简聊钱包互转 · 接口、回调与代码 Demo

核对日期:2026-10-07。以下按当前服务端、Web 与 App 宿主实现说明;环境地址和应用配置以平台管理员提供的值为准。

第三方应用不接收 IM 登录 token。client_secret 只保存在自己的后端(BFF),不放进前端代码或 URL。授权码、跳转票及 OAuth token 不写入日志;跳转票不放进 query / hash。

接入总览:一次登录,衔接钱包互转

本文统一说明工作台网页应用的 OAuth 登录与简聊钱包支付通道。只接登录,阅读第 1–8 节;还需钱包互转,使用同一个 confidential 应用,继续阅读第 9–14 节。

  1. 配置应用:取得 issuer、host_web_origin、client_id;需要钱包时还需 client_secret、通道币种 / 汇率和独立的 webhook_secret。
  2. 完成登录:通过授权码或端内跳转票,在自己的 BFF 建立会话,保存可信的 (issuer, sub)。
  3. 创建订单:BFF 从当前会话读取 sub,生成并持久化 merchant_order_no,再调用充值或提现接口。不能接受浏览器任意指定收款用户。
  4. 确认入账:充值通过端内宿主支付单扣款;BFF 验证回调或主动查单,再按本地订单幂等更新应用账本。
操作(以应用为视角)余额流向对应接口
充值到应用用户简聊钱包 → 应用余额prepay → 宿主支付单 → 回调 / 查单入账
提现到简聊应用余额 → 用户简聊钱包应用先扣减自身余额 → payout → 确认结果

这里的互转是配置币种的虚拟余额通道。钱包商户接口使用与 OAuth 相同的 client_id / client_secret 认证;用户 access_token 不能代替商户认证。webhook_secret 专门用于回调验签,与 client_secret 分开管理。

1. OAuth:准备应用配置

  1. 在简聊工作台登记网页应用,网页地址必须是与简聊 Web / API 等平台地址不同源的绝对 HTTPS 地址,不能带 fragment。
  2. 启用 OAuth,选择客户端类型、登记回调地址及允许的 scope;把应用加入工作台分组或轮播。
  3. 向管理员获取下面的配置。confidential 应用还需生成密钥;轮换后应立即更新 BFF。
配置项示例用途
issuerhttps://api.example.com/v1API 的 /v1 根,无末尾斜杠;发现、换 token、取用户信息。
host_web_originhttps://im.example.com简聊 Web 的 origin(协议 + 域名 + 可选端口),用于验证 iframe 父窗口。不能用 issuer 或 API origin 代替。
client_idyour-app-id工作台应用 APP ID。
client_typepublic / confidentialpublic 无密钥;confidential 有可信后端和 client_secret。
redirect_urihttps://app.example.com/oauth/callback必须预登记,授权申请和兑换时完全一致(含路径、查询参数、末尾斜杠)。不支持通配符、fragment。
scopeopenid profile必含 openid;仅请求已开通且业务需要的权限。

本文 Demo 的网页地址为 https://app.example.com/,回调为 https://app.example.com/oauth/callback。文中所有 example.com 地址都需要替换。

2. 先选择登录流程

打开环境publicconfidential
简聊 Web iframehello → ready → request(PKCE)→ code → token → userinfohello → ready → jump → BFF redeem → 本地会话
简聊 App WebView原生 auth JSBridge 携带 PKCE → 授权码 → token → userinfohello → ready → jump → BFF redeem → 本地会话
外部 / 系统浏览器先建立 IdP 浏览器会话 → authorize(PKCE)→ 回调 code + state → token → userinfo

所有授权码流程都必须使用 PKCE S256,包括 confidential。端内 confidential 的跳转票是独立流程:不需要主动发送 request,不是“先收 code,再收 jump”,redeem 也不返回 OAuth token。

Web iframe 的 public 示例和外部浏览器示例均包含在下方 Demo。App confidential 支持点击握手;App public 的原生 JSBridge 适配不在此 Demo 范围内。

3. 发现与公开接口

GET {issuer}/.well-known/openid-configuration

发现文档提供 issuer、各端点、RS256 验签算法、S256 PKCE 和支持的 scopes。对接固定可信环境,检查返回的 issuer 与配置一致。

用途方法和路径说明
授权页GET {issuer}/oauth/authorize浏览器顶层导航;依赖 IdP 会话。
兑换 / 刷新POST {issuer}/oauth/token表单或 JSON;confidential 支持 HTTP Basic 或 client_secret 字段。
用户信息GET {issuer}/oauth/userinfoAuthorization: Bearer access_token。
撤销 tokenPOST {issuer}/oauth/revoke表单或 JSON 的 token 字段。
验签公钥GET {issuer}/oauth/jwks验证 id_token 使用。
兑换跳转票POST {issuer}/oauth/jump/redeem仅 confidential BFF;JSON,无 CORS。

浏览 OpenAPI · OAuth YAML 契约

4. 端内:握手、授权码与跳转票

Web iframe 的握手

先注册 message 监听,再发 hello。除不含凭据的 hello 外,postMessage 必须指定准确 origin。接收时同时检查 event.source === window.parent 和预配置的 host_web_origin;不能把任意 ready 消息中的 parent_origin 当成可信来源。

// config 来自自己后端的可信配置;不要从 URL 参数覆盖。
window.addEventListener("message", (event) => {
  if (event.source !== window.parent ||
      event.origin !== config.hostWebOrigin) return;
  const data = event.data;
  if (!data || typeof data !== "object") return;
  if (data.type === "tsdd.oauth.ready") {
    if (data.parent_origin !== config.hostWebOrigin ||
        data.app_id !== config.clientId) return;
    // public:只发送一次带 PKCE 的 request。
    // confidential:等待 jump,不再发送 request。
  }
});
window.parent.postMessage({ type: "tsdd.oauth.hello" }, "*");

public:申请并接收授权码

每次登录生成随机 state(至少 16 字符)和 code_verifier(43–128 位允许字符),challenge 为 SHA-256(verifier) 的无 padding base64url 编码。Demo 在 BFF 生成并保存 verifier,浏览器只拿到 challenge。

window.parent.postMessage({
  type: "tsdd.oauth.request",
  client_id: config.clientId,
  redirect_uri: "https://app.example.com/oauth/callback",
  scope: "openid profile",
  state,
  code_challenge,
  code_challenge_method: "S256"
}, config.hostWebOrigin);

// 宿主响应(示意):
{ "type": "tsdd.oauth.code", "code": "oac_...",
  "state": "原样返回的state", "expires_in": 60 }

宿主固定当前工作台应用身份。收到 code 后,必须检查来源及 state 与本次事务完全一致;缺少 state、没有待处理事务、重复结果都不能继续登录。用同一事务的 verifier 兑换 code。处理 tsdd.oauth.denied、tsdd.oauth.error,不要自动无限重试。

confidential:接收跳转票并交给 BFF

// 宿主在 ready 后下发(授权弹窗由宿主处理):
{ "type": "tsdd.oauth.jump", "app_id": "your-app-id",
  "token": "ajw_1....", "expires_in": 60 }

// 校验消息来源、app_id,且本次登录尚未处理结果后:
await fetch("/api/jump", {
  method: "POST",
  credentials: "same-origin",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ token: data.token, csrf: transaction.csrf })
});

/api/jump 是下方 Demo 自己的 BFF 路由。BFF 使用配置好的 client_id 与当前密钥调用:

POST /v1/oauth/jump/redeem
Content-Type: application/json

{"token":"ajw_1....","client_id":"your-app-id","client_secret":"仅后端持有"}

// 成功响应示意(name / picture 按授权返回):
{"sub":"user-uid","app_id":"your-app-id","exp":1791331260,
 "name":"示例用户","picture":"https://api.example.com/users/user-uid/avatar"}

App WebView 的区别

当前 Android / iOS confidential 宿主向页面自身 window 注入 ready / jump,origin 为应用页面 origin;不是 Web iframe 的父窗口 origin。应明确选择 App 适配路径并校验 event.source === window、event.origin === location.origin 和 app_id。Demo 的「端内登录」按钮在顶层页面使用此路径。public 则需要客户端原生 auth JSBridge,不能只发送 Web iframe 的 request。

5. 外部浏览器:授权码 + PKCE

直接打开 authorize 不会展示账号密码登录页。当前实现依赖简聊的 IdP 浏览器会话。应由用户从已登录的简聊工作台外开应用,宿主先通过 {issuer}/oauth/session 建立会话,再跳到登记的网页地址。第三方无需自己获取宿主票据或持有 IM token。

应用生成并保存本次 state、verifier,然后顶层跳转:

GET {issuer}/oauth/authorize?response_type=code
  &client_id=your-app-id
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
  &scope=openid%20profile
  &state=随机且至少16字符
  &code_challenge=BASE64URL_SHA256_VERIFIER
  &code_challenge_method=S256

用 URL / URLSearchParams 构造一条完整 URL,不要直接拼接未编码的参数。成功回调携带 code、state;拒绝或需要授权时可能携带 error、state。成功和错误回调都先验证 state;兑换后清理回调 URL 与待处理事务。

IdP 会话当前为 600 秒;失效会得到 oauth_login_required。返回简聊重新从工作台打开。prompt=none 不能绕过同意,可能返回 consent_required。

6. Token、用户字段和有效期

POST {issuer}/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&client_id=your-app-id&code=oac_...
&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
&code_verifier=本次保存的verifier

// confidential 还需由后端添加 client_secret,或使用 HTTP Basic。
// 响应示意:
{"access_token":"oat_...","token_type":"Bearer","expires_in":3600,
 "scope":"openid profile","id_token":"eyJ...","refresh_token":"ort_..."}

public 响应不包含 refresh_token。授权码 60 秒有效且只能使用一次,access_token 当前为 3600 秒,confidential 的 refresh_token 当前为 14 天;以响应和服务端实际校验为准。

GET {issuer}/oauth/userinfo
Authorization: Bearer oat_...

{"sub":"user-uid","name":"示例用户",
 "picture":"https://api.example.com/users/user-uid/avatar"}
scopeuserinfo 字段
openidsub:用户标识,始终返回。
profilename、picture:昵称和头像。
emailemail:授权且账号有邮箱时返回。
phonephone_number:授权且账号有手机号时返回。

权限受后台配置与实际授权限制,不保证请求的 scope 全部获批。userinfo 不接受 query 中的 access_token;public 可从已登记回调地址的 origin 直调 token / userinfo(CORS 不允许 credentials),也可采用本 Demo 的 BFF 方式。confidential 的密钥兑换始终在后端完成。

若用 id_token 建立身份,必须通过 JWKS 验证 RS256 签名及 iss、aud、exp,并校验请求时保存的 nonce(如果使用)。不要只 base64 解码就信任。本 Demo 以 BFF 通过 Bearer 请求的 userinfo 建立身份,不使用 id_token。

// 刷新:仅 confidential 后端,表单提交并携带客户端认证
POST {issuer}/oauth/token
grant_type=refresh_token&refresh_token=ort_...&client_id=...&client_secret=...

// 撤销:表单或 JSON
POST {issuer}/oauth/revoke
token=需要撤销的access_token或refresh_token

刷新会轮换 refresh_token:原子保存新值,避免并发刷新;重放旧 refresh_token 会撤销该刷新令牌族。撤销接口返回 200 不代表 token 原先一定有效。本地退出还需清理自己的会话。

7. OAuth Demo:前端 + Node.js

两个文件放在同一目录,Node.js 22+,无需 npm 依赖:

把应用 HTTPS 域名反向代理到本机 127.0.0.1:3000(保留路径),在管理后台登记上面第 1 节的网页地址与回调地址,然后启动:

export OAUTH_ISSUER='https://api.example.com/v1'
export HOST_WEB_ORIGIN='https://im.example.com'
export APP_ORIGIN='https://app.example.com'
export OAUTH_CLIENT_ID='your-app-id'
# confidential:通过服务端环境或密钥管理器注入 OAUTH_CLIENT_SECRET。
# public:不设置 OAUTH_CLIENT_SECRET;不要将其写进 HTML 或提交到仓库。
node server.mjs
  1. 从简聊工作台打开 Demo:Web iframe 自动握手。public 会发送 PKCE 请求;confidential 等待 jump 并 POST 给 BFF。
  2. App confidential 在页面中点「端内登录」。App public 需要另接原生 JSBridge。
  3. 系统浏览器模式从工作台外开后,点「浏览器登录」;BFF 保存 verifier 并返回授权 URL,回调验证 state 后建立会话。
  4. 完成登录后点「读取本地会话」,应看到 issuer、sub 及授权后的昵称头像;浏览器不会收到密钥或 OAuth token。

BFF 的 /api/start、/api/code、/api/jump、/api/me 是示例自身的接口,不是平台 OpenAPI。state、verifier 和 token 均由 BFF 管理。POST 校验 Origin,结果提交还校验与会话绑定的 csrf;消息去重后只兑换一次。

Demo 仅演示登录:会话保存在单进程内存,重启即丢失;不持久化 OAuth token,也不演示刷新。上线时接入业务用户库、共享会话存储、限流与退出逻辑。Cookie 使用 Secure / HttpOnly / SameSite=None,必须通过 HTTPS 访问。跨站 iframe 仍可能被浏览器第三方 Cookie 策略拦截,遇到此情况请通过工作台外开浏览器完成登录,不能靠反复兑换票据解决。

关键后端兑换代码(完整错误处理见下载文件)
// BFF 使用预配置的 issuer、clientId、clientSecret。
const response = await fetch(`${issuer}/oauth/jump/redeem`, {
  method: "POST",
  redirect: "error",
  signal: AbortSignal.timeout(10000),
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ token, client_id: clientId, client_secret: clientSecret })
});
const user = await response.json();
if (!response.ok) throw new Error(user.code || "redeem failed");
if (user.app_id !== clientId || !user.sub || user.exp * 1000 <= Date.now()) {
  throw new Error("invalid identity");
}
// 用 (issuer, user.sub) 查找/创建本地用户,轮换会话 ID 并设置 HttpOnly Cookie。
// 不把 token、client_secret 或完整上游响应写入日志。

8. OAuth 常见问题

现象检查项 / 处理
网页地址保存失败绝对 HTTPS、与平台不同源、无 fragment;确保应用是网页类型且已上架。
iframe 等不到 ready先监听再 hello;host_web_origin 填简聊 Web origin,不是 API 域名;确认窗口来源与 app_id。
App 没有 codepublic 需要原生 auth JSBridge;confidential 等待 jump;旧客户端需升级。
oauth_login_requiredIdP Cookie 未建立或已过期。从已登录的简聊工作台重新外开应用。
invalid_request / oauth_invalid_redirect / oauth_pkce_required回调地址精确匹配、scope 含 openid、state 至少 16 字符、S256 challenge 正确。
invalid_client检查 client_id、类型和当前 client_secret,确认应用 OAuth 已启用。
invalid_grantcode 已使用或过期、verifier 不匹配、refresh_token 已失效;重新发起登录,不重试旧凭据。
jump 兑换失败票超过 60 秒、重复兑换、密钥已轮换、app_id 不匹配、用户授权已撤销等。重新打开应用获取新票。
consent_required / access_denied需要用户同意或用户拒绝;交还用户操作,不静默重试。
Demo 登录事务失效 / 登录后仍 401确认 HTTPS 代理、浏览器 Cookie 策略及单实例会话;多实例需共享存储。

token 接口错误通常为 {error, error_description};userinfo / jump redeem 错误为 {status, msg, code}。authorize 还可能返回 HTML 错误页或携带 error、state 的回调。先检查 HTTP 状态,再按接口解析;日志仅保留错误码等非凭据字段。

9. 钱包互转:开通条件与共用身份

简聊钱包支付通道的商户接口无 CORS,只能由自己的 BFF 调用。client_secret 与 webhook_secret 留在后端;支付密码只在宿主支付单输入,应用页面只传 prepay_id。

仅已登记的工作台网页应用,且为 confidential(有 client_secret)、 已按前文登录流程将当前用户 sub 保存到自己的服务端会话。 public 客户端不能开通此通道;系统浏览器没有端内支付单,页面不展示「简聊钱包」渠道。

请管理员启用钱包模块、应用 OAuth 和支付通道,配置 IM / 应用币种、汇率、HTTPS 回调地址并生成 webhook_secret。充值成功增加通道头寸,提现成功减少头寸;提现前需确认头寸足够。

10. 钱包商户接口:充值、提现、查单

issuer 是 API 的 /v1 根。前缀 {issuer}。JSON snake_case。错误体 {status,msg,code},客户端不要解析中文 msg。

方法路径说明
POST{issuer}/wallet/channel/prepay充值预付单
POST{issuer}/wallet/channel/payout提现入用户简聊钱包
GET{issuer}/wallet/channel/merchant/orders?merchant_order_no=查单(必填商户单号)

下面的 sub 必须来自 BFF 当前登录会话;不要从 query 或前端表单直接取值。先持久化本地订单,再请求平台,避免响应丢失后无法定位订单。

prepay 请求:

POST {issuer}/wallet/channel/prepay
Content-Type: application/json
Authorization: Basic base64(client_id:client_secret)

{
  "merchant_order_no": "c_recharge_…",
  "sub": "<uid>",
  "app_amount": 10000,
  "notify_url": "https://app.example/callbacks/jianliao-wallet"
}

notify_url 若传必须是 https 绝对 URL;不传则用通道配置的 webhook。 两者都空 → wallet_channel_notify_required。

成功(prepay_id 与 order_no 均为 wcp_ 前缀):

{
  "prepay_id": "wcp_…",
  "order_no": "wcp_…",
  "im_currency": "COIN",
  "im_amount": 10000,
  "app_currency": "PLATFORM_COIN",
  "app_amount": 10000,
  "rate_im": 1,
  "rate_app": 1,
  "expire_at": "2026-08-22T01:00:00Z",
  "status": "prepay"
}

payout 请求字段与 prepay 相同;响应无 prepay_id 和 expire_at。成功 status=paid,order_no 前缀 wco_。 提现前你必须先扣自己的余额;简聊头寸不足返回 wallet_channel_float_insufficient 且不加款,你必须把已扣余额退回用户。 提现不弹简聊支付密码。

POST {issuer}/wallet/channel/payout
Content-Type: application/json
Authorization: Basic base64(client_id:client_secret)

{"merchant_order_no":"app_payout_001","sub":"来自当前会话的uid","app_amount":10000}

GET {issuer}/wallet/channel/merchant/orders?merchant_order_no=app_payout_001
Authorization: Basic base64(client_id:client_secret)

网络超时或连接中断时结果未知:保留原商户单号查单 / 重试确认,不要直接退回余额后用新单号再次提现。只有确认未成功出款,才能执行本地补偿。

密钥认证失败返回 401 oauth_invalid_client,不泄露订单是否存在;认证正确但单号不存在时返回 wallet_not_found。

11. 端内唤起支付单

与 OAuth 相同:先 listen,再 tsdd.oauth.hello(仅这一次允许 *)。 按OAuth 章节验证来源、app_id 和 ready 后复用同一个宿主连接,无需再次兑换跳转票。 禁止 postMessage(..., "*") 发送 tsdd.wallet.pay。 系统浏览器忽略该消息,不要展示「简聊钱包」渠道。

Web iframe 的目标是 window.parent,origin 为已验证的 host_web_origin;App WebView 的目标是 window,origin 为当前应用 origin。以下为 iframe 示例:

window.parent.postMessage(
  { type: "tsdd.wallet.pay", prepay_id: "wcp_…" },
  parent_origin
);

接收结果时验证 event.source、event.origin 和当前订单的 prepay_id,再更新页面状态:

{
  "type": "tsdd.wallet.pay.result",
  "prepay_id": "wcp_…",
  "status": "paid|cancelled|failed",
  "code": "wallet_insufficient?"
}

页面只轮询自己的商户订单。不要在应用页收集支付密码。

12. 回调 HMAC 与对账

POST notify_url,超时 5s。返回 HTTP 2xx 视为成功。验签失败不得入账。按 app_id + merchant_order_no 幂等;比较 app_id、sub、direction、金额和币种与本地订单一致后,在同一事务中记录入账并更新订单状态。

使用收到的原始请求字节 raw_body 计算签名,不能 JSON.parse 后重新序列化。使用恒定时间比较,并检查时间戳窗口(参考 Demo 为前后 5 分钟)。webhook 与主动查单补账必须共用同一套幂等入账逻辑。

Headers:

{
  "order_no": "wcp_…",
  "merchant_order_no": "…",
  "app_id": "…",
  "sub": "…",
  "direction": "pay",
  "status": "paid",
  "im_currency": "COIN",
  "im_amount": 10000,
  "app_currency": "PLATFORM_COIN",
  "app_amount": 10000,
  "paid_at": "…"
}

投递失败会按 1s / 5s / 30s / 2m / 10m 最多重试 5 次,耗尽后不再投。 你必须能 GET 查单补账。固定向量(仅用于自测,不是生产密钥): secret wcs_test_secret,timestamp 1700000000, body {"order_no":"wcp_ab","status":"paid"} → sha256=f9481542f257bb171be3370459fcf0b7839136c3a6aaf5219228b74952e8d831。

13. 钱包常见错误码

code何时
oauth_invalid_client密钥错
wallet_channel_disabled未开通道或非 confidential
wallet_channel_rate金额与汇率无法整除
wallet_channel_notify_required无 webhook 且无 notify_url
wallet_channel_float_insufficient提现头寸不足
wallet_not_found查单不存在(密钥正确时)
wallet_disabled钱包模块关闭
wallet_rate_limitedHTTP 429,请求过于频繁,稍后再试。

探测:非法单查单,期望 wallet_not_found 或错密钥 oauth_invalid_client。

14. 钱包对接 Demo

OAuth 登录可先运行第 7 节的 Node.js Demo。钱包交互另提供 Go BFF + 页面参考文件,集中在这里下载:

把两个文件放到同一目录,准备跨源 HTTPS 应用域名与能接收平台回调的地址,代理到 127.0.0.1:8787,然后配置:

export TSDD_API='https://api.example.com/v1'
export PUBLIC_BASE='https://wallet-app.example.com'
export CLIENT_ID='your-app-id'
# 从服务端环境注入 CLIENT_SECRET 与 WEBHOOK_SECRET。
# 登记此应用地址,并配置通知地址:
# https://wallet-app.example.com/callbacks/jianliao-wallet
go run wallet-channel-sample-bff.go

两个 Demo 独立运行,不共享内存会话。正式接入时,把钱包接口接到同一个业务 BFF,从已经建立的登录会话读取 sub,并复用已验证的宿主消息来源;不要把一次性跳转票分别交给两个后端重复兑换。

钱包样例用于阅读和测试接口流程:余额是单进程共享的模拟值,未实现按用户持久化账本;其会话 Cookie、消息来源校验和提现失败补偿也需按本文完善。尤其不能照搬“网络错误立即退款”的简化逻辑。真实接入需实现按用户记账、订单事务、查单补偿,以及端内跨站 Cookie 适配。

只在已验证的简聊端内展示「简聊钱包」渠道;普通浏览器不展示。卡通(独立仓库 cartoon)是现有商户示例,汇率以简聊管理端配置为准。

返回接入总览 · OAuth Demo