feat(login): align desktop credentials login with upstream Heicode design

Per heicode.md / heicode-runtime-auth-newapi-secret-design.md /
Heicode-登录接口对接文档.md, identity is owned by the Manager
(mcp-server), NewAPI is just the model gateway. The previous local
flow hit NewAPI's /api/user/login directly, which deviates from the
documented design — that endpoint is the legacy upstream NewAPI password
login that the current production web frontend already bypasses.

New flow inside POST /api/heicode-auth/login-with-credentials:

  1. POST <managerLoginUrl>/api/auth/login (mcp-server)
       Body: {email, password, role: "user"}
       → 200 {success, data{token, refreshToken, user{id, channelId,
                                                       role, email,
                                                       name}}}

  2. POST <baseUrl>/api/user/session/from-agnet (heicode 后端)
       Body: {access_token, refresh_token}
       → 200 + Set-Cookie: session=...
       JIT-syncs the local NewAPI user from the Agnet identity:
       users.group becomes the channelId returned by mcp-server,
       which matches NewAPI's abilities/channel routing model.

  3. GET <baseUrl>/heicode/oauth/authorize?... (heicode 后端)
       Headers: Cookie + New-Api-User
       redirect: 'manual' to capture the 302 Location header
       → token=sk-XXXX is parsed out and handed to the existing
         loginAndActivate pipeline (which probes /v1/models and
         persists the active provider).

