开发文档前端 · ReactWeb Storage / Client RPC

STORAGE BOUNDARIES · BROWSER RPC · SSO

Web Storage 的边界从 SESSION 差异到手机 LOCAL 空值
存储分区与 postMessage 客户端 RPC

认证站独立打开时有值,嵌入业务页后为什么可能返回 null?结合桌面与手机反馈,对照 CrosStorage 与认证 iframe,讲清同源、同站、存储分区和登录态共享方案。

2026.09.27源码对照 · 13 个步骤含官方依据与诊断探针
存储边界与客户端 RPC业务窗口通过请求和响应调用认证 iframe;iframe 只能访问自己的存储分区。 TOP-LEVEL ORIGIN B 业务窗口 / ClientCrosStorage.getToken()Promise + requestId 认证 iframe / Adispatch(command)getItem(key) 请求响应 当前可见的 Storage 分区取决于页面会话、存储 origin 与顶层边界RPC success ≠ token found
01 /

结论先行

同一认证 origin 在顶层页面和业务页 iframe 中,可能访问不同的 Web Storage 分区。消息正常往返,并不能保证 iframe 读到顶层登录时保存的数据;sessionStorage 和 localStorage 都需要按实际浏览器与页面拓扑验证。

当前同站子域架构的解决方向:用 Cookie 承载登录凭据

将 token 或会话标识存入正确作用域的 Cookie,并让 API 从 Cookie 完成鉴权,可以消除当前登录链路对 iframe 读取 localStorage 的依赖。前端、后端和凭据策略需要配套迁移;这解决的是本案例的存储共享依赖,不代表所有跨站、浏览器策略或登录问题都会自动消失。

后续 LOCAL 测试反馈

桌面 LOCAL:读取成功
手机 LOCAL:反馈为 null

重点验证顶层写入 → iframe 读取这条链路。用户已确认两边使用 localStorage;手机的精确版本、实际 URL、隐私设置与写入上下文尚未完整记录,分区目前是待验证的解释。

01 / STORAGE BOUNDARY

同一 tab 不等于同一存储区

页面会话、存储 origin,以及浏览器附加的顶层分区条件,共同决定读到哪份数据。

02 / CLIENT RPC

把操作交给 iframe 执行

调用端发出命令,认证 iframe 读取自己的存储,携带相同 requestId 返回结果。

03 / TWO CONDITIONS

通信与数据可见性分别成立

消息能够往返,不代表 iframe 能看到登录时的 token。RPC 可以成功返回 null。

本文的主结论与适用范围

早期 SESSION 测试中出现 Safari 不可读、Chrome 可读;后续 LOCAL 测试则反馈桌面 Chrome、Safari 成功,手机返回 null。这些是不同环境下的记录。WebKit 的顶层 origin 分区与空值现象相符,但手机根因还需探针确认;现代 Chrome 也有分区,不能按浏览器名称或桌面/手机直接判定结果。

依据:WebKit 对分区边界的说明 · Chrome 存储分区说明
02 /

问题背景

应用包含一个认证站和多个业务站。用户在认证站登录,前端保存 token,再跳回原业务页。业务页创建指向认证站的隐藏 iframe,通过共享类 CrosStorage 获取 token,供业务 API 请求使用。

早期问题发生在 SESSION 链路:同一标签页中,Safari 的 iframe 读取返回 null,Chrome 正常。后续又收到 LOCAL 反馈:PC 的 Chrome、Safari 可用,手机测试的浏览器返回空值。用户确认两边都选了 localStorage,因此后续排查重点是 key、实际 origin、写入上下文与存储分区。

现场记录与证据强度
已知事实能够得出的判断不能据此推断
早期 SESSION:同一 tab,认证站与业务站使用同主机不同端口属于不同 origin;本次是同站开发场景端口不同就一定是不同 site
早期 SESSION:Chrome 能取 token,Safari 在 GET 处读到 null执行已经进入 iframe 的存储读取逻辑所有 null 都由分区导致,或者 token 一定没有写入
早期 LOCAL 探针成功;后续反馈 PC Chrome、Safari 可读对应桌面环境下,这条 LOCAL 链路可用手机、其他版本、隐私模式及不同部署拓扑也必然可读
后续手机测试返回 null,两边均选择 localStorage优先核对响应事件、key、origin 与顶层/iframe 上下文已经证明分区是唯一原因,或所有手机浏览器必然失败

保留桌面 LOCAL 成功的记录,同时补充手机失败的反馈。前者不能覆盖后者,后者也不能推翻前者。手机系统、浏览器精确版本、普通/无痕模式、实际地址及写入位置仍需补齐;本文把存储分区列为优先验证方向,不将推断写成已完成的手机实测。

脱敏约定:以 A 表示认证 origin,以 B 表示业务 origin。文中的 auth.example.com、app.example.com 为示意域名;开发端口记作 P_AUTH、P_APP,不是可直接访问的地址。仅保留定位实现所必需的相对代码路径与类名。

03 /

详细内容

STEP 01先区分 origin、site、页面会话与存储分区

Origin · 源
由协议、主机、端口决定。路径和 query 改变通常不改变 origin。
Site · 站点
现代 schemeful site 通常按协议与可注册域划分,不包含端口;本地主机需按其站点规则理解。
Page session · 页面会话
sessionStorage 的会话生命周期边界,通常以标签页/顶层可遍历上下文理解。
Storage partition · 存储分区
浏览器进一步隔离嵌入上下文的数据空间;它可以把同一 origin 在不同顶层上下文中的存储拆开。
地址关系同源?同站?
同协议、同主机、同端口,仅路径不同是是
同协议、同本地主机,P_AUTH ≠ P_APP否:端口不同通常是
https://auth.example.com 与 https://app.example.com否:主机不同是
https://auth.example.com 与 https://app.example.org否否
http://app.example.com 与 https://app.example.com否schemeful site 下也不同

