255 lines
12 KiB
JavaScript
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;
|
|
}
|