Add parallel skill credential path to uds-auth for agent/cron use.

UI session behavior stays unchanged; login writes a separate skill cache, with optional retain-on-logout and loopback agent-credentials/outbound APIs plus helpers/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-08 23:01:11 +08:00
parent 564d6ac189
commit 64761c8300
13 changed files with 798 additions and 10 deletions

1
uds-auth/.gitignore vendored
View file

@ -8,3 +8,4 @@ config.runtime.json
Thumbs.db
session-owners.json
user-workspaces.json
skill-credentials.json

View file

@ -17,8 +17,15 @@ uacBaseUrl: https://uac.zte.com.cn
userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search
loginSystemCode: '100000455558'
originSystemCode: ''
retainSkillCredentialsOnLogout: true
skillCredentialTtlSeconds: 604800
outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
```
Skill auth standard (Chinese): [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md). Sample skill: `../skills/uds-icenter`.
Loopback agent APIs: `GET|POST /uds-auth/agent-credentials`, `POST /uds-auth/outbound`.
## Layout
| Piece | Path | Role |

View file

@ -36,11 +36,19 @@ originSystemCode: ''
- `GET /uds-auth/qr?data=` — 二维码 SVG
- `POST /uds-auth/qr-proxy` — 代理 UAC 扫码校验
- `GET /uds-auth/api/me` — 当前用户
- `POST /uds-auth/api/logout` — 登出
- `POST /uds-auth/api/logout` — 登出(UI 会话;skill 凭证是否保留见配置)
- `GET|POST /uds-auth/agent-credentials` — **loopback**:按 `DSH_SESSION_ID` 取 skill 用 empNo+token
- `POST /uds-auth/outbound` — **loopback**:白名单出站并注入鉴权头
- 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*`
- **默认兜底账号**(扫码不可用时):用户名 `administrator`,密码 `Admin@123`(首次启动自动启用;可在设置中改密或关闭)
- **ACL**:租户边界以工作区为准(可见工作区下的会话可访问);`session-owners.json` 仅记录工号供导出/分析,不是主鉴权键
## Skill 认证
见 [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md)。官方样板 skill:`../skills/uds-icenter`。
可配置:`retainSkillCredentialsOnLogout`(默认 true)、`skillCredentialTtlSeconds`、`outboundAllowedHosts`。
## License
MIT

View file

@ -16,4 +16,13 @@ originSystemCode: ''
# Per-user workspaces under workspaceRoot/<empNo>; empty => $DSH_HOME/user-workspaces
workspaceRoot: ""
# Skill 凭证:UI 退出时是否保留(默认 true,供 cron/skill 继续用)
retainSkillCredentialsOnLogout: true
# Skill 凭证 TTL(秒),默认 7 天
skillCredentialTtlSeconds: 604800
# outbound 白名单 hosts(逗号分隔)
outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
# 兜底管理员默认在首次启动启用:administrator / Admin@123(roles.json 可改密或关闭)

View file