因此,讨论“跨域 SSO”时,应进一步说清是跨 origin 但同 site,还是真正跨 site。把域名字符串简单截掉第一段来计算 site 也不可靠,可注册域取决于 Public Suffix List。

概念依据:web.dev · Same-site and same-origin ↗。
“同域”需要拆成同源与同站

顶层页面与直接嵌入的 iframe 若协议、完整主机名、端口都相同,且没有 sandbox 等额外隔离,通常共享 localStorage;iframe 本身不会天然得到一份全新的存储。app.example.com 与 auth.example.com 则是同站、不同源。这里要比较的是认证页两次执行:A 独立打开与B 顶层下的 A iframe,两次 A 的 origin 相同,但顶层环境不同。

sessionStorage 不是服务端登录 Session

浏览器的页面会话决定客户端数据放在哪、活多久;服务端会话/token 校验决定请求能否被授权。浏览器读取不到本地 token,并不能证明服务端会话已经失效。

STEP 02sessionStorage 的基础边界:标签页内仍按 origin 分组

规范层面,session storage 随顶层可遍历上下文维护,并通过 storage key 找到对应存储区域。入门时可以把它理解为 页面会话 × 存储 origin,再叠加浏览器实际的隐私分区规则。单独写“sessionStorage 只按标签页隔离”是不完整的。

规范基础:WHATWG · Storage keys 与 Storage sheds ↗ · WHATWG · Web storage ↗。
操作理解方式
刷新同一页面通常保留当前页面会话的数据,刷新不等于开始全新 sessionStorage。
同一标签页内导航origin 决定访问哪份区域;从顶层变为 iframe 还可能改变隐私分区。
独立打开新标签页通常创建独立页面会话,不能依靠 sessionStorage 持续共享。
带 opener 打开新页面可能有初始存储副本,此后相互独立;初始值相同不代表实时同步。
关闭页面/恢复会话正常关闭结束页面会话,但恢复行为也需验证;不能用“关 tab”替代服务端注销。
生命周期与 opener:MDN · Window.sessionStorage ↗。

STEP 03早期 SESSION 案例:Safari 与 Chrome 的边界差在哪里?

读写代码都属于认证 origin A,变化的是 A 所处的顶层上下文。登录时 top = A;回跳业务站之后,top = B、iframe = A。即使 tab 没变,浏览器也可能把这两次访问映射到不同存储区。

写入阶段

认证站作为顶层页面

页面会话 T
顶层 origin:A
存储 origin:A

T × A × A
读取阶段

业务站嵌入认证 iframe

页面会话 T
顶层 origin:B
存储 origin:A

T × B × A

上图是 WebKit 已记录的分区方式的教学模型,不是所有版本统一的规范公式,也不是对本次机器内部存储键的直接观测。

WebKit 工程师在对应问题中解释,其存储分区会使用主 frame 与子 frame 的 origin。于是同一 tab 里的 A 顶层 与 B → A iframe 可以访问不同区域。这与本次 Safari 的 SESSION 现象一致。

WebKit 实现依据:SessionStorage 分区边界说明 ↗。

Chrome 的现代分区通常考虑当前 origin 与顶层 site;SESSION 还保留页面会话维度。单层嵌入时,如果 A 与 B 是同站,不同端口改变了 origin,却没有改变顶层 site,因此可以解释本次 Chrome 仍可读的表现。Chrome 的跨站嵌套还可能引入祖先位,不能把“tab + origin”当作它的完整永久规则。

教学模型用于理解的边界
基础页面会话模型page session × storage origin
WebKit 已记录的分区page session × top-level origin × storage origin
现代 Chrome,简化单层场景page session × top-level site × storage origin;复杂嵌套另有条件
Chrome 实现依据:Chrome · Storage Partitioning ↗。
准确的归纳

针对早期 SESSION 现象,顶层分区边界差异是与官方实现相符的解释,仍应通过相同 key 与上下文探针验证。口语上的“不是同一个 session”含义不清:需要核对客户端可见的存储分区;iframe 并不会天然创建新标签页,也不能据此判断服务端 Session 已变化。手机 LOCAL 的后续反馈按下一节单独排查。

STEP 04LOCAL 也要逐环境验证:桌面成功,手机为何返回 null?

早期顶层写入、iframe 读取的 LOCAL 探针成功,后续 PC 的 Chrome 与 Safari 测试也反馈可读。但手机测试返回 null,且两边都是 localStorage。改成 LOCAL 只改变存储类型,不能保证不同顶层上下文访问同一个区域。

localStorage 通常不随单个标签页结束而清空,但“保存得更久”和“跨分区可见”是两个问题。WebKit 也记录过 localStorage 分区行为及其与隐私设置的关系,因此不能从这次成功推断用户关闭了某个设置,也不能把 LOCAL 写成所有 Safari 场景的通用修复。

适用边界:WebKit #273193 · LocalStorage 分区 ↗。

同一个认证 origin,为什么还可能读到不同的数据?

localStorage 顶层边界示意:A = auth.example.com,B = app.example.com,均使用 HTTPS
认证页的运行方式顶层 origin存储 originWebKit 分区教学模型
独立打开认证页 AAAA × A
业务页 B 嵌入认证页 ABAB × A

