2020 年做 App 内 H5 页面时,为了让页面快速获得登录态,最早的方案把 token 拼进 URL:?token=...。联调很方便,安全同学一看就否决了。URL 会进入代理日志、统计系统、浏览器历史和 Referer;H5 里任何第三方脚本也可能读到它。更麻烦的是,App token 生命周期很长,一次泄漏的影响远超当前页面。

我们最终把认证拆成三层:原生容器持有长期身份;Bridge 只暴露受控换票能力;H5 使用短期、受众受限的会话 token。

Native、Bridge 与 Web 在登录态传递中的责任边界

图 1:H5 不需要知道原生长期凭证,只需要得到完成当前业务所需的短期能力。

Bridge 返回一次性授权码

H5 启动后调用明确版本的能力:

type AuthCodeRequest = {
  capability: "auth.issueCode";
  version: 2;
  audience: "content-web";
  nonce: string;
};
 
type AuthCodeResponse = {
  code: string;
  expiresInSeconds: 30;
};

原生端确认当前 WebView 来源在白名单内,再用长期凭证向服务端申请一次性 code。H5 把 code 交给自己的 BFF 换取短期 HttpOnly 会话 cookie。code 使用一次即失效,绑定 audience、设备会话和 nonce。

H5 -> Native: auth.issueCode(audience, nonce)
Native -> Auth API: issue code with app credential
Auth API -> Native: one-time code (30s)
Native -> H5: code
H5 -> Web BFF: exchange code
Web BFF -> H5: Secure + HttpOnly session cookie

来源校验不能只看页面声明

H5 可以传 audience,但原生不能无条件相信。Bridge 根据实际加载 URL、App 包签名、环境和能力白名单共同判断。跳转到第三方域名后,敏感能力立即不可用。

检查 失败结果
当前 URL 不在可信 origin 拒绝调用,不返回凭证
Bridge 版本不支持 返回稳定错误码,H5 提示升级 App
nonce 已使用 拒绝重放
App 会话已失效 引导原生重新登录
H5 BFF audience 不匹配 换票失败并审计

仅靠 JavaScript 隐藏方法名没有安全意义。决定是否执行的策略必须在原生和服务端。

过期与刷新由谁负责

H5 会话短,必须明确恢复路径。接口返回 SESSION_EXPIRED 后,页面只允许一次静默换票;多个并发请求共享同一个刷新 Promise,避免同时弹出登录或创建多份会话。换票失败则让原生接管登录,不在 H5 内保存长期凭证兜底。

let refreshing: Promise<void> | null = null;
 
async function refreshSessionOnce() {
  if (!refreshing) {
    refreshing = exchangeNativeCode().finally(() => { refreshing = null; });
  }
  return refreshing;
}

调试日志必须默认看不到凭证

我们曾在 Bridge 日志里直接打印完整参数,测试包很方便,线上却留下泄漏风险。后来日志只记录 capability、版本、来源域、结果码和 requestId;token、code、cookie 和用户数据在日志层统一脱敏。

多端一致性靠协议,不靠各写一套

iOS 和 Android 对 Bridge 回调、超时、页面销毁的处理不同。协议中因此增加 requestId、超时语义和重复回调保护。H5 只消费统一 envelope:

type BridgeResult<T> =
  | { requestId: string; ok: true; data: T }
  | { requestId: string; ok: false; code: string; message: string };

这次设计让我意识到,跨端登录不是“把 token 传过去”这么简单。身份、能力和会话是不同层次。把长期凭证留在最能保护它的地方,让 H5 只获得短期且可撤销的能力,边界才经得住日志、跳转和第三方脚本这些真实环境。