@ -0,0 +1,106 @@
# Skill 认证标准(uds-auth)
面向 DeepSeekHarness:现场安装 **uds-auth** 后,第三方 / 自研 skill 按本标准取凭证或出站调用。官方样板:[skills/uds-icenter](../../skills/uds-icenter)。
## 现场形态
```text
安装 uds-auth 插件 → 用户扫码登录 → 加载 skill → 可用
```
无需再配全局 `coclaw_token` / `AUTH_VALUE`。
## 双轨模型
| 轨道 | 用途 | 行为 |
|------|------|------|
| UI | Cookie + `sessionStore` + `/api/me` | **保持原样**;logout 仍清 cookie 并 `sessionStore.delete` |
| Skill | `skillCredentialCache`(`skill-credentials.json`) | 登录成功时写入/刷新;供 agent / cron |
配置项:
- `retainSkillCredentialsOnLogout`(默认 `true`):UI 退出时是否保留 skill token
- `skillCredentialTtlSeconds`(默认 `604800` = 7 天)
- `outboundAllowedHosts`:出站白名单(逗号分隔)
管理员删除用户会同时清掉该用户的 skill 凭证。
## Agent 环境
Shell 已注入(非密钥):
- `DSH_SESSION_ID` — 当前 agent 会话
- `DSH_WEB_URL` — 可选,用于拼 Host 地址
Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron 可按 `ownerEmpNo` 解析。
## 两种合法模式
### Mode Outbound(推荐新 skill)
```http
POST /uds-auth/outbound
X-DSH-Session-Id: <DSH_SESSION_ID>
{ "url": "https://icenterapi.zte.com.cn/...", "method": "POST", "headers": {...}, "body": {...} }
```
- 仅 **loopback**
- Host 注入 `X-Emp-No` / `X-Auth-Value`
- 剥离客户端自带鉴权头
- host 必须在白名单内
Python:
```python
from uds_skill_auth import request
out = request("POST", url, headers={...}, body={...})
# out["statusCode"], out["json"], out["body"]
```
### Mode Creds(必须自管 HTTP 时)
```http
POST /uds-auth/agent-credentials
X-DSH-Session-Id: <DSH_SESSION_ID>
```
返回 `{ empNo, token }`。可设进程内 `EMP_NO` / `AUTH_VALUE` 别名,**禁止**写进 SKILL.md 教模型 `printenv`。
```python
from uds_skill_auth import resolve
creds = resolve() # empNo + token;默认写入 EMP_NO/AUTH_VALUE
```
Helpers 路径:`uds-auth/skill-helpers/python/`(或环境变量 `UDS_AUTH_HELPERS`)。
## 自研 Skill 清单
1. 建目录:`SKILL.md` + `scripts/`
2. 引用 helpers(见样板 `lib_uds.py`)
3. 默认走 `request()` outbound;发信等桌面端口可用 `resolve()` 贴头
4. SKILL 只写「需已 UDS 登录」,不写 token 变量名
5. JSON stdout;日志脱敏
## 禁止项
- 全局共享 token / 把 token 注入 `shellEnv`
- SKILL 教打印环境变量中的密钥
- 无白名单的开放代理
- 用 UI `sessionStore` 的 30min TTL 冒充 skill 长凭证
## 验收
- UI 登录/退出与改前一致
- 默认配置下 UI 退出后 skill/cron 仍可用
- `retainSkillCredentialsOnLogout=false` 时退出后取证失败
- 两会话不串号;未登录错误可理解且无 token 泄露
## 样板走读
| 文件 | 作用 |
|------|------|
| `skills/uds-icenter/SKILL.md` | 给模型的用法 |
| `scripts/lib_uds.py` | 定位 helpers |
| `scripts/contacts.py` | 搜人/搜群/群成员(outbound) |
| `scripts/messaging.py` | 本机发信 + resolve 贴头 |
| `scripts/cli.py` | 统一入口 |

189
uds-auth/lib/agent-auth.js Normal file
View file