WebKit 工程师在 localStorage 跨子域共享问题中确认了按 origin 分区的设计,以及它与隐私设置的关系。即使 A、B 属于同一主域,也可能得到不同区域。上表是帮助理解该实现的简化模型,不是所有浏览器统一的公式,也不是对本次手机内部存储键的直接观测。

Chrome 的分区通常考虑顶层 site、存储 origin 及跨站祖先条件,同站子域与真正跨站应分别验证。桌面 Safari 成功时,还要对照手机的版本、隐私设置和写入位置;不能只凭“同一个浏览器名称”推断两端规则相同。

官方实现说明:WebKit · LocalStorage 分区 · Chrome · 顶层 site 与祖先条件。

手机端最短排查路径:在当前 iframe 内写,再从父页读

当前两个调试页都固定使用 hello,它是测试 key。真正登录 token 的 key 由 TOKEN_KEY 配置。只有匹配 GET 请求的 storage-result 或 response-received 中 value = null 且没有 error,才应按“当前可见区域没有该 key”分析;单独一条请求到达事件不能证明读取结果。

  1. 手机打开业务读取页,保持当前顶层页面;在页面下方已经嵌入的认证调试 iframe 内选择 localStorage,写入 hello = mobile-probe-v1。
  2. 立即点击 iframe 自己的读取按钮,确认写入成功;记录所选 storage、key、当前 origin 与事件类型。
  3. 在同一个页面上,父页也选择 localStorage,再点击读取按钮;对照 requestId,确认是否读到相同的探针值。
  4. 再比较独立打开认证页时同一 key 的值。若 iframe 自读和父页代读一致,而独立页不同,在排除 URL、模式及写入失败差异后,强烈支持存储上下文隔离。
根据结果继续定位

iframe 自己也读不到刚写入的值:先检查写入错误与 key。iframe 自读有值、父页返回 null:先核对请求的 storage、key、目标 frame 与部署版本。只有上下文改变时值发生差异,才进一步验证分区。测试完成后分别清理各上下文的测试 key。

源码复核没有发现 GET 正常值被转换为 null:代理直接返回 getItem 的结果,异常会附 error,客户端收到 error 会拒绝 Promise。另有待补齐的 event.source 校验,见后文加固章节;目前没有证据把它认定为此次手机空值的原因。模拟环境中的逻辑检查也不能替代真实手机的分区验证。

通用探针:在开发者工具中分别比较 SESSION 与 LOCAL

诊断示例 1 / 顶层写入两个独立 Storage
// 在认证站的顶层页面执行;这是测试值,不是真实 token。
(() => {
  const key = '__storage_boundary_probe__';
  for (const type of ['sessionStorage', 'localStorage']) {
    try {
      window[type].setItem(key, 'probe-v1');
      console.log(type, { origin: location.origin, written: true });
    } catch (error) {
      console.log(type, { written: false, error: error.name });
    }
  }
})();
诊断示例 2 / 同一 tab 回跳后在认证 iframe 中读取
// 回到业务页,把开发者工具执行上下文切到认证 iframe。
(() => {
  const key = '__storage_boundary_probe__';
  for (const type of ['sessionStorage', 'localStorage']) {
    try {
      console.log(type, {
        origin: location.origin,
        embedded: window.parent !== window,
        matched: window[type].getItem(key) === 'probe-v1'
      });
    } catch (error) {
      console.log(type, { error: error.name });
    }
  }
})();
// 记录结果后,在写入和读取上下文分别删除测试 key。
  1. 记录浏览器与系统版本、普通/无痕模式、隐私设置、完整跳转链和两端 origin;不要记录真实 token。
  2. 先确认顶层写入成功,再在 iframe 中读取,避免把“未写入”误判成“被分区”。
  3. 比较同一 key、同一 storage 类型;切换 LOCAL 后重新登录,SESSION 数据不会自动搬过去。
  4. 如果 LOCAL 可读而 SESSION 不可读,支持按存储边界继续排查;如果都不可读,还应检查实际地址、上下文、写入失败和浏览器策略。
  5. LOCAL 的成功记录只覆盖对应探针环境;手机、真实 token 获取、刷新、多标签、登出与生产拓扑仍应单独验收。

STEP 05iframe + postMessage:把跨域访问转换为客户端 RPC

业务页不能直接取得跨源 iframe 的 Storage 对象。它持有 iframe.contentWindow,向这个窗口发送操作描述;认证 iframe 收到消息后,在自己的执行上下文与存储权限内完成操作,再把结果发回业务页。

CALLER / 调用端

CrosStorage.ts

getToken()
创建 requestId
注册响应监听
返回 Promise

请求 →postMessage← 响应异步窗口消息
CALLEE / 执行端

cros-storage/page.tsx

校验来源与消息
分发 GET / SET / REMOVE
操作自己的 Storage
返回 value / error

“远程”指另一个窗口执行上下文,不一定跨机器,也不要求每次读存储都经过 HTTP 服务端。
RPC 概念本项目对应实现承担的职责
Client stubCrosStorage.get/set/remove把本地方法调用编码为命令,向业务暴露 Promise 接口。
TransportpostMessage + message在窗口间传输结构化克隆后的消息对象。
Server stub / dispatcher代理页的 handleMessage 与 switch校验消息并调用对应的本地存储方法。
CorrelationrequestId把异步响应匹配到发起它的 Promise。
Result / failurevalue 与 error区分有效返回值、空值和执行失败。
LifecycleINIT、超时、cleanup、destroy解决就绪等待与请求结束后的资源释放。

