登录信号 (cookie)
v2 把跨标签 / 跨子域的「登录已完成」待办收敛到一种机制——同 site cookie 信号。SDK 在登录成功路径写一份 __tripo_login_signal__ cookie,由同根域实例竞争认领,业务侧通过 auth.signal.onLogin(handler) 订阅;它不是向所有 tab fan-out 的广播。本页讲清楚协议、架构、token 兜底、SSR 安全、退出登录与典型 handler 写法。
为什么是 cookie
v1 用 BroadcastChannel 在同 origin 三页之间通信,跨 origin 业务自己写 postMessage。v2 改用 cookie 一统天下,原因:
- 跨子域天然可达:
Domain=.tripo3d.ai让所有子域共享同一份 cookie;登录页(www.tripo3d.ai)的事件可以被业务子域(studio.tripo3d.ai)感知,业务方零协议工作 - OAuth 回跳带得上:
SameSite=Lax让 OIDC 完成页的 cookie 在 IdP redirect 链路上不丢 - 跨 tab 可见:cookie 全局可见,不依赖 BroadcastChannel 或 window 引用;由其中一方认领,不保证每个 tab 都派发
- COOP/COEP 友好:popup 失去 opener 引用后 BroadcastChannel 仍可用,但 cookie 是更朴素的兜底
- API 简洁:业务方只看到
onLogin(handler)一个入口,订阅 / 取消订阅 / 跨标签竞争消费 / 跨子域送达由 SDK 处理
协议
cookie 全字段一览:
| 字段 | 值 |
|---|---|
| 名 | __tripo_login_signal__ |
| Domain | 自动探测到的可注册根域(如 .tripo3d.ai);localhost / IP 退化为 host-only |
| Path | / |
| SameSite | Lax(让 OAuth 回跳能带上) |
| Max-Age | 60s(由常量 LOGIN_SIGNAL_MAX_AGE_SECONDS 控制) |
| Secure | https 下加;http 本地调试不加(浏览器会拒写) |
| value | base64url(JSON(SignalCookiePayload)) |
SignalCookiePayload 在线协议:
interface SignalCookiePayload {
v: 1 | 2;
provider: string; // 'code' | 'password' | OIDC provider id ('google' / 'apple' / ...)
ts: number; // Date.now()
params?: string; // base64url(JSON(LoginParams)),可选
d?: 1; // v2 才出现:发布方已完成本 tab 派发,可选
}普通待派发信号使用 v:1。One Tap 这类当场完成登录、且已有订阅者的入口会在本 tab 派发后写 v:2,d:1:新版 SDK 将其视为已派发,旧版 SDK 因只识别 v1 而安全忽略,从而阻断同根域混合版本期间的二次埋点。代价是旧端不会针对这条已完成登录再执行本地收尾;这是 SDK 选择“宁可旧端漏一次刷新,也不双报”。d 字段名压到一个字母,是因为 cookie 值会经 base64url 膨胀。完成登录时若尚无订阅者,仍写 v:1 并保留内存 pending,等待后续订阅或轮询认领。
业务侧拿到的 LoginSignal 是解码后的形态:
interface LoginSignal {
readonly v: 1;
readonly provider: string;
readonly ts: number;
readonly params?: LoginParams; // 已解码回原值;编码失败时不出现
}写入时机
SDK 在以下 5 个位置写一份 cookie:
| 路径 | 写入端 | provider |
|---|---|---|
| 邮箱验证码登录 | auth.login.submitCode 成功 | 'code' |
| 邮箱密码登录 | auth.login.submitPassword 成功 | 'password' |
| 密码重置 | auth.reset.submitNewPassword 成功 | 'password' |
| OIDC 完成页 | auth.oidc.handleComplete() | URL provider query(如 'google') |
| Google One Tap | auth.oidc.submitIdToken() 成功(promptGoogleOneTap 内部调用) | 'google'(提交时传入的 provider) |
OIDC edge(账号合并 / 补信息)流程本身不单独写 cookie:它最终跳回 OIDC 完成页,由上表的 handleComplete() 统一写入。
密码重置(auth.reset.submitNewPassword)成功后会写 cookie(provider: 'password')——recovery 验证码校验通过时 ory 已把当前会话认证为登录态,重置密码即完成一次登录,故与其它登录路径一致写信号、由容器收尾直接进入登录态。退出登录走 auth.logout(),它会 clearSignal() 主动失效 cookie,再派发 onLogout 事件(见下文「退出登录」章节)。
业务侧 API
订阅 login / logout:
import type { LoginPayload, LogoutPayload, Unsubscribe } from '@tripo3d/auth';
const unsubLogin: Unsubscribe = auth.signal.onLogin(async ({ signal, context }) => {
// SDK 已在派发前确保 token 就绪;这里 token.get() 直接拿得到值
await fetchCurrentUser();
if (context.isRedirect) {
// 业务页冷启路径(从 LoginPage / OIDC redirect 跳回来的)
}
});
const unsubLogout: Unsubscribe = auth.signal.onLogout(({ signal }) => {
// SDK 已在派发前 clear 了 token;这里 peek() 是 undefined
clearLocalUserStore();
});
// 组件卸载时
unsubLogin();
unsubLogout();handler 签名:
type OnLoginHandler = (payload: LoginPayload) => void | Promise<void>;
type OnLogoutHandler = (payload: LogoutPayload) => void | Promise<void>;
interface LoginPayload { signal: LoginSignal; context: LoginContext; }
interface LogoutPayload { signal: LogoutSignal; }
interface LoginContext { isRedirect: boolean; }- 同步抛错 / 异步 reject 都被 SDK 静默吞掉并走
report(业务方传入的onError),一个 handler 抛错不影响别的 - handler 可以注册多个;每收到一份信号都会派发给所有当前已注册的 handler
- client only:onLogin / onLogout 在 SSR 上下文(无
window)直接返回 no-op unsubscribe,安全无副作用
context.isRedirect 的语义
| 值 | 触发路径 | 业务用法 |
|---|---|---|
false | 本 tab 主动派发(popup / dialog 模式 useConsumeSignal 命中 cookie) | 主流程,按常规更新业务态 |
true | 业务页冷启时检测到 pendingSignals 自动补偿派发(独立页 / OIDC redirect 回跳) | 通常等价于 false,可选地跳过"登录成功动画"等同页面交互 |
判定规则集中在 SDK 单点(emitLogin 路径 = false,pendingSignals 补偿 = true),业务侧无须传或猜。
架构:单 bus + 两层
bus (createEventBus<{ login: LoginPayload; logout: LogoutPayload }>)
▲
┌───────────────────────────┼───────────────────────────┐
│ │ │
sugar emitLogin sugar emitLogout sugar onLogin / onLogout
(仅实际交付时消费 (token.clear + (bus.on + 仅 login 路径
matching pending + bus.emit) pendingSignals 补偿派发)
ensureTokenReady + bus.emit)
│ │
└───────── ↑ ──────────────┘
UI 层 useConsumeSignal / useReadSignal
(polling cookie,命中后调 emitLogin)第 1 层:SignalApi(业务面,core/signal/api.ts)
auth.signal namespace 暴露 3 组方法:
| 类型 | 方法 | 说明 |
|---|---|---|
| 原子 | readSignal() | 读 cookie,不删;脏数据 / 过期返回 null 但不清理 |
| 原子 | readSignalRecord() | 同上,但额外返回「已派发」标记:{ signal, dispatched } |
| 原子 | writeSignal(input) | 写 cookie(submitCode / submitPassword / handleComplete 用);input.dispatched 为真时打上标记 |
| 原子 | clearSignal() | 主动清掉 cookie |
| Login sugar | emitLogin(signal) | UI 命中 cookie 后调它;派发前自动 await token.refresh() 兜底 |
| Login sugar | publishLogin(signal) | 给无轮询容器兜底的入口用(One Tap):有订阅者时写带标记 cookie + 立即派发;无订阅者时写未标记 cookie + 保留 pending,等待后续认领 |
| Login sugar | consumeSignal() | 读 + 清 cookie,返回 { signal, shouldEmit };shouldEmit 为假表示已被发布方派发过,只需本地收尾 |
| Login sugar | onLogin(handler) | 业务订阅 = bus.on + 有效窗口内对每个晚注册订阅者的 pendingSignals 定向补偿 |
| Logout sugar | emitLogout() | 派发前同步 token.clear();由 auth.logout() 内部调用 |
| Logout sugar | onLogout(handler) | 业务订阅 logout 事件;无 pendingSignals 补偿(登出不持久化) |
事件总线底层是 @tripo3d/utils 的 createEventBus——它已经处理好 handlers Map、快照后 forEach(防 handler 内 off 跳过相邻订阅)、try/catch 隔离一个 handler 抛错不影响其他。SDK 注册时套一层 wrapper 补齐:
- async handler 的 reject 用
Promise.resolve(...).catch(...)兜住,避免变成 unhandled rejection - 抛错走
TripoAuthError({ code: 'onlogin_handler_failed', ... })+ reporter(业务可监控),替代 utils 默认的console.error
Token 状态承诺
| sugar | 派发前 SDK 做什么 | handler 同步入口的 token 状态 |
|---|---|---|
emitLogin | if (!token.peek()) await token.refresh() —— refresh 失败仅 report 不阻断派发 | 通常已就绪;refresh 失败时仍为 undefined(业务方可在 handler 里再 fallback) |
emitLogout | token.clear()(同步) | 一定是 undefined |
token.refresh 内部有并发去重 + 冷却窗口,业务 handler 里再 await token.refresh() 不会重复打后端,作为容错保留是安全的。
pendingSignals 补偿
createSignalApi 闭包构造时立刻读 cookie record 并清掉;只有未标记信号会按 provider + ts 进入 pendingSignals Map。多个尚未实际送达的信号可以并存,轮询认领另一份 cookie 不会覆盖已有的内存后备。每次写入前会同步清理已过期项,且最多保留最近 20 条,超过时淘汰最早记录;这不创建后台定时器:
const pendingSignals = new Map<string, LoginSignal>();
if (safeWindow()) {
const record = readSignalRecord();
if (record) {
clearSignal();
if (!record.dispatched) {
retainPendingSignal(record.signal);
}
}
}意图:业务页冷启时若 cookie 还在(OIDC redirect 或 LoginPage 跳回来的),以及运行期 publishLogin 完成时尚无订阅者,即便业务 onLogin 注册得晚(plugin / onMounted / 懒加载路由),也能通过 microtask 补偿拿到每份待送达信号。每个晚注册订阅者都有自己的补偿任务;cookie 被轮询认领或空 bus.emit 不等于已经送达,因此本实例会保留/建立同 identity 的 pending,直到实际向有效订阅者广播或定向调用。主派发会先登记同一信号的 delivery claim,令在 token await 中的补偿让位,防止二次派发。首个真正执行的补偿只清除仍匹配该 pending 的未标记 cookie,阻断后续轮询,但保留内存 pending 并只调用当前 wrapped handler:
onLogin(handler) {
if (!safeWindow()) return noop;
const wrapped = wrapWithReporter(handler);
const unsub = bus.on('login', wrapped);
const pendingSnapshots = getFreshPendingSignals();
for (const pending of pendingSnapshots) {
queueMicrotask(() => {
if (bus.has('login', wrapped)) {
void emitLoginCompensate(wrapped, pending);
// ensureTokenReady → 确认同 identity 的 pending 未被主路径消费
// → 确认没有被 main delivery claim 认领
// → 首次补偿清匹配 cookie → 保留 pending → 只定向调用当前 wrapped
}
});
}
return unsub;
}何时失效:
- 主路径
emitLogin实际向仍有效订阅者广播同一 identity 时只移除对应的 Map 项;空 bus.emit 不会移除任何 pending,已排队补偿也会让位给已认领的主派发 - 首个成功补偿会清除仍匹配该信号的未标记 cookie,避免后续轮询再次派发;内存 pending 继续保留,供有效窗口内后注册的订阅者分别补偿
- 自身
ts距今超过 cookie Max-Age(默认 60s)——写入路径、getFreshPendingSignals()/ 同 identity 查询都会懒清对应 Map 项。避免冷启 redirect 路径下永不触发主路径 emit 时晚注册的 handler 拿到"幽灵登录" - 保留数量超过 20 条时淘汰最早记录;这是防御异常高频的无订阅者发布,正常登录流不应触及该边界
- SDK 实例销毁
已知行为边界:
- 每份 pending 信号在 60s 窗口内持续有效,直到其主路径实际送达或过期。每个晚注册 handler 对每份 pending 各收到一次定向补偿,先前已补偿的 handler 不会因后续注册再次触发 ✓
- 业务方
onLogin(同一 handler)重复注册会产生多份 listener、handler 被触发多次。业务方应保证一个 handler 只注册一次(SDK 不做幂等去重,避免引入 WeakMap) emitLoginCompensate内await ensureTokenReady()后会再次检查bus.has('login', wrapped)、同 identity pending 与主派发 claim:await 窗口内 handler 已 unsub,或同一信号已由主路径送达时均不再派发
第 2 层:UI composables(@tripo3d/auth/ui)
// LoginDialog 用:dialog 本身就是登录目的地,可以消费 cookie
useConsumeSignal(auth, (signal) => {
dialog.close(opener)
})
// 内部:
// onMounted setInterval 内
// const sig = auth.signal.readSignal()
// if (sig) {
// auth.signal.clearSignal() // 清 cookie 防同 tab 重复消费
// auth.signal.emitLogin(sig) // sugar 派发;内部会 await token.refresh 兜底
// onSignal(sig) // 本地回调(关 dialog)
// }
// LoginPage 独立页用:cookie 要原样留给业务页消费
useReadSignal(auth, (signal) => {
window.location.assign(returnTo)
})
// 内部:
// onMounted setInterval 内
// const sig = auth.signal.readSignal()
// if (sig) onSignal(sig) // 只调本地回调,不动 cookie、不 emit两个 composable 都是 (auth, onSignal) 签名,onUnmounted 清 timer。
分层解决了什么
- polling 移出 SDK → 不开 dialog 就不轮询,无空跑;业务方更可控
useConsumeSignal通过emitLoginsugar 替 SDK 派发 → 业务侧通过auth.signal.onLogin注册的 handler 在 dialog 打开期间也能收到信号useReadSignal不动 cookie → 独立登录页跳回业务时 cookie 完好,业务页SignalApi构造期readSignalRecord()捕获未标记信号 → 晚注册的onLogin分别定向补偿
v2 早期版本里独立页直接走「读+删」路径,cookie 被独立页吃掉,业务页拿不到 → PC / 移动端独立页登录后业务侧 onLogin 永不触发。当前架构通过 useReadSignal 的「只读不删」彻底修掉这个问题。
退出登录
auth.logout() 是完整的退出闭环:
// SDK 内部
this._logout = async () => {
await runLogout({ api, onSuccess: () => this.signal.emitLogout() });
};
// runLogout: 调 ory updateLogoutFlow 失效 session → clearSignal() 清 cookie → onSuccess
// emitLogout: token.clear() → bus.emit('logout', { signal })业务侧只需要订阅 onLogout:
auth.signal.onLogout(({ signal }) => {
// 此时 token.peek() === undefined(SDK 派发前已 clear)
clearLocalUserStore();
router.push('/');
});
button.onClick = () => auth.logout(); // 触发完整链路注意:
emitLogout不持久化、不跨 tab —— 其他 tab 自然会在下次 token.refresh 拿到 401 时感知LogoutSignal当前只有{ ts, v };将来扩展(如reason: 'user' | 'session_expired')走可选字段,向后兼容
一次信号一次处理
readSignal() 是纯读,不会自己清 cookie。「读+删」的语义由调用方组合:
const sig = auth.signal.readSignal()
if (sig) {
auth.signal.clearSignal()
// 处理 sig
}useConsumeSignal composable 已内置 read + clear + emit 组合,业务方不必直接调原子方法。
跨 tab 多个 polling 会尽力竞争同一份全局待办 cookie:先抢到 clearSignal 的 tab 通常触发 handler,其他 tab 下一轮读到 null;但 cookie 的 read + clear 不具备跨子域原子 compare-and-claim,两个实例同时读取时无法给出数学意义的全局 exactly-once 保证。若 One Tap 发布方已在本 tab 派发并写入 v:2,d:1,新版其他 tab 即使先读到也只清 cookie,不触发 onLogin;旧版会直接忽略 v2。其他 tab 通过共享 session/token 在后续请求中自然感知登录态;若产品要求所有已打开 tab 立即刷新用户态,或需要跨子域严格全局一次,应由宿主提供服务端 claim/权威 relay,而不是依赖 cookie。
错误隔离
handler 抛错被严格隔离,不会污染派发循环:
function safeRun(invoke: () => unknown): void {
try {
Promise.resolve(invoke()).catch(err => report(toReportable(err)));
} catch (err) {
report(toReportable(err));
}
}Promise.resolve包一层:同步抛错和异步 reject 统一兜- 多 handler 场景下一个抛错不影响其他 handler 派发(底层 EventBus 复制 handlers 后 forEach,每个 handler 各自 try/catch + Promise.resolve 兜底,相互独立)
- 失败通过
TripoAuthOptions.onError上报为onlogin_handler_failed(code字段);不传onError时回落console.warn
跨子域 / 跨标签场景图解
场景 1:业务子域 + 独立登录页(OIDC redirect)
[Tab A: studio.tripo3d.ai] [Tab A: www.tripo3d.ai/login]
auth.signal.onLogin(handler) 已注册
showLogin → startOidc → SDK 推 redirect → location.assign(idpUrl)
用户完成 OIDC
回到业务传入的 completeUrl(绝对 URL)?...
OidcCompletePage.handleComplete()
→ writeSignal(...)
→ location.assign(returnTo='https://studio.tripo3d.ai/...')
[Tab A: studio.tripo3d.ai] ◄────────────────────────
business page 冷启动
new TripoAuth({...}) → createSignalApi 构造时立刻 readSignalRecord() → 未标记信号进入 pendingSignals Map + clearSignal()
app onMounted 注册 onLogin(handler)
→ queueMicrotask: ensureTokenReady + 校验同 identity pending + 定向补偿当前 handler,context.isRedirect=true场景 2:popup 模式 + 跨 tab 感知
[Main Tab: studio.tripo3d.ai] [OIDC Popup: completeUrl(绝对 URL)]
auth.signal.onLogin(handler) 已注册(业务侧)
LoginDialog 打开 → useConsumeSignal 起 polling
startOidc → SDK 推 popup → window.open(idpUrl, 'popup', ...)
用户完成 OIDC → 回到完成页
OidcCompletePage.handleComplete()
→ writeSignal(...)
→ window.close()
[Main Tab]
200ms 后 useConsumeSignal polling 命中 cookie
auth.signal.consumeSignal() → 返回 { signal, shouldEmit } 并删 cookie 防重复
shouldEmit=true → auth.signal.emitLogin(signal) → ensureTokenReady + 派发给业务 onLogin handler(context.isRedirect=false)
本地 onSignal(signal) → dialog.close(opener)场景 3:同 tab 内邮箱登录
[Tab A: studio.tripo3d.ai]
auth.signal.onLogin(handler) 已注册(业务侧)
LoginDialog 打开 → useConsumeSignal 起 polling
showLogin → LoginDialog 里调 auth.login.submitCode(...) 成功
→ writeSignal({ provider: 'code' })
200ms 后 useConsumeSignal polling 命中 cookie:
consumeSignal → shouldEmit=true → emitLogin (ensureTokenReady) → 业务 onLogin handler 触发,context.isRedirect=false
本地 onSignal → 关 dialog典型 handler 写法
// app 启动时一次性注册全局 handler
auth.signal.onLogin(async ({ signal, context }) => {
// 1. SDK 已经在派发前做了 token.refresh 兜底,token.get() 直接可用
// 可选:保留 refresh 作为容错(内部并发去重,不会重复打后端)
await auth.token.refresh();
// 2. 初始化用户信息
await fetchCurrentUser();
// 3. context.isRedirect 区分冷启 vs 当前页登录(按需)
if (!context.isRedirect) {
closeAuthDialog();
playLoginSuccessAnimation();
}
// 4. 埋点
trackEvent('login_success', {
provider: signal.provider,
is_redirect: context.isRedirect,
source: (signal.params as { source?: string } | undefined)?.source,
});
});
auth.signal.onLogout(({ signal }) => {
clearLocalUserStore();
router.push('/');
trackEvent('logout', { ts: signal.ts });
});要点:
- handler 是 async,SDK 用
Promise.resolve包了 - 类型上
LoginParams = unknown,业务侧自己 narrow:约定一份LoginParams业务类型,每次submitCode / startOidc传时都符合 - 不要在 handler 里再调
auth.login.*触发新登录——会写新 cookie 但 polling 在useConsumeSignal内已经 clear cookie 了,行为不可预测 - 同一 handler 不要重复
onLogin(myHandler)—— 会产生多份 listener、被触发多次
注意事项
本地调试
本地 dev 通常跑在 localhost:5173 / 127.0.0.1,不在 .tripo3d.ai 子域下:
- cookie 自动探测的根域是 host-only(不写 Domain)
- 本地调试登录流不能跨 tab 感知
- 推荐:在 hosts 配
127.0.0.1 dev.tripo3d.ai,开 https + 让 dev server 跑在dev.tripo3d.ai子域
时钟漂移
readSignal 有二次过期校验 Date.now() - payload.ts > 60000。客户端时钟严重错乱会导致:
- 时钟快 → cookie 看起来已过期,被 drop
- 时钟慢 → cookie 看起来更新鲜,正常消费
实践中很少撞到,无需特别处理;真撞了业务侧加埋点即可。
SSR
createSignalApi闭包内safeWindow()兜底,服务端实例化不会触发 cookie 操作;pendingSignals一律为空 MaponLogin/onLogout在无window上下文直接返回 no-op unsubscribe,不注册任何 listener;业务方可放心在通用代码里调- 客户端 hydrate 后构造新 SignalApi,再走一次
readSignalRecord()——若 cookie 存在且未标记为已派发,则按 identity 进入pendingSignalsMap,等待业务onLogin晚注册补偿
API 参考
| 方法 | 签名 | 说明 |
|---|---|---|
auth.signal.readSignal | () => LoginSignal | null | 原子:读 cookie,不删。脏数据 / 过期返回 null 但不清理 |
auth.signal.readSignalRecord | () => SignalRecord | null | 原子:只读 cookie,并额外返回 dispatched 标记 |
auth.signal.writeSignal | (input: WriteSignalInput) => void | 原子:写 cookie;submitCode / submitPassword / handleComplete 用 |
auth.signal.clearSignal | () => void | 原子:主动清掉 cookie;logout / useConsumeSignal 用 |
auth.signal.consumeSignal | () => ConsumedSignal | null | Login sugar:读并清 cookie,返回 shouldEmit 告知轮询容器是否仍需派发 |
auth.signal.emitLogin | (signal: LoginSignal) => Promise<void> | Login sugar:派发前 await token.refresh() 兜底 + bus.emit;UI composable 命中 cookie 后调 |
auth.signal.publishLogin | (signal: LoginSignal) => Promise<void> | Login sugar:One Tap 使用;根据当前订阅状态写派发标记并立即派发或保留 pending |
auth.signal.onLogin | (handler: OnLoginHandler) => Unsubscribe | Login sugar:业务订阅 + 有效窗口内逐订阅者 pendingSignals 补偿;SSR 返回 no-op |
auth.signal.emitLogout | () => void | Logout sugar:派发前同步 token.clear() + bus.emit;由 auth.logout() 内部调用 |
auth.signal.onLogout | (handler: OnLogoutHandler) => Unsubscribe | Logout sugar:订阅退出事件;SSR 返回 no-op |
decodeLoginParams | (encoded: string | undefined) => LoginParams | undefined | 导出工具:从 URL params query 还原原值;UI 子路径 LoginPage 用它解 query |
encodeLoginParams | (params: LoginParams | undefined) => string | undefined | 导出工具:把 params 编进 URL;通常由 SDK 内部用,导出便于业务 SSR 场景手工拼 |
下一步
- 验证码流程 — 触发
provider: 'code'信号的入口 - OIDC 登录 — 触发 provider 为 IdP id 的信号的入口
- UI 子路径 /ui —
useConsumeSignal/useReadSignal怎么用 - Token 管理 — handler 里通常做的第一件事