@ -0,0 +1,189 @@
/**
* Loopback agent APIs: credentials + outbound proxy.
*/
import { directRequest } from './uds/user-search.js'
import { requestIsLoopback } from './skill-credentials.js'
const DEFAULT_OUTBOUND_HOSTS = [
'icenterapi.zte.com.cn',
'icentermsg.dt.zte.com.cn',
]
function sendJSON(res, code, data) {
res.statusCode = code >= 200 && code < 300 ? 200 : code
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.end(JSON.stringify(data))
}
function readSessionId(req, body) {
const h = req.headers || {}
return (
h['x-dsh-session-id']
|| h['X-DSH-Session-Id']
|| body?.sessionId
|| body?.dshSessionId
|| null
)
}
async function readJsonBody(req) {
let raw = ''
for await (const chunk of req) raw += chunk
if (!raw) return {}
try { return JSON.parse(raw) } catch { return {} }
}
/**
* @param {{
* resolveCredentialsForSession: (sessionId: string) => Promise<{empNo:string,token:string}|null>,
* resolveCredentialsForEmpNo: (empNo: string) => Promise<{empNo:string,token:string}|null>,
* empNoHeader?: string,
* authValueHeader?: string,
* outboundHosts?: string[],
* }} deps
*/
export function createAgentAuthHandlers(deps) {
const empNoHeader = deps.empNoHeader || 'X-Emp-No'
const authValueHeader = deps.authValueHeader || 'X-Auth-Value'
function allowedHosts() {
const list = typeof deps.outboundHosts === 'function'
? deps.outboundHosts()
: deps.outboundHosts
if (Array.isArray(list) && list.length) return list.map(String)
return DEFAULT_OUTBOUND_HOSTS.slice()
}
function hostAllowed(hostname) {
const host = String(hostname || '').toLowerCase()
return allowedHosts().some((h) => host === h.toLowerCase() || host.endsWith('.' + h.toLowerCase()))
}
async function resolveFromRequest(req, body = {}) {
const sessionId = readSessionId(req, body)
if (sessionId) {
const creds = await deps.resolveCredentialsForSession(String(sessionId))
if (creds) return { creds, sessionId: String(sessionId) }
}
const empNo = body.empNo || req.headers['x-uds-emp-no']
if (empNo) {
const creds = await deps.resolveCredentialsForEmpNo(String(empNo))
if (creds) return { creds, sessionId: sessionId ? String(sessionId) : null }
}
return { creds: null, sessionId: sessionId ? String(sessionId) : null }
}
async function handleAgentCredentials(req, res) {
if (!requestIsLoopback(req)) {
return sendJSON(res, 403, { error: 'agent-credentials is loopback-only' })
}
const method = (req.method || 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
return sendJSON(res, 405, { error: 'Method not allowed' })
}
const body = method === 'POST' ? await readJsonBody(req) : {}
const { creds, sessionId } = await resolveFromRequest(req, body)
if (!creds) {
return sendJSON(res, 401, {
error: 'no_skill_credentials',
message: '请先完成 UDS 扫码登录',
sessionId: sessionId || null,
})
}
return sendJSON(res, 200, {
empNo: creds.empNo,
token: creds.token,
updatedAt: creds.updatedAt || null,
})
}
async function handleOutbound(req, res) {
if (!requestIsLoopback(req)) {
return sendJSON(res, 403, { error: 'outbound is loopback-only' })
}
const method = (req.method || 'POST').toUpperCase()
if (method !== 'POST') {
return sendJSON(res, 405, { error: 'Method not allowed' })
}
const body = await readJsonBody(req)
const { creds } = await resolveFromRequest(req, body)
if (!creds) {
return sendJSON(res, 401, {
error: 'no_skill_credentials',
message: '请先完成 UDS 扫码登录',
})
}
const targetUrl = body.url
if (!targetUrl || typeof targetUrl !== 'string') {
return sendJSON(res, 400, { error: 'url required' })
}
let parsed
try {
parsed = new URL(targetUrl)
} catch {
return sendJSON(res, 400, { error: 'invalid url' })
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return sendJSON(res, 400, { error: 'unsupported protocol' })
}
if (!hostAllowed(parsed.hostname)) {
return sendJSON(res, 403, { error: 'host_not_allowed', host: parsed.hostname })
}
const upstreamMethod = String(body.method || 'POST').toUpperCase()
const headers = { ...(body.headers && typeof body.headers === 'object' ? body.headers : {}) }
for (const k of Object.keys(headers)) {
const lower = k.toLowerCase()
if (lower === 'x-auth-value' || lower === 'x-emp-no' || lower === 'host' || lower === 'content-length') {
delete headers[k]
}
}
headers[empNoHeader] = creds.empNo
headers[authValueHeader] = creds.token
if (!headers['Content-Type'] && !headers['content-type'] && body.body != null) {
headers['Content-Type'] = 'application/json;charset=UTF-8'
}
let upstreamBody = body.body
if (upstreamBody != null && typeof upstreamBody !== 'string' && !Buffer.isBuffer(upstreamBody)) {
upstreamBody = JSON.stringify(upstreamBody)
}
try {
const out = await directRequest(parsed, {
method: upstreamMethod,
headers,
body: upstreamBody,
timeoutMs: Number(body.timeoutMs) || 30000,
})
res.statusCode = out.statusCode || 502
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Uds-Outbound-Status', String(out.statusCode || 0))
// Wrap so skill gets status + body without leaking injected auth headers
res.end(JSON.stringify({
statusCode: out.statusCode,
headers: sanitizeUpstreamHeaders(out.headers),
body: out.body,
json: out.json,
}))
} catch (err) {
return sendJSON(res, 502, { error: 'upstream_failed', message: err.message || String(err) })
}
}
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS }
}
function sanitizeUpstreamHeaders(headers) {
if (!headers || typeof headers !== 'object') return {}
const out = {}
for (const [k, v] of Object.entries(headers)) {
const lower = k.toLowerCase()
if (lower === 'x-auth-value' || lower.includes('token') || lower.includes('cookie')) continue
out[k] = v
}
return out
}