postMessage() 本身不会返回 iframe 的计算结果;它也不会自动把对端异常变成调用端异常。请求协议、返回值关联、Promise、超时和错误传播,都是 CrosStorage 在消息 API 之上构建的 RPC 层。

消息 API 语义:MDN · Window.postMessage ↗ · WHATWG · Cross-document messaging ↗。RPC 映射是对当前源码的工程归纳。
通信能力不能合并存储分区

iframe 如果只能看到空的存储区,就会把 null 正常发回来。增加消息次数、延长等待时间,都不会让它自动改读另一个分区。

STEP 06协议:用数据描述调用,用 requestId 关联返回值

协议定义在 CrosProtocol.ts。它没有传递函数引用,而是传递“执行什么、操作哪种存储、哪个 key、写什么值”。下面保留当前类型定义;枚举字符串即实际传输值。

当前协议 / CrosProtocol.ts
// 定义消息协议

export enum CommandType {
  GET = "get",
  SET = "set",
  REMOVE = "remove",
  INIT = "init"
}
export enum StorageType{
  LOCAL = "local",
  SESSION = "session",
  AUTOMATIC = "automatic"
}

export interface StorageRequest {
  storage: StorageType;
  command: CommandType;
  key: string;
  requestId: string; // 唯一请求ID,用于匹配响应
  value?: string;
}

export interface StorageResponse {
  requestId: string;
  value: string | null;
  error?: string;
}
字段 / 命令含义与边界
requestId当前请求的关联标识;响应必须原样携带。不是 token,也不是调用权限证明。
commandGET 读、SET 写、REMOVE 删除;INIT 用于代理就绪通知,不是一次普通存储请求。
storageLOCAL / SESSION / AUTOMATIC,实际字符串为 local / session / automatic。
key / valuekey 指定存储键;SET 需要字符串 value,GET 与 REMOVE 不需要请求 value。
response.valueGET 返回读取值;SET 返回写入值;REMOVE 返回删除前的值。缺失键可返回 null。
response.error代理执行异常的错误消息;当前客户端据此 reject。

两个调用可以同时发出,例如读取登录 token 与读取用户偏好。响应先后顺序不应成为关联依据;即使响应顺序改变,各自的 requestId 也能让结果回到正确的等待者。当前实现为每个请求注册独立 listener,而非共用一个会被覆盖的回调。

TypeScript 接口在运行时不存在,因此“声明了 StorageResponse 类型”不等于来自窗口消息的数据已经可信,接收端仍需要运行时检查。

STEP 07CrosStorage 调用端:同源直读,跨源创建代理并等待 INIT

common/lib/CrosStorage.ts 是业务调用入口。构造时把当前页面与 STORAGE_PROXY 比较:协议、主机、端口一致则直接操作本页存储;否则创建或复用隐藏的认证 iframe。

  1. 创建 iframe,并把父页 origin 编码到代理 URL 的 query。该 query 是路由信息,不是认证凭据。
  2. 父页先安装 INIT 消息监听器,再把 iframe 加入 DOM,减少错过就绪通知的风险。
  3. 代理解析 query,校验 allowlist,先注册 handleMessage,再发送 INIT。
  4. 父页收到预期 origin 的 INIT,把 iframe 的 loaded 属性置为 true。这里是应用层握手,不是只依赖 DOM load。
  5. 请求轮询 loaded,每 100ms 检查一次;就绪后发送请求。等待握手的时间计入该请求的 10 秒超时。
当前逻辑节选 / URL 携带父 origin,业务请求等待应用层就绪
iframe.src = `${STORAGE_PROXY}?${encodeURIComponent(window.location.origin)}`;

// 代理页:先注册处理器,再宣告就绪。
window.addEventListener('message', handleMessage);
window.parent.postMessage({
  storage: StorageType.AUTOMATIC,
  requestId: Utils.randomUUID(),
  key: 'cros-iframe-storage',
  command: CommandType.INIT,
}, parentOrigin);
INIT 只表示“消息处理器准备好了”

它不代表用户已登录,不代表存储里存在 token,也不代表浏览器已经允许读取顶层认证页的存储区。

AUTOMATIC 通常在调用端先被解析:配置严格等于 SESSION 时选择 session,否则选择 local。代理也保留 automatic 兜底分支,但不能据此认为所有调用都由认证站配置统一决定。

当前逻辑 / 调用端先决定 storage 类型
private getStorageType(storageType: StorageType) {
  if (storageType === StorageType.AUTOMATIC) {
    storageType = TOKEN_STORAGE === "SESSION"
      ? StorageType.SESSION
      : StorageType.LOCAL;
  }
  return storageType;
}

所以登录写入端、业务读取端都要核对配置。只改一处就可能变成“写 LOCAL、读 SESSION”。调整公开构建变量后,应确保运行中的开发服务/构建产物使用新配置,并重新登录。

STEP 08request():把异步消息包装为一次可等待的调用

这是客户端 RPC 的核心。每个请求拥有自己的 Promise、响应处理器、轮询定时器和超时定时器。匹配 origin 与 requestId 的响应才能作为结果结算;超时、发送异常与 destroy 也会结束请求。