Provider preset gains an optional managerLoginUrl field (default
https://apimtaiji.azure-api.net/api/mcp for taijiaicloud), with an
env override HEICODE_TAIJIAICLOUD_MANAGER_LOGIN_URL for dev.

End-to-end verified locally with the documented test account
55@55.com / By@123456.: each step returns 200, /heicode/oauth/authorize
mints a sk- token tied to channelId 6e6fc470-76f8-4bb1-8ea4-625dc5b12bc6,
and /v1/models returns the full live model catalogue under that channel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:28:21 +08:00
co-authored by Claude Opus 4.7
parent f70d80ca4c
commit 86bad2323f
3 changed files with 124 additions and 42 deletions
+85 -36
View File
@@ -194,16 +194,27 @@ async function handleLoginWithApiKey(req: Request): Promise<Response> {
}
/**
* Email/password login (path B — matches the original Heicode design doc).
* Email/password login — aligned with the upstream Heicode design
* (heicode.md / heicode-runtime-auth-newapi-secret-design.md /
* Heicode-登录接口对接文档.md).
*
* Local server talks to the Manager directly, no browser dance:
* 1. POST <baseUrl>/api/user/login → grab Set-Cookie session + user.id
* 2. GET <baseUrl>/heicode/oauth/authorize with that cookie + a stub
* loopback redirect_uri → Manager 302's with `token=sk-XXX` in the
* Location header. We capture it without ever following.
* 3. Hand sk-XXX to loginAndActivate, which probes /v1/models and saves.
* The local server orchestrates the documented 3-step flow without ever
* opening a browser:
*
* The session cookie + token mint never leave this process.
* 1. POST <managerLoginUrl>/api/auth/login (mcp-server / Manager)
* → JWT pair {access_token, refresh_token} + user{id, channelId}
* 2. POST <baseUrl>/api/user/session/from-agnet (NewAPI / heicode 后端)
* Body: {access_token, refresh_token}
* → heicode JIT-syncs local user from Agnet, returns Set-Cookie session
* 3. GET <baseUrl>/heicode/oauth/authorize (NewAPI / heicode 后端)
* with that session cookie + redirect: 'manual'
* → 302 Location: ...?token=sk-XXX (NewAPI access token)
*
* Hand sk-XXX to loginAndActivate, which probes /v1/models and saves the
* provider for subsequent LLM calls.
*
* Identity is owned by the Manager; NewAPI is just the model gateway. The
* sk- token never leaves this Bun process.
*/
async function handleLoginWithCredentials(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
@@ -217,53 +228,93 @@ async function handleLoginWithCredentials(req: Request): Promise<Response> {
if (!base) {
throw ApiError.badRequest(`${preset.name} 未配置 baseUrl,无法登录`)
}
const managerBase =
preset.managerLoginUrl?.replace(/\/+$/, '') ??
'https://apimtaiji.azure-api.net/api/mcp'
// ── Step 1: POST /api/user/login ───────────────────────────────
const loginRes = await fetch(`${base}/api/user/login`, {
// ── Step 1: POST <managerLoginUrl>/api/auth/login ──────────────
const mgrRes = await fetch(`${managerBase}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: base,
Accept: 'application/json',
},
body: JSON.stringify({ username: email, password }),
body: JSON.stringify({ email, password, role: 'user' }),
redirect: 'manual',
signal: AbortSignal.timeout(15000),
}).catch((err) => {
throw ApiError.badRequest(
`无法连接到 ${preset.name}:${err instanceof Error ? err.message : String(err)}`,
`无法连接到 Heicode 平台 (${managerBase}): ${err instanceof Error ? err.message : String(err)}`,
)
})
let loginJson: { success?: boolean; message?: string; data?: { id?: number } } = {}
try {
loginJson = (await loginRes.json()) as typeof loginJson
} catch {
/* ignore */
let mgrJson: {
success?: boolean
message?: string | null
detail?: string
data?: {
token?: string
refreshToken?: string
user?: { id?: string; email?: string; channelId?: string }
}
} = {}
try { mgrJson = (await mgrRes.json()) as typeof mgrJson } catch { /* */ }
if (!mgrJson.success) {
const msg = mgrJson.message || mgrJson.detail || `HTTP ${mgrRes.status}`
throw ApiError.badRequest(`Heicode 平台登录失败: ${msg}`)
}
if (!loginJson.success) {
throw ApiError.badRequest(loginJson.message || '登录失败:账号或密码错误')
const accessToken = mgrJson.data?.token
const refreshToken = mgrJson.data?.refreshToken
if (!accessToken || !refreshToken) {
throw ApiError.badRequest('平台登录响应缺少 token 字段')
}
const userId = loginJson.data?.id
if (!userId || userId <= 0) {
throw ApiError.badRequest('登录响应缺少 user.id')
// ── Step 2: POST <baseUrl>/api/user/session/from-agnet ─────────
const faRes = await fetch(`${base}/api/user/session/from-agnet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ access_token: accessToken, refresh_token: refreshToken }),
redirect: 'manual',
signal: AbortSignal.timeout(15000),
}).catch((err) => {
throw ApiError.badRequest(
`无法连接到 Heicode 网关 (${base}): ${err instanceof Error ? err.message : String(err)}`,
)
})
let faJson: {
success?: boolean
message?: string
data?: { id?: number; require_2fa?: boolean }
} = {}
try { faJson = (await faRes.json()) as typeof faJson } catch { /* */ }
if (!faJson.success) {
throw ApiError.badRequest(`同步 Heicode 会话失败: ${faJson.message || `HTTP ${faRes.status}`}`)
}
if (faJson.data?.require_2fa) {
throw ApiError.badRequest('该账号开启了 2FA,请先在 Web 端完成 2FA 设置或临时关闭后再试。')
}
const localUserId = faJson.data?.id
if (!localUserId || localUserId <= 0) {
throw ApiError.badRequest('from-agnet 响应缺少本地 user.id')
}
// Collect Set-Cookie for the next request. fetch's getSetCookie() returns
// an array of full Set-Cookie header values; we only need name=value pairs.
const rawSetCookies =
typeof loginRes.headers.getSetCookie === 'function'
? loginRes.headers.getSetCookie()
: (loginRes.headers.get('set-cookie') ?? '').split(/,(?=[^;]+=)/)
typeof faRes.headers.getSetCookie === 'function'
? faRes.headers.getSetCookie()
: (faRes.headers.get('set-cookie') ?? '').split(/,(?=[^;]+=)/)
const cookieHeader = rawSetCookies
.map((sc) => sc.split(';')[0]?.trim())
.filter((p): p is string => Boolean(p))
.join('; ')
if (!cookieHeader) {
throw ApiError.badRequest('Manager 没有返回 session cookie,无法继续')
throw ApiError.badRequest('from-agnet 没有返回 session cookie')
}
// ── Step 2: GET /heicode/oauth/authorize → capture token from 302 ──
// ── Step 3: GET /heicode/oauth/authorize → capture token from 302 ──
const state = randomUrlSafe(24)
const stubRedirect = 'http://127.0.0.1:1/_heicode_stub'
const authorizeUrl =
@@ -277,7 +328,7 @@ async function handleLoginWithCredentials(req: Request): Promise<Response> {
method: 'GET',
headers: {
Cookie: cookieHeader,
'New-Api-User': String(userId),
'New-Api-User': String(localUserId),
Accept: 'text/html,application/xhtml+xml',
},
redirect: 'manual',
@@ -286,19 +337,17 @@ async function handleLoginWithCredentials(req: Request): Promise<Response> {
if (authRes.status !== 302 && authRes.status !== 301 && authRes.status !== 303) {
throw ApiError.badRequest(
`授权端点未返回重定向(HTTP ${authRes.status}),可能 session 未生效或后端未实装 /heicode/oauth/authorize`,
`授权端点未返回重定向 (HTTP ${authRes.status}),请确认 Heicode 后端已部署 heicode_oauth.go`,
)
}
const location = authRes.headers.get('location') || ''
const tokenMatch = location.match(/[?&]token=(sk-[A-Za-z0-9_-]+)/)
const token = tokenMatch?.[1]
if (!token) {
throw ApiError.badRequest(
'授权重定向未携带 token,请确认 Manager 已部署最新版 heicode_oauth.go',
)
throw ApiError.badRequest('授权重定向未携带 token,可能后端版本过旧')
}
// ── Step 3: Save + activate via the existing pipeline ─────────
// ── Step 4: Save + activate via the existing pipeline ─────────
return await loginAndActivate(providerId, token, displayName)
}
@@ -17,6 +17,7 @@
"id": "taijiaicloud",
"name": "Heicode",
"baseUrl": "https://code.xinghanlab.com",
"managerLoginUrl": "https://apimtaiji.azure-api.net/api/mcp",
"apiFormat": "anthropic",
"defaultModels": {
"main": "claude-sonnet-4-6",
@@ -27,7 +28,7 @@
"needsApiKey": true,
"websiteUrl": "https://code.xinghanlab.com",
"apiKeyUrl": "https://code.xinghanlab.com/dashboard/keys",
"promoText": "通过 Heicode Manager 登录,自动配置模型与额度。",
"promoText": "通过 Heicode 平台账号登录,自动同步模型与额度。",
"featured": true,
"defaultEnv": {
"API_TIMEOUT_MS": "3000000",
+37 -5
View File
@@ -17,6 +17,16 @@ const ProviderPresetSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
baseUrl: z.string(),
/**
* Heicode Manager (mcp-server) login base URL. When set, the credentials
* login flow goes:
* POST <managerLoginUrl>/api/auth/login → JWT pair + channelId
* POST <baseUrl>/api/user/session/from-agnet → session cookie
* GET <baseUrl>/heicode/oauth/authorize → mint sk- token
* If unset, login falls back to <baseUrl>/api/user/login (legacy NewAPI
* password) — not recommended; deviates from upstream design.
*/
managerLoginUrl: z.string().optional(),
apiFormat: ApiFormatSchema,
defaultModels: ModelMappingSchema,
needsApiKey: z.boolean(),
@@ -49,12 +59,34 @@ const PROVIDER_BASE_URL_ENV_MAP: Record<string, string> = {
clawdrouter: 'HEICODE_CLAWDROUTER_BASE_URL',
}
/**
* Heicode Manager URL overrides — same idea as base URL but for the mcp-server
* login endpoint. Lets you point credentials login at a local mcp-server during
* dev without rebuilding.
*
* HEICODE_TAIJIAICLOUD_MANAGER_LOGIN_URL=http://localhost:8000/api/mcp
*/
const PROVIDER_MANAGER_LOGIN_URL_ENV_MAP: Record<string, string> = {
taijiaicloud: 'HEICODE_TAIJIAICLOUD_MANAGER_LOGIN_URL',
}
const parsedPresets = ProviderPresetsSchema.parse(providerPresetsJson)
export const PROVIDER_PRESETS = parsedPresets.map((preset) => {
const envKey = PROVIDER_BASE_URL_ENV_MAP[preset.id]
if (!envKey) return preset
const override = process.env[envKey]?.trim()
if (!override) return preset
return { ...preset, baseUrl: override.replace(/\/+$/, '') }
let next = preset
const baseEnvKey = PROVIDER_BASE_URL_ENV_MAP[preset.id]
if (baseEnvKey) {
const baseOverride = process.env[baseEnvKey]?.trim()
if (baseOverride) {
next = { ...next, baseUrl: baseOverride.replace(/\/+$/, '') }
}
}
const mgrEnvKey = PROVIDER_MANAGER_LOGIN_URL_ENV_MAP[preset.id]
if (mgrEnvKey) {
const mgrOverride = process.env[mgrEnvKey]?.trim()
if (mgrOverride) {
next = { ...next, managerLoginUrl: mgrOverride.replace(/\/+$/, '') }
}
}
return next
})