View file

@ -14,7 +14,13 @@ import { requirePermission } from './middleware/auth-middleware.js'
* POST /fallback/password → super_admin only(设置兜底密码)
* POST /fallback/clear → super_admin only(清除兜底密码)
*/
export function createApiHandlers(config, sessionStore, rolesStore) {
/**
* @param {object} config
* @param {import('./session/store.js').SessionStore} sessionStore
* @param {*} rolesStore
* @param {{ skillCredentials?: { delete: (empNo: string) => void }, retainSkillCredentialsOnLogout?: () => boolean }} [extra]
*/
export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) {
async function sendRes(res, code, data) {
res.statusCode = code >= 200 && code < 300 ? 200 : code
res.setHeader('Content-Type', 'application/json; charset=utf-8')
@ -24,6 +30,13 @@ export function createApiHandlers(config, sessionStore, rolesStore) {
async function logout(ctx) {
const empNo = ctx.empNo
if (empNo) await sessionStore.delete(empNo)
// Skill 凭证轨:默认保留;retainSkillCredentialsOnLogout=false 时清除
const retain = extra.retainSkillCredentialsOnLogout
? extra.retainSkillCredentialsOnLogout() !== false
: true
if (empNo && !retain) {
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
}
// Cookie clear attrs must match login (Secure + HttpOnly), or browsers keep the old cookie.
const clear = []
for (const name of [
@ -122,6 +135,7 @@ export function createApiHandlers(config, sessionStore, rolesStore) {
try {
await rolesStore.removeUser(empNo, ctx.role)
await sessionStore.delete(empNo)
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
await sendRes(ctx.res, 200, { message: `${empNo} 已删除` })
} catch (err) {
await sendRes(ctx.res, 400, { error: err.message })

View file

@ -21,6 +21,9 @@ const DEFAULT_CONFIG = {
userSearchUrl: 'https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search',
loginSystemCode: '100000455558',
originSystemCode: '',
retainSkillCredentialsOnLogout: true,
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
}
// 内部常量(不暴露)

View file

@ -62,6 +62,12 @@ const CONFIG_DEFAULTS = {
loginSystemCode: '100000455558',
originSystemCode: '',
workspaceRoot: '',
/** UI 退出时是否保留 skill 凭证缓存(默认保留,供 cron/skill) */
retainSkillCredentialsOnLogout: true,
/** skill 凭证 TTL(秒),默认 7 天 */
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
/** outbound 白名单 host(逗号分隔或数组) */
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
}
// 内部常量(不暴露给用户,UDS 固定协议)
@ -97,6 +103,9 @@ function loadConfigSchemaAndSettings(ctx) {
loginSystemCode: z.string().default(CONFIG_DEFAULTS.loginSystemCode),
originSystemCode: z.string().default(CONFIG_DEFAULTS.originSystemCode),
workspaceRoot: z.string().default(CONFIG_DEFAULTS.workspaceRoot),
retainSkillCredentialsOnLogout: z.boolean().default(CONFIG_DEFAULTS.retainSkillCredentialsOnLogout),
skillCredentialTtlSeconds: z.number().default(CONFIG_DEFAULTS.skillCredentialTtlSeconds),
outboundAllowedHosts: z.string().default(CONFIG_DEFAULTS.outboundAllowedHosts),
})
_DshSettings = require('@deepseek-ai/dsh-settings')
ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded')
@ -122,6 +131,8 @@ let _sessionAcl = null
let _userWorkspaces = null
let _pluginCtx = null
let _logger = console
let _skillCredentials = null
let _agentAuthHandlers = null
function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) {
if (/^https?:\/\//.test(uacQrVerifyPath)) return uacQrVerifyPath
@ -312,8 +323,11 @@ async function handleConfigGet(req, res) {
userSearchUrl: c.userSearchUrl,
loginSystemCode: c.loginSystemCode,
originSystemCode: c.originSystemCode,
workspaceRoot: c.workspaceRoot,
},
workspaceRoot: c.workspaceRoot,
retainSkillCredentialsOnLogout: c.retainSkillCredentialsOnLogout !== false,
skillCredentialTtlSeconds: c.skillCredentialTtlSeconds,
outboundAllowedHosts: c.outboundAllowedHosts,
},
}))
}
@ -494,10 +508,25 @@ const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json')
async function saveRuntimeConfig(partial) {
if (!_currentConfig) throw new Error('配置未初始化')
// 只允许修改 4 个可配置字段
const allowed = ['uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode', 'workspaceRoot']
const allowed = [
'uacBaseUrl',
'userSearchUrl',
'loginSystemCode',
'originSystemCode',
'workspaceRoot',
'retainSkillCredentialsOnLogout',
'skillCredentialTtlSeconds',
'outboundAllowedHosts',
]
for (const k of allowed) {
if (partial[k] !== undefined) {
_currentConfig[k] = String(partial[k])
if (k === 'retainSkillCredentialsOnLogout') {
_currentConfig[k] = partial[k] === true || partial[k] === 'true'
} else if (k === 'skillCredentialTtlSeconds') {
_currentConfig[k] = Number(partial[k]) || CONFIG_DEFAULTS.skillCredentialTtlSeconds
} else {
_currentConfig[k] = typeof partial[k] === 'string' ? partial[k] : String(partial[k])
}
}
}
// 写运行时配置文件(不覆盖原始 config.default.yaml)
@ -550,6 +579,26 @@ async function handleAllRoutes(req, res) {
return await handleUserInfo(req, res)
}
// Skill / agent:本机凭证与出站代理(loopback-only)
if (pathname === '/uds-auth/agent-credentials' && (method === 'GET' || method === 'POST')) {
if (!_agentAuthHandlers) {
res.statusCode = 503
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'skill credentials not ready' }))
return
}
return await _agentAuthHandlers.handleAgentCredentials(req, res)
}
if (pathname === '/uds-auth/outbound' && method === 'POST') {
if (!_agentAuthHandlers) {
res.statusCode = 503
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'outbound not ready' }))
return
}
return await _agentAuthHandlers.handleOutbound(req, res)
}
// 客户端配置(RPC config.get 在 web 端走 HTTP)
if (pathname === '/uds-auth/config.get' && (method === 'GET' || method === 'POST')) {
return await handleConfigGet(req, res)
@ -805,6 +854,8 @@ async function initServices(ctx, config) {
const { SessionAclStore } = await import('./session-acl.js')
const { UserWorkspaceStore } = await import('./workspace-provision.js')
const { patchWebServerWithIdentity, resolveIdentityFromRequest, resolveIdentityFromRequestSync, installDshAcl } = await import('./dsh-acl.js')
const { SkillCredentialCache } = await import('./skill-credentials.js')
const { createAgentAuthHandlers } = await import('./agent-auth.js')
_pluginCtx = ctx
_currentConfig = config
@ -819,8 +870,31 @@ async function initServices(ctx, config) {
}
} catch { /* runtime config optional */ }
// Normalize skill-related config after merge
if (_currentConfig.retainSkillCredentialsOnLogout === undefined) {
_currentConfig.retainSkillCredentialsOnLogout = CONFIG_DEFAULTS.retainSkillCredentialsOnLogout
} else {
_currentConfig.retainSkillCredentialsOnLogout =
_currentConfig.retainSkillCredentialsOnLogout === true
|| _currentConfig.retainSkillCredentialsOnLogout === 'true'
}
if (!_currentConfig.skillCredentialTtlSeconds) {
_currentConfig.skillCredentialTtlSeconds = CONFIG_DEFAULTS.skillCredentialTtlSeconds
}
if (!_currentConfig.outboundAllowedHosts) {
_currentConfig.outboundAllowedHosts = CONFIG_DEFAULTS.outboundAllowedHosts
}
_sessionStore = await createSessionStore(INTERNAL.session)
const ttlMs = Math.max(60, Number(_currentConfig.skillCredentialTtlSeconds) || CONFIG_DEFAULTS.skillCredentialTtlSeconds) * 1000
_skillCredentials = new SkillCredentialCache({
file: resolve(__dirname, '..', 'skill-credentials.json'),
ttlMs,
logger: ctx.logger || console,
})
await _skillCredentials.init()
const rolesFile = resolve(__dirname, '..', 'roles.json')
_rolesStore = new RolesStore({ rolesFile })
await _rolesStore.init()
@ -831,6 +905,10 @@ async function initServices(ctx, config) {
_userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(__dirname, '..', 'user-workspaces.json') })
await _userWorkspaces.init()
const rememberSkillCreds = (empNo, token) => {
try { _skillCredentials?.set(empNo, token) } catch { /* ignore */ }
}
_authMiddleware = createAuthMiddleware({
userSearchUrl: _currentConfig.userSearchUrl,
udsAuth: {
@ -841,8 +919,48 @@ async function initServices(ctx, config) {
authValueHeader: INTERNAL.authValueHeader,
},
session: INTERNAL.session,
}, _sessionStore, _rolesStore)
_apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore)
}, _sessionStore, _rolesStore, { onSkillCredentials: rememberSkillCreds })
_apiHandlers = createApiHandlers(
{ session: INTERNAL.session },
_sessionStore,
_rolesStore,
{
skillCredentials: _skillCredentials,
retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false,
},
)
function parseOutboundHosts() {
const raw = _currentConfig?.outboundAllowedHosts
if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
return String(raw || CONFIG_DEFAULTS.outboundAllowedHosts)
.split(/[,;\s]+/)
.map((s) => s.trim())
.filter(Boolean)
}
async function resolveCredentialsForEmpNo(empNo) {
if (!empNo) return null
const row = _skillCredentials?.get(empNo)
if (!row?.token) return null
return { empNo: row.empNo, token: row.token, updatedAt: row.updatedAt }
}
async function resolveCredentialsForSession(sessionId) {
if (!sessionId) return null
const empNo = _sessionAcl?.getOwner(sessionId) || null
if (!empNo) return null
return resolveCredentialsForEmpNo(empNo)
}
_agentAuthHandlers = createAgentAuthHandlers({
resolveCredentialsForSession,
resolveCredentialsForEmpNo,
empNoHeader: INTERNAL.empNoHeader,
authValueHeader: INTERNAL.authValueHeader,
outboundHosts: () => parseOutboundHosts(),
})
const identityDeps = {
sessionStore: _sessionStore,
@ -938,10 +1056,15 @@ async function initServices(ctx, config) {
getSessionOwner(sessionId) {
return _sessionAcl?.getOwner(sessionId) || null
},
resolveCredentialsForSession,
resolveCredentialsForEmpNo,
rememberSkillCredentials(empNo, token) {
rememberSkillCreds(empNo, token)
},
}
ctx.provide('udsAuth', udsAuth)
ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + udsAuth service)')
ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + skill credentials + udsAuth service)')
} catch (err) {
ctx.logger?.error?.('[uds-auth] Init failed: ' + (err.message || err))
}