展开当前 request() 实现源码节选 / 非加固版本
CrosStorage.ts / 当前 request(),保留现状用于分析
private request(req: StorageRequest): Promise<string | null> {
        return new Promise((resolve, reject) => {
            let poll: ReturnType<typeof setTimeout>;
            const cleanup = () => {
                clearTimeout(poll);
                clearTimeout(timeout);
                window.removeEventListener("message", handleMessage);
                this.pending.delete(cancel);
            };
            const cancel = () => {
                cleanup();
                reject(new Error("Storage client destroyed"));
            };
            const handleMessage = (event: MessageEvent<StorageResponse>) => {
                if (event.origin !== this.iframeOrigin ||
                    !event.data || event.data.requestId !== req.requestId) return;
                cleanup();
                if (event.data.error) reject(new Error(event.data.error));
                else resolve(event.data.value);
            };
            this.pending.add(cancel);
            window.addEventListener("message", handleMessage);
            const timeout = setTimeout(() => {
                cleanup();
                reject(new Error("Account storage request timed out"));
            }, 10000);
            const send = () => {
                if (this.iframe.getAttribute("loaded") !== "true") {
                    poll = setTimeout(send, 100);
                    return;
                }
                try {
                    this.iframe.contentWindow?.postMessage(req, this.iframeOrigin);
                } catch (error) {
                    cleanup();
                    reject(error);
                }
            };
            send();
        });
    }
阶段为什么需要
先注册响应 listener在发送前就准备好接收,避免快速响应先到、调用端尚未监听。
按 origin + requestId 筛选当前实现用于筛除其他 origin 或其他请求的响应;仍缺预期窗口 source 校验,见 STEP 12。
ready 后发送一次轮询是在等握手,不是在反复发送 GET/SET。
10 秒超时包含 INIT 等待时间。超时表示调用未按时完成,不等于“存储里没有值”。
cleanup移除本请求 listener、清除计时器、从 pending 集合删除取消函数。
destroy取消当前实例的未完成请求;共享 iframe 本身保留。

当前 pending 是取消函数的 Set,不是 Map<requestId, Promise>。用统一 listener 与 pending Map 是可选重构,不应写成当前已有实现。

超时 ≠ 对端操作被撤销

如果 SET 已经发送并执行,只是响应没有及时到达,客户端超时并不能回滚写入。当前协议没有对端取消、重试去重或 exactly-once 保证。未来扩展到扣款、提交订单等有副作用的命令时,必须另行设计幂等与确认机制。

STEP 09cros-storage 页面:在自己的权限内执行,再显式返回

(cros)/cros-storage/page.tsx 是客户端 RPC 的服务端角色。这里的“服务端”是协议角色,实际代码仍运行在浏览器 iframe 内。React 页面返回 null,因为它提供存储服务而非可见 UI;监听逻辑放在 useEffect 中,在浏览器运行。

现有页面已经做了基础校验:必须嵌入运行;query 中的父 origin 可解析并在 allowlist 中;消息 origin 与父 origin 一致;数据为对象;requestId/key 为字符串;command/storage 属于允许枚举;SET 的 value 为字符串。通过后才进入存储执行分支。

代理页 / 命令分发与结果封装
// 当前代理执行分支的等价节选:省略上方基础校验与调试日志。
const response: StorageResponse = {
  requestId: request.requestId,
  value: null,
};
try {
  const local = request.storage === StorageType.LOCAL ||
    (request.storage === StorageType.AUTOMATIC && TOKEN_STORAGE !== 'SESSION');
  const storage = local ? window.localStorage : window.sessionStorage;

  switch (request.command) {
    case CommandType.GET:
      response.value = storage.getItem(request.key);
      break;
    case CommandType.SET:
      if (typeof request.value !== 'string')
        throw new Error('Value is required for set command');
      storage.setItem(request.key, request.value);
      response.value = request.value;
      break;
    case CommandType.REMOVE:
      response.value = storage.getItem(request.key);
      storage.removeItem(request.key);
      break;
  }
} catch (error) {
  response.error = error instanceof Error ? error.message : 'Storage unavailable';
}
window.parent.postMessage(response, parentOrigin);

异常必须被序列化到 response.error 才能被调用端感知。iframe 内直接 throw,不会自动穿过 postMessage 通道。另一方面,getItem() 返回 null 是一次合法读取结果,不会触发 catch。

调用端看到的结果含义下一步
resolve(string)代理返回了字符串后端继续验证 token 的有效性与权限。
resolve(null)RPC 成功完成,当前可见区域没有该键核对写入、key、storage 类型、上下文和分区。
reject(error)执行异常或本地生命周期错误按错误分类排查,不统一吞成未登录。
timeout未获得匹配响应检查 iframe 加载、INIT、消息过滤和实例是否被销毁。

当前代理卸载时移除 handleMessage;各客户端实例的清理是否真的被调用,还取决于调用方 Hook 的实现。下面的源码审查会区分两层责任。

STEP 10用一次消息往返理解 RPC:通信成功也可能返回 null

下面演示请求从创建、就绪、执行到完成的过程。选择不同结果,再逐步推进,可比较“无 token”和“请求没有完成”的区别。

客户端 RPC 状态演示

纯教学模拟 · 不访问实际存储
  1. 1 创建请求
  2. 2 INIT 就绪
  3. 3 发送与执行
  4. 4 响应与清理
Promise = pending

requestId = demo-request-1。先安装响应监听器,再等待 iframe 就绪。

步骤 1 / 4

两次并发调用即使收到顺序颠倒的响应,也要按 requestId 分别结算。当前代码在超时后移除该请求 listener,因此迟到响应不应再改变已结束的 Promise。进一步提高可靠性时,可加入协议版本、命名空间和严格响应 schema。

STEP 11将 RPC 接入 SSO:共享登录材料,认证仍由后端完成

当前登录页先等待 setToken() 完成,再回跳。Fetcher 调用 getToken() 后构造 Authorization 请求头。用户信息的本地解码与缓存只服务于界面展示,不能替代后端对签名、有效期和权限的验证。

