Skip to content

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推荐用法说明
PCdialogpopup业务嵌入主窗开小弹窗跑 OIDC;完成页 window.close() 后业务靠 onLogin 收尾
PCstandalonepopup独立登录页同上;登录后业务侧 redirect 由 returnTo 控制
Mobiledialogredirect移动端嵌入iOS Safari 限制 popup;整页跳走 → IdP → 完成页 → returnTo
Mobilestandaloneredirect移动端独立登录页同上

策略建议:

  • PC 走 popup,体验最好;OIDC popup 完成后业务主窗保留状态
  • Mobile 走 redirect,避开 popup 拦截与 COOP 限制;注意 showLogin 不会 resolve

startOidc 详解 ​

ts
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);
}

内部流程:

  1. buildCompleteUrl(completeUrl, ctx) 在业务传入的绝对 URL completeUrl 上追加 mode=popup&provider=google&... query;completeUrl 走绝对 URL 校验、returnTo 走 isSameSiteTripo 校验,非法 completeUrl / 非 tripo3d.ai 子域 returnTo 直接抛 TripoAuthError(name: 'VALIDATION')(开放重定向防护)
  2. ory.createBrowserLoginFlow({ returnTo: completeUrl }) 创建 ory login flow
  3. ory.updateLoginFlow({ method: 'oidc', provider, csrf_token }) 触发 IdP 跳转
  4. 从 ory 返回的 redirect_browser_to URL 上 searchParams.set('prompt', 'login'),强制每次都让用户重新选账号
  5. 返回 { url, mode },业务方按 mode 自己开窗 / 跳转

签名:

字段类型说明
providerstringory 配置里的 provider id,如 google / apple / discord
completeUrlstring必填: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
returnTostring完成页回跳目标 URL;必须是 tripo3d.ai 同站,否则抛 VALIDATION
paramsLoginParams (= unknown)透传到 onLogin handler;base64url JSON 编进 URL,再写进 signal cookie

返回 { url: string; mode: 'popup' | 'redirect' }——mode 是 SDK 实际选用值,业务方按它决定 window.open / window.location.assign。

PC + popup 模式 ​

业务方在主窗同步开 popup(必须同步开,避免丢失用户激活态触发拦截):

ts
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 模式 ​

ts
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 提供的页面组件:

ts
// www.tripo3d.ai 的 router 配置
import { OidcCompletePage } from '@tripo3d/auth/ui';

routes: [
  { path: '/login/oidc', component: OidcCompletePage },
]

如果业务侧因为定制原因要自己实现完成页,签名很简单:

vue
<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()(业务主窗的 onLogin handler 才有完整上下文)
  • 自己 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 分支渲染表单:

ts
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 类型签名:

ts
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-code body { email, provider }
  • submitCode → POST {basePath}/idp-ext/auth/verify-email-code body { 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 在两个层面被校验,防止开放重定向:

  1. 写侧 buildCompleteUrl(completeUrl, ctx):ctx.returnTo 必须是 tripo3d.ai 或其子域,否则抛 TripoAuthError(name: 'VALIDATION')
  2. 读侧 readCompleteContext(currentUrl):URL 里的 returnTo query 二次校验;跨站值静默 drop(不报错,登录正常继续,只是不跳回业务页)

判定函数:

ts
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)
  &params=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,用户无感知,失败不阻断业务。

ts
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_state cookie)+ 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 分发,GSI state 字段本 SDK 不消费);同一时刻只呈现一个 GSI 入口。
  • 能弹卡的前置条件:clientId 必须是 Web application 类型且匹配该环境 Kratos 的 google provider;当前页面 origin 必须在 Google 控制台该 client 的 Authorized JavaScript origins 列表里;浏览器需已登录 Google 账号。任一不满足 → 卡不弹(静默)。

GoogleOneTapConfig / GoogleOneTapOptions(从 @tripo3d/auth 导出):

字段类型说明
clientIdstring必填: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 等浏览器端拿到凭证的场景。

ts
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 返回什么

下一步 ​

基于 MIT 协议发布(内部使用)