ken_nogi/NodeJS/アルファオフィススケジュール/getUserList.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

276 lines
8.6 KiB
JavaScript

"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { stringify } = require("csv-stringify/sync");
/*
仕様書
https://developers.worksmobile.com/jp/docs/user-list#User
使い方:
node getUserList.js <tenantDomainId> [outputCsvPath]
例:
node getUserList.js 10000001
node getUserList.js 10000001 ./users-10000001.csv
環境変数:
LW_CLIENT_ID
LW_CLIENT_SECRET
LW_SERVICE_ACCOUNT
LW_PRIVATE_KEY
LW_PRIVATE_KEY_FILE
LW_SCOPE 既定: user.read
*/
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
const LW_API_BASE_URL = "https://www.worksapis.com/v1.0";
const PRIVATE_KEY_FILE = process.env.LW_PRIVATE_KEY_FILE
? path.resolve(process.env.LW_PRIVATE_KEY_FILE)
: path.join(__dirname, "private_20260307184804.key");
const LW_CLIENT_ID = process.env.LW_CLIENT_ID || "tre8J_Tk8RblfsMSyZRh";
const LW_CLIENT_SECRET = process.env.LW_CLIENT_SECRET || "O5_R5gcHxg";
const LW_SERVICE_ACCOUNT = process.env.LW_SERVICE_ACCOUNT || "wh4k8.serviceaccount@next-hd.co.jp";
const LW_SCOPE = process.env.LW_SCOPE || "user.read";
const LW_COUNT_PER_PAGE = 100;
const LW_PRIVATE_KEY = (process.env.LW_PRIVATE_KEY && process.env.LW_PRIVATE_KEY.trim())
? process.env.LW_PRIVATE_KEY
: (fs.existsSync(PRIVATE_KEY_FILE) ? fs.readFileSync(PRIVATE_KEY_FILE, "utf8") : "");
function parseArgs() {
const domainIdRaw = process.argv[2];
const outputPathRaw = process.argv[3];
if (!domainIdRaw) {
throw new Error("tenantDomainId を引数で指定してください。例: node getUserList.js 10000001");
}
const domainId = Number(domainIdRaw);
if (!Number.isInteger(domainId) || domainId <= 0) {
throw new Error(`tenantDomainId が不正です: ${domainIdRaw}`);
}
const outputPath = outputPathRaw
? path.resolve(outputPathRaw)
: path.join(__dirname, `users-${domainId}-${formatDateForFile(new Date())}.csv`);
return { domainId, outputPath };
}
function formatDateForFile(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
const ss = String(date.getSeconds()).padStart(2, "0");
return `${y}${m}${d}-${hh}${mm}${ss}`;
}
function ensureRequiredEnvForAuth() {
const missing = [];
if (!LW_CLIENT_ID) missing.push("LW_CLIENT_ID");
if (!LW_CLIENT_SECRET) missing.push("LW_CLIENT_SECRET");
if (!LW_SERVICE_ACCOUNT) missing.push("LW_SERVICE_ACCOUNT");
if (!LW_PRIVATE_KEY) missing.push("LW_PRIVATE_KEY");
if (missing.length > 0) {
if (missing.includes("LW_PRIVATE_KEY") && !fs.existsSync(PRIVATE_KEY_FILE)) {
throw new Error(`秘密鍵が見つかりません: ${PRIVATE_KEY_FILE}`);
}
throw new Error(`環境変数が不足しています: ${missing.join(", ")}`);
}
}
function base64UrlEncode(value) {
return Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function createJwtAssertion() {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iss: LW_CLIENT_ID,
sub: LW_SERVICE_ACCOUNT,
aud: LW_TOKEN_URL,
iat: now,
exp: now + 300,
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signer = crypto.createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
const signature = signer
.sign(LW_PRIVATE_KEY)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
return `${signingInput}.${signature}`;
}
async function getAccessToken(scope = LW_SCOPE) {
ensureRequiredEnvForAuth();
const assertion = createJwtAssertion();
const form = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
client_id: LW_CLIENT_ID,
client_secret: LW_CLIENT_SECRET,
scope,
});
const response = await fetch(LW_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`アクセストークン取得失敗: ${response.status} ${detail}`);
}
const data = await response.json();
if (!data.access_token) {
throw new Error("アクセストークン取得失敗: access_token が返却されませんでした");
}
return data.access_token;
}
async function fetchAllUsers(accessToken, domainId) {
const users = [];
let cursor = "";
while (true) {
const params = new URLSearchParams();
params.set("domainId", String(domainId));
params.set("count", String(LW_COUNT_PER_PAGE));
if (cursor) {
params.set("cursor", cursor);
}
const url = `${LW_API_BASE_URL}/users?${params.toString()}`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const raw = await response.text();
let data;
try {
data = raw ? JSON.parse(raw) : {};
} catch (_err) {
throw new Error(`ユーザー一覧レスポンスがJSONではありません: ${raw}`);
}
if (!response.ok) {
throw new Error(`ユーザー一覧取得失敗: ${response.status} ${JSON.stringify(data)}`);
}
const pageUsers = Array.isArray(data.users) ? data.users : [];
users.push(...pageUsers);
const nextCursor = data?.responseMetaData?.nextCursor || "";
if (!nextCursor || nextCursor === cursor) {
break;
}
cursor = nextCursor;
}
return users;
}
function pickPrimaryOrgUnit(user) {
const organizations = Array.isArray(user.organizations) ? user.organizations : [];
const primaryOrg = organizations.find((org) => org && org.primary) || organizations[0] || null;
if (!primaryOrg) {
return { organizationName: "", orgUnitName: "" };
}
const orgUnits = Array.isArray(primaryOrg.orgUnits) ? primaryOrg.orgUnits : [];
const primaryOrgUnit = orgUnits.find((ou) => ou && ou.primary) || orgUnits[0] || null;
return {
organizationName: primaryOrg.organizationName || "",
orgUnitName: primaryOrgUnit ? (primaryOrgUnit.orgUnitName || "") : "",
};
}
function toCsvRows(users) {
return users.map((user) => {
const userName = user.userName || {};
const primary = pickPrimaryOrgUnit(user);
return {
domainId: user.domainId ?? "",
userId: user.userId || "",
userExternalKey: user.userExternalKey || "",
email: user.email || "",
lastName: userName.lastName || "",
firstName: userName.firstName || "",
nickName: user.nickName || "",
isAdministrator: user.isAdministrator ?? "",
isPending: user.isPending ?? "",
isSuspended: user.isSuspended ?? "",
isDeleted: user.isDeleted ?? "",
isAwaiting: user.isAwaiting ?? "",
locale: user.locale || "",
timeZone: user.timeZone || "",
organizationName: primary.organizationName,
orgUnitName: primary.orgUnitName,
};
});
}
function writeCsv(outputPath, rows) {
const outputDir = path.dirname(outputPath);
fs.mkdirSync(outputDir, { recursive: true });
const csv = stringify(rows, {
header: true,
bom: true,
});
fs.writeFileSync(outputPath, csv, "utf8");
}
async function main() {
if (typeof fetch !== "function") {
throw new Error("この Node.js 実行環境は fetch に未対応です。Node.js 18 以上を使用してください。");
}
const { domainId, outputPath } = parseArgs();
const accessToken = await getAccessToken();
const users = await fetchAllUsers(accessToken, domainId);
const rows = toCsvRows(users);
writeCsv(outputPath, rows);
console.log(`取得件数: ${users.length}`);
console.log(`CSV出力先: ${outputPath}`);
}
main().catch((err) => {
console.error("ユーザーリスト出力に失敗しました。");
console.error(err.message);
process.exit(1);
});