这套方案成立需要两个独立前提:授权业务站可以与代理通信;代理能读到登录流程保存的同一份 token。前者由 RPC 与消息授权解决,后者由存储类型、写入位置、浏览器策略和页面拓扑决定。

问题层次由谁解决
跨源窗口如何交换操作与结果postMessage 与客户端 RPC 协议。
iframe 能否看到登录时的 token存储边界与实际浏览器验证;postMessage 无法改变这一点。
业务站能否读取跨源 API 响应服务端 CORS 配置及请求的凭据策略。
token 是否有效、能否访问某个资源服务端认证与授权。
网络层边界:MDN · CORS ↗。
先确认分区,再选择登录方案

桌面 LOCAL 成功支持继续验证该环境;手机反馈则需要完成前面的上下文探针。若确认顶层认证页与嵌入代理的数据隔离,扩大 allowlist、开启 CROS_DEBUG 或延长 RPC 超时都不能合并两个分区。登录态共享需要建立在目标浏览器支持的会话机制上。

当前同站架构优先评估 Cookie,跨站场景保留授权码方案

本项目的生产配置使用 HTTPS,认证站、业务站与集中 API 属于同一可注册域下的不同子域。对于这个拓扑,用 Cookie 承载 token 或会话标识,并由服务端完成鉴权,可以让业务请求直接获得认证,消除从认证 iframe 取 localStorage 的依赖。这里描述的是待实施方案,当前 Fetcher、上传与 WebSocket 仍然依赖前端读取 token。

这条流程中,业务页不需要知道 Cookie 的具体值,也不需要通过 iframe 读取认证页的 Web Storage。HttpOnly Cookie 仍会随符合条件的请求发送,但前端 JavaScript 无法读取它;因此它不能直接用于现有的“getToken() 后拼 Authorization”代码。

示意配置 / 由 api.example.com 的登录响应设置 host-only Cookie
Set-Cookie: auth_token=<REDACTED>; Path=/; HttpOnly; Secure; SameSite=Lax

# 没有 Domain:Cookie 限定在设置它的 api.example.com。
# 示例不表示当前后端已经支持该 Cookie 名称或鉴权方式。
# 过期时间、刷新和撤销策略须与实际 token / 会话设计一致。
示意请求 / 与 API 同站、不同源的 HTTPS 业务页
// /example-resource 是示意业务路径,需替换为实际接口。
const response = await fetch('https://api.example.com/example-resource', {
  credentials: 'include'
});
// Cookie 由浏览器处理;无需先调用 CrosStorage.getToken()。
落地条件需要做什么缺失时的结果
服务端消费 Cookie登录响应设置凭据,业务 API 从 Cookie 校验 token 或会话;明确定义刷新、到期与退出。只把字符串存到 Cookie,现有只认 Authorization 的接口仍无法认证。
前端凭据请求跨源登录及业务 fetch 配置 credentials: include;纯 Cookie 路径移除对 getToken() 的前置依赖。同站但不同源的 fetch 默认不会按需携带 Cookie;保留前置读取还可能继续等待 iframe。
凭据 CORSAPI 按白名单返回精确的 Access-Control-Allow-Origin,并设置 Access-Control-Allow-Credentials: true;有预检时正确处理。浏览器无法把响应提供给前端;带凭据响应不能使用通配来源 *。
Cookie 属性与部署核对实际请求主机、Domain、Path、Secure、SameSite 与过期策略;本示例各站均使用 HTTPS 且同站。Cookie 可能保存不到预期位置,或不会随目标请求发送。
自动携带凭据后的请求保护对有副作用的请求按服务端设计校验 CSRF token / Origin;联动上传、WebSocket 握手与退出逻辑。认证链路不完整,或请求保护仍沿用不适合 Cookie 鉴权的假设。

当前 Fetcher 只有显式传入 withCookie: true 才设置 credentials,但即使打开这个选项,仍会先执行 getToken()。因此迁移不能只修改存储介质或新增一个开关。文中示例是改造方向,落地前还需确认后端的 Cookie 鉴权能力,并完成前后端适配。

用法谁读取凭据作用域与取舍
API 的 HttpOnly Cookie浏览器发送,API 读取;业务 JavaScript 不能读取。所有业务都调用 api.example.com 时,可以使用 API 的 host-only Cookie,无需给每个子域共享可读 token。
父域下的 JavaScript 可读 Cookie匹配作用域的业务页面通过 document.cookie 读 token,再构造 Authorization。例如 Domain=example.com、Path=/,且不能设置 HttpOnly。可保留现有 Authorization 接口,但匹配作用域的子域脚本也能读取凭据,需要接受相应信任边界。

同主域不会自动把 host-only Cookie 变成共享 Cookie。认证主机可以在规则允许时设置父域 Cookie,但不能给兄弟 API 主机设置它专属的 host-only Cookie;后一种应由 API 自己的响应设置。

适用范围:消除本例存储依赖,不等于解决所有问题

Cookie 仍受浏览器策略影响。真正跨站的嵌入或请求可能遇到第三方 Cookie 阻止;SameSite=None; Secure 不能强制浏览器接受第三方 Cookie。Cookie 也不会自动修复 CORS、token 过期、撤销或 WebSocket 鉴权。项目仍需对目标手机和完整登录/登出链路验收。

Cookie 与凭据依据:MDN · Set-Cookie 属性与作用域 · WHATWG Fetch · 凭据与 CORS · WebKit · 第三方 Cookie 限制。

保留 Authorization 或扩展到跨站:顶层授权码回跳

