跳转至

OAuth 自动化协议(源码增强版)

1. 协议架构

MonkeyCode 的代理层实现了 纯 HTTP 的 OAuth 自动化登录,不使用浏览器或 Playwright,全程通过 HTTP 请求完成百智云 OAuth 认证。

                 纯 HTTP OAuth 自动化登录(6 步)
                 ─────────────────────────────
  Step 1         GET /api/v1/users/login → 302
  [OAuth 启动]    获取 OAuth 参数 (client_id, state, redirect_uri)
  Step 2         POST *.s-captcha-r1.com/v1/api/challenge
  [SCaptcha]      获取 SCaptcha token (绕过人机验证)
  Step 3         POST baizhi.cloud/api/v1/user/phone_code
  [发送短信]       请求发送短信验证码到手机号
  Step 4         POST baizhi.cloud/api/v1/user/login/phone
  [百智云登录]     验证短信码 → 获取百智云 Session Cookie
  Step 5         GET baizhi.cloud/api/v1/oauth/authorize
  [OAuth Authorize] 用百智云 Cookie 授权 → 获取 redirect code
  Step 6         GET callback_url (MonkeyCode)
  [回调]          获取 monkeycode_ai_session Cookie
               ✅ 登录成功

2. 流程详解

Step 1: 获取 OAuth 重定向 URL

// proxy/src/admin-login.ts
export async function startOAuthLogin(): Promise<{
  oauthUrl: string
  state: string
  clientId: string
  redirectUri: string
  scope: string
}> {
  const resp = await fetch(`${MONKEYCODE_BASE_URL}/api/v1/users/login`, {
    headers: mkHeaders(),
    redirect: "manual",  // 不自动跟随 302
  })

  if (resp.status !== 302) {
    throw new Error(`Expected 302 redirect, got ${resp.status}`)
  }

  const location = resp.headers.get("Location") || ""
  const url = new URL(location)

  return {
    oauthUrl: location,
    state: url.searchParams.get("state") || "",
    clientId: url.searchParams.get("client_id") || "",
    redirectUri: url.searchParams.get("redirect_uri") || "",
    scope: url.searchParams.get("scope") || "",
  }
}

参数提取结果示例:

{
  "oauthUrl": "https://baizhi.cloud/api/v1/oauth/authorize?client_id=monkeycode-ai&redirect_uri=https://monkeycode-ai.com/api/v1/users/login/callback&scope=openid+profile+email&state=xxx&response_type=code",
  "state": "xxx",
  "clientId": "monkeycode-ai",
  "redirectUri": "https://monkeycode-ai.com/api/v1/users/login/callback",
  "scope": "openid profile email"
}

Step 2: SCaptcha token 获取

export async function getSCaptchaToken(): Promise<string> {
  const originalTlsSetting = process.env.NODE_TLS_REJECT_UNAUTHORIZED
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"

  let resp: Response
  try {
    resp = await fetch(`${SCAPTCHA_API}/v1/api/challenge`, {
      method: "POST",
      headers: scHeaders(),
      body: JSON.stringify({ business_id: SCAPTCHA_BUSINESS_ID }),
    })
  } finally {
    // 恢复原始的 TLS 设置
    if (originalTlsSetting === undefined) {
      delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
    } else {
      process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalTlsSetting
    }
  }

  const data = await resp.json()
  return data.data?.token || ""
}

关键常量:

const SCAPTCHA_BUSINESS_ID = "0196c95c-620c-7cde-9c2d-b10d0faf5583"
const SCAPTCHA_API = `https://${SCAPTCHA_BUSINESS_ID}.safepoint.s-captcha-r1.com`

Step 3: 发送短信验证码

export async function sendSmsCode(phone: string, captchaToken: string): Promise<boolean> {
  const resp = await fetch(`${BAIZHI_URL}/api/v1/user/phone_code`, {
    method: "POST",
    headers: bzHeaders({ "Content-Type": "application/json" }),
    body: JSON.stringify({
      phone,
      kind: "login",     // 登录类型短信
      token: captchaToken,  // SCaptcha token
    }),
  })

  const data = await resp.json()
  if (data.code !== 0) {
    throw new Error(`SMS send error: code=${data.code}, msg=${data.message}`)
  }
  return true
}

Step 4: 百智云手机号登录

async function baizhiPhoneLogin(
  phone: string,
  code: string  // 短信验证码
): Promise<{ cookies: string; data: any }> {
  const resp = await fetch(`${BAIZHI_URL}/api/v1/user/login/phone`, {
    method: "POST",
    headers: bzHeaders({ "Content-Type": "application/json" }),
    body: JSON.stringify({ phone, code }),
  })

  // 从 Set-Cookie 中提取百智云 cookies
  const setCookie = resp.headers.get("Set-Cookie") || ""
  const cookies: string[] = []
  for (const part of setCookie.split(",")) {
    const match = part.trim().match(/^([^=]+)=([^;]+)/)
    if (match) {
      cookies.push(`${match[1]}=${match[2]}`)
    }
  }

  return { cookies: cookies.join("; "), data: data.data }
}

