ken_nogi/NodeSrv/docs/superpowers/plans/2026-08-24-lineworks-user-auth.md
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

1774 lines
66 KiB
Markdown

# 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]])
---
## 事前準備(ユーザー側、コード実装と並行して進めてよい)
実装タスクではないが、後続タスクの前提になるため明記する。
1. **LINEWORKS Developer Console**でUser Account OAuth用アプリを新規作成
- リダイレクトURI: `https://lwauth29.next-hd.net/auth/callback`
- 発行されたclient_id/client_secretは`.env`へ(後述Task 1)
2. **DNS**: `lwauth29.next-hd.net``52.193.142.134`(Dokploy Node A、実機確認済み)へAレコード登録
3. **プリザンター**: 「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`へコピー**
```bash
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`:
```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`確認**
```bash
cd apps/lineworks-user-auth
npm install
cp .env.example .env
npm run dev
```
別ターミナルで:
```bash
curl -s http://localhost:3000/health
```
Expected: `{"status":"healthy"}`
確認後、devサーバーを停止する。
- [ ] **Step 6: Commit**
```bash
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`:
```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: テスト失敗を確認**
```bash
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`:
```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: テスト成功を確認**
```bash
cd apps/lineworks-user-auth && node --test test/tokenCrypto.test.js
```
Expected: PASS(3 tests)
- [ ] **Step 5: Commit**
```bash
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`:
```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: テスト失敗を確認**
```bash
cd apps/lineworks-user-auth && node --test test/pleasanterClient.test.js
```
Expected: FAIL(モジュール未存在)
- [ ] **Step 3: 実装**
`apps/lineworks-user-auth/src/lib/pleasanterClient.js`:
```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: テスト成功を確認**
```bash
cd apps/lineworks-user-auth && node --test test/pleasanterClient.test.js
```
Expected: PASS(5 tests)
- [ ] **Step 5: Commit**
```bash
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`:
```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`:
```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: テスト失敗を確認**
```bash
cd apps/lineworks-user-auth && node --test test/tokenRecordStore.test.js
```
Expected: FAIL(モジュール未存在)
- [ ] **Step 4: 実装**
`apps/lineworks-user-auth/src/lib/tokenRecordStore.js`:
```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: テスト成功を確認**
```bash
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**
```bash
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`:
```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: テスト失敗を確認**
```bash
cd apps/lineworks-user-auth && node --test test/oauthState.test.js
```
Expected: FAIL(モジュール未存在)
- [ ] **Step 3: 実装**
`apps/lineworks-user-auth/src/lib/oauthState.js`:
```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: テスト成功を確認**
```bash
cd apps/lineworks-user-auth && node --test test/oauthState.test.js
```
Expected: PASS(3 tests)
- [ ] **Step 5: Commit**
```bash
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}) -> string`
- `exchangeCodeForTokens({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`:
```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: テスト失敗を確認**
```bash
cd apps/lineworks-user-auth && node --test test/lineworksOAuth.test.js
```
Expected: FAIL(モジュール未存在)
- [ ] **Step 3: 実装**
`apps/lineworks-user-auth/src/lib/lineworksOAuth.js`:
```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: テスト成功を確認**
```bash
cd apps/lineworks-user-auth && node --test test/lineworksOAuth.test.js
```
Expected: PASS(4 tests)
- [ ] **Step 5: Commit**
```bash
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`:
```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: テスト失敗を確認**
```bash
cd apps/lineworks-user-auth && node --test test/tokenService.test.js
```
Expected: FAIL(モジュール未存在)
- [ ] **Step 3: 実装**
`apps/lineworks-user-auth/src/lib/tokenService.js`:
```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: テスト成功を確認**
```bash
cd apps/lineworks-user-auth && node --test test/tokenService.test.js
```
Expected: PASS(3 tests)
- [ ] **Step 5: Commit**
```bash
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`:
```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: テスト失敗を確認**
```bash
cd apps/lineworks-user-auth && node --test test/executionKeys.test.js
```
Expected: FAIL(モジュール未存在)
- [ ] **Step 3: 実装**
`apps/lineworks-user-auth/src/lib/executionKeys.js`:
```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: テスト成功を確認**
```bash
cd apps/lineworks-user-auth && node --test test/executionKeys.test.js
```
Expected: PASS(4 tests)
- [ ] **Step 5: Commit**
```bash
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`:
```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: テスト失敗を確認**
```bash
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`:
```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)**
```bash
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`:
```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)**
```bash
cd apps/lineworks-user-auth && node --test test/adminView.test.js
```
Expected: FAIL(モジュール未存在)
- [ ] **Step 7: 実装(adminView.js)**
`apps/lineworks-user-auth/src/adminView.js`:
```js
"use strict";
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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)**
```bash
cd apps/lineworks-user-auth && node --test test/adminView.test.js
```
Expected: PASS(4 tests)
- [ ] **Step 9: Commit**
```bash
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`:
```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**
```bash
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呼び出しはしない範囲で確認)**
```bash
cd apps/lineworks-user-auth
cp .env.example .env
```
`.env`の`AUTH_MASTER_KEY`と`TOKEN_ENCRYPTION_KEY`だけ実値を入れる(他はダミーで可):
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
出力された値を`TOKEN_ENCRYPTION_KEY`へ、任意の文字列を`AUTH_MASTER_KEY`へ設定。
- [ ] **Step 2: Dockerでビルド・起動**
```bash
docker compose -f docker-compose.local.yml up --build
```
- [ ] **Step 3: `/health`確認**
別ターミナルで:
```bash
curl -s http://localhost:3000/health
```
Expected: `{"status":"healthy"}`
- [ ] **Step 4: `/auth/login`画面確認**
```bash
curl -s http://localhost:3000/auth/login | grep masterKey
```
Expected: `name="masterKey"`を含むHTML断片が出力される
- [ ] **Step 5: `/token`が未設定実行キーで401を返すことを確認**
```bash
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/token?key=lineworks-form-sync"
```
Expected: `401`
- [ ] **Step 6: Dockerコンテナを停止**
```bash
docker compose -f docker-compose.local.yml down
```
- [ ] **Step 7: 全テストを通しで実行**
```bash
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]]、何度も値を変えて試さない)
```bash
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**
```bash
git push gitea main
```
- [ ] **Step 2: Dokploy Compose作成**(ユーザー確認後に実行)
```bash
dokploy compose create \
--name "lineworks-user-auth" \
--environmentId "Cm0HjMIFyl11UdIcIGRy8" \
--composeType "docker-compose" \
--appName "lineworks-user-auth" \
--json
```
- [ ] **Step 3: Gitea連携へ切替**(ユーザー確認後に実行、`<composeId>`はStep 2の結果)
```bash
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`:
```yaml
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:
```bash
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の結果)
```bash
dokploy compose deploy --composeId "<composeId>" --title "初回デプロイ" --json
```
- [ ] **Step 7: 外部疎通確認**
```bash
curl -I https://lwauth29.next-hd.net/health
```
Expected: `HTTP/2 200`
- [ ] **Step 8: SSH経由でコンテナ状態確認**
```bash
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]](Form連携アプリ)側の実装計画を別途作成する。そちらは本アプリの`GET /token?key=lineworks-form-sync`を呼ぶだけで済み、OAuth関連のコードは持たない。