如果需要继续使用现有 token 接口,或部署扩展到真正跨站、Cookie 不能按预期携带的环境,可以采用顶层认证回跳 + 一次性授权码兑换。各业务站在自己的上下文中管理凭据,不依赖认证 iframe 的持久化存储可见性。

采用成熟的授权码协议时,配套精确回调地址校验、请求绑定与 PKCE 等要求;前端公共客户端不能保管 client secret。URL 只传短期一次性 code,长期凭据通过受保护的兑换流程获取。当前前端尚未发现授权码签发与兑换接口,这是需要后端配合的迁移建议,并非已实现功能。

方案如何建立登录态项目适配范围
Cookie token / 服务端会话API 设置 HttpOnly、Secure Cookie;业务前端请求 API 时携带凭据。当前同站集中 API 架构优先评估;需要后端 Cookie 鉴权、前端凭据请求与完整链路适配。
顶层授权码回跳认证站以顶层方式访问自己的登录态;业务站兑换凭据并管理本地登录态。适用于保留 Authorization 或跨站拓扑;需要授权码服务、回调、凭据生命周期及登出流程。
统一到真正同源的入口页面与相关存储逻辑在同一协议、主机和端口下运行。需要部署及访问路径统一,并设计旧数据迁移;仅反向代理一个 iframe 地址不会自动迁移旧 origin 的存储。

若业务都请求同一个 api.example.com,Cookie 会话可以限定在该 API 主机,不必默认给所有子域共享 Cookie。跨站部署仍需单独评估凭据策略;Cookie 方案不能被概括为绕过一切浏览器限制。

迁移依据:RFC 9700 · OAuth 2.0 安全最佳实践 · WebKit · 登录集成与服务端会话建议。项目适配判断来自当前前端源码,未审查后端实现。

隐藏 iframe 中直接调用 requestStorageAccess() 也不是自动修复。应核对目标浏览器、存储类型、用户手势及权限要求;Cookie 的授权不能直接当作 Web Storage 的解分区保证。

API 背景:WebKit · Introducing Storage Access API ↗;该早期说明用于解释 Cookie 与其他存储的区别,不作为所有新版本功能支持表。

STEP 12以当前源码为准:已有能力与需要补强的部分

文章分析的是当前工作区源码,不把建议伪装成已经完成的实现。尤其要注意:客户端注释写着同时匹配 origin 和 iframe window,但当前实际条件尚未检查 event.source。

项目当前源码建议
消息来源双向使用精确 targetOrigin;代理检查父 origin 与 allowlist;客户端核对响应 origin。INIT、响应与代理请求都增加预期窗口 source 校验。
消息结构代理有基础运行时校验;客户端响应主要检查 requestId。严格验证 value/error 类型,增加 namespace/version。
调用权限已允许的来源可以指定任意 key,执行 GET/SET/REMOVE。按来源限定 key 与命令,限制字段长度和调用频率。
日志代理打印完整 request 和读取值,SET 的 value 也可能进入日志。只记录 requestId、command、storage、hasValue 等诊断信息。
请求释放请求完成、异常、超时、destroy 会清理本请求资源。首个 INIT listener 在始终未就绪时仍需额外清理;导航后需重新握手。
Hook 清理当前空依赖 effect 的 cleanup 捕获初始 state,可能未调用新实例的 destroy。在 effect 中创建局部 client,cleanup 直接关闭该实例。
存储一致性访客 token 生成后调用 setToken 时未转传显式 storage 参数。保持显式读取类型与后续写入类型一致。
登出与失效这套前端代码提供 removeToken,尚未实现完整跨站登出广播或与后端撤销流程的联动;本文未审查后端撤销能力。按会话产品需求补充 token 失效、轮换与业务状态同步。
建议加固 / 当前源码尚未加入 source 检查
// 父页面:INIT 和普通响应都需要验证预期 iframe。
if (event.origin !== iframeOrigin ||
    event.source !== iframe.contentWindow) return;

// 代理页:限定为发起嵌入的直接父窗口。
if (event.origin !== parentOrigin ||
    event.source !== window.parent ||
    !allowOrigin(event.origin)) return;

// 之后继续验证消息 schema、允许的 command 与 key。
// requestId 只关联响应,不证明调用者身份。

消息机制把哪些业务站可以读取认证数据的决定交给了应用。接收 token 的站点因此进入信任范围。精确 origin/source 校验、最小 key 授权与不记录明文 token,都是这一 RPC 服务自身需要完成的职责。

跨文档通信的安全要求:WHATWG · Cross-document messaging ↗。

另外,同源分支先同步执行 Storage 方法,再包 Promise.resolve;存储异常可能同步抛出。不能把所有分支描述为已经统一的异步异常模型。当前 Utils.randomUUID 使用随机字符串拼接,其用途应限定为请求关联。

STEP 13验证矩阵:分别验收消息、存储与登录态

维度至少覆盖记录内容
浏览器与设置桌面 Safari / Chrome 与目标手机浏览器;普通与无痕窗口精确版本、隐私设置;不要混合不同配置的结果。
站点拓扑本地不同端口、同站子域、真正跨站顶层 origin/site、iframe origin、跳转链。
存储类型SESSION 与 LOCAL;同一 key 分别验证顶层写入、iframe 内自写自读、父页代读的结果及异常类型。
页面生命周期同 tab 回跳、刷新、新 tab、关闭再开存储是否保留、是否是 opener 初始副本。
RPC 行为INIT 延迟、并发请求、null、执行错误、超时每个 requestId 是否恰好结束一次,监听器是否释放。
Cookie 迁移Set-Cookie、作用域、凭据 CORS、API 认证与登出确认请求实际携带 Cookie;记录认证结果,验证无需 getToken();日志不输出真实凭据。
真实登录链路登录、接口鉴权、登出、token 过期凭据随请求送达后,后端是否正确认证并拒绝失效 token 或会话。