View file

@ -11,8 +11,13 @@ import { searchUserByEmpNoToken } from '../uds/user-search.js'
* 出站请求绕过 HTTP(S)_PROXY。
*
* 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话。
*
* 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。
*/
export function createAuthMiddleware(config, sessionStore, rolesStore) {
export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {}) {
const onSkillCredentials = typeof hooks.onSkillCredentials === 'function'
? hooks.onSkillCredentials
: null
const udsClient = new UdsClient(config.udsAuth)
const validatorConfig = {
...config.udsAuth,
@ -107,6 +112,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) {
Math.floor(cookieMaxAge / 1000),
userContext,
)
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
} else {
// 查不到资料则作废 trust 会话,避免“假登录”
await sessionStore.delete(extracted.empNo)
@ -127,6 +133,9 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) {
userContext,
)
}
if (userContext.token) {
try { onSkillCredentials?.(extracted.empNo, userContext.token) } catch { /* ignore */ }
}
const role = await resolveRole(extracted.empNo, extracted.kind)
ctx.userContext = userContext
ctx.empNo = extracted.empNo
@ -172,6 +181,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) {
Math.floor(cookieMaxAge / 1000),
userContext,
)
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
const role = await resolveRole(profile.empNo, 'uds')
ctx.userContext = userContext

