# 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`、`createSiteItem({baseUrl, apiKey, siteId, fields, fetchImpl}) -> Promise`(新規ItemId)、`updateSiteItem({baseUrl, apiKey, itemId, fields, fetchImpl}) -> Promise`、`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` - `upsert({baseUrl, apiKey, siteId, encryptionKey, key, account, scope, accessToken, refreshToken, expiresAt, fetchImpl, now}) -> Promise` - `touchLastUsed({baseUrl, apiKey, itemId, fetchImpl, now}) -> Promise` - `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`(有効な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(" { 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function renderLoginPage(errorMessage) { return ` lineworks-user-auth ログイン

lineworks-user-auth

${errorMessage ? `

${escapeHtml(errorMessage)}

` : ""}
`; } function renderDashboardPage({ records, message }) { const rows = records .map( (r) => `${escapeHtml(r.key)}${escapeHtml(r.account)}${escapeHtml(r.status)}${escapeHtml(r.expiresAt)}
` ) .join(""); return ` lineworks-user-auth

LINEWORKS User Account 連携キー一覧

${message ? `

${escapeHtml(message)}

` : ""} ${rows}
連携キー対象アカウントステータス有効期限

新規登録

`; } 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連携へ切替**(ユーザー確認後に実行、``はStep 2の結果) ```bash dokploy compose update \ --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: デプロイ実行**(ユーザー確認後に実行、``はStep 2の結果) ```bash dokploy compose deploy --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関連のコードは持たない。