最有效的诊断顺序是:写入成功 → 读写配置一致 → iframe 确实就绪 → 请求到达 → 当前分区读到什么 → 响应是否匹配 → 后端如何处理 token。这样能把存储分区与消息协议故障分开,避免在 null 已经明确返回时继续调整握手超时。

04 /

总结归纳

COOKIE CREDENTIALS · SERVER AUTHENTICATION

用 Cookie 承载登录凭据,消除 iframe 存储依赖。

针对当前 HTTPS 同站子域与集中 API 架构,Cookie 是值得优先落地验证的解决方向。由服务端设置并校验 token 或会话 Cookie,让浏览器随 API 请求携带凭据,可以避免因认证页与 iframe 的 localStorage 分区不同而拿不到登录材料。前后端鉴权、凭据策略和生命周期必须一起适配。

同源与同站要区分,存储持久性与跨分区可见性也要区分。真正同源的顶层页面与 iframe 通常共享 localStorage;同一认证 origin 从独立页变为另一源下的 iframe,则可能进入不同分区。桌面 LOCAL 成功与手机空值是分别成立的反馈,手机根因仍需验证。

客户端 RPC 将“直接取得另一源的 Storage”改为“请该源的 iframe 代为执行”。CrosStorage 负责请求与等待,cros-storage 页面负责校验、执行与返回。它能传递执行结果,但不能改变执行端能够看到的数据范围。

  1. 讨论边界:origin、site、页面会话和存储分区分别说明,不把“跨域”当作单一条件。
  2. 尊重实测:桌面 LOCAL 成功不保证手机成功,手机失败也不证明所有环境必然隔离;按版本、设置和拓扑分别验收。
  3. 区分成功:消息送达、存储命中、后端认证成功是三个不同结果。
  4. 协议闭环:就绪握手、requestId、响应校验、错误、超时与清理共同构成可用的客户端 RPC。
  5. 当前架构优先评估 Cookie:配套服务端鉴权与凭据请求,消除 Web Storage 分区依赖;跨站 Cookie 限制、CORS 与凭据生命周期仍需单独处理,必要时使用顶层授权码回跳。
05 /

官方依据与代码索引

资料核对:2026-09-27。现场结果来自本次调试反馈;标准、浏览器公开实现说明与源码分析分别标注。浏览器规则会变化,部署结论需要目标环境复测。

代码标识本文中的职责
common/lib/CrosStorage.ts客户端 stub、同源分支、iframe 初始化、请求生命周期。
(cros)/cros-storage/page.tsx代理端基础校验、命令分发、Storage 操作与响应。
(cros)/cros-storage-debug/page.tsx固定 hello 测试 key 的手动读写、存储事件与 iframe 上下文探针。
CrosProtocol.ts / Env.ts协议字段、存储配置与来源 allowlist。
sign-in/page.tsx / Fetcher.ts登录后写入 token,调用 API 前读取 token。
CrosStorageHook.tsx / LoginUser.ts实例生命周期与用户界面缓存;不能替代服务端鉴权。
  1. [01]
    WHATWG · Storage keys 与 Storage sheds ↗

    规范基础模型、origin 与页面会话的关系。

  2. [02]
    WHATWG · Web storage ↗

    Storage 接口、存储区域与用户代理的隐私策略。

  3. [03]
    MDN · Window.sessionStorage ↗

    页面会话、刷新、opener 初始复制与标签页生命周期。

  4. [04]
    web.dev · Same-site and same-origin ↗

    协议、主机、端口与可注册域的区别。

  5. [05]
    WebKit #247565 · SessionStorage 分区 ↗

    工程师解释顶层 origin 与 frame origin 的分区边界;该记录可解释本文 SESSION 现象。

  6. [06]
    Chrome · Storage Partitioning ↗

    现代 Chrome 也分区,需考虑顶层 site 和嵌套跨站祖先。

  7. [07]
    WebKit #273193 · LocalStorage 分区 ↗

    localStorage 并非全环境免分区;设置与拓扑需要实测。

  8. [08]
    MDN · Window.postMessage ↗

    消息异步传递、结构化克隆、targetOrigin、origin 与 source。

  9. [09]
    WHATWG · Cross-document messaging ↗

    跨文档通信、安全检查与应用层协议。

  10. [10]
    MDN · CORS ↗

    网络响应读取许可,与窗口消息和存储可见性分开理解。

  11. [11]
    WebKit · Introducing Storage Access API ↗

    解释 API 最初围绕 Cookie 的权限设计;不能假设一次调用就会合并 Web Storage。

  12. [12]
    React · useEffect ↗

    浏览器端监听器注册与清理的生命周期。

  13. [13]
    RFC 9700 · OAuth 2.0 安全最佳实践 ↗

    授权码、PKCE、回调地址与凭据保护;迁移应采用成熟协议。

  14. [14]
    WebKit · Third-Party Cookie Blocking and More ↗

    跨站登录集成与服务端会话的设计方向。

  15. [15]
    MDN · Set-Cookie ↗

    Domain、Path、HttpOnly、Secure、SameSite 与跨源响应中的凭据要求。

  16. [16]
    WHATWG Fetch · CORS 协议 ↗

    跨源凭据请求的处理与响应访问要求。