View file

@ -0,0 +1,132 @@
/**
* Skill credential cache — separate from UI sessionStore.
* empNo → { token, updatedAt }; long TTL for cron/skills after UI logout.
*/
import { readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { mkdir } from 'node:fs/promises'
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000
export class SkillCredentialCache {
/**
* @param {{ file?: string, ttlMs?: number, logger?: Console }} opts
*/
constructor(opts = {}) {
this._file = opts.file || null
this._ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
this._logger = opts.logger || console
/** @type {Map<string, { token: string, updatedAt: string, expiresAt: number }>} */
this._map = new Map()
this._dirty = false
this._flushTimer = null
}
async init() {
if (!this._file) return
try {
const raw = await readFile(this._file, 'utf-8')
const data = JSON.parse(raw)
const entries = data?.entries && typeof data.entries === 'object' ? data.entries : data
if (!entries || typeof entries !== 'object') return
const now = Date.now()
for (const [empNo, row] of Object.entries(entries)) {
if (!row?.token) continue
const expiresAt = Number(row.expiresAt) || (now + this._ttlMs)
if (expiresAt <= now) continue
this._map.set(String(empNo), {
token: String(row.token),
updatedAt: row.updatedAt || new Date().toISOString(),
expiresAt,
})
}
} catch {
/* optional file */
}
}
/**
* @param {string} empNo
* @param {string} token
*/
set(empNo, token) {
if (!empNo || !token) return
if (String(empNo).startsWith('__')) return
if (empNo === 'administrator') return
const now = Date.now()
this._map.set(String(empNo), {
token: String(token),
updatedAt: new Date().toISOString(),
expiresAt: now + this._ttlMs,
})
this._scheduleFlush()
}
/**
* @param {string} empNo
* @returns {{ empNo: string, token: string, updatedAt: string } | null}
*/
get(empNo) {
if (!empNo) return null
const key = String(empNo)
const row = this._map.get(key)
if (!row) return null
if (row.expiresAt && Date.now() > row.expiresAt) {
this._map.delete(key)
this._scheduleFlush()
return null
}
return { empNo: key, token: row.token, updatedAt: row.updatedAt }
}
/**
* @param {string} empNo
*/
delete(empNo) {
if (!empNo) return
if (this._map.delete(String(empNo))) this._scheduleFlush()
}
_scheduleFlush() {
if (!this._file) return
this._dirty = true
if (this._flushTimer) return
this._flushTimer = setTimeout(() => {
this._flushTimer = null
void this._flush()
}, 250)
}
async _flush() {
if (!this._file || !this._dirty) return
this._dirty = false
const entries = {}
for (const [empNo, row] of this._map.entries()) {
entries[empNo] = {
token: row.token,
updatedAt: row.updatedAt,
expiresAt: row.expiresAt,
}
}
try {
await mkdir(dirname(this._file), { recursive: true })
await writeFile(this._file, JSON.stringify({ entries }, null, 2), 'utf-8')
} catch (err) {
this._logger?.warn?.('[uds-auth] skillCredentialCache flush failed:', err.message)
}
}
}
export function isLoopbackAddress(addr) {
if (!addr) return false
const a = String(addr).replace(/^::ffff:/i, '')
return a === '127.0.0.1' || a === '::1' || a === 'localhost'
}
export function requestIsLoopback(req) {
const ra = req?.socket?.remoteAddress
if (isLoopbackAddress(ra)) return true
const xf = String(req?.headers?.['x-forwarded-for'] || '').split(',')[0].trim()
if (xf && isLoopbackAddress(xf)) return true
return false
}

View file

@ -0,0 +1,4 @@
# uds-auth skill helpers
#
# Python: add this directory to PYTHONPATH or sys.path, then:
# from uds_skill_auth import resolve, request, UdsAuthError

View file

@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
"""
Official uds-auth skill helpers (Python).
Resolve current-user SSO credentials via loopback Host APIs, or call intranet
APIs through the outbound proxy (token never needed in skill code for outbound).
Environment:
DSH_SESSION_ID — agent session id (injected by DSH shell-env)
DSH_WEB_URL — optional base URL of the Harness web server
UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth
"""
from __future__ import annotations
import json
import os
import ssl
import urllib.error
import urllib.request
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlparse
class UdsAuthError(RuntimeError):
def __init__(self, message: str, *, code: str = "uds_auth_error", status: int = 0):
super().__init__(message)
self.code = code
self.status = status
def _base_url() -> str:
explicit = (os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/")
if explicit:
return explicit
web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/")
if web:
return web + "/uds-auth"
# Common local defaults — try DSH_WEB_URL first in production.
return "http://127.0.0.1:8787/uds-auth"
def _session_id() -> str:
return (os.environ.get("DSH_SESSION_ID") or "").strip()
def _opener_no_proxy():
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
def _http_json(
method: str,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
body: Any = None,
timeout: float = 30.0,
) -> Tuple[int, Any, str]:
data = None
hdrs = dict(headers or {})
if body is not None:
if isinstance(body, (dict, list)):
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json;charset=UTF-8")
elif isinstance(body, str):
data = body.encode("utf-8")
elif isinstance(body, bytes):
data = body
else:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json;charset=UTF-8")
req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper())
ctx = ssl._create_unverified_context()
try:
# Prefer no-proxy opener for loopback Host
if "127.0.0.1" in url or "localhost" in url.lower():
opener = _opener_no_proxy()
with opener.open(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
status = getattr(resp, "status", 200) or 200
else:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
raw = resp.read().decode("utf-8", errors="replace")
status = getattr(resp, "status", 200) or 200
except urllib.error.HTTPError as e:
raw = (e.fp.read().decode("utf-8", errors="replace") if e.fp else "")
status = e.code
except urllib.error.URLError as e:
raise UdsAuthError(f"无法连接 uds-auth: {e.reason}", code="uds_unreachable") from e
parsed: Any = None
if raw.strip():
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
return status, parsed, raw
def resolve(*, apply_env_aliases: bool = True) -> Dict[str, str]:
"""
Fetch {empNo, token} for the current DSH session from Host.
Optionally set process-local EMP_NO / AUTH_VALUE (and coclaw_* aliases).
"""
sid = _session_id()
if not sid:
raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session")
url = _base_url() + "/agent-credentials"
status, parsed, raw = _http_json(
"POST",
url,
headers={"X-DSH-Session-Id": sid, "Accept": "application/json"},
body={"sessionId": sid},
timeout=15.0,
)
if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"):
msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None
raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status)
if status != 200 or not isinstance(parsed, dict):
raise UdsAuthError(
f"获取凭证失败 (HTTP {status})",
code="credentials_http",
status=status,
)
emp_no = str(parsed.get("empNo") or "").strip()
token = str(parsed.get("token") or "").strip()
if not emp_no or not token:
raise UdsAuthError("凭证响应不完整", code="bad_credentials")
if apply_env_aliases:
os.environ["EMP_NO"] = emp_no
os.environ["AUTH_VALUE"] = token
os.environ["coclaw_empno"] = emp_no
os.environ["coclaw_token"] = token
return {"empNo": emp_no, "token": token, "updatedAt": str(parsed.get("updatedAt") or "")}
def request(
method: str,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
body: Any = None,
timeout: float = 30.0,
) -> Dict[str, Any]:
"""
Call an intranet URL via Host outbound proxy (injects X-Emp-No / X-Auth-Value).
Returns {statusCode, headers, body, json}.
"""
sid = _session_id()
if not sid:
raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session")
host = urlparse(url).hostname or ""
payload = {
"sessionId": sid,
"method": method.upper(),
"url": url,
"headers": headers or {},
"body": body,
"timeoutMs": int(timeout * 1000),
}
status, parsed, raw = _http_json(
"POST",
_base_url() + "/outbound",
headers={"X-DSH-Session-Id": sid, "Accept": "application/json"},
body=payload,
timeout=timeout + 5.0,
)
if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"):
msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None
raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status)
if status == 403 and isinstance(parsed, dict) and parsed.get("error") == "host_not_allowed":
raise UdsAuthError(f"主机不在 outbound 白名单: {host}", code="host_not_allowed", status=403)
if not isinstance(parsed, dict) or "statusCode" not in parsed:
raise UdsAuthError(
f"outbound 失败 (HTTP {status}): {raw[:200]}",
code="outbound_http",
status=status,
)
return parsed