import { spawn } from "node:child_process"; import { createHmac, generateKeyPairSync, createSign } from "node:crypto"; import { createServer } from "node:http"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; const root = resolve(import.meta.dirname, ".."); const apiPort = 8797; const issuerPort = 8798; const api = `http://127.0.0.1:${apiPort}`; const issuer = `http://127.0.0.1:${issuerPort}`; const secret = `smoke-secret-${Date.now()}`; const tempRoot = await mkdtemp(`${tmpdir()}/ai-drama-oidc-`); const dbPath = resolve(tempRoot, "platform.sqlite"); const runId = Date.now(); const mockEmail = `sso-${runId}@local.test`; const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const publicJwk = { ...publicKey.export({ format: "jwk" }), kid: "smoke-key", use: "sig", alg: "RS256" }; let authorizationRequest = null; let authorizationCode = null; let apiProcess = null; let providerServer = null; let childLogs = ""; function assert(condition, message) { if (!condition) throw new Error(message); } function json(res, status, payload) { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify(payload)); } function redirect(res, location) { res.writeHead(302, { location, "cache-control": "no-store" }); res.end(); } function base64url(value) { return Buffer.from(value).toString("base64url"); } function decodeBase32(value) { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const normalized = String(value || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, ""); let bits = 0; let buffer = 0; const output = []; for (const character of normalized) { buffer = (buffer << 5) | alphabet.indexOf(character); bits += 5; if (bits >= 8) { bits -= 8; output.push((buffer >> bits) & 0xff); } } return Buffer.from(output); } function totp(secret, timestamp = Date.now()) { const counter = Math.floor(timestamp / 30000); const counterBuffer = Buffer.alloc(8); counterBuffer.writeBigUInt64BE(BigInt(counter)); const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest(); const offset = digest[digest.length - 1] & 0x0f; const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); return String(value % 1000000).padStart(6, "0"); } function signIdToken(claims) { const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid: "smoke-key" })); const payload = base64url(JSON.stringify(claims)); const input = `${header}.${payload}`; const signer = createSign("RSA-SHA256"); signer.update(input); signer.end(); return `${input}.${signer.sign(privateKey).toString("base64url")}`; } async function readRequestBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); } async function startProvider() { providerServer = createServer(async (req, res) => { const url = new URL(req.url, issuer); if (req.method === "GET" && url.pathname === "/.well-known/openid-configuration") { return json(res, 200, { issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, userinfo_endpoint: `${issuer}/userinfo`, jwks_uri: `${issuer}/jwks` }); } if (req.method === "GET" && url.pathname === "/jwks") return json(res, 200, { keys: [publicJwk] }); if (req.method === "GET" && url.pathname === "/authorize") { authorizationRequest = Object.fromEntries(url.searchParams.entries()); authorizationCode = `smoke-code-${runId}`; return redirect(res, `${api}/api/auth/sso/callback?code=${encodeURIComponent(authorizationCode)}&state=${encodeURIComponent(authorizationRequest.state)}`); } if (req.method === "POST" && url.pathname === "/token") { const body = new URLSearchParams(await readRequestBody(req)); assert(body.get("code") === authorizationCode, "Mock OIDC code 不匹配"); assert(body.get("client_id") === "smoke-client", "Mock OIDC client_id 不匹配"); assert(body.get("code_verifier"), "Mock OIDC 缺少 PKCE verifier"); const now = Math.floor(Date.now() / 1000); return json(res, 200, { token_type: "Bearer", access_token: `smoke-access-${runId}`, id_token: signIdToken({ iss: issuer, sub: `mock-sub-${runId}`, aud: "smoke-client", nonce: authorizationRequest.nonce, email: mockEmail, email_verified: true, name: "本地 SSO 测试用户", preferred_username: mockEmail, iat: now, exp: now + 300 }) }); } if (req.method === "GET" && url.pathname === "/userinfo") return json(res, 200, { name: "本地 SSO 测试用户" }); return json(res, 404, { error: "not_found" }); }); await new Promise((resolvePromise, reject) => providerServer.listen(issuerPort, "127.0.0.1", resolvePromise).on("error", reject)); } async function waitFor(url, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const response = await fetch(url); if (response.ok) return; } catch {} await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); } throw new Error(`等待服务超时:${url}`); } async function request(path, options = {}) { const response = await fetch(`${api}${path}`, { ...options, redirect: "manual", headers: { "content-type": "application/json", ...(options.headers || {}) } }); const payload = await response.json().catch(() => ({})); return { response, payload }; } async function main() { await startProvider(); apiProcess = spawn(process.execPath, ["server/local-api.mjs"], { cwd: root, env: { ...process.env, AI_DRAMA_API_PORT: String(apiPort), AI_DRAMA_API_ORIGIN: api, AI_DRAMA_FRONTEND_ORIGIN: "http://127.0.0.1:5173", AI_DRAMA_DB_PATH: dbPath, AI_DRAMA_OIDC_SMOKE_SECRET: secret }, stdio: ["ignore", "pipe", "pipe"] }); apiProcess.stdout.on("data", (chunk) => { childLogs += chunk.toString(); }); apiProcess.stderr.on("data", (chunk) => { childLogs += chunk.toString(); }); await waitFor(`${api}/api/health`); const owner = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); assert(owner.response.ok && owner.payload.session?.token, "OIDC smoke 管理员登录失败"); const ownerHeaders = { authorization: `Bearer ${owner.payload.session.token}` }; const created = await request("/api/system/identity/providers", { method: "POST", headers: ownerHeaders, body: JSON.stringify({ name: `Smoke OIDC Runtime ${runId}`, kind: "oidc", organizationId: "org-studio-lab", workspaceId: "ws-local-aidrama", issuerUrl: issuer, clientId: "smoke-client", clientSecretRef: "AI_DRAMA_OIDC_SMOKE_SECRET", autoProvision: true, defaultRoleKey: "org_member", defaultWorkspaceRoleKey: "writer", enabled: true }) }); assert(created.response.status === 201, `OIDC smoke 提供商登记失败:${JSON.stringify(created.payload)}`); const provider = created.payload.providers.find((item) => item.name.includes(`Smoke OIDC Runtime ${runId}`)); assert(provider, "OIDC smoke 找不到新建提供商"); const probed = await request(`/api/system/identity/providers/${encodeURIComponent(provider.id)}/probe`, { method: "POST", headers: ownerHeaders, body: "{}" }); assert(probed.response.ok && probed.payload.provider?.status === "ready", `OIDC smoke discovery 探测失败:${JSON.stringify(probed.payload)}`); const policy = await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: true }) }); assert(policy.response.ok, `OIDC smoke 启用 SSO 失败:${JSON.stringify(policy.payload)}`); const start = await request(`/api/auth/sso/start?providerId=${encodeURIComponent(provider.id)}&returnTo=%2F%23creator-home`); assert(start.response.status === 302, `OIDC smoke 登录发起失败:${JSON.stringify(start.payload)}`); const authorizeUrl = start.response.headers.get("location"); assert(authorizeUrl?.startsWith(`${issuer}/authorize`), "OIDC smoke 没有跳转到 Mock Provider"); const authorize = await fetch(authorizeUrl, { redirect: "manual" }); assert(authorize.status === 302, "Mock Provider authorize 没有返回 callback"); const callbackUrl = authorize.headers.get("location"); const callback = await fetch(callbackUrl, { redirect: "manual" }); assert(callback.status === 302, `OIDC callback 失败:${await callback.text()}`); const frontendUrl = new URL(callback.headers.get("location")); const ticket = frontendUrl.searchParams.get("sso_ticket"); assert(ticket, "OIDC callback 没有签发一次性票据"); const redeemed = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) }); assert(redeemed.response.ok && redeemed.payload.session?.token, `OIDC 票据兑换失败:${JSON.stringify(redeemed.payload)}`); assert(redeemed.payload.user?.email === mockEmail, "OIDC 自动创建用户邮箱不匹配"); assert(redeemed.payload.context?.currentOrganization?.id === "org-studio-lab", "OIDC 用户没有进入绑定组织"); const replay = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) }); assert(replay.response.status === 401, "OIDC 票据重放没有被拒绝"); const enforced = await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: true, mfaRequiredForAll: true }) }); assert(enforced.response.ok, `OIDC smoke 启用全员 MFA 失败:${JSON.stringify(enforced.payload)}`); const secondStart = await request(`/api/auth/sso/start?providerId=${encodeURIComponent(provider.id)}`); const secondAuthorize = await fetch(secondStart.response.headers.get("location"), { redirect: "manual" }); const secondCallback = await fetch(secondAuthorize.headers.get("location"), { redirect: "manual" }); const secondTicket = new URL(secondCallback.headers.get("location")).searchParams.get("sso_ticket"); const enrollment = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket: secondTicket }) }); assert(enrollment.response.ok && enrollment.payload.mfaEnrollmentRequired && enrollment.payload.enrollmentToken, "SSO 用户命中强制 MFA 时必须返回 enrollment challenge"); const setup = await request("/api/auth/mfa/enroll/setup", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollment.payload.enrollmentToken }) }); assert(setup.response.status === 201 && setup.payload.setup?.methodId, "SSO MFA enrollment setup 失败"); const enrolled = await request("/api/auth/mfa/enroll/enable", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollment.payload.enrollmentToken, methodId: setup.payload.setup.methodId, code: totp(setup.payload.setup.secret) }) }); assert(enrolled.response.ok && enrolled.payload.session?.token, "SSO MFA enrollment 完成后必须签发正式 session"); const invalid = await request(`/api/auth/sso/callback?format=json&code=bad&state=${"tampered"}`); assert(invalid.response.status === 401, "OIDC 篡改 state 没有被拒绝"); console.log(`oidc smoke passed: ${api} user=${mockEmail}`); await cleanup(); } async function cleanup() { if (providerServer) await new Promise((resolvePromise) => providerServer.close(resolvePromise)); if (apiProcess && !apiProcess.killed) apiProcess.kill("SIGTERM"); await rm(tempRoot, { recursive: true, force: true }); } try { await main(); } catch (error) { console.error(error.message); if (apiProcess) console.error(childLogs || "OIDC smoke API 没有输出日志"); await cleanup(); process.exitCode = 1; }