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,主窗口接管
无论哪种形态,触发入口都是同一个方法:
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_token2. 主窗口调用 loginFlow.oidc(provider) 提交 method=oidc,Kratos 返回 redirect_browser_to3. 主窗口window.open() 打开 redirect_browser_to(已附加 prompt=login),并监听 BroadcastChannel + postMessage4. 弹窗跳到第三方授权页,用户登录并授权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,需要走整页跳转:
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 在新页面恢复。
popup 模式(默认)
SDK 通过 window.open 打开弹窗,自动居中并支持多显示器:
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
发送端的简化伪代码:
// 弹窗内部(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, '*');
}
}
}接收端(主窗口):
// 主窗口(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,然后广播给主窗口。
最简实现:
<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 配置决定,常见做法是同一回调页根据响应码分支)。
用户取消 / 第三方报错
弹窗内显式上报这两种情况:
// 用户在第三方授权页点了「取消」
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 模板:
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:
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 + csrfToken6. 主窗口生成隐藏 form,POST 到 action,Ory 完成注册并设置 session cookie7. 主窗口页面被 Ory 重定向 / 调 initTokenized() 拿到 JWT,登录完成
完整代码:
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 提供 continueFlowProxyBasePath 让 sendVerificationCode / verifyEmailCode / getContinueFlow 走业务自身的反向代理:
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) 来完成补码绑定:
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,这是浏览器要求(postMessage 的 targetOrigin 不支持多值)。接收端则做反向白名单校验。
推荐生产配置:
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)或其他错误 |
下一步
- 错误处理 —
TripoAuthError的 8 种name与统一 catch 策略 - 架构总览 — 双通道协议在整体架构中的位置
- API Reference — 自动生成的完整签名