OIDC 登录
v2 OIDC 走 4 模式矩阵 + cookie 信号 模型,与 v1 的「popup-only + BroadcastChannel session.done」彻底切割:
- 入口统一:
auth.login.startOidc(provider, { completeUrl, locale?, returnTo?, params?, mode? })→ 返回{ url, mode } - 完成页统一:业务方传入的
completeUrl(绝对 URL)所在路由由OidcCompletePage挂auth.oidc.handleComplete() - 中间页统一:edge 路由(如
/login/oidc/edge?flow=...,由 ory 服务端配置决定)由OidcEdgePage调auth.oidc.loadEdgeFlow(flowId, ctx) - 收尾统一:完成页写
__tripo_login_signal__cookie;业务方在主窗注册的auth.signal.onLogin(handler)收到信号收尾
业务方不再 await 任何 OIDC 终态 Promise——所有路径走 onLogin。
4 模式矩阵
mode 字段 不该由业务方传 —— 是 (UA, 容器) 的衍生物,由 SDK 内部 decideOidcMode() 按 isMobileUA() 推导(mobile → redirect,pc → popup)。仅保留为可选入参作为 E2E / 强制场景的逃生口。返回值带 mode,调用方按它决定 window.open 还是 window.location.assign。
4 种典型用法:
| 端 | UI 容器 | OIDC mode | 推荐用法 | 说明 |
|---|---|---|---|---|
| PC | dialog | popup | 业务嵌入 | 主窗开小弹窗跑 OIDC;完成页 window.close() 后业务靠 onLogin 收尾 |
| PC | standalone | popup | 独立登录页 | 同上;登录后业务侧 redirect 由 returnTo 控制 |
| Mobile | dialog | redirect | 移动端嵌入 | iOS Safari 限制 popup;整页跳走 → IdP → 完成页 → returnTo |
| Mobile | standalone | redirect | 移动端独立登录页 | 同上 |
策略建议:
- PC 走
popup,体验最好;OIDC popup 完成后业务主窗保留状态 - Mobile 走
redirect,避开 popup 拦截与 COOP 限制;注意showLogin不会 resolve
startOidc 详解
const { url, mode } = await auth.login.startOidc('google', {
completeUrl: 'https://www.tripo3d.ai/login/oidc', // 必填:OIDC 完成页绝对 URL
// mode 不传:SDK 按 isMobileUA 推 mobile→redirect / pc→popup
locale: 'zh', // 可选:写进完成页 URL 给 i18n 还原
returnTo: 'https://studio.tripo3d.ai/projects', // 可选:完成页跳回业务页(同站强校验)
params: { fromBanner: 'home_v2' }, // 可选:透传到 onLogin handler
});
// 按 SDK 决定的 mode 选开窗策略:
if (mode === 'popup') {
window.open(url, 'tripo3d_oidc_popup', 'popup=yes,width=500,height=600');
} else {
window.location.assign(url);
}内部流程:
buildCompleteUrl(completeUrl, ctx)在业务传入的绝对 URLcompleteUrl上追加mode=popup&provider=google&...query;completeUrl走绝对 URL 校验、returnTo走isSameSiteTripo校验,非法 completeUrl / 非 tripo3d.ai 子域 returnTo 直接抛TripoAuthError(name: 'VALIDATION')(开放重定向防护)ory.createBrowserLoginFlow({ returnTo: completeUrl })创建 ory login flowory.updateLoginFlow({ method: 'oidc', provider, csrf_token })触发 IdP 跳转- 从 ory 返回的
redirect_browser_toURL 上searchParams.set('prompt', 'login'),强制每次都让用户重新选账号 - 返回
{ url, mode },业务方按 mode 自己开窗 / 跳转
签名:
| 字段 | 类型 | 说明 |
|---|---|---|
provider | string | ory 配置里的 provider id,如 google / apple / discord |
completeUrl | string | 必填:OIDC 完成页绝对 URL(如 https://www.tripo3d.ai/login/oidc);非法绝对 URL 抛 VALIDATION |
mode? | "popup" | "redirect" | 可选;不传时 SDK 按 isMobileUA 推导。仅 E2E / 强制场景使用 |
locale? | string | 可选;core 会通过 buildCompleteUrl 把 locale 写到完成页回跳 URL 的 ?locale= 上,OIDC 完成页 / edge 页读回作为显示语言。不传则下游落地页回落 en |
returnTo | string | 完成页回跳目标 URL;必须是 tripo3d.ai 同站,否则抛 VALIDATION |
params | LoginParams (= unknown) | 透传到 onLogin handler;base64url JSON 编进 URL,再写进 signal cookie |
返回 { url: string; mode: 'popup' | 'redirect' }——mode 是 SDK 实际选用值,业务方按它决定 window.open / window.location.assign。
PC + popup 模式
业务方在主窗同步开 popup(必须同步开,避免丢失用户激活态触发拦截):
async function loginByGoogle() {
const { url, mode } = await auth.login.startOidc('google', {
completeUrl: 'https://www.tripo3d.ai/login/oidc', // 必填,绝对 URL
// mode 不传,PC 上 SDK 自动选 popup
locale: 'zh',
returnTo: 'https://studio.tripo3d.ai/projects',
params: { source: 'home_v2' },
});
if (mode !== 'popup') return; // 防御性:mobile UA 下 SDK 会推 redirect
const popup = window.open(url, 'tripo3d_oidc_popup', 'popup=yes,width=500,height=600');
if (!popup) {
toast.error('弹窗被拦截,请允许后重试');
return;
}
popup.focus();
// 不需要 await 任何 done Promise;onLogin handler 会在 cookie 信号到达时被触发
}完成页路径(业务方不需要实现,由 OidcCompletePage 内置):
1. ory 把 IdP 回调跳回 completeUrl?mode=popup&provider=google&...(completeUrl 即业务传入的绝对 URL)
2. OidcCompletePage onMounted → auth.oidc.handleComplete()
3. handleComplete 内部:
- readCompleteContext(URL) → ctx
- decodeLoginParams(ctx.params) → LoginParams 原值
- writeSignal({ provider, ts, params })
- window.close() ← popup 模式只关窗;4 模式契约要求不主动碰主窗 location
4. 主窗的 onLogin handler 在下一次 polling 触发⚠️ popup 模式 SDK 不主动跳主窗(4 模式契约):哪怕业务方传了 returnTo,handleComplete 也不会调 opener.location.assign。主窗的收尾完全由它自己注册的 useConsumeSignal(独立登录页)或业务方 onLogin handler 处理。
PC / Mobile + redirect 模式
async function loginByGoogle() {
const { url, mode } = await auth.login.startOidc('google', {
completeUrl: 'https://www.tripo3d.ai/login/oidc', // 必填,绝对 URL
// mode 不传,mobile UA 上 SDK 自动选 redirect
returnTo: 'https://studio.tripo3d.ai/projects',
});
if (mode === 'redirect') {
window.location.assign(url);
// 此函数后面的代码永远不会跑到
}
}完成页路径:
1. ory 把 IdP 回调跳回 completeUrl?mode=redirect&...(completeUrl 即业务传入的绝对 URL)
2. OidcCompletePage onMounted → auth.oidc.handleComplete()
3. handleComplete:
- writeSignal(...)
- win.location.assign(ctx.returnTo ?? '/') // 整页跳走
4. 业务页加载完成 → 主 app 的 onLogin handler 通过 SignalApi 构造时立刻消费一次 cookie 拿到信号注意:因为 showLogin 的 Promise 在整页跳走之前不可能 resolve,redirect 模式下业务侧只能靠 onLogin 兜底。业务方接入 OIDC 必须先注册 onLogin handler 再开窗。
完成页(独立 site)
OidcCompletePage 已经把 handleComplete() 挂到 onMounted,正常接入直接用 SDK 提供的页面组件:
// www.tripo3d.ai 的 router 配置
import { OidcCompletePage } from '@tripo3d/auth/ui';
routes: [
{ path: '/login/oidc', component: OidcCompletePage },
]如果业务侧因为定制原因要自己实现完成页,签名很简单:
<script setup lang="ts">
import { onMounted } from 'vue';
import { TripoAuthError } from '@tripo3d/auth';
import { auth } from '~/plugins/auth';
onMounted(async () => {
try {
await auth.oidc.handleComplete();
// 成功路径:handleComplete 内部已经做了 window.close() 或 location.assign(returnTo)
// 走到这里只可能是它静默吞了 close/SecurityError 异常
} catch (error) {
// 不会抛业务错;唯一可能抛错是 URL 里 mode/provider 字段不合法(OIDC name)
if (error instanceof TripoAuthError) console.warn(error.name, error.message);
}
});
</script>
<template>
<div>认证回调处理中…</div>
</template>完成页保持很薄。不要在完成页里:
- 自己调
auth.token.refresh()(业务主窗的onLoginhandler 才有完整上下文) - 自己 postMessage(v2 不再需要跨窗协议;信号靠 cookie)
- 判断 continue / merge(那是中间页的事,由 ory 路由到不同 URL)
中间页(continue / merge)
ory 在两种场景把用户跳到 edge 路由 /login/oidc/edge?flow=<flowId>(该路径由 ory 服务端配置决定,前端 SDK 不存储;业务方挂 OidcEdgePage 的路由要与 ory 后台一致):
- continue:第三方账号没绑邮箱,需要让用户补一次 traits.email 才能完成注册
- merge:第三方账号关联的邮箱已经存在另一个本地账号,需要验证码合并
auth.oidc.loadEdgeFlow(flowId, ctx) 返回 EdgeFlow,业务侧按 kind 分支渲染表单:
import type { EdgeFlow, EdgeLoadContext } from '@tripo3d/auth';
// 第二参用 EdgeLoadContext(无 provider 字段);SDK 内部从 ory ui.nodes 实时读出真实渠道
const ctx: EdgeLoadContext = {
completeUrl: 'https://www.tripo3d.ai/login/oidc', // 必填:merge 成功后 SDK 把页面 replace 到这里
// returnTo? params? locale? mode? 可选;mode 不传时 SDK 按 window.opener 探测
};
const flow: EdgeFlow = await auth.oidc.loadEdgeFlow(flowId, ctx);
// 真实 OIDC 渠道(如 'google');由 SDK 从 ory flow ui.nodes 实时读出
console.log(flow.provider);
if (flow.kind === 'continue') {
// 渲染补邮箱表单
await flow.sendCode('user@example.com');
await flow.submitCode('user@example.com', '123456');
// submitCode 内部构造隐藏 form 整页提交回 ory;本调用永不 resolve(await 一直挂着)
}
if (flow.kind === 'merge') {
// 渲染验证码合并表单
await flow.sendCode('user@example.com');
await flow.submitCode('user@example.com', '123456');
// SDK 自动 window.location.replace(completeUrl) → handleComplete 接管 → 写 signal cookie
// 完成页 URL 的 provider query 用的是 flow.provider 真实渠道,归因准确
}EdgeFlow 与 EdgeLoadContext 类型签名:
interface EdgeLoadContext {
completeUrl: string; // 必填:merge 成功后 replace 过去的 OIDC 完成页绝对 URL
mode?: 'popup' | 'redirect'; // 可选;不传时 SDK 按 window.opener 兜底(popup 内有 / redirect 整页跳过来没有)
returnTo?: string; // 必须同站 tripo3d.ai
params?: string; // 已 base64url 编码的 LoginParams
locale?: string;
}
interface EdgeFlow {
kind: 'continue' | 'merge';
readonly provider: string; // 从 ory ui.nodes 真实读出的渠道名,如 'google' / 'github'
sendCode: (email: string) => Promise<void>;
submitCode: (email: string, code: string) => Promise<void>;
cancel: () => void;
}注意:
cancel()当前没有副作用要清理,签名保留以便后续扩展;用户主动放弃时业务侧自行跳走即可flow.kind由 SDK 在loadEdgeFlow内部 probe ory/self-service/registration/flows?id=...决定:200 → continue;404/410/「unable to locate / flow expired」消息 → merge;其他错抛TripoAuthError(name: 'FLOW')- 与 v1 不同:v2 没有
closed / continue / merge三态——cancelled 终态由业务自己处理,不再由 SDK 通过 BroadcastChannel 广播 OidcEdgePage已经把上面这套逻辑封好;定制需求建议先看 UI 子路径 /ui
continue 分支的特殊性
continue.sendCode / continue.submitCode 走的是业务自建接口而非 ory:
sendCode→POST {basePath}/idp-ext/auth/send-verification-codebody{ email, provider }submitCode→POST {basePath}/idp-ext/auth/verify-email-codebody{ email, code },校验通过后构造隐藏<form>POST 到 ory registration flow action,让浏览器接管 redirect 链
submitCode 的 await 永远不会 resolve——浏览器在 form submit 之后就开始整页跳转,调用栈被截断。业务方不应在 await 之后写任何逻辑。
merge 分支的收尾
merge.submitCode 跑通 ory method: 'code' 后调 win.location.replace(buildCompleteUrl(...)),把页面切到业务传入的 completeUrl?...,然后由 handleComplete() 写 signal cookie + window.close。
业务方传给 loadEdgeFlow 的第二参 EdgeLoadContext 决定了 merge 成功后跳的完成页 URL;OidcEdgePage 不再硬编码 mode,由 SDK 内部 detectEdgeMode() 看 window.opener 兜底(popup 内有 / redirect 整页跳过来没有)。真实 provider 由 SDK 内部从 ory flow.ui.nodes 读出(pickProvider),无需调用方传,避免归因写死成占位。
⚠️ edge 页固有限制:ory 跳到 /login/oidc/edge 时不会带原始 returnTo query。OidcEdgePage 会从当前 URL 透传 returnTo / params / locale 给 loadEdgeFlow,但实际场景里这些字段往往是空的。merge 完成后跳完成页 → 完成页 returnTo 也为空 → handleComplete 兜底跳 /(redirect 模式)或只 close(popup 模式)。靠业务方注册的 onLogin handler 兜底。
returnTo 同站校验
returnTo 在两个层面被校验,防止开放重定向:
- 写侧
buildCompleteUrl(completeUrl, ctx):ctx.returnTo必须是tripo3d.ai或其子域,否则抛TripoAuthError(name: 'VALIDATION') - 读侧
readCompleteContext(currentUrl):URL 里的returnToquery 二次校验;跨站值静默 drop(不报错,登录正常继续,只是不跳回业务页)
判定函数:
function isSameSiteTripo(url: string): boolean {
const { hostname } = new URL(url);
return hostname === 'tripo3d.ai' || hostname.endsWith('.tripo3d.ai');
}业务方在传 returnTo 时如果带的是相对路径(如 /projects),需要自己拼到同站 origin 上再传;SDK 不会自动补 base。
完成页 URL Schema
了解一下 URL 结构便于排障:
completeUrl(业务传入的绝对 URL,如 https://www.tripo3d.ai/login/oidc)
?mode=popup|redirect
&provider=google
&returnTo=https%3A%2F%2Fstudio.tripo3d.ai%2Fprojects (可选,跨站会被 drop)
¶ms=eyJzb3VyY2UiOiJob21lX3YyIn0 (可选,base64url JSON)
&locale=zh (可选)params 字段在 URL 里以 base64url 原文出现;OidcCompletePage 通过 decodeLoginParams 还原后再写入 signal cookie,所以业务方在 onLogin handler 里拿到的 params 就是 startOidc 传入时的原值。
Google One Tap 一键登录
auth.oidc.promptGoogleOneTap(config, options?) —— 浏览器端一键登录:加载 Google Identity Services 脚本 → 与同页 GSI 按钮复用唯一的初始化、nonce 与 callback → 弹「一键登录」卡 → 用户选号后 SDK 内部用 submitIdToken 把 Google 签发的 id_token 直传 Kratos(离线验签)→ publishLogin。fire-and-forget,用户无感知,失败不阻断业务。
const { $auth } = useNuxtApp();
onMounted(() => {
if (typeof window === 'undefined') return; // SSR 守卫
if ($auth.token.peek()) return; // 已登录不打扰
// 失败已在 SDK 内部静默处理;.catch 仅兜底防 rejection
void $auth.oidc.promptGoogleOneTap(
{ clientId: GOOGLE_CLIENT_ID }, // Web 类型 OAuth client id
{ params: { traceKey: 'home_one_tap' } }, // 透传到 onLogin handler
).catch(() => {});
});要点 / 不变量:
- 完成统一靠
onLogin(与其余登录路径一致):成功后 handler 拿到的signal.provider === 'google',params原样透传。 - 失败默认静默且可恢复:GSI 加载 / 渲染 / 弹卡阶段失败不 throw;仅「用户已选号、提交 Kratos 失败」才 report
one_tap_submit_failed,且不 rethrow。SSR/宿主预注入的脚本失败后节点仍归宿主所有,但 SDK 会在下一次调用注入自己的恢复脚本;自注入脚本也会在页面 load 后 10 秒无官方回调时释放,避免永久卡住。同页混用不同clientId(同页仅允许一个)时,SDK 走console.warn告警并拒绝后续入口、不覆盖已生效的 callback / nonce;同clientId则直接复用首次初始化。 - SDK 不自动 cancel 卡片:自动 cancel 会被 Google 计为 dismiss,叠加 Google 指数退避(
g_statecookie)+ Chrome FedCM 节流,几次后 prompt 不再显示。卡片关闭权交给用户与 Google——但业务方可在需要时主动调auth.oidc.cancelOneTap()关卡(见下)。 - 主动关卡
auth.oidc.cancelOneTap():当以cancelOnTapOutside: false让卡片常驻(点外部不关)时,用户改走自家登录入口 / 登录成功后应主动调它关掉常驻卡片,否则卡片不会自行消失。同步、fire-and-forget:SSR / GSI 未加载均为安全 no-op,不 throw;仅隐藏卡片,不重置登录协调状态、不改cancelOnTapOutside语义。替代业务侧直接摸window.google.accounts.id.cancel()。 - 默认值:
cancelOnTapOutside默认true、useFedCmForPrompt默认false(关 FedCM 才能让「点击外部关闭」生效)、autoSelect默认false。 - 同页约束:One Tap 与
renderGoogleButton可以共存,但必须使用同一个clientId——锁的维度只有clientId,同clientId的后续入口直接复用首次初始化,不再比对初始化选项。SDK 只维护单个当前生效凭证处理器,最后注册者生效(不按state分发,GSIstate字段本 SDK 不消费);同一时刻只呈现一个 GSI 入口。 - 能弹卡的前置条件:
clientId必须是 Web application 类型且匹配该环境 Kratos 的 google provider;当前页面 origin 必须在 Google 控制台该 client 的 Authorized JavaScript origins 列表里;浏览器需已登录 Google 账号。任一不满足 → 卡不弹(静默)。
GoogleOneTapConfig / GoogleOneTapOptions(从 @tripo3d/auth 导出):
| 字段 | 类型 | 说明 |
|---|---|---|
clientId | string | 必填:Web application 类型的 Google OAuth Client ID,须匹配该环境 Kratos 的 google provider |
autoSelect? | boolean | 默认 false;是否自动选择已登录账号(避免静默误登) |
cancelOnTapOutside? | boolean | 默认 true;点击弹框外部是否关闭(仅非 FedCM 流程下生效) |
useFedCmForPrompt? | boolean | 默认 false;关 FedCM 才能让「点击外部关闭」生效。需适配三方 cookie 淘汰可显式传 true |
options.params? | LoginParams (= unknown) | 透传到 onLogin handler(与 startOidc 的 params 同义) |
options.transientPayload? | Record<string, unknown> | 本次调用附加的临时数据,与构造期 deviceId 合并注入 transient_payload |
submitIdToken(id_token 直传)
auth.oidc.submitIdToken(args, options?) —— 协议层:浏览器端已拿到 IdP 签发的 id_token 时直传 Kratos 完成登录,无需跳转。One Tap 内部用的就是它(provider: 'google'),provider 无关,未来可复用于 Apple Sign-in 等浏览器端拿到凭证的场景。
await auth.oidc.submitIdToken(
{ idToken, nonce, provider: 'google' },
{ params: { source: 'one_tap' } },
);- 内部:
createBrowserLoginFlow拿 CSRF →updateLoginFlow({ method:'oidc', provider, id_token, id_token_nonce, transient_payload }),Kratos 用 IdP 公钥离线验签下发 session cookie。 - 只写 signal cookie(跨 tab 感知),本 tab 的
publishLogin留给 One Tap 编排器决定;直接裸调submitIdToken的场景需自己决定如何消费/派发。
API 参考
| 方法 | 签名 | 说明 |
|---|---|---|
auth.login.startOidc | (provider, { completeUrl, locale?, returnTo?, params?, mode? }) => Promise<{ url; mode }> | completeUrl 必填(绝对 URL);创建 ory login flow + method=oidc,返回带 prompt=login 的 IdP 跳转 URL 和 SDK 实际选用的 mode。实现文件在 core/oidc/start-oidc.ts,API 仍挂 auth.login |
auth.oidc.handleComplete | () => Promise<void> | 完成页:写 signal cookie → location.assign(returnTo) (redirect) 或 window.close() (popup) |
auth.oidc.loadEdgeFlow | (flowId, ctx: EdgeLoadContext) => Promise<EdgeFlow> | ctx.completeUrl 必填;probe ory 决定 continue 还是 merge;mode 未传时 SDK 按 window.opener 兜底;返回 EdgeFlow |
auth.oidc.promptGoogleOneTap | (config: GoogleOneTapConfig, options?: GoogleOneTapOptions) => Promise<void> | Google One Tap 编排器:与同页 GSI 按钮共用一次初始化/nonce/callback → 弹卡 → submitIdToken → publishLogin。fire-and-forget;脚本失败可由 SDK 后续调用恢复,异 clientId 走 console.warn 拒绝,提交失败 report one_tap_submit_failed |
auth.oidc.cancelOneTap | () => void | 主动关闭常驻的 One Tap 卡片(对应 google.accounts.id.cancel())。同步、fire-and-forget:SSR / GSI 未加载均为安全 no-op;仅隐藏卡片,不重置登录协调状态。替代业务侧直接摸 window.google |
auth.oidc.submitIdToken | (args: { idToken; nonce?; provider }, options?) => Promise<void> | 协议层:id_token 直传 Kratos 离线验签;只写 signal cookie,消费/派发留给调用方或 One Tap 编排器 |
调试建议
- 主窗收不到
onLogin:- 完成页是否真在业务传入的
completeUrl路由下挂handleComplete()? - cookie Domain 是否落到
.tripo3d.ai?本地调试要在*.tripo3d.ai子域才能命中 Domain,否则 cookie 写不进去 - 主窗的
onLogin(handler)是否在唤起 OIDC 前就注册?
- 完成页是否真在业务传入的
startOidc抛TripoAuthError(name: 'VALIDATION'):returnTo跨站了;改成tripo3d.ai子域或拿掉startOidc抛TripoAuthError(name: 'OIDC'):ory 没返回redirect_browser_to;通常是 ory 配置缺这个 provider- 中间页报
EdgeFlow加载失败:先看 URL 有没有?flow=,再看 ory/self-service/registration/flows?id=直接 curl 返回什么
下一步
- 登录信号 (cookie) — 理解 cookie 信号协议与
onLogin双通道 - UI 子路径 /ui —
OidcCompletePage/OidcEdgePage直接复用 - 错误处理 —
TripoAuthError(name: 'OIDC' | 'FLOW' | 'NETWORK')分流