fix(uds-auth): surface real cause of QR login network errors (0.3.16)
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run

The QR poll showed a generic "network error" for any failure, and the
server collapsed every upstream failure into an opaque 502 with no log.

- Classify upstream failures (DNS, timeout, unreachable, TLS cert, HTTP
  status, bad response) and return host-specific localized messages.
- Report why the post-scan user lookup failed instead of a generic
  invalid-credentials error.
- Log the real errno/message server-side for every qr-proxy failure.
- Show the server message in the QR poll UI; keep "network error" only
  for failed local fetches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 13:34:23 +08:00
parent 07cfbefb6c
commit 08a5520791
8 changed files with 182 additions and 15 deletions

View file

@ -132,6 +132,16 @@ window.__ModuleLoader__.load({
"err.rate_limited": "尝试过多,请稍后再试",
"err.config_not_ready": "配置未初始化",
"err.request_failed": "请求失败",
"err.upstream_dns": "无法解析 {host}(DNS 失败),请确认本机已接入公司内网 / VPN",
"err.upstream_timeout": "连接 {host} 超时,请确认本机已接入公司内网 / VPN,且未被防火墙拦截",
"err.upstream_unreachable": "无法连接 {host}(连接被拒绝或中断),请检查网络、防火墙与代理",
"err.upstream_cert": "与 {host} 的 TLS 证书校验失败:Node 不读取系统证书库,请设置 NODE_OPTIONS=--use-system-ca 或 NODE_EXTRA_CA_CERTS 后重启",
"err.upstream_error": "访问 {host} 失败,详见服务端日志",
"err.upstream_http_status": "{host} 返回 HTTP {status}",
"err.invalid_upstream_response": "{host} 返回了无法识别的响应",
"err.profile_http_status": "用户信息服务 {host} 返回 HTTP {status}(该服务需内网直连,不能经代理)",
"err.profile_rejected": "扫码成功,但用户信息校验未通过({reason})",
"err.login_finalize_failed": "扫码成功,但本机创建会话失败({detail}),详见服务端日志",
"err.method_not_allowed": "方法不允许",
"err.missing_qr_params": "缺少 qrCodeKey 或 qrCodeValue",
"err.missing_emp_token": "缺少 empNo 或 token",
@ -279,6 +289,16 @@ window.__ModuleLoader__.load({
"err.rate_limited": "Too many attempts, try later",
"err.config_not_ready": "Config not initialized",
"err.request_failed": "Request failed",
"err.upstream_dns": "Cannot resolve {host} (DNS failed). Make sure this machine is on the corporate network / VPN",
"err.upstream_timeout": "Connection to {host} timed out. Make sure this machine is on the corporate network / VPN and not firewalled",
"err.upstream_unreachable": "Cannot connect to {host} (refused or reset). Check network, firewall and proxy",
"err.upstream_cert": "TLS certificate check for {host} failed. Node ignores the OS cert store: set NODE_OPTIONS=--use-system-ca or NODE_EXTRA_CA_CERTS and restart",
"err.upstream_error": "Request to {host} failed; see server logs",
"err.upstream_http_status": "{host} returned HTTP {status}",
"err.invalid_upstream_response": "{host} returned an unrecognized response",
"err.profile_http_status": "User lookup service {host} returned HTTP {status} (needs direct intranet access, no proxy)",
"err.profile_rejected": "Scan succeeded but user lookup was rejected ({reason})",
"err.login_finalize_failed": "Scan succeeded but creating the local session failed ({detail}); see server logs",
"err.method_not_allowed": "Method not allowed",
"err.missing_qr_params": "Missing qrCodeKey or qrCodeValue",
"err.missing_emp_token": "Missing empNo or token",
@ -1735,8 +1755,11 @@ function reloadAfterLogin() {
setQrExpired(false)
setQrFailed(false)
setQrStatus(result.bo?.msg || boCode || t('ui.loginFailed'))
} catch {
if (qrRef.current.key === qrCodeKey) setQrStatus(t('ui.networkError'))
} catch (err) {
// A reply from the local server carries a classified, localized message
// (DNS / timeout / TLS cert / lookup rejected); only a failed fetch is a real network error.
const detail = err && err.data && err.data.message
if (qrRef.current.key === qrCodeKey) setQrStatus(detail || t('ui.networkError'))
} finally {
qrRef.current.polling = false
}

View file

@ -131,6 +131,16 @@ export const MESSAGES = {
'err.qr_expired': '二维码已过期',
'err.qr_mismatch': '二维码校验失败',
'err.upstream_too_large': '上游响应过大',
'err.upstream_dns': '无法解析 {host}(DNS 失败),请确认本机已接入公司内网 / VPN',
'err.upstream_timeout': '连接 {host} 超时,请确认本机已接入公司内网 / VPN,且未被防火墙拦截',
'err.upstream_unreachable': '无法连接 {host}(连接被拒绝或中断),请检查网络、防火墙与代理',
'err.upstream_cert': '与 {host} 的 TLS 证书校验失败:Node 不读取系统证书库,请设置 NODE_OPTIONS=--use-system-ca 或 NODE_EXTRA_CA_CERTS 后重启',
'err.upstream_error': '访问 {host} 失败,详见服务端日志',
'err.upstream_http_status': '{host} 返回 HTTP {status}',
'err.invalid_upstream_response': '{host} 返回了无法识别的响应',
'err.profile_http_status': '用户信息服务 {host} 返回 HTTP {status}(该服务需内网直连,不能经代理)',
'err.profile_rejected': '扫码成功,但用户信息校验未通过({reason})',
'err.login_finalize_failed': '扫码成功,但本机创建会话失败({detail}),详见服务端日志',
'err.config_not_ready': '配置未初始化',
'err.request_failed': '请求失败',
'err.method_not_allowed': '方法不允许',
@ -313,6 +323,16 @@ export const MESSAGES = {
'err.qr_expired': 'QR challenge expired',
'err.qr_mismatch': 'QR challenge mismatch',
'err.upstream_too_large': 'Upstream response too large',
'err.upstream_dns': 'Cannot resolve {host} (DNS failed). Make sure this machine is on the corporate network / VPN',
'err.upstream_timeout': 'Connection to {host} timed out. Make sure this machine is on the corporate network / VPN and not firewalled',
'err.upstream_unreachable': 'Cannot connect to {host} (refused or reset). Check network, firewall and proxy',
'err.upstream_cert': 'TLS certificate check for {host} failed. Node ignores the OS cert store: set NODE_OPTIONS=--use-system-ca or NODE_EXTRA_CA_CERTS and restart',
'err.upstream_error': 'Request to {host} failed; see server logs',
'err.upstream_http_status': '{host} returned HTTP {status}',
'err.invalid_upstream_response': '{host} returned an unrecognized response',
'err.profile_http_status': 'User lookup service {host} returned HTTP {status} (needs direct intranet access, no proxy)',
'err.profile_rejected': 'Scan succeeded but user lookup was rejected ({reason})',
'err.login_finalize_failed': 'Scan succeeded but creating the local session failed ({detail}); see server logs',
'err.config_not_ready': 'Config not initialized',
'err.request_failed': 'Request failed',
'err.method_not_allowed': 'Method not allowed',

View file

@ -30,6 +30,7 @@ import {
SESSION_COOKIE,
} from './session/request-auth.js'
import { checkRequestOrigin, requireJsonContentType } from './utils/origin-guard.js'
import { classifyNetworkError, hostOfUrl } from './utils/net-error.js'
import { requestIsLoopback } from './skill-credentials.js'
import { atomicWriteJson } from './utils/atomic-write.js'
import { readBodyLimited, MAX_QR_PROXY_BODY, MAX_QR_DATA_LEN, DEFAULT_MAX_BODY } from './utils/read-body.js'
@ -302,6 +303,24 @@ function peekQrChallenge(key, value, binding) { return _qrChallenges.peek(key, v
function consumeQrChallenge(key, value, binding) { return _qrChallenges.consume(key, value, binding) }
function isQrLoginSuccessPayload(payload) { return !!qrLoginCredentials(payload) }
/** Log the real cause server-side; give the browser a classified, actionable code. */
function sendUpstreamErr(res, req, err, host, stage) {
_logger?.warn?.('[uds-auth] qr-proxy %s failed (host=%s): %s %s', stage, host,
err?.code || '', err?.message || '')
return sendErr(res, req, 502, classifyNetworkError(err), { host })
}
/** Why the post-scan profile lookup failed -> [status, code, vars]. */
function profileLookupFailure(out) {
if (out?.reason === 'network') {
return [502, classifyNetworkError({ code: out.code, message: out.error }), { host: out.host || '' }]
}
if (out?.reason === 'http_auth' || out?.reason === 'http_status') {
return [502, 'profile_http_status', { host: out.host || '', status: out.statusCode || '' }]
}
return [401, 'profile_rejected', { reason: out?.reason || 'unknown' }]
}
async function handleQrStart(req, res) {
if (req.method !== 'POST' && req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' })
@ -404,10 +423,12 @@ async function handleQRProxy(req, res) {
void (async () => {
let payload
try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
return sendErr(res, req, 502, 'invalid_upstream_response')
_logger?.warn?.('[uds-auth] qr-proxy upstream %s returned non-JSON (HTTP %s)', url.hostname, proxyRes.statusCode)
return sendErr(res, req, 502, 'invalid_upstream_response', { host: url.hostname })
}
if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) {
return sendErr(res, req, 502, 'upstream_error')
_logger?.warn?.('[uds-auth] qr-proxy upstream %s returned HTTP %s', url.hostname, proxyRes.statusCode)
return sendErr(res, req, 502, 'upstream_http_status', { host: url.hostname, status: proxyRes.statusCode })
}
const credentials = qrLoginCredentials(payload)
if (!credentials) {
@ -417,14 +438,20 @@ async function handleQRProxy(req, res) {
if (top === '0000' && ['4002', '1002'].includes(boCode)) {
return sendJSON(res, 200, { code: { code: top }, bo: { code: boCode } })
}
return sendErr(res, req, 502, 'invalid_upstream_response')
_logger?.warn?.('[uds-auth] qr-proxy upstream %s unexpected result code=%s bo=%s', url.hostname, top, boCode)
return sendErr(res, req, 502, 'invalid_upstream_response', { host: url.hostname })
}
const consumed = consumeQrChallenge(parsedBody.qrCodeKey, parsedBody.qrCodeValue,
req.headers['x-uds-qr-binding'])
if (!consumed.ok) return sendErr(res, req, 409, consumed.reason)
// Reserve once before asynchronous profile validation or session minting.
const profile = await _authMiddleware.verifyEmpNoAndToken(credentials.empNo, credentials.token)
if (!profile) return sendErr(res, req, 401, 'invalid_credentials')
let lookupFailure = null
const profile = await _authMiddleware.verifyEmpNoAndToken(credentials.empNo, credentials.token,
{ onFailure: (out) => { lookupFailure = out } })
if (!profile) {
const [status, code, vars] = profileLookupFailure(lookupFailure)
return sendErr(res, req, status, code, { host: hostOfUrl(_currentConfig.userSearchUrl), ...vars })
}
const id = profile.empNo || credentials.empNo
const role = await _authMiddleware.resolveRoleOnLogin(id)
const now = new Date().toISOString()
@ -444,20 +471,21 @@ async function handleQRProxy(req, res) {
auth: { success: true, empNo: id, role, bridge,
userContext: { empNo: id, username: userContext.username, role, isAuthenticated: true } } })
})().catch(err => {
if (!res.headersSent) sendErr(res, req, 502, err.code || 'upstream_error')
_logger?.warn?.('[uds-auth] qr-proxy post-scan login failed: %s %s', err?.code || '', err?.message || err)
if (!res.headersSent) sendErr(res, req, 502, 'login_finalize_failed', { detail: err?.code || err?.message || '' })
})
})
proxyRes.on('error', () => {
if (!res.headersSent) sendErr(res, req, 502, 'upstream_error')
proxyRes.on('error', (err) => {
if (!res.headersSent) sendUpstreamErr(res, req, err, url.hostname, 'response')
})
})
proxyReq.on('timeout', () => {
try { proxyReq.destroy() } catch { /* ignore */ }
try { proxyReq.destroy(Object.assign(new Error('upstream timeout'), { code: 'ETIMEDOUT' })) } catch { /* ignore */ }
})
proxyReq.on('error', (err) => {
if (!res.headersSent) sendErr(res, req, 502, 'upstream_error')
if (!res.headersSent) sendUpstreamErr(res, req, err, url.hostname, 'request')
})
proxyReq.write(body)

View file

@ -55,7 +55,8 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
).trim()
const initialAdminEmpNo = resolveInitialAdmin()
async function verifyEmpNoAndToken(empNo, token) {
/** @param {{ onFailure?: (out: object) => void }} [opts] lets callers surface why the lookup failed */
async function verifyEmpNoAndToken(empNo, token, opts = {}) {
const out = await searchUserByEmpNoToken({
userSearchUrl,
empNo,
@ -66,11 +67,13 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
})
if (!out.ok) {
console.warn('[uds-auth] verifyEmpNoAndToken failed:', out)
try { opts.onFailure?.(out) } catch { /* diagnostics only */ }
return null
}
const subject = String(out.profile?.empNo || '').trim()
if (!subject || subject !== String(empNo).trim()) {
console.warn('[uds-auth] verifyEmpNoAndToken subject mismatch')
try { opts.onFailure?.({ ok: false, reason: 'emp_mismatch' }) } catch { /* diagnostics only */ }
return null
}
return out.profile

View file

@ -122,7 +122,9 @@ export async function searchUserByEmpNoToken({
try {
res = await directRequest(userSearchUrl, { method: 'POST', headers, body, timeoutMs })
} catch (err) {
return { ok: false, reason: 'network', error: err.message }
let host = ''
try { host = new URL(userSearchUrl).hostname } catch { /* diagnostics only */ }
return { ok: false, reason: 'network', error: err.message, code: err.code, host }
}
// Only accept 2xx — do not treat HTTP 500 + biz success as ok (SEC-16).

View file

@ -0,0 +1,40 @@
/**
* Map a Node network/TLS error to a stable, user-actionable api error code
* (see `err.upstream_*` in i18n.js). Keeps raw errno out of API responses.
*/
const DNS_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'EAI_FAIL', 'EAI_NODATA'])
const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'ESOCKETTIMEDOUT', 'ECONNABORTED'])
const UNREACHABLE_CODES = new Set([
'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'EADDRNOTAVAIL',
])
const CERT_CODES = new Set([
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'SELF_SIGNED_CERT_IN_CHAIN',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'CERT_HAS_EXPIRED',
'CERT_NOT_YET_VALID',
'CERT_UNTRUSTED',
'ERR_TLS_CERT_ALTNAME_INVALID',
'HOSTNAME_MISMATCH',
])
/**
* @param {{ code?: string, message?: string } | null | undefined} err
* @returns {'upstream_dns' | 'upstream_timeout' | 'upstream_unreachable' | 'upstream_cert' | 'upstream_error'}
*/
export function classifyNetworkError(err) {
const code = String(err?.code || '')
const msg = String(err?.message || '')
if (CERT_CODES.has(code) || /certificate|self[- ]signed/i.test(msg)) return 'upstream_cert'
if (DNS_CODES.has(code)) return 'upstream_dns'
if (TIMEOUT_CODES.has(code) || /timeout|timed out/i.test(msg)) return 'upstream_timeout'
if (UNREACHABLE_CODES.has(code) || /socket hang up/i.test(msg)) return 'upstream_unreachable'
return 'upstream_error'
}
/** Host for diagnostics only; never throws. */
export function hostOfUrl(raw) {
try { return new URL(String(raw)).hostname } catch { return '' }
}

View file

@ -1,6 +1,6 @@
{
"name": "uds-auth",
"version": "0.3.15",
"version": "0.3.16",
"description": "UDS authentication plugin for DeepSeek Harness — trusted single-instance access control (not strong multi-tenant isolation)",
"type": "module",
"main": "lib/index.js",

View file

@ -0,0 +1,51 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { classifyNetworkError, hostOfUrl } from '../lib/utils/net-error.js'
import { apiError, MESSAGES } from '../lib/i18n.js'
import { readFileSync } from 'node:fs'
const CODES = [
'upstream_dns', 'upstream_timeout', 'upstream_unreachable', 'upstream_cert', 'upstream_error',
'upstream_http_status', 'invalid_upstream_response', 'profile_http_status', 'profile_rejected',
'login_finalize_failed',
]
describe('classifyNetworkError', () => {
it('maps errno/TLS codes to actionable api codes', () => {
assert.equal(classifyNetworkError({ code: 'ENOTFOUND' }), 'upstream_dns')
assert.equal(classifyNetworkError({ code: 'EAI_AGAIN' }), 'upstream_dns')
assert.equal(classifyNetworkError({ code: 'ETIMEDOUT' }), 'upstream_timeout')
assert.equal(classifyNetworkError({ message: 'directRequest timeout after 3000ms: x' }), 'upstream_timeout')
assert.equal(classifyNetworkError({ code: 'ECONNREFUSED' }), 'upstream_unreachable')
assert.equal(classifyNetworkError({ message: 'socket hang up' }), 'upstream_unreachable')
assert.equal(classifyNetworkError({ code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }), 'upstream_cert')
assert.equal(classifyNetworkError({ code: 'SELF_SIGNED_CERT_IN_CHAIN' }), 'upstream_cert')
assert.equal(classifyNetworkError({ message: 'unable to get local issuer certificate' }), 'upstream_cert')
assert.equal(classifyNetworkError({ code: 'WHATEVER' }), 'upstream_error')
assert.equal(classifyNetworkError(null), 'upstream_error')
})
it('extracts host without throwing', () => {
assert.equal(hostOfUrl('https://icenterapi.zte.com.cn/a/b'), 'icenterapi.zte.com.cn')
assert.equal(hostOfUrl('not a url'), '')
})
})
describe('upstream error messages', () => {
it('are defined in zh and en and interpolate host', () => {
for (const code of CODES) {
assert.ok(MESSAGES.zh['err.' + code], 'zh ' + code)
assert.ok(MESSAGES.en['err.' + code], 'en ' + code)
}
const m = apiError('upstream_dns', 'zh', { host: 'uac.zte.com.cn' })
assert.equal(m.error, 'upstream_dns')
assert.match(m.message, /uac\.zte\.com\.cn/)
})
it('are mirrored in the browser client dictionary', () => {
const client = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
for (const code of CODES) {
assert.equal(client.split(`"err.${code}":`).length - 1, 2, code)
}
})
})