Skip to content

OIDC 登录

OIDC(OpenID Connect)登录用第三方账号(Google、Apple、GitHub 等)完成 Tripo 注册或登录。@tripo3d/auth 把 Ory Kratos 的 OIDC 流程封装成 createLoginFlow().oidc(provider, { mode }),内部处理弹窗管理、双通道通信、用户取消、第三方报错以及缺邮箱时的 continue-flow。

概述

OIDC 在本 SDK 里有三种形态,本页全部覆盖:

  • redirect 模式:整页跳转到第三方,授权完成后由 callback 页拉回业务页
  • popup 模式(默认):弹出 500x600 居中窗口,主窗口通过双通道接收结果
  • continue-flow:第三方未提供邮箱时的补充注册流程,弹窗抛出 1001,主窗口接管

无论哪种形态,触发入口都是同一个方法:

ts
const loginFlow = await auth.createLoginFlow();
await loginFlow.oidc('google', { mode: 'popup' });

mode 缺省为 popup。SDK 内部会调用 Kratos POST /self-service/login/flows/{flowId} 提交 method: 'oidc',Kratos 返回 redirect_browser_to,SDK 据此打开弹窗或整页跳转。

基础流程

popup 模式下完整时序如下:

  • 1. 主窗口调用 createLoginFlow() 创建 Kratos login flow,拿到 flow.id 与 csrf_token
  • 2. 主窗口调用 loginFlow.oidc(provider) 提交 method=oidc,Kratos 返回 redirect_browser_to
  • 3. 主窗口window.open() 打开 redirect_browser_to(已附加 prompt=login),并监听 BroadcastChannel + postMessage
  • 4. 弹窗跳到第三方授权页,用户登录并授权
  • 5. 弹窗第三方重定向回业务的 /auth/oidc?flow=...&redirect_to=...
  • 6. 弹窗业务回调页执行 auth.oidcCallback(),内部调 initTokenized() 拿 JWT,广播给主窗口
  • 7. 主窗口收到 tokenized → setTokenized(),关闭弹窗,resolve oidc() Promise

整个过程对调用方就是一个 await loginFlow.oidc('google')

redirect 模式

iOS Safari 等浏览器会拦截 popup,需要走整页跳转:

ts
await loginFlow.oidc('google', { mode: 'redirect' });

SDK 行为:

  • redirect_browser_to(附加 prompt=login)直接赋值给 window.location.href
  • 返回一个永不 resolve 的 Promise,防止调用方 await 后继续执行 initUser / 埋点等代码(因为浏览器导航会销毁当前 JS 上下文)
  • 第三方授权完成后回到 /auth/oidc 路由,业务方在该页执行 oidcCallback(),SDK 检测到 URL 上的 redirect_to 参数后整页跳回业务起点

此模式下 BroadcastChannel / postMessage 都不参与,因为没有"主窗口/弹窗"这种关系——所有状态通过 Cookie + JWT 在新页面恢复。

SDK 通过 window.open 打开弹窗,自动居中并支持多显示器:

ts
const width = 500;
const height = 600;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
window.open(url, 'tripo3d_oidc_popup', `popup=yes,width=${width},height=${height},left=${left},top=${top},...`);

弹窗被浏览器拦截时,oidc() 会立即抛出 TripoAuthError(name: 'OIDC'),业务方应提示用户"允许弹窗后重试"。

主窗口会注册三类监听:

监听器用途
BroadcastChannel('tripo3d_auth')同源标签页/iframe 的可靠通道
window.addEventListener('message')跨域 postMessage 通道
setInterval 轮询 proxy.closed检测用户手动关闭弹窗(浏览器不发关闭事件)

任一通道收到消息或弹窗被关闭时,SDK 会清理所有监听并 resolve / reject。

双通道协议

弹窗与主窗口的通信走两条通道,SDK 同时使用以兼容不同环境:

  • BroadcastChannel(tripo3d_auth)同源场景下的主通道,跨标签页可靠
    • 弹窗端new BroadcastChannel(「tripo3d_auth」).postMessage(payload)
    • 主窗口端channel.addEventListener(「message」, ...)
  • window.postMessage跨域场景下的兜底通道,支持 opener 通信
    • 弹窗端window.opener.postMessage({ ...payload, source: 「tripo3d_auth」 }, targetOrigin)
    • 主窗口端window.addEventListener(「message」, ...) 校验 source === 「tripo3d_auth」 与 origin

发送端的简化伪代码:

ts
// 弹窗内部(SDK 已封装)
function postOidcChannelMessage(message) {
  const channel = new BroadcastChannel('tripo3d_auth');
  channel.postMessage(message);
  channel.close();
}

function postOidcMessageToOpener(message) {
  if (window.opener) {
    const payload = { ...message, source: 'tripo3d_auth' };
    if (allowedOrigins.length > 0) {
      for (const origin of allowedOrigins) {
        window.opener.postMessage(payload, origin);
      }
    } else {
      window.opener.postMessage(payload, '*');
    }
  }
}

接收端(主窗口):

ts
// 主窗口(SDK 已封装)
const channel = new BroadcastChannel('tripo3d_auth');
channel.addEventListener('message', (e) => handleMessage(e.data));

