#!/usr/bin/env node // Keycloak(nexthdレルム)に同期済みの全ユーザーをXWikiへ事前登録する。 // これにより、LINEWORKS SSO初回ログイン時にXWiki OIDC拡張が「新規ユーザー」と // 判定せず、事前登録したこのユーザーへマッピングされる(自然作成の抑制)。 // // マッピングの仕組み: OIDC拡張は XWiki.OIDC.UserClass オブジェクトの // issuer/subject プロパティで既存ユーザーを検索する(issuer=Keycloakのissuer URL、 // subject=KeycloakユーザーのUUID=IDトークンのsubクレーム)。 // // 使い方: node provision-oidc-users.js [--dry-run] [--limit=N] const fs = require("fs"); const path = require("path"); const envPath = path.join(__dirname, "..", ".env"); for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { const m = line.match(/^([A-Z_]+)=(.*)$/); if (m) process.env[m[1]] = m[2].replace(/^"|"$/g, ""); } const XWIKI_BASE_URL = process.env.XWIKI_BASE_URL; const XWIKI_AUTH = "Basic " + Buffer.from(`${process.env.XWIKI_ADMIN_USER}:${process.env.XWIKI_ADMIN_PASSWORD}`).toString("base64"); const KEYCLOAK_BASE_URL = process.env.KEYCLOAK_BASE_URL; const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM; const ISSUER = `${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}`; const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const limitArg = args.find((a) => a.startsWith("--limit=")); const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : Infinity; async function getKeycloakToken() { const res = await fetch(`${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: process.env.KEYCLOAK_ADMIN_CLIENT_ID, client_secret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET, }), }); const json = await res.json(); return json.access_token; } async function getAllKeycloakUsers(token) { const res = await fetch(`${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users?max=1000`, { headers: { Authorization: `Bearer ${token}` }, }); return res.json(); } // XWikiページ名として使えない文字(. / : \ ~)を除去・置換する。 // 既存のOIDC自動生成ユーザー名は"."を除去する挙動だったため、それに合わせる。 function toXWikiPageName(username) { return username.replace(/\./g, "").replace(/\//g, "/").replace(/:/g, ":").replace(/\\/g, "\").replace(/~/g, "~"); } async function xwikiRequest(method, urlPath, body, contentType) { return fetch(`${XWIKI_BASE_URL}${urlPath}`, { method, headers: { Authorization: XWIKI_AUTH, ...(contentType ? { "Content-Type": contentType } : {}), }, body, }); } function escapeXml(s) { return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // REST Query API(/rest/wikis/xwiki/query)はBasic認証だとXWQL実行権限が無く常に空を返す // (実機検証済み)。Groovyページ経由でXWQLを実行し、既存のissuer/subject一覧を1回で取得する。 async function getAllExistingSubjects() { const groovyBody = `{{groovy}} def xwql = "select oidc.subject, doc.fullName from Document doc, doc.object(XWiki.OIDC.UserClass) as oidc" def results = services.query.xwql(xwql).execute() results.each { row -> println("SUBJECT_MAP: " + row[0] + " | " + row[1]) } {{/groovy}}`; const pageXml = ` GetAllSubjectsTmp xwiki/2.1 ${escapeXml(groovyBody)} `; await xwikiRequest("PUT", "/rest/wikis/xwiki/spaces/Sandbox/pages/GetAllSubjectsTmp", pageXml, "application/xml; charset=UTF-8"); await new Promise((r) => setTimeout(r, 1500)); const res = await xwikiRequest("GET", "/bin/get/Sandbox/GetAllSubjectsTmp"); const text = await res.text(); const map = {}; const re = /SUBJECT_MAP: (\S+) \| (\S+)/g; let m; while ((m = re.exec(text))) { map[m[1]] = m[2]; } await xwikiRequest("DELETE", "/rest/wikis/xwiki/spaces/Sandbox/pages/GetAllSubjectsTmp"); return map; } async function provisionUser(kcUser, existingSubjects) { if (!kcUser.username || !kcUser.email) { console.warn(` ! skip (username/email欠損): ${kcUser.id}`); return "skipped"; } const existing = existingSubjects[kcUser.id]; if (existing) { console.log(` = 既存: ${kcUser.username} -> ${existing}`); return "existing"; } const pageName = toXWikiPageName(kcUser.username); console.log(` + 新規登録: ${kcUser.username} -> XWiki.${pageName}`); if (dryRun) return "would-create"; // 1. ページ作成(存在すれば上書きにならないよう、まず存在確認) const checkRes = await xwikiRequest("GET", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}`); if (checkRes.status === 200) { console.warn(` ! ページ名衝突(別ユーザーが既に使用中の可能性): XWiki.${pageName}`); return "conflict"; } const title = `${kcUser.lastName || ""} ${kcUser.firstName || ""}`.trim() || kcUser.username; const createXml = ` ${escapeXml(title)} `; const createRes = await xwikiRequest("PUT", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}`, createXml, "application/xml; charset=UTF-8"); if (createRes.status !== 201 && createRes.status !== 202) { console.warn(` ! ページ作成失敗(${createRes.status}): ${pageName}`); return "error"; } // 2. XWiki.XWikiUsersオブジェクト追加 const addUsersObjRes = await xwikiRequest( "POST", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects`, `XWiki.XWikiUsers`, "application/xml; charset=UTF-8" ); if (addUsersObjRes.status !== 201) { console.warn(` ! XWikiUsersオブジェクト追加失敗(${addUsersObjRes.status}): ${pageName}`); return "error"; } // 3. XWikiUsersプロパティ設定 const usersPropXml = ` XWiki.XWikiUsers ${escapeXml(kcUser.firstName || "")} ${escapeXml(kcUser.lastName || "")} ${escapeXml(kcUser.email)} 1 `; await xwikiRequest("PUT", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects/XWiki.XWikiUsers/0`, usersPropXml, "application/xml; charset=UTF-8"); // 4. XWiki.OIDC.UserClassオブジェクト追加 const addOidcObjRes = await xwikiRequest( "POST", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects`, `XWiki.OIDC.UserClass`, "application/xml; charset=UTF-8" ); if (addOidcObjRes.status !== 201) { console.warn(` ! OIDC.UserClassオブジェクト追加失敗(${addOidcObjRes.status}): ${pageName}`); return "error"; } // 5. OIDC.UserClassプロパティ設定(issuer/subject = 既存ログイン済みユーザーと同じキー) const oidcPropXml = ` XWiki.OIDC.UserClass ${escapeXml(ISSUER)} ${escapeXml(kcUser.id)} `; const oidcPropRes = await xwikiRequest("PUT", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects/XWiki.OIDC.UserClass/0`, oidcPropXml, "application/xml; charset=UTF-8"); if (oidcPropRes.status !== 200 && oidcPropRes.status !== 202) { console.warn(` ! OIDCプロパティ設定失敗(${oidcPropRes.status}): ${pageName}`); return "error"; } return "created"; } async function main() { console.log(`Issuer: ${ISSUER}`); const token = await getKeycloakToken(); const users = await getAllKeycloakUsers(token); console.log(`Keycloakユーザー数: ${users.length}${dryRun ? " [DRY RUN]" : ""}`); const existingSubjects = await getAllExistingSubjects(); console.log(`XWiki側の既存OIDCユーザー数: ${Object.keys(existingSubjects).length}`); const stats = {}; let count = 0; for (const u of users) { if (count >= limit) break; count++; const result = await provisionUser(u, existingSubjects); stats[result] = (stats[result] || 0) + 1; } console.log(`\n完了: ${JSON.stringify(stats)}`); } main().catch((e) => { console.error(e); process.exit(1); });