Files
ai-drama-platform/scripts/smoke-oidc.mjs
T

255 lines
12 KiB
JavaScript

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;
}