2020 年做 App 内 H5 页面时,为了让页面快速获得登录态,最早的方案把 token 拼进 URL:?token=...。联调很方便,安全同学一看就否决了。URL 会进入代理日志、统计系统、浏览器历史和 Referer;H5 里任何第三方脚本也可能读到它。更麻烦的是,App token 生命周期很长,一次泄漏的影响远超当前页面。
我们最终把认证拆成三层:原生容器持有长期身份;Bridge 只暴露受控换票能力;H5 使用短期、受众受限的会话 token。
图 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 只获得短期且可撤销的能力,边界才经得住日志、跳转和第三方脚本这些真实环境。