window.addEventListener('message', (e) => {
  if (!e.data || e.data.source !== 'tripo3d_auth') return;
  handleMessage(e.data);
});

消息载荷结构:

名称 类型 默认值 说明
tokenized`string`登录成功的 JWT,主窗口收到即视为成功
flow`string | null`continue-flow 场景下回传的 flow id
type`「cancelled」 | 「continue-flow」`消息分类标记
error`string`弹窗或第三方授权失败的描述

OIDC 回调页实现

业务方需要实现一个回调路由(默认约定为 /auth/oidc),供第三方重定向回来。该页在加载时调用 auth.oidcCallback(),SDK 会读取 URL 的 flow / redirect_to 参数、调 initTokenized 拿 JWT,然后广播给主窗口。

最简实现:

vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { TripoAuth } from '@tripo3d/auth';

const auth = new TripoAuth({
  basePath: 'https://auth.tripo3d.ai',
  allowedOrigins: [window.location.origin],
});

onMounted(async () => {
  try {
    await auth.oidcCallback();
    // 同源场景下主窗口已收到广播,弹窗可以直接关闭
    if (window.opener) {
      window.close();
    }
  }
  catch (e) {
    auth.oidcErrorCallback(e instanceof Error ? e.message : String(e));
    window.close();
  }
});
</script>

<template>
  <div>正在完成登录,请稍候…</div>
</template>

如果 Kratos 返回 422(缺邮箱),Kratos 会先把弹窗重定向到一个带 flow= 参数的 URL,业务方需要在该 URL 调 continueFlowCallback() 而不是 oidcCallback()(具体路径由 Ory 配置决定,常见做法是同一回调页根据响应码分支)。

用户取消 / 第三方报错

弹窗内显式上报这两种情况:

ts
// 用户在第三方授权页点了「取消」
auth.oidcCancelledCallback();

// 第三方报错(如 access_denied、invalid_request)
auth.oidcErrorCallback('Authorization was denied by user');

主窗口的 oidc() 调用会以两种方式 reject:

场景reject 值备注
用户取消(显式或关闭弹窗){ handled: true, type: 'cancelled' }不是 TripoAuthError,不需要 toast
第三方报错new TripoAuthError({ name: 'OIDC', message })应展示给用户
BroadcastChannel 收到无法识别的消息new TripoAuthError({ name: 'OIDC', message: 'OIDC sign-in was cancelled or could not be completed.' })兜底

业务侧通用 catch 模板:

ts
try {
  await loginFlow.oidc('google');
}
catch (err: any) {
  if (err?.handled && err.type === 'cancelled') {
    // 用户取消,静默
    return;
  }
  if (err?.code === '1001') {
    // continue-flow,见下一节
    return enterContinueFlow(err.data.id);
  }
  if (err instanceof Error && err.name === 'OIDC') {
    toast.error(err.message);
    return;
  }
  throw err;
}

如果在 reject 之后还想知道"到底是哪一步失败的",可以用 getLoginFlowOutcome(flowId) 反查 Kratos:

ts
const { cancelled, message } = await auth.getLoginFlowOutcome(flowId);
if (cancelled) {
  // 用户在第三方页面拒绝了授权(access_denied)
}
else if (message) {
  // 其他错误,message 是 Kratos UI 上的提示文本
}

注意 flowId 来自 createLoginFlow 返回值——SDK 没有直接暴露,需要在创建时自行保留(见错误处理页)。

Continue-flow(422)

触发场景:用户用 Apple 登录,但 Apple 没把邮箱共享给我们;或 Google 账号未验证邮箱。Ory Kratos 会返回 422,要求补充邮箱才能完成注册。

弹窗中由 continueFlowCallback()flow id 通过 BroadcastChannel 广播,主窗口的 oidc() Promise 以 { code: '1001', data: { id: flowId } } reject。整体时序:

  • 1. 主窗口await loginFlow.oidc(「apple」) → catch 到 { code: 「1001」, data: { id } }
  • 2. 主窗口展示「请输入邮箱」对话框,收集用户邮箱
  • 3. 主窗口await auth.sendVerificationCode(email, provider) — 走业务后端发码
  • 4. 主窗口用户输入收到的验证码,await auth.verifyEmailCode(email, code)
  • 5. 主窗口await auth.getContinueFlow(flowId) 拿到 Kratos action URL + csrfToken
  • 6. 主窗口生成隐藏 form,POST 到 action,Ory 完成注册并设置 session cookie
  • 7. 主窗口页面被 Ory 重定向 / 调 initTokenized() 拿到 JWT,登录完成

完整代码:

ts
async function handleContinueFlow(flowId: string) {
  // 1. 让用户输入邮箱
  const email = await promptEmail();

  // 2. 业务后端发送验证码
  await auth.sendVerificationCode(email, 'apple');

  // 3. 用户输入验证码,业务后端校验
  const code = await promptCode();
  const verified = await auth.verifyEmailCode(email, code);
  if (!verified) {
    throw new Error('验证码错误');
  }

  // 4. 拿到 Kratos action URL 与 csrfToken
  const { action, csrfToken, provider } = await auth.getContinueFlow(flowId);

  // 5. 用隐藏 form 提交,Kratos 完成注册
  const form = document.createElement('form');
  form.method = 'POST';
  form.action = action;
  form.style.display = 'none';

  const append = (name: string, value: string) => {
    const input = document.createElement('input');
    input.type = 'hidden';
    input.name = name;
    input.value = value;
    form.appendChild(input);
  };

  append('csrf_token', csrfToken);
  append('method', 'oidc');
  if (provider) append('provider', provider);
  append('traits.email', email);

  document.body.appendChild(form);
  form.submit();
}

getContinueFlow 返回的 action 是 Kratos /self-service/registration 流程的提交端点,必须用浏览器 form POST(而不是 fetch),因为它依赖 Kratos 的同源 Cookie + 重定向链路。

provider 字段从 flow UI nodes 里提取(group=oidc 的 submit 节点),业务方一般原样回传。

代理路径与 continueFlowProxyBasePath

如果业务部署在 app.tripo3d.ai,而 Kratos 在 auth.tripo3d.ai,跨域 Cookie 在某些浏览器(尤其 Safari)可能不可靠。SDK 提供 continueFlowProxyBasePathsendVerificationCode / verifyEmailCode / getContinueFlow 走业务自身的反向代理:

ts
const auth = new TripoAuth({
  basePath: 'https://auth.tripo3d.ai',
  continueFlowProxyBasePath: '/api/auth-proxy',
});

设置后,这三个方法的 URL 会变为 ${window.location.origin}/api/auth-proxy/...,由业务网关再转发到 Kratos,避免跨域 Cookie 问题。

账号合并(Merge)

场景:用户原本用密码注册了 a@b.com,后来又用同邮箱的 Google 账号登录,需要把 OIDC 凭证绑定到原账号。Ory Kratos 在这种场景下会让 OIDC 流程进入"需要二次验证身份"的状态,SDK 用 createMergeFlow(flowId) 来完成补码绑定:

ts
const merge = await auth.createMergeFlow(existingFlowId);

// 方法 1: 给原邮箱发码
await merge.send({ email: 'a@b.com' });

// 方法 2: 用户输入验证码,完成合并
await merge.code({ email: 'a@b.com', code: '123456' });
// 此后 auth.tokenized() 可拿到合并后的 JWT

注意 createMergeFlow 的入参 id 是已有的登录 flow id(由 OIDC 流程的 reject 携带,或从 URL 解析),它会调 getLoginFlow 复用上下文而不是新建 flow。

merge.send / merge.code 与普通 login flow 的 code 子方法签名一致,支持 { email }{ phone } 两种 identifier。

allowedOrigins 安全模型

postMessage 跨域场景必须校验来源,否则任何页面都能往主窗口发伪造消息。SDK 提供 allowedOrigins 选项:

配置行为适用场景
不设置 / []接受任意来源(等同 *)仅本地开发
[window.location.origin]只接受同源消息绝大多数生产场景
['https://app.tripo3d.ai', 'https://www.tripo3d.ai']多个白名单多域部署(主站 + 子站)

发送端:allowedOrigins 非空时,SDK 会对每个 origin 各发一遍 postMessage,这是浏览器要求(postMessagetargetOrigin 不支持多值)。接收端则做反向白名单校验。

推荐生产配置:

ts
const auth = new TripoAuth({
  basePath: 'https://auth.tripo3d.ai',
  allowedOrigins: [window.location.origin],
});

API 参考

方法签名说明
createLoginFlow().oidc`(provider: string, options?: { mode?: 「popup」 | 「redirect」, transient_payload?: Record<string, any> }) => Promise<void>`触发 OIDC 登录,popup 模式返回 Promise 等待结果,redirect 模式整页跳转
createMergeFlow`(id: string) => Promise<{ send, code }>`基于已有 flow id 走账号合并(补码绑定)
oidcCallback`() => Promise<void>`弹窗内调用,读取 URL 参数 + initTokenized,广播 tokenized 给主窗口
continueFlowCallback`() => void`弹窗内调用(422 场景),广播 { type: 「continue-flow」, flow }
oidcCancelledCallback`() => void`弹窗内调用,广播 { type: 「cancelled」 }
oidcErrorCallback`(message: string) => void`弹窗内调用,广播 { error: message }
sendVerificationCode`(email: string, provider: string) => Promise<{ success?: boolean }>`422 流程:业务后端发邮箱验证码
verifyEmailCode`(email: string, code: string) => Promise<boolean>`422 流程:业务后端校验验证码
getContinueFlow`(flowId: string) => Promise<{ action: string, csrfToken: string, provider?: string }>`422 流程:取 Kratos 注册 flow 的 action URL,供 form POST 提交
getLoginFlowMessages`(flowId: string) => Promise<UiText[]>`取指定 login flow 的 ui.messages
getLoginFlowOutcome`(flowId: string) => Promise<{ cancelled: boolean, message?: string }>`解析 ui.messages 判断是否用户取消(access_denied)或其他错误

下一步

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