ken_nogi/NodeSrv/apps/xwiki/scripts/provision-oidc-users.js
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。
NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは
別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter
インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:37:06 +09:00

214 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// 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 = `<?xml version="1.0" encoding="UTF-8"?>
<page xmlns="http://www.xwiki.org">
<title>GetAllSubjectsTmp</title>
<syntax>xwiki/2.1</syntax>
<content>${escapeXml(groovyBody)}</content>
</page>`;
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 = `<?xml version="1.0" encoding="UTF-8"?>
<page xmlns="http://www.xwiki.org">
<title>${escapeXml(title)}</title>
<content></content>
</page>`;
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`,
`<?xml version="1.0" encoding="UTF-8"?><object xmlns="http://www.xwiki.org"><className>XWiki.XWikiUsers</className></object>`,
"application/xml; charset=UTF-8"
);
if (addUsersObjRes.status !== 201) {
console.warn(` ! XWikiUsersオブジェクト追加失敗(${addUsersObjRes.status}): ${pageName}`);
return "error";
}
// 3. XWikiUsersプロパティ設定
const usersPropXml = `<?xml version="1.0" encoding="UTF-8"?>
<object xmlns="http://www.xwiki.org">
<className>XWiki.XWikiUsers</className>
<property name="first_name"><value>${escapeXml(kcUser.firstName || "")}</value></property>
<property name="last_name"><value>${escapeXml(kcUser.lastName || "")}</value></property>
<property name="email"><value>${escapeXml(kcUser.email)}</value></property>
<property name="active"><value>1</value></property>
</object>`;
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`,
`<?xml version="1.0" encoding="UTF-8"?><object xmlns="http://www.xwiki.org"><className>XWiki.OIDC.UserClass</className></object>`,
"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 = `<?xml version="1.0" encoding="UTF-8"?>
<object xmlns="http://www.xwiki.org">
<className>XWiki.OIDC.UserClass</className>
<property name="issuer"><value>${escapeXml(ISSUER)}</value></property>
<property name="subject"><value>${escapeXml(kcUser.id)}</value></property>
</object>`;
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);
});