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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user