Step 5: OAuth 授权

async function baizhiOAuthAuthorize(
  baizhiCookies: string,
  clientId: string,
  redirectUri: string,
  scope: string,
  state: string
): Promise<{ code: string; callbackUrl: string }> {
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope,
    state,
    response_type: "code",
  })

  const resp = await fetch(`${BAIZHI_URL}/api/v1/oauth/authorize?${params}`, {
    headers: {
      ...bzHeaders(),
      Cookie: baizhiCookies,
      // 这里切换到页面导航模式(处理 302 重定向)
      Accept: navHeaders("baizhi.cloud").Accept,
      "Sec-Fetch-Dest": "document",
      "Sec-Fetch-Mode": "navigate",
    },
    redirect: "manual",
  })

  // 百智云返回 302 → 带 code 参数的重定向 URL
  const location = resp.headers.get("Location") || ""
  const url = new URL(location)
  const code = url.searchParams.get("code") || ""

  return { code, callbackUrl: location }
}

OAuth 请求头切换: 第 5 步从 bzHeaders()(XHR 模式)切换到 navHeaders() + Sec-Fetch-Mode: navigate(页面导航模式),模拟浏览器地址栏跳转。

async function monkeycodeCallback(callbackUrl: string): Promise<string> {
  // 首次请求(直接跟随 callback URL)
  const resp = await fetch(callbackUrl, {
    headers: navHeaders("monkeycode-ai.com", {
      "Sec-Fetch-Site": "cross-site",
      Referer: "https://baizhi.cloud/",
    }),
    redirect: "manual",
  })

  // 尝试从 Set-Cookie 直接提取
  const setCookie = resp.headers.get("Set-Cookie") || ""
  const match = setCookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`))
  if (match) return match[1]

  // 如果 callback 又重定向了(多段重定向),跟随到最终
  const location = resp.headers.get("Location") || ""
  if (location) {
    const resp2 = await fetch(
      location.startsWith("http") ? location : `${MONKEYCODE_BASE_URL}${location}`,
      { headers: mkHeaders(), redirect: "manual" }
    )
    const setCookie2 = resp2.headers.get("Set-Cookie") || ""
    const match2 = setCookie2.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`))
    if (match2) return match2[1]
  }

  throw new Error("Failed to extract session cookie from callback")
}

3. 会话状态管理

// 全局 OAuth 会话存储 — 跨多步 API 调用保持状态
let currentOAuthSession: OAuthSession | null = null

export interface OAuthSession {
  phone: string
  state: string
  clientId: string
  redirectUri: string
  scope: string
  baizhiCookies: string      // 百智云 session cookies
  createdAt: number           // 创建时间(用于 10 分钟超时)
}

状态管理关键点: - 全局变量存储(不适用多实例部署) - 10 分钟超时自动清空 - initiateLogin() 创建 → completeLogin() 消费 → 清空

4. 完整的 HTTP API 端点

代理通过两个端点暴露 OAuth 流程:

// Step 1+2+3: 发送短信验证码
app.post("/admin/login/send-code", async (req, res) => {
  // 1. 获取 OAuth URL 参数
  // 2. 请求 SCaptcha token
  // 3. 发送短信验证码
  // 返回: { message, state }
})

// Step 4+5+6: 验证短信码 → 完成登录
app.post("/admin/login/verify", async (req, res) => {
  // 4. 百智云手机号登录
  // 5. OAuth 授权
  // 6. MonkeyCode 回调 → 获取 session cookie
  // 自动: 注入 AuthManager, 发现 image_id, 刷新模型缓存
  // 返回: { status, sessionCookie, imageId, modelCount, user }
})

5. 备用模式:回调 URL 直接登录

// 手动模式: 用户从浏览器获取 OAuth 回调 URL 后直接提交
app.post("/admin/login/callback", async (req, res) => {
  const { callbackUrl } = req.body
  // 跳过步骤 1-5,直接从步骤 6 开始
  const result = await loginWithCallbackUrl(callbackUrl)
  // 自动注入 AuthManager
})

6. 各步骤头部切换总结

步骤 目标域名 请求头函数 Sec-Fetch-Mode 说明
1 monkeycode-ai.com mkHeaders() cors 获取 OAuth 参数
2 *.s-captcha-r1.com scHeaders() 绕过人机验证
3 baizhi.cloud bzHeaders() cors 发送短信
4 baizhi.cloud bzHeaders() cors 百智云登录
5 baizhi.cloud bzHeaders() + navHeaders() navigate OAuth 授权
6 monkeycode-ai.com navHeaders() navigate 回调获取 Cookie

相关章节