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>
66 KiB
LINEWORKS User Account共通認証基盤 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: LINEWORKS User Account OAuth(Authorization Code)認証を、複数の社内システムが共通利用できる独立Node.jsサービスapps/lineworks-user-authを構築する。
Architecture: Express製Webアプリ。/auth/*はマスターキー+セッションCookie保護のブラウザUIで、連携キーごとの新規登録・再認可(LINEWORKS Authorization Codeフロー)を行う。/token?key=<連携キー>は消費側システム(実行キー認証)向けAPIで、プリザンター専用テーブルに暗号化保存されたaccess/refresh tokenを読み、期限切れなら自動リフレッシュしてから返す。状態は全てプリザンター側に一元化し、本アプリ自体はステートレス。
Tech Stack: Node.js 22(CommonJS)、Express 4、node:test(標準テストランナー)、node:crypto(AES-256-GCM暗号化・HMAC署名)。外部依存はexpressのみ(既存アプリ群の慣習を踏襲)。
Global Constraints
- 設計書:
docs/superpowers/specs/2026-08-24-lineworks-user-auth-design.md(このプランの元仕様、矛盾があれば設計書優先) - 1アプリ1フォルダ1Dokploy Compose、他アプリと完全独立(
apps/lineworks-user-auth配下で完結、他アプリのコードをimportしない。プリザンターAPIクライアント等は共通化せずコピーする既存方針を踏襲) - Node.js CommonJS(
"type": "commonjs")、node --testでテスト実行 - 外部I/O(fetch)を行う関数は全て
fetchImpl = fetch引数でDI可能にする(既存コードベースの慣習、テストでモック注入するため) /healthエンドポイントは削除・変更しない(Dokployヘルスチェック対象)- 機密情報(APIキー・秘密鍵・トークン)を絶対にログ・コミット・チャット出力に含めない
- n8nのData Table機能は使用しない(状態は全てプリザンター側で持つ、feedback_n8n_datatable_prohibition)
事前準備(ユーザー側、コード実装と並行して進めてよい)
実装タスクではないが、後続タスクの前提になるため明記する。
- LINEWORKS Developer ConsoleでUser Account OAuth用アプリを新規作成
- リダイレクトURI:
https://lwauth29.next-hd.net/auth/callback - 発行されたclient_id/client_secretは
.envへ(後述Task 1)
- リダイレクトURI:
- DNS:
lwauth29.next-hd.net→52.193.142.134(Dokploy Node A、実機確認済み)へAレコード登録 - プリザンター: 「LINEWORKS連携トークン」テーブルを以下の物理列構成で新規作成し、SiteId・APIキーを控える
| 物理列 | 用途 |
|---|---|
| ClassA | 連携キー |
| Class001 | 対象LINEWORKSアカウント |
| Class002 | スコープ |
| Class003 | ステータス備考 |
| Description001 | アクセストークン(暗号化) |
| Description002 | リフレッシュトークン(暗号化) |
| Date001 | アクセストークン有効期限 |
| Date002 | リフレッシュトークン取得日時 |
| Date003 | 最終使用日時 |
このアプリ専用のAPIキーを発行し、通常ユーザーの閲覧権限は外す。
Task 1: アプリ雛形作成
Files:
- Create:
apps/lineworks-user-auth/package.json - Create:
apps/lineworks-user-auth/src/index.js - Create:
apps/lineworks-user-auth/Dockerfile - Create:
apps/lineworks-user-auth/docker-compose.local.yml - Create:
apps/lineworks-user-auth/.env.example - Create:
apps/lineworks-user-auth/.gitignore
Interfaces:
-
Produces:
PORT環境変数でリッスンするExpressアプリ、GET /healthが{status:'healthy'}を200で返す -
Step 1:
apps/_templateをapps/lineworks-user-authへコピー
cp -r apps/_template apps/lineworks-user-auth
rm -rf apps/lineworks-user-auth/node_modules
- Step 2: package.jsonを編集
apps/lineworks-user-auth/package.json:
{
"name": "lineworks-user-auth",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.0"
}
}
- Step 3:
.env.example作成
apps/lineworks-user-auth/.env.example:
PORT=3000
NODE_ENV=development
# LINEWORKS OAuth app (Developer Console, User Account認証用に新規作成)
LW_OAUTH_CLIENT_ID=
LW_OAUTH_CLIENT_SECRET=
LW_OAUTH_REDIRECT_URI=https://lwauth29.next-hd.net/auth/callback
LW_OAUTH_SCOPE=form form.read
# プリザンター(LINEWORKS連携トークン専用テーブル)
PLEASANTER_BASE_URL=
PLEASANTER_API_KEY=
PLEASANTER_TOKEN_SITE_ID=
# トークン暗号化鍵(32byte、hex 64文字。生成例: openssl rand -hex 32)
TOKEN_ENCRYPTION_KEY=
# /auth 管理UIのマスターキー・セッション署名鍵
AUTH_MASTER_KEY=
# 消費側システムの実行キー(JSON、{"連携キー": "実行キー"}形式)
CONSUMER_EXECUTION_KEYS={}
- Step 4:
.gitignore確認・追記
apps/lineworks-user-auth/.gitignoreに以下が含まれることを確認(なければ追記):
node_modules/
.env
- Step 5: ローカルでnpm installして
/health確認
cd apps/lineworks-user-auth
npm install
cp .env.example .env
npm run dev
別ターミナルで:
curl -s http://localhost:3000/health
Expected: {"status":"healthy"}
確認後、devサーバーを停止する。
- Step 6: Commit
git add apps/lineworks-user-auth/package.json apps/lineworks-user-auth/src/index.js apps/lineworks-user-auth/Dockerfile apps/lineworks-user-auth/docker-compose.local.yml apps/lineworks-user-auth/.env.example apps/lineworks-user-auth/.gitignore
git commit -m "feat(lineworks-user-auth): アプリ雛形作成"
Task 2: トークン暗号化(tokenCrypto.js)
Files:
- Create:
apps/lineworks-user-auth/src/lib/tokenCrypto.js - Test:
apps/lineworks-user-auth/test/tokenCrypto.test.js
Interfaces:
-
Produces:
encrypt(plainText, keyHex) -> string(暗号文、iv:authTag:cipherTextのhex連結形式)、decrypt(encryptedText, keyHex) -> string(平文に復号) -
Step 1: 失敗するテストを書く
apps/lineworks-user-auth/test/tokenCrypto.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { encrypt, decrypt } = require("../src/lib/tokenCrypto");
const KEY_HEX = crypto.randomBytes(32).toString("hex");
test("encrypt then decrypt returns the original plain text", () => {
const plain = "sample-access-token-value";
const encrypted = encrypt(plain, KEY_HEX);
assert.notStrictEqual(encrypted, plain);
assert.strictEqual(decrypt(encrypted, KEY_HEX), plain);
});
test("encrypt output contains iv, authTag, cipherText separated by colons", () => {
const encrypted = encrypt("abc", KEY_HEX);
const parts = encrypted.split(":");
assert.strictEqual(parts.length, 3);
});
test("decrypt throws when the key does not match", () => {
const encrypted = encrypt("abc", KEY_HEX);
const otherKey = crypto.randomBytes(32).toString("hex");
assert.throws(() => decrypt(encrypted, otherKey));
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/tokenCrypto.test.js
Expected: FAIL(Cannot find module '../src/lib/tokenCrypto')
- Step 3: 実装
apps/lineworks-user-auth/src/lib/tokenCrypto.js:
"use strict";
const crypto = require("node:crypto");
const ALGORITHM = "aes-256-gcm";
function encrypt(plainText, keyHex) {
const key = Buffer.from(keyHex, "hex");
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const cipherText = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${cipherText.toString("hex")}`;
}
function decrypt(encryptedText, keyHex) {
const key = Buffer.from(keyHex, "hex");
const [ivHex, authTagHex, cipherTextHex] = encryptedText.split(":");
const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"));
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
const plainText = Buffer.concat([decipher.update(Buffer.from(cipherTextHex, "hex")), decipher.final()]);
return plainText.toString("utf8");
}
module.exports = { encrypt, decrypt };
- Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/tokenCrypto.test.js
Expected: PASS(3 tests)
- Step 5: Commit
git add apps/lineworks-user-auth/src/lib/tokenCrypto.js apps/lineworks-user-auth/test/tokenCrypto.test.js
git commit -m "feat(lineworks-user-auth): AES-256-GCMによるトークン暗号化を実装"
Task 3: プリザンターAPIクライアント(pleasanterClient.js)
org-master-sync(apps/org-master-sync/src/lib/pleasanterClient.js)から、本アプリで使う関数のみ移植する(アプリ間は完全独立のためコピー、既存方針踏襲)。
Files:
- Create:
apps/lineworks-user-auth/src/lib/pleasanterClient.js - Test:
apps/lineworks-user-auth/test/pleasanterClient.test.js
Interfaces:
-
Produces:
getSiteItems({baseUrl, apiKey, siteId, fetchImpl}) -> Promise<Array>、createSiteItem({baseUrl, apiKey, siteId, fields, fetchImpl}) -> Promise<number>(新規ItemId)、updateSiteItem({baseUrl, apiKey, itemId, fields, fetchImpl}) -> Promise<void>、toHashPayload(fields) -> object -
Step 1: 失敗するテストを書く
apps/lineworks-user-auth/test/pleasanterClient.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { getSiteItems, createSiteItem, updateSiteItem, toHashPayload } = require("../src/lib/pleasanterClient");
test("toHashPayload nests flat Class*/Date*/Description* keys under their Hash, leaves others as-is", () => {
const payload = toHashPayload({ ClassA: "key1", Date001: "2026-08-24T00:00:00.000Z", Description001: "enc" });
assert.deepStrictEqual(payload, {
ClassHash: { ClassA: "key1" },
DateHash: { Date001: "2026-08-24T00:00:00.000Z" },
DescriptionHash: { Description001: "enc" },
});
});
test("getSiteItems pages through results using top-level Offset and flattens Hash keys", async () => {
let call = 0;
const fetchImpl = async (url, opts) => {
const body = JSON.parse(opts.body);
call++;
if (body.Offset === 0) {
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 1, ClassHash: { ClassA: "a" } }], TotalCount: 2 } }) };
}
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 2, ClassHash: { ClassA: "b" } }], TotalCount: 2 } }) };
};
const items = await getSiteItems({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl });
assert.strictEqual(call, 2);
assert.deepStrictEqual(items.map((i) => i.ClassA), ["a", "b"]);
});
test("createSiteItem sends fields nested under Hash keys and returns the new item Id", async () => {
let capturedBody;
const fetchImpl = async (url, opts) => { capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 42, StatusCode: 200 }) }; };
const id = await createSiteItem({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fields: { ClassA: "key1" }, fetchImpl });
assert.strictEqual(id, 42);
assert.deepStrictEqual(capturedBody.ClassHash, { ClassA: "key1" });
});
test("updateSiteItem posts Hash-nested fields to /api/items/{itemId}/update without siteId in the path", async () => {
let capturedUrl, capturedBody;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 7, StatusCode: 200 }) }; };
await updateSiteItem({ baseUrl: "https://example.test/", apiKey: "k", itemId: 7, fields: { Date001: "2026-08-24T00:00:00.000Z" }, fetchImpl });
assert.strictEqual(capturedUrl, "https://example.test/api/items/7/update");
assert.deepStrictEqual(capturedBody.DateHash, { Date001: "2026-08-24T00:00:00.000Z" });
});
test("throws when StatusCode is not 200", async () => {
const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 400, Message: "bad" }) });
await assert.rejects(() => getSiteItems({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl }));
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/pleasanterClient.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-user-auth/src/lib/pleasanterClient.js:
"use strict";
// Pleasanter Items APIは分類・日付・説明項目をフラットなキーでなく
// ClassHash/DateHash/DescriptionHashへネストして送る必要がある(org-master-syncで実機確認済み、
// apps/org-master-sync/src/lib/pleasanterClient.js参照)。本アプリ用に必要な関数のみ移植。
const HASH_PREFIXES = [
["Description", "DescriptionHash"],
["Class", "ClassHash"],
["Date", "DateHash"],
];
function toHashPayload(fields) {
const result = {};
for (const [key, value] of Object.entries(fields)) {
const entry = HASH_PREFIXES.find(([prefix]) => key.startsWith(prefix));
if (entry) {
const hashName = entry[1];
if (!result[hashName]) result[hashName] = {};
result[hashName][key] = value;
} else {
result[key] = value;
}
}
return result;
}
function buildUrl(baseUrl, pathname) {
return baseUrl.replace(/\/$/, "") + pathname;
}
async function callApi({ baseUrl, apiKey, pathname, body, fetchImpl = fetch }) {
const res = await fetchImpl(buildUrl(baseUrl, pathname), {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ ApiVersion: "1.1", ApiKey: apiKey, ...body }),
});
const data = await res.json();
if (!res.ok || data.StatusCode !== 200) {
throw new Error(`${pathname} failed: ${JSON.stringify(data)}`);
}
return data;
}
function flattenHashes(item) {
const { ClassHash, DateHash, DescriptionHash, ...rest } = item;
return { ...rest, ...ClassHash, ...DateHash, ...DescriptionHash };
}
async function getSiteItems({ baseUrl, apiKey, siteId, fetchImpl = fetch }) {
const items = [];
let offset = 0;
while (true) {
const data = await callApi({ baseUrl, apiKey, pathname: `/api/items/${siteId}/get`, body: { Offset: offset }, fetchImpl });
const page = data.Response.Data || [];
items.push(...page.map(flattenHashes));
if (page.length === 0 || items.length >= data.Response.TotalCount) break;
offset += page.length;
}
return items;
}
async function createSiteItem({ baseUrl, apiKey, siteId, fields, fetchImpl = fetch }) {
const data = await callApi({ baseUrl, apiKey, pathname: `/api/items/${siteId}/create`, body: toHashPayload(fields), fetchImpl });
return data.Id;
}
async function updateSiteItem({ baseUrl, apiKey, itemId, fields, fetchImpl = fetch }) {
await callApi({ baseUrl, apiKey, pathname: `/api/items/${itemId}/update`, body: toHashPayload(fields), fetchImpl });
}
module.exports = { getSiteItems, createSiteItem, updateSiteItem, toHashPayload };
- Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/pleasanterClient.test.js
Expected: PASS(5 tests)
- Step 5: Commit
git add apps/lineworks-user-auth/src/lib/pleasanterClient.js apps/lineworks-user-auth/test/pleasanterClient.test.js
git commit -m "feat(lineworks-user-auth): プリザンターAPIクライアントを移植"
Task 4: 物理列マッピングとトークンレコードストア(tokenRecordStore.js)
Files:
- Create:
apps/lineworks-user-auth/src/config/pleasanterColumns.js - Create:
apps/lineworks-user-auth/src/lib/tokenRecordStore.js - Test:
apps/lineworks-user-auth/test/tokenRecordStore.test.js
Interfaces:
-
Consumes:
getSiteItems/createSiteItem/updateSiteItem(Task 3)、encrypt/decrypt(Task 2) -
Produces:
findByKey({baseUrl, apiKey, siteId, encryptionKey, key, fetchImpl}) -> Promise<TokenRecord|null>upsert({baseUrl, apiKey, siteId, encryptionKey, key, account, scope, accessToken, refreshToken, expiresAt, fetchImpl, now}) -> Promise<void>touchLastUsed({baseUrl, apiKey, itemId, fetchImpl, now}) -> Promise<void>TokenRecordの形:{ itemId, key, account, scope, status, accessToken, refreshToken, expiresAt, refreshedAt, lastUsedAt }(accessToken/refreshTokenは復号済み平文)
-
Step 1: 物理列マッピング定数を書く
apps/lineworks-user-auth/src/config/pleasanterColumns.js:
"use strict";
// プリザンター「LINEWORKS連携トークン」テーブルの物理列マッピング。
// テーブル自体はユーザー側が下記構成で作成する(docs/superpowers/plans/2026-08-24-lineworks-user-auth.md「事前準備」参照)。
module.exports = {
KEY: "ClassA",
ACCOUNT: "Class001",
SCOPE: "Class002",
STATUS: "Class003",
ACCESS_TOKEN: "Description001",
REFRESH_TOKEN: "Description002",
EXPIRES_AT: "Date001",
REFRESHED_AT: "Date002",
LAST_USED_AT: "Date003",
};
- Step 2: 失敗するテストを書く
apps/lineworks-user-auth/test/tokenRecordStore.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { encrypt } = require("../src/lib/tokenCrypto");
const { findByKey, upsert, touchLastUsed } = require("../src/lib/tokenRecordStore");
const KEY_HEX = crypto.randomBytes(32).toString("hex");
const BASE = { baseUrl: "https://example.test/", apiKey: "k", siteId: 1, encryptionKey: KEY_HEX };
test("findByKey returns null when no item matches the 連携キー", async () => {
const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) });
const record = await findByKey({ ...BASE, key: "lineworks-form-sync", fetchImpl });
assert.strictEqual(record, null);
});
test("findByKey decrypts tokens and maps physical columns to a TokenRecord", async () => {
const fetchImpl = async () => ({
ok: true,
json: async () => ({
StatusCode: 200,
Response: {
Data: [{
ResultId: 5,
ClassHash: { ClassA: "lineworks-form-sync", Class001: "svc@example.co.jp", Class002: "form form.read", Class003: "" },
DescriptionHash: { Description001: encrypt("access-1", KEY_HEX), Description002: encrypt("refresh-1", KEY_HEX) },
DateHash: { Date001: "2026-08-24T01:00:00.000Z", Date002: "2026-08-24T00:00:00.000Z", Date003: "2026-08-24T00:30:00.000Z" },
}],
TotalCount: 1,
},
}),
});
const record = await findByKey({ ...BASE, key: "lineworks-form-sync", fetchImpl });
assert.strictEqual(record.itemId, 5);
assert.strictEqual(record.accessToken, "access-1");
assert.strictEqual(record.refreshToken, "refresh-1");
assert.strictEqual(record.expiresAt, "2026-08-24T01:00:00.000Z");
});
test("upsert creates a new item when no existing record for the key (encrypts tokens before sending)", async () => {
let getCall = 0;
let capturedCreateBody;
const fetchImpl = async (url, opts) => {
if (url.endsWith("/get")) {
getCall++;
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) };
}
if (url.endsWith("/create")) {
capturedCreateBody = JSON.parse(opts.body);
return { ok: true, json: async () => ({ Id: 9, StatusCode: 200 }) };
}
throw new Error(`unexpected url ${url}`);
};
await upsert({
...BASE, key: "lineworks-form-sync", account: "svc@example.co.jp", scope: "form form.read",
accessToken: "access-1", refreshToken: "refresh-1", expiresAt: "2026-08-24T01:00:00.000Z",
fetchImpl, now: () => new Date("2026-08-24T00:00:00.000Z"),
});
assert.strictEqual(getCall, 1);
assert.strictEqual(capturedCreateBody.ClassHash.ClassA, "lineworks-form-sync");
assert.notStrictEqual(capturedCreateBody.DescriptionHash.Description001, "access-1");
});
test("upsert updates the existing item when a record for the key already exists", async () => {
let capturedUrl, capturedBody;
const fetchImpl = async (url, opts) => {
if (url.endsWith("/get")) {
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 5, ClassHash: { ClassA: "lineworks-form-sync" }, DescriptionHash: {}, DateHash: {} }], TotalCount: 1 } }) };
}
capturedUrl = url;
capturedBody = JSON.parse(opts.body);
return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) };
};
await upsert({
...BASE, key: "lineworks-form-sync", account: "svc@example.co.jp", scope: "form form.read",
accessToken: "access-2", refreshToken: "refresh-2", expiresAt: "2026-08-24T02:00:00.000Z",
fetchImpl, now: () => new Date("2026-08-24T01:00:00.000Z"),
});
assert.strictEqual(capturedUrl, "https://example.test/api/items/5/update");
assert.strictEqual(capturedBody.DateHash.Date002, "2026-08-24T01:00:00.000Z");
});
test("touchLastUsed updates only the last-used-at column", async () => {
let capturedUrl, capturedBody;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) }; };
await touchLastUsed({ baseUrl: "https://example.test/", apiKey: "k", itemId: 5, fetchImpl, now: () => new Date("2026-08-24T03:00:00.000Z") });
assert.strictEqual(capturedUrl, "https://example.test/api/items/5/update");
assert.deepStrictEqual(capturedBody.DateHash, { Date003: "2026-08-24T03:00:00.000Z" });
});
- Step 3: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/tokenRecordStore.test.js
Expected: FAIL(モジュール未存在)
- Step 4: 実装
apps/lineworks-user-auth/src/lib/tokenRecordStore.js:
"use strict";
const COLS = require("../config/pleasanterColumns");
const { getSiteItems, createSiteItem, updateSiteItem } = require("./pleasanterClient");
const { encrypt, decrypt } = require("./tokenCrypto");
function toRecord(item, encryptionKey) {
return {
itemId: item.ResultId,
key: item[COLS.KEY],
account: item[COLS.ACCOUNT],
scope: item[COLS.SCOPE],
status: item[COLS.STATUS] || "",
accessToken: item[COLS.ACCESS_TOKEN] ? decrypt(item[COLS.ACCESS_TOKEN], encryptionKey) : null,
refreshToken: item[COLS.REFRESH_TOKEN] ? decrypt(item[COLS.REFRESH_TOKEN], encryptionKey) : null,
expiresAt: item[COLS.EXPIRES_AT] || null,
refreshedAt: item[COLS.REFRESHED_AT] || null,
lastUsedAt: item[COLS.LAST_USED_AT] || null,
};
}
async function findByKey({ baseUrl, apiKey, siteId, encryptionKey, key, fetchImpl = fetch }) {
const items = await getSiteItems({ baseUrl, apiKey, siteId, fetchImpl });
const match = items.find((item) => item[COLS.KEY] === key);
return match ? toRecord(match, encryptionKey) : null;
}
async function upsert({ baseUrl, apiKey, siteId, encryptionKey, key, account, scope, accessToken, refreshToken, expiresAt, fetchImpl = fetch, now = () => new Date() }) {
const items = await getSiteItems({ baseUrl, apiKey, siteId, fetchImpl });
const existing = items.find((item) => item[COLS.KEY] === key);
const fields = {
[COLS.KEY]: key,
[COLS.ACCOUNT]: account,
[COLS.SCOPE]: scope,
[COLS.ACCESS_TOKEN]: encrypt(accessToken, encryptionKey),
[COLS.REFRESH_TOKEN]: encrypt(refreshToken, encryptionKey),
[COLS.EXPIRES_AT]: expiresAt,
[COLS.REFRESHED_AT]: now().toISOString(),
};
if (existing) {
await updateSiteItem({ baseUrl, apiKey, itemId: existing.ResultId, fields, fetchImpl });
} else {
await createSiteItem({ baseUrl, apiKey, siteId, fields, fetchImpl });
}
}
async function touchLastUsed({ baseUrl, apiKey, itemId, fetchImpl = fetch, now = () => new Date() }) {
await updateSiteItem({ baseUrl, apiKey, itemId, fields: { [COLS.LAST_USED_AT]: now().toISOString() }, fetchImpl });
}
module.exports = { findByKey, upsert, touchLastUsed };
- Step 5: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/tokenRecordStore.test.js
Expected: PASS(5 tests)
- Step 6: 日時フォーマットの実機確認について注記
Date001等へ渡す日時文字列はtoISOString()形式(2026-08-24T01:00:00.000Z)を前提にしているが、プリザンターのDateTime列が実際にどの形式を受け付けるかは未検証。Task 12(事前準備完了後)で実際のテーブルに対して1回だけ疎通確認し、拒否される場合はここ(tokenRecordStore.jsのupsert/touchLastUsed)のフォーマット変換のみ調整する。何度も値を変えて試行錯誤しない(feedback_no_trial_and_error_debugging)。
- Step 7: Commit
git add apps/lineworks-user-auth/src/config/pleasanterColumns.js apps/lineworks-user-auth/src/lib/tokenRecordStore.js apps/lineworks-user-auth/test/tokenRecordStore.test.js
git commit -m "feat(lineworks-user-auth): トークンレコードストアを実装"
Task 5: CSRF対策state生成・検証(oauthState.js)
Files:
- Create:
apps/lineworks-user-auth/src/lib/oauthState.js - Test:
apps/lineworks-user-auth/test/oauthState.test.js
Interfaces:
-
Produces:
createState({key, account, secret, now}) -> string、verifyState({state, secret, maxAgeMs, now}) -> {key, account}|null -
Step 1: 失敗するテストを書く
apps/lineworks-user-auth/test/oauthState.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { createState, verifyState } = require("../src/lib/oauthState");
const SECRET = "test-secret";
test("createState then verifyState round-trips key and account", () => {
const now = () => new Date("2026-08-24T00:00:00.000Z").getTime();
const state = createState({ key: "lineworks-form-sync", account: "svc@example.co.jp", secret: SECRET, now });
const data = verifyState({ state, secret: SECRET, now });
assert.deepStrictEqual(data, { key: "lineworks-form-sync", account: "svc@example.co.jp" });
});
test("verifyState returns null when the signature does not match (tampered state)", () => {
const state = createState({ key: "lineworks-form-sync", account: "svc@example.co.jp", secret: SECRET });
const tampered = state.slice(0, -1) + (state.at(-1) === "0" ? "1" : "0");
assert.strictEqual(verifyState({ state: tampered, secret: SECRET }), null);
});
test("verifyState returns null when the state is older than maxAgeMs", () => {
const issuedAt = () => new Date("2026-08-24T00:00:00.000Z").getTime();
const state = createState({ key: "lineworks-form-sync", account: "svc@example.co.jp", secret: SECRET, now: issuedAt });
const later = () => issuedAt() + 11 * 60 * 1000;
assert.strictEqual(verifyState({ state, secret: SECRET, maxAgeMs: 10 * 60 * 1000, now: later }), null);
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/oauthState.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-user-auth/src/lib/oauthState.js:
"use strict";
const crypto = require("node:crypto");
function base64url(buf) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64urlDecode(str) {
return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
function sign(payload, secret) {
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}
function timingSafeEqualStrings(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
function createState({ key, account, secret, now = Date.now }) {
const payload = JSON.stringify({ key, account, ts: now(), nonce: crypto.randomBytes(8).toString("hex") });
const b64 = base64url(Buffer.from(payload, "utf8"));
return `${b64}.${sign(b64, secret)}`;
}
function verifyState({ state, secret, maxAgeMs = 10 * 60 * 1000, now = Date.now }) {
if (!state || typeof state !== "string") return null;
const parts = state.split(".");
if (parts.length !== 2) return null;
const [b64, signature] = parts;
if (!timingSafeEqualStrings(signature, sign(b64, secret))) return null;
let data;
try {
data = JSON.parse(base64urlDecode(b64).toString("utf8"));
} catch {
return null;
}
if (!Number.isFinite(data.ts) || now() - data.ts > maxAgeMs) return null;
return { key: data.key, account: data.account };
}
module.exports = { createState, verifyState };
- Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/oauthState.test.js
Expected: PASS(3 tests)
- Step 5: Commit
git add apps/lineworks-user-auth/src/lib/oauthState.js apps/lineworks-user-auth/test/oauthState.test.js
git commit -m "feat(lineworks-user-auth): OAuth state生成・検証(CSRF対策)を実装"
Task 6: LINEWORKS OAuthクライアント(lineworksOAuth.js)
Files:
- Create:
apps/lineworks-user-auth/src/lib/lineworksOAuth.js - Test:
apps/lineworks-user-auth/test/lineworksOAuth.test.js
Interfaces:
-
Produces:
buildAuthorizeUrl({clientId, redirectUri, scope, state}) -> stringexchangeCodeForTokens({clientId, clientSecret, redirectUri, code, fetchImpl}) -> Promise<{accessToken, refreshToken, expiresIn}>refreshAccessToken({clientId, clientSecret, refreshToken, fetchImpl}) -> Promise<{accessToken, refreshToken, expiresIn}>
-
Step 1: 失敗するテストを書く
apps/lineworks-user-auth/test/lineworksOAuth.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { buildAuthorizeUrl, exchangeCodeForTokens, refreshAccessToken } = require("../src/lib/lineworksOAuth");
test("buildAuthorizeUrl builds the LINEWORKS authorize URL with required query params", () => {
const url = new URL(buildAuthorizeUrl({
clientId: "cid", redirectUri: "https://lwauth29.next-hd.net/auth/callback", scope: "form form.read", state: "state-abc",
}));
assert.strictEqual(url.origin + url.pathname, "https://auth.worksmobile.com/oauth2/v2.0/authorize");
assert.strictEqual(url.searchParams.get("client_id"), "cid");
assert.strictEqual(url.searchParams.get("redirect_uri"), "https://lwauth29.next-hd.net/auth/callback");
assert.strictEqual(url.searchParams.get("scope"), "form form.read");
assert.strictEqual(url.searchParams.get("state"), "state-abc");
assert.strictEqual(url.searchParams.get("response_type"), "code");
});
test("exchangeCodeForTokens posts grant_type=authorization_code and returns camelCase token fields", async () => {
let capturedBody;
const fetchImpl = async (url, opts) => {
capturedBody = opts.body;
return { ok: true, json: async () => ({ access_token: "at-1", refresh_token: "rt-1", expires_in: 3600 }) };
};
const result = await exchangeCodeForTokens({ clientId: "cid", clientSecret: "secret", redirectUri: "https://x/callback", code: "code-1", fetchImpl });
assert.deepStrictEqual(result, { accessToken: "at-1", refreshToken: "rt-1", expiresIn: 3600 });
assert.ok(capturedBody.includes("grant_type=authorization_code"));
assert.ok(capturedBody.includes("code=code-1"));
});
test("refreshAccessToken posts grant_type=refresh_token and returns camelCase token fields", async () => {
let capturedBody;
const fetchImpl = async (url, opts) => {
capturedBody = opts.body;
return { ok: true, json: async () => ({ access_token: "at-2", refresh_token: "rt-2", expires_in: 3600 }) };
};
const result = await refreshAccessToken({ clientId: "cid", clientSecret: "secret", refreshToken: "rt-1", fetchImpl });
assert.deepStrictEqual(result, { accessToken: "at-2", refreshToken: "rt-2", expiresIn: 3600 });
assert.ok(capturedBody.includes("grant_type=refresh_token"));
assert.ok(capturedBody.includes("refresh_token=rt-1"));
});
test("exchangeCodeForTokens throws when the response is not ok", async () => {
const fetchImpl = async () => ({ ok: false, status: 400, json: async () => ({ error: "invalid_grant" }) });
await assert.rejects(() => exchangeCodeForTokens({ clientId: "cid", clientSecret: "secret", redirectUri: "https://x/callback", code: "bad", fetchImpl }));
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/lineworksOAuth.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-user-auth/src/lib/lineworksOAuth.js:
"use strict";
const AUTHORIZE_URL = "https://auth.worksmobile.com/oauth2/v2.0/authorize";
const TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
function buildAuthorizeUrl({ clientId, redirectUri, scope, state }) {
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", scope);
url.searchParams.set("state", state);
url.searchParams.set("response_type", "code");
return url.toString();
}
function toTokens(data) {
return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in };
}
async function postToken(params, fetchImpl) {
const res = await fetchImpl(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const data = await res.json();
if (!res.ok) {
throw new Error(`LINEWORKS token request failed: ${res.status} ${JSON.stringify(data)}`);
}
return toTokens(data);
}
async function exchangeCodeForTokens({ clientId, clientSecret, redirectUri, code, fetchImpl = fetch }) {
const params = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
});
return postToken(params, fetchImpl);
}
async function refreshAccessToken({ clientId, clientSecret, refreshToken, fetchImpl = fetch }) {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
});
return postToken(params, fetchImpl);
}
module.exports = { buildAuthorizeUrl, exchangeCodeForTokens, refreshAccessToken };
- Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/lineworksOAuth.test.js
Expected: PASS(4 tests)
- Step 5: Commit
git add apps/lineworks-user-auth/src/lib/lineworksOAuth.js apps/lineworks-user-auth/test/lineworksOAuth.test.js
git commit -m "feat(lineworks-user-auth): LINEWORKS OAuthクライアントを実装"
Task 7: 有効トークン取得ロジック(tokenService.js)
Files:
- Create:
apps/lineworks-user-auth/src/lib/tokenService.js - Test:
apps/lineworks-user-auth/test/tokenService.test.js
Interfaces:
-
Consumes:
findByKey/upsert/touchLastUsed(Task 4)、refreshAccessToken(Task 6) -
Produces:
getValidAccessToken({key, pleasanter, oauth, fetchImpl, now}) -> Promise<string>(有効なaccess tokenを返す。未登録キーはErrorをthrow)pleasanter引数の形:{baseUrl, apiKey, siteId, encryptionKey}oauth引数の形:{clientId, clientSecret}
-
Step 1: 失敗するテストを書く
apps/lineworks-user-auth/test/tokenService.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { encrypt } = require("../src/lib/tokenCrypto");
const { getValidAccessToken } = require("../src/lib/tokenService");
const KEY_HEX = crypto.randomBytes(32).toString("hex");
const PLEASANTER = { baseUrl: "https://example.test/", apiKey: "k", siteId: 1, encryptionKey: KEY_HEX };
const OAUTH = { clientId: "cid", clientSecret: "secret" };
function itemResponse({ expiresAt, accessToken = "access-current", refreshToken = "refresh-current" }) {
return {
ok: true,
json: async () => ({
StatusCode: 200,
Response: {
Data: [{
ResultId: 5,
ClassHash: { ClassA: "lineworks-form-sync", Class001: "svc@example.co.jp", Class002: "form form.read" },
DescriptionHash: { Description001: encrypt(accessToken, KEY_HEX), Description002: encrypt(refreshToken, KEY_HEX) },
DateHash: { Date001: expiresAt },
}],
TotalCount: 1,
},
}),
};
}
test("returns the stored access token as-is when it is still valid (no refresh call made)", async () => {
const now = () => new Date("2026-08-24T00:30:00.000Z");
let refreshCalled = false;
const fetchImpl = async (url) => {
if (url.endsWith("/get")) return itemResponse({ expiresAt: "2026-08-24T01:00:00.000Z" });
if (url.includes("auth.worksmobile.com")) { refreshCalled = true; }
if (url.endsWith("/update")) return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) };
throw new Error(`unexpected url ${url}`);
};
const token = await getValidAccessToken({ key: "lineworks-form-sync", pleasanter: PLEASANTER, oauth: OAUTH, fetchImpl, now });
assert.strictEqual(token, "access-current");
assert.strictEqual(refreshCalled, false);
});
test("refreshes and persists a new token when the stored one has expired", async () => {
const now = () => new Date("2026-08-24T02:00:00.000Z");
let updateBody;
const fetchImpl = async (url, opts) => {
if (url.endsWith("/get")) return itemResponse({ expiresAt: "2026-08-24T01:00:00.000Z" });
if (url.includes("auth.worksmobile.com")) {
return { ok: true, json: async () => ({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 }) };
}
if (url.endsWith("/update")) { updateBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) }; }
throw new Error(`unexpected url ${url}`);
};
const token = await getValidAccessToken({ key: "lineworks-form-sync", pleasanter: PLEASANTER, oauth: OAUTH, fetchImpl, now });
assert.strictEqual(token, "access-new");
assert.strictEqual(updateBody.DateHash.Date001, "2026-08-24T03:00:00.000Z");
});
test("throws a descriptive error when the key is not registered", async () => {
const fetchImpl = async (url) => {
if (url.endsWith("/get")) return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) };
throw new Error(`unexpected url ${url}`);
};
await assert.rejects(
() => getValidAccessToken({ key: "unknown-key", pleasanter: PLEASANTER, oauth: OAUTH, fetchImpl, now: () => new Date() }),
/unknown-key/
);
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/tokenService.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-user-auth/src/lib/tokenService.js:
"use strict";
const tokenRecordStore = require("./tokenRecordStore");
const { refreshAccessToken } = require("./lineworksOAuth");
async function getValidAccessToken({ key, pleasanter, oauth, fetchImpl = fetch, now = () => new Date() }) {
const record = await tokenRecordStore.findByKey({ ...pleasanter, key, fetchImpl });
if (!record) {
throw new Error(`連携キー ${key} は未登録です`);
}
const isValid = record.expiresAt && new Date(record.expiresAt).getTime() > now().getTime();
if (isValid) {
await tokenRecordStore.touchLastUsed({ ...pleasanter, itemId: record.itemId, fetchImpl, now });
return record.accessToken;
}
const refreshed = await refreshAccessToken({
clientId: oauth.clientId,
clientSecret: oauth.clientSecret,
refreshToken: record.refreshToken,
fetchImpl,
});
const expiresAt = new Date(now().getTime() + refreshed.expiresIn * 1000).toISOString();
await tokenRecordStore.upsert({
...pleasanter,
key: record.key,
account: record.account,
scope: record.scope,
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || record.refreshToken,
expiresAt,
fetchImpl,
now,
});
return refreshed.accessToken;
}
module.exports = { getValidAccessToken };
- Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/tokenService.test.js
Expected: PASS(3 tests)
- Step 5: Commit
git add apps/lineworks-user-auth/src/lib/tokenService.js apps/lineworks-user-auth/test/tokenService.test.js
git commit -m "feat(lineworks-user-auth): 有効トークン取得ロジック(自動リフレッシュ込み)を実装"
Task 8: 消費側実行キー検証(executionKeys.js)
Files:
- Create:
apps/lineworks-user-auth/src/lib/executionKeys.js - Test:
apps/lineworks-user-auth/test/executionKeys.test.js
Interfaces:
-
Produces:
verifyExecutionKey({key, providedKey, configJson}) -> boolean(configJsonは.envのCONSUMER_EXECUTION_KEYSの生文字列、{"連携キー": "実行キー"}形式) -
Step 1: 失敗するテストを書く
apps/lineworks-user-auth/test/executionKeys.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { verifyExecutionKey } = require("../src/lib/executionKeys");
const CONFIG = JSON.stringify({ "lineworks-form-sync": "exec-key-abc" });
test("returns true when the provided key matches the configured key for that 連携キー", () => {
assert.strictEqual(verifyExecutionKey({ key: "lineworks-form-sync", providedKey: "exec-key-abc", configJson: CONFIG }), true);
});
test("returns false when the provided key does not match", () => {
assert.strictEqual(verifyExecutionKey({ key: "lineworks-form-sync", providedKey: "wrong", configJson: CONFIG }), false);
});
test("returns false when the 連携キー is not present in the config", () => {
assert.strictEqual(verifyExecutionKey({ key: "unknown-key", providedKey: "exec-key-abc", configJson: CONFIG }), false);
});
test("returns false when providedKey is missing", () => {
assert.strictEqual(verifyExecutionKey({ key: "lineworks-form-sync", providedKey: undefined, configJson: CONFIG }), false);
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/executionKeys.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-user-auth/src/lib/executionKeys.js:
"use strict";
const crypto = require("node:crypto");
function timingSafeEqualStrings(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
function verifyExecutionKey({ key, providedKey, configJson }) {
if (!providedKey) return false;
let config;
try {
config = JSON.parse(configJson || "{}");
} catch {
return false;
}
const expected = config[key];
if (!expected) return false;
return timingSafeEqualStrings(providedKey, expected);
}
module.exports = { verifyExecutionKey };
- Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/executionKeys.test.js
Expected: PASS(4 tests)
- Step 5: Commit
git add apps/lineworks-user-auth/src/lib/executionKeys.js apps/lineworks-user-auth/test/executionKeys.test.js
git commit -m "feat(lineworks-user-auth): 消費側実行キー検証を実装"
Task 9: 管理UI認証(adminAuth.js)とHTML(adminView.js)
apps/app-portal/src/adminAuth.jsを移植(アプリ間独立のためコピー)。Cookie Pathを/adminから/authへ変更する。
Files:
- Create:
apps/lineworks-user-auth/src/adminAuth.js - Create:
apps/lineworks-user-auth/src/adminView.js - Test:
apps/lineworks-user-auth/test/adminAuth.test.js - Test:
apps/lineworks-user-auth/test/adminView.test.js
Interfaces:
-
Produces(
adminAuth.js):verifyMasterKey(input, masterKey)、createSessionToken(secret, now)、verifySessionToken(token, secret, now)、parseCookies(header)、buildSessionCookieHeader(token)、buildLogoutCookieHeader()、SESSION_COOKIE_NAME -
Produces(
adminView.js):renderLoginPage(errorMessage) -> string(HTML)、renderDashboardPage({records, message}) -> string(HTML、recordsは{key, account, status, expiresAt}[]) -
Step 1: 失敗するテストを書く(adminAuth)
apps/lineworks-user-auth/test/adminAuth.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { verifyMasterKey, createSessionToken, verifySessionToken, buildSessionCookieHeader, parseCookies, SESSION_COOKIE_NAME } = require("../src/adminAuth");
test("verifyMasterKey returns true only for the exact configured key", () => {
assert.strictEqual(verifyMasterKey("correct", "correct"), true);
assert.strictEqual(verifyMasterKey("wrong", "correct"), false);
});
test("createSessionToken then verifySessionToken succeeds within TTL", () => {
const now = new Date("2026-08-24T00:00:00.000Z").getTime();
const token = createSessionToken("secret", now);
assert.strictEqual(verifySessionToken(token, "secret", now + 1000), true);
});
test("verifySessionToken fails after the session TTL has elapsed", () => {
const now = new Date("2026-08-24T00:00:00.000Z").getTime();
const token = createSessionToken("secret", now);
assert.strictEqual(verifySessionToken(token, "secret", now + 25 * 60 * 60 * 1000), false);
});
test("buildSessionCookieHeader scopes the cookie to /auth", () => {
const header = buildSessionCookieHeader("tok");
assert.ok(header.includes("Path=/auth"));
assert.ok(header.startsWith(`${SESSION_COOKIE_NAME}=`));
});
test("parseCookies parses a raw Cookie header into a key-value map", () => {
assert.deepStrictEqual(parseCookies(`${SESSION_COOKIE_NAME}=abc; other=1`), { [SESSION_COOKIE_NAME]: "abc", other: "1" });
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/adminAuth.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装(adminAuth.js、app-portalから移植・Path変更)
apps/lineworks-user-auth/src/adminAuth.js:
"use strict";
const crypto = require("node:crypto");
const SESSION_COOKIE_NAME = "lwauth_admin_session";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
function timingSafeEqualStrings(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
function verifyMasterKey(input, masterKey) {
if (!masterKey || !input) return false;
return timingSafeEqualStrings(input, masterKey);
}
function sign(payload, secret) {
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}
function createSessionToken(secret, now = Date.now()) {
const payload = String(now);
return `${payload}.${sign(payload, secret)}`;
}
function verifySessionToken(token, secret, now = Date.now()) {
if (!token || typeof token !== "string") return false;
const parts = token.split(".");
if (parts.length !== 2) return false;
const [payload, signature] = parts;
if (!timingSafeEqualStrings(signature, sign(payload, secret))) return false;
const issuedAt = Number(payload);
if (!Number.isFinite(issuedAt)) return false;
return now - issuedAt < SESSION_TTL_MS;
}
function parseCookies(cookieHeader) {
const result = {};
if (!cookieHeader) return result;
cookieHeader.split(";").forEach((pair) => {
const idx = pair.indexOf("=");
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) result[key] = decodeURIComponent(value);
});
return result;
}
function buildSessionCookieHeader(token) {
return [
`${SESSION_COOKIE_NAME}=${encodeURIComponent(token)}`,
"HttpOnly",
"Secure",
"Path=/auth",
"SameSite=Strict",
`Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
].join("; ");
}
function buildLogoutCookieHeader() {
return [`${SESSION_COOKIE_NAME}=`, "HttpOnly", "Secure", "Path=/auth", "SameSite=Strict", "Max-Age=0"].join("; ");
}
module.exports = {
SESSION_COOKIE_NAME,
SESSION_TTL_MS,
verifyMasterKey,
createSessionToken,
verifySessionToken,
parseCookies,
buildSessionCookieHeader,
buildLogoutCookieHeader,
};
- Step 4: テスト成功を確認(adminAuth)
cd apps/lineworks-user-auth && node --test test/adminAuth.test.js
Expected: PASS(5 tests)
- Step 5: 失敗するテストを書く(adminView)
apps/lineworks-user-auth/test/adminView.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { renderLoginPage, renderDashboardPage } = require("../src/adminView");
test("renderLoginPage includes a master key input form", () => {
const html = renderLoginPage();
assert.ok(html.includes('name="masterKey"'));
assert.ok(html.includes("<form"));
});
test("renderLoginPage includes the given error message when provided", () => {
const html = renderLoginPage("Master Keyが正しくありません");
assert.ok(html.includes("Master Keyが正しくありません"));
});
test("renderDashboardPage lists each record's key, account, and status", () => {
const html = renderDashboardPage({
records: [{ key: "lineworks-form-sync", account: "svc@example.co.jp", status: "有効", expiresAt: "2026-08-24T01:00:00.000Z" }],
message: null,
});
assert.ok(html.includes("lineworks-form-sync"));
assert.ok(html.includes("svc@example.co.jp"));
assert.ok(html.includes("有効"));
});
test("renderDashboardPage includes a new-registration form with key and account fields", () => {
const html = renderDashboardPage({ records: [], message: null });
assert.ok(html.includes('name="key"'));
assert.ok(html.includes('name="account"'));
});
- Step 6: テスト失敗を確認(adminView)
cd apps/lineworks-user-auth && node --test test/adminView.test.js
Expected: FAIL(モジュール未存在)
- Step 7: 実装(adminView.js)
apps/lineworks-user-auth/src/adminView.js:
"use strict";
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function renderLoginPage(errorMessage) {
return `<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><title>lineworks-user-auth ログイン</title></head>
<body>
<h1>lineworks-user-auth</h1>
${errorMessage ? `<p style="color:red">${escapeHtml(errorMessage)}</p>` : ""}
<form method="post" action="/auth/login">
<label>Master Key: <input type="password" name="masterKey" /></label>
<button type="submit">ログイン</button>
</form>
</body></html>`;
}
function renderDashboardPage({ records, message }) {
const rows = records
.map(
(r) => `<tr><td>${escapeHtml(r.key)}</td><td>${escapeHtml(r.account)}</td><td>${escapeHtml(r.status)}</td><td>${escapeHtml(r.expiresAt)}</td>
<td><form method="get" action="/auth/start" style="display:inline"><input type="hidden" name="key" value="${escapeHtml(r.key)}" /><input type="hidden" name="account" value="${escapeHtml(r.account)}" /><button type="submit">再認可</button></form></td></tr>`
)
.join("");
return `<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><title>lineworks-user-auth</title></head>
<body>
<h1>LINEWORKS User Account 連携キー一覧</h1>
${message ? `<p>${escapeHtml(message)}</p>` : ""}
<table border="1"><thead><tr><th>連携キー</th><th>対象アカウント</th><th>ステータス</th><th>有効期限</th><th></th></tr></thead>
<tbody>${rows}</tbody></table>
<h2>新規登録</h2>
<form method="get" action="/auth/start">
<label>連携キー: <input type="text" name="key" required /></label>
<label>対象LINEWORKSアカウント: <input type="text" name="account" required /></label>
<button type="submit">LINEWORKSでログインして登録</button>
</form>
<form method="post" action="/auth/logout"><button type="submit">ログアウト</button></form>
</body></html>`;
}
module.exports = { renderLoginPage, renderDashboardPage };
- Step 8: テスト成功を確認(adminView)
cd apps/lineworks-user-auth && node --test test/adminView.test.js
Expected: PASS(4 tests)
- Step 9: Commit
git add apps/lineworks-user-auth/src/adminAuth.js apps/lineworks-user-auth/src/adminView.js apps/lineworks-user-auth/test/adminAuth.test.js apps/lineworks-user-auth/test/adminView.test.js
git commit -m "feat(lineworks-user-auth): 管理UI認証とHTML画面を実装"
Task 10: ルーティング統合(src/index.js)
Files:
- Modify:
apps/lineworks-user-auth/src/index.js(Task 1で作成した雛形を全面書き換え)
Interfaces:
- Consumes: 全ライブラリ(Task 2〜9)
- Produces:
GET /health、GET /token、GET /auth/login・POST /auth/login・POST /auth/logout・GET /auth・GET /auth/start・GET /auth/callback
このタスクはExpressルーティングの配線が中心で、既存コードベース(apps/app-portal/src/index.js)同様にユニットテストは設けず、Task 11でのローカルDocker起動時に手動疎通確認する(app-portalの慣習を踏襲)。
- Step 1: 実装
apps/lineworks-user-auth/src/index.js:
"use strict";
const express = require("express");
const {
verifyMasterKey,
createSessionToken,
verifySessionToken,
parseCookies,
buildSessionCookieHeader,
buildLogoutCookieHeader,
SESSION_COOKIE_NAME,
} = require("./adminAuth");
const { renderLoginPage, renderDashboardPage } = require("./adminView");
const { verifyExecutionKey } = require("./lib/executionKeys");
const { getValidAccessToken } = require("./lib/tokenService");
const { findByKey, upsert } = require("./lib/tokenRecordStore");
const { buildAuthorizeUrl, exchangeCodeForTokens } = require("./lib/lineworksOAuth");
const { createState, verifyState } = require("./lib/oauthState");
const { getSiteItems } = require("./lib/pleasanterClient");
const COLS = require("./config/pleasanterColumns");
const app = express();
const PORT = process.env.PORT || 3000;
const MASTER_KEY = process.env.AUTH_MASTER_KEY;
const CONSUMER_EXECUTION_KEYS = process.env.CONSUMER_EXECUTION_KEYS || "{}";
const PLEASANTER = {
baseUrl: process.env.PLEASANTER_BASE_URL,
apiKey: process.env.PLEASANTER_API_KEY,
siteId: Number(process.env.PLEASANTER_TOKEN_SITE_ID),
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
};
const OAUTH_APP = {
clientId: process.env.LW_OAUTH_CLIENT_ID,
clientSecret: process.env.LW_OAUTH_CLIENT_SECRET,
redirectUri: process.env.LW_OAUTH_REDIRECT_URI,
scope: process.env.LW_OAUTH_SCOPE,
};
app.use(express.urlencoded({ extended: false }));
app.get("/health", (req, res) => {
res.status(200).json({ status: "healthy" });
});
// ---- 消費側システム向けトークン提供API ----
app.get("/token", async (req, res) => {
const key = req.query.key;
const providedKey = req.header("X-Execution-Key");
if (!key || !verifyExecutionKey({ key, providedKey, configJson: CONSUMER_EXECUTION_KEYS })) {
res.sendStatus(401);
return;
}
try {
const accessToken = await getValidAccessToken({ key, pleasanter: PLEASANTER, oauth: OAUTH_APP });
res.json({ accessToken });
} catch (err) {
console.error("token fetch failed", err.message);
res.status(502).json({ error: err.message });
}
});
// ---- 管理UI(社内限定) ----
function requireAdminSession(req, res, next) {
const cookies = parseCookies(req.headers.cookie);
if (!verifySessionToken(cookies[SESSION_COOKIE_NAME], MASTER_KEY)) {
res.redirect("/auth/login");
return;
}
next();
}
app.get("/auth/login", (req, res) => {
res.set("Content-Type", "text/html; charset=utf-8").send(renderLoginPage());
});
app.post("/auth/login", (req, res) => {
if (!verifyMasterKey(req.body.masterKey, MASTER_KEY)) {
res.set("Content-Type", "text/html; charset=utf-8").send(renderLoginPage("Master Keyが正しくありません"));
return;
}
res.set("Set-Cookie", buildSessionCookieHeader(createSessionToken(MASTER_KEY)));
res.redirect("/auth");
});
app.post("/auth/logout", (req, res) => {
res.set("Set-Cookie", buildLogoutCookieHeader());
res.redirect("/auth/login");
});
app.get("/auth", requireAdminSession, async (req, res) => {
try {
const items = await getSiteItems({ baseUrl: PLEASANTER.baseUrl, apiKey: PLEASANTER.apiKey, siteId: PLEASANTER.siteId });
const records = items.map((item) => ({
key: item[COLS.KEY],
account: item[COLS.ACCOUNT],
status: item[COLS.EXPIRES_AT] && new Date(item[COLS.EXPIRES_AT]).getTime() > Date.now() ? "有効" : "要再認可",
expiresAt: item[COLS.EXPIRES_AT],
}));
res.set("Content-Type", "text/html; charset=utf-8").send(renderDashboardPage({ records, message: null }));
} catch (err) {
console.error("dashboard render failed", err.message);
res.status(500).send("一覧取得に失敗しました");
}
});
app.get("/auth/start", requireAdminSession, (req, res) => {
const { key, account } = req.query;
if (!key || !account) {
res.status(400).send("連携キーと対象アカウントを指定してください");
return;
}
const state = createState({ key, account, secret: MASTER_KEY });
res.redirect(buildAuthorizeUrl({ clientId: OAUTH_APP.clientId, redirectUri: OAUTH_APP.redirectUri, scope: OAUTH_APP.scope, state }));
});
app.get("/auth/callback", requireAdminSession, async (req, res) => {
const data = verifyState({ state: req.query.state, secret: MASTER_KEY });
if (!data) {
res.status(400).send("認可リクエストが無効です(期限切れまたは改ざん)。最初からやり直してください");
return;
}
try {
const tokens = await exchangeCodeForTokens({
clientId: OAUTH_APP.clientId,
clientSecret: OAUTH_APP.clientSecret,
redirectUri: OAUTH_APP.redirectUri,
code: req.query.code,
});
const expiresAt = new Date(Date.now() + tokens.expiresIn * 1000).toISOString();
await upsert({
...PLEASANTER,
key: data.key,
account: data.account,
scope: OAUTH_APP.scope,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt,
});
res.redirect("/auth");
} catch (err) {
console.error("oauth callback failed", err.message);
res.status(502).send("認可コードの交換に失敗しました");
}
});
app.listen(PORT, () => {
console.log(`lineworks-user-auth listening on port ${PORT}`);
});
- Step 2: Commit
git add apps/lineworks-user-auth/src/index.js
git commit -m "feat(lineworks-user-auth): ルーティングを統合(/health /token /auth/*)"
Task 11: ローカルDocker動作確認
Files: なし(既存ファイルの動作確認のみ)
- Step 1:
.envを用意(ダミー値でよい、実際の外部API呼び出しはしない範囲で確認)
cd apps/lineworks-user-auth
cp .env.example .env
.envのAUTH_MASTER_KEYとTOKEN_ENCRYPTION_KEYだけ実値を入れる(他はダミーで可):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
出力された値をTOKEN_ENCRYPTION_KEYへ、任意の文字列をAUTH_MASTER_KEYへ設定。
- Step 2: Dockerでビルド・起動
docker compose -f docker-compose.local.yml up --build
- Step 3:
/health確認
別ターミナルで:
curl -s http://localhost:3000/health
Expected: {"status":"healthy"}
- Step 4:
/auth/login画面確認
curl -s http://localhost:3000/auth/login | grep masterKey
Expected: name="masterKey"を含むHTML断片が出力される
- Step 5:
/tokenが未設定実行キーで401を返すことを確認
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/token?key=lineworks-form-sync"
Expected: 401
- Step 6: Dockerコンテナを停止
docker compose -f docker-compose.local.yml down
- Step 7: 全テストを通しで実行
cd apps/lineworks-user-auth && node --test
Expected: 全テストPASS(tokenCrypto/pleasanterClient/tokenRecordStore/oauthState/lineworksOAuth/tokenService/executionKeys/adminAuth/adminView、計29テスト)
このタスクはコード変更を伴わないため、commitは不要。
Task 12: 事前準備の実施とプリザンター疎通確認
このタスクはコード実装ではなく、ユーザー側での準備作業と、実データに対する最小限の疎通確認。
-
Step 1: LINEWORKS Developer ConsoleでOAuthアプリ登録(冒頭「事前準備」1.参照)、client_id/client_secretを控える
-
Step 2: DNS登録(冒頭「事前準備」2.参照)、
lwauth29.next-hd.net→52.193.142.134 -
Step 3: プリザンターに「LINEWORKS連携トークン」テーブルを作成(冒頭「事前準備」3.の物理列構成で)、SiteId・APIキーを控える
-
Step 4:
.envへ本番相当の値を設定し、upsertが実際にItemを作成できることを1回だけ確認する(feedback_no_trial_and_error_debugging、何度も値を変えて試さない)
cd apps/lineworks-user-auth
node --env-file=.env -e "
const { upsert, findByKey } = require('./src/lib/tokenRecordStore');
(async () => {
await upsert({
baseUrl: process.env.PLEASANTER_BASE_URL,
apiKey: process.env.PLEASANTER_API_KEY,
siteId: Number(process.env.PLEASANTER_TOKEN_SITE_ID),
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
key: 'smoke-test',
account: 'smoke-test@example.co.jp',
scope: 'form form.read',
accessToken: 'smoke-access',
refreshToken: 'smoke-refresh',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
});
const record = await findByKey({
baseUrl: process.env.PLEASANTER_BASE_URL,
apiKey: process.env.PLEASANTER_API_KEY,
siteId: Number(process.env.PLEASANTER_TOKEN_SITE_ID),
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
key: 'smoke-test',
});
console.log('roundtrip ok:', record.accessToken === 'smoke-access');
})();
"
Expected: roundtrip ok: true
日時フォーマットが拒否される場合(「JSONデータが不正です」等のエラー)は、この1回の実機応答を見てtokenRecordStore.jsの日時変換のみ修正し、再度この確認を1回行う(闇雲な試行錯誤はしない)。
-
Step 5: 疎通確認用に作った
smoke-testレコードをプリザンター側で削除(手動) -
Step 6:
CONSUMER_EXECUTION_KEYS・LW_OAUTH_CLIENT_ID等、本番用の値を.env(Dokploy側は後述Task 13で環境変数として設定)へ反映
Task 13: Dokployへのデプロイ
.claude/skills/dokploy-webapp/SKILL.mdの手順に従う。本番操作のため、各コマンド実行前に必ずユーザーへ内容を提示し確認を取る。
- Step 1: Gitea remoteへpush
git push gitea main
- Step 2: Dokploy Compose作成(ユーザー確認後に実行)
dokploy compose create \
--name "lineworks-user-auth" \
--environmentId "Cm0HjMIFyl11UdIcIGRy8" \
--composeType "docker-compose" \
--appName "lineworks-user-auth" \
--json
- Step 3: Gitea連携へ切替(ユーザー確認後に実行、
<composeId>はStep 2の結果)
dokploy compose update \
--composeId "<composeId>" \
--sourceType "gitea" \
--giteaId "O5-CqLQwVdlzXw3KfmN-8" \
--giteaOwner "mygit-admin" \
--giteaRepository "NodeSrv" \
--giteaBranch "main" \
--composePath "apps/lineworks-user-auth/docker-compose.yml" \
--json
- Step 4:
docker-compose.yml(本番用、Traefikラベル付き)を作成
apps/lineworks-user-auth/docker-compose.yml:
services:
lineworks-user-auth:
build: .
expose:
- 3000
env_file:
- .env
networks:
- dokploy-network
labels:
- traefik.enable=true
- traefik.http.routers.lineworks-user-auth-web.rule=Host(`lwauth29.next-hd.net`)
- traefik.http.routers.lineworks-user-auth-web.entrypoints=web
- traefik.http.routers.lineworks-user-auth-web.middlewares=redirect-to-https
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
- traefik.http.routers.lineworks-user-auth-web.service=lineworks-user-auth-svc
- traefik.http.routers.lineworks-user-auth-websecure.rule=Host(`lwauth29.next-hd.net`)
- traefik.http.routers.lineworks-user-auth-websecure.entrypoints=websecure
- traefik.http.routers.lineworks-user-auth-websecure.tls.certresolver=letsencrypt
- traefik.http.routers.lineworks-user-auth-websecure.service=lineworks-user-auth-svc
- traefik.http.services.lineworks-user-auth-svc.loadbalancer.server.port=3000
restart: unless-stopped
networks:
dokploy-network:
external: true
commit:
git add apps/lineworks-user-auth/docker-compose.yml
git commit -m "feat(lineworks-user-auth): 本番用docker-compose.ymlを追加"
git push gitea main
-
Step 5: Dokploy上で環境変数(.env相当)を設定(Dokploy管理画面、またはCLIで。実際の
LW_OAUTH_CLIENT_ID等の秘密値をユーザーに確認しながら設定) -
Step 6: デプロイ実行(ユーザー確認後に実行、
<composeId>はStep 2の結果)
dokploy compose deploy --composeId "<composeId>" --title "初回デプロイ" --json
- Step 7: 外部疎通確認
curl -I https://lwauth29.next-hd.net/health
Expected: HTTP/2 200
- Step 8: SSH経由でコンテナ状態確認
ssh -i Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
"sudo docker ps --filter name=lineworks-user-auth"
完了後
このプランの実装が完了したら、[project_lineworks_form_pleasanter_sync]側の実装計画を別途作成する。そちらは本アプリのGET /token?key=lineworks-form-syncを呼ぶだけで済み、OAuth関連のコードは持たない。