#!/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`,
``,
"application/xml; charset=UTF-8"
);
if (addUsersObjRes.status !== 201) {
console.warn(` ! XWikiUsersオブジェクト追加失敗(${addUsersObjRes.status}): ${pageName}`);
return "error";
}
// 3. XWikiUsersプロパティ設定
const usersPropXml = `
`;
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`,
``,
"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 = `
`;
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);
});