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

66 KiB

LINEWORKS User Account共通認証基盤 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: LINEWORKS User Account OAuth(Authorization Code)認証を、複数の社内システムが共通利用できる独立Node.jsサービスapps/lineworks-user-authを構築する。

Architecture: Express製Webアプリ。/auth/*はマスターキー+セッションCookie保護のブラウザUIで、連携キーごとの新規登録・再認可(LINEWORKS Authorization Codeフロー)を行う。/token?key=<連携キー>は消費側システム(実行キー認証)向けAPIで、プリザンター専用テーブルに暗号化保存されたaccess/refresh tokenを読み、期限切れなら自動リフレッシュしてから返す。状態は全てプリザンター側に一元化し、本アプリ自体はステートレス。

Tech Stack: Node.js 22(CommonJS)、Express 4、node:test(標準テストランナー)、node:crypto(AES-256-GCM暗号化・HMAC署名)。外部依存はexpressのみ(既存アプリ群の慣習を踏襲)。

Global Constraints

  • 設計書: docs/superpowers/specs/2026-08-24-lineworks-user-auth-design.md(このプランの元仕様、矛盾があれば設計書優先)
  • 1アプリ1フォルダ1Dokploy Compose、他アプリと完全独立(apps/lineworks-user-auth配下で完結、他アプリのコードをimportしない。プリザンターAPIクライアント等は共通化せずコピーする既存方針を踏襲)
  • Node.js CommonJS("type": "commonjs")、node --testでテスト実行
  • 外部I/O(fetch)を行う関数は全てfetchImpl = fetch引数でDI可能にする(既存コードベースの慣習、テストでモック注入するため)
  • /healthエンドポイントは削除・変更しない(Dokployヘルスチェック対象)
  • 機密情報(APIキー・秘密鍵・トークン)を絶対にログ・コミット・チャット出力に含めない
  • n8nのData Table機能は使用しない(状態は全てプリザンター側で持つ、feedback_n8n_datatable_prohibition)

事前準備(ユーザー側、コード実装と並行して進めてよい)

実装タスクではないが、後続タスクの前提になるため明記する。

  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.net52.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/_templateapps/lineworks-user-authへコピー

cp -r apps/_template apps/lineworks-user-auth
rm -rf apps/lineworks-user-auth/node_modules
  • Step 2: package.jsonを編集

apps/lineworks-user-auth/package.json:

{
  "name": "lineworks-user-auth",
  "version": "0.1.0",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "dev": "node --watch src/index.js",
    "start": "node src/index.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}
  • Step 3: .env.example作成

apps/lineworks-user-auth/.env.example:

PORT=3000
NODE_ENV=development

# LINEWORKS OAuth app (Developer Console, User Account認証用に新規作成)
LW_OAUTH_CLIENT_ID=
LW_OAUTH_CLIENT_SECRET=
LW_OAUTH_REDIRECT_URI=https://lwauth29.next-hd.net/auth/callback
LW_OAUTH_SCOPE=form form.read

# プリザンター(LINEWORKS連携トークン専用テーブル)
PLEASANTER_BASE_URL=
PLEASANTER_API_KEY=
PLEASANTER_TOKEN_SITE_ID=

# トークン暗号化鍵(32byte、hex 64文字。生成例: openssl rand -hex 32)
TOKEN_ENCRYPTION_KEY=

# /auth 管理UIのマスターキー・セッション署名鍵
AUTH_MASTER_KEY=

# 消費側システムの実行キー(JSON、{"連携キー": "実行キー"}形式)
CONSUMER_EXECUTION_KEYS={}
  • Step 4: .gitignore確認・追記

apps/lineworks-user-auth/.gitignoreに以下が含まれることを確認(なければ追記):

node_modules/
.env
  • Step 5: ローカルでnpm installして/health確認
cd apps/lineworks-user-auth
npm install
cp .env.example .env
npm run dev

別ターミナルで:

curl -s http://localhost:3000/health

Expected: {"status":"healthy"}

確認後、devサーバーを停止する。

  • Step 6: Commit
git add apps/lineworks-user-auth/package.json apps/lineworks-user-auth/src/index.js apps/lineworks-user-auth/Dockerfile apps/lineworks-user-auth/docker-compose.local.yml apps/lineworks-user-auth/.env.example apps/lineworks-user-auth/.gitignore
git commit -m "feat(lineworks-user-auth): アプリ雛形作成"

Task 2: トークン暗号化(tokenCrypto.js)

Files:

  • Create: apps/lineworks-user-auth/src/lib/tokenCrypto.js
  • Test: apps/lineworks-user-auth/test/tokenCrypto.test.js

Interfaces:

  • Produces: encrypt(plainText, keyHex) -> string(暗号文、iv:authTag:cipherTextのhex連結形式)、decrypt(encryptedText, keyHex) -> string(平文に復号)

  • Step 1: 失敗するテストを書く

apps/lineworks-user-auth/test/tokenCrypto.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { encrypt, decrypt } = require("../src/lib/tokenCrypto");

const KEY_HEX = crypto.randomBytes(32).toString("hex");

test("encrypt then decrypt returns the original plain text", () => {
  const plain = "sample-access-token-value";
  const encrypted = encrypt(plain, KEY_HEX);
  assert.notStrictEqual(encrypted, plain);
  assert.strictEqual(decrypt(encrypted, KEY_HEX), plain);
});

test("encrypt output contains iv, authTag, cipherText separated by colons", () => {
  const encrypted = encrypt("abc", KEY_HEX);
  const parts = encrypted.split(":");
  assert.strictEqual(parts.length, 3);
});

test("decrypt throws when the key does not match", () => {
  const encrypted = encrypt("abc", KEY_HEX);
  const otherKey = crypto.randomBytes(32).toString("hex");
  assert.throws(() => decrypt(encrypted, otherKey));
});
  • Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/tokenCrypto.test.js

Expected: FAIL(Cannot find module '../src/lib/tokenCrypto')

  • Step 3: 実装

apps/lineworks-user-auth/src/lib/tokenCrypto.js:

"use strict";
const crypto = require("node:crypto");

const ALGORITHM = "aes-256-gcm";

function encrypt(plainText, keyHex) {
  const key = Buffer.from(keyHex, "hex");
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
  const cipherText = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]);
  const authTag = cipher.getAuthTag();
  return `${iv.toString("hex")}:${authTag.toString("hex")}:${cipherText.toString("hex")}`;
}

function decrypt(encryptedText, keyHex) {
  const key = Buffer.from(keyHex, "hex");
  const [ivHex, authTagHex, cipherTextHex] = encryptedText.split(":");
  const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"));
  decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
  const plainText = Buffer.concat([decipher.update(Buffer.from(cipherTextHex, "hex")), decipher.final()]);
  return plainText.toString("utf8");
}

module.exports = { encrypt, decrypt };
  • Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/tokenCrypto.test.js

Expected: PASS(3 tests)

  • Step 5: Commit
git add apps/lineworks-user-auth/src/lib/tokenCrypto.js apps/lineworks-user-auth/test/tokenCrypto.test.js
git commit -m "feat(lineworks-user-auth): AES-256-GCMによるトークン暗号化を実装"

Task 3: プリザンターAPIクライアント(pleasanterClient.js)

org-master-sync(apps/org-master-sync/src/lib/pleasanterClient.js)から、本アプリで使う関数のみ移植する(アプリ間は完全独立のためコピー、既存方針踏襲)。

Files:

  • Create: apps/lineworks-user-auth/src/lib/pleasanterClient.js
  • Test: apps/lineworks-user-auth/test/pleasanterClient.test.js

Interfaces:

  • Produces: getSiteItems({baseUrl, apiKey, siteId, fetchImpl}) -> Promise<Array>createSiteItem({baseUrl, apiKey, siteId, fields, fetchImpl}) -> Promise<number>(新規ItemId)、updateSiteItem({baseUrl, apiKey, itemId, fields, fetchImpl}) -> Promise<void>toHashPayload(fields) -> object

  • Step 1: 失敗するテストを書く

apps/lineworks-user-auth/test/pleasanterClient.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const { getSiteItems, createSiteItem, updateSiteItem, toHashPayload } = require("../src/lib/pleasanterClient");

test("toHashPayload nests flat Class*/Date*/Description* keys under their Hash, leaves others as-is", () => {
  const payload = toHashPayload({ ClassA: "key1", Date001: "2026-08-24T00:00:00.000Z", Description001: "enc" });
  assert.deepStrictEqual(payload, {
    ClassHash: { ClassA: "key1" },
    DateHash: { Date001: "2026-08-24T00:00:00.000Z" },
    DescriptionHash: { Description001: "enc" },
  });
});

test("getSiteItems pages through results using top-level Offset and flattens Hash keys", async () => {
  let call = 0;
  const fetchImpl = async (url, opts) => {
    const body = JSON.parse(opts.body);
    call++;
    if (body.Offset === 0) {
      return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 1, ClassHash: { ClassA: "a" } }], TotalCount: 2 } }) };
    }
    return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 2, ClassHash: { ClassA: "b" } }], TotalCount: 2 } }) };
  };
  const items = await getSiteItems({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl });
  assert.strictEqual(call, 2);
  assert.deepStrictEqual(items.map((i) => i.ClassA), ["a", "b"]);
});

test("createSiteItem sends fields nested under Hash keys and returns the new item Id", async () => {
  let capturedBody;
  const fetchImpl = async (url, opts) => { capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 42, StatusCode: 200 }) }; };
  const id = await createSiteItem({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fields: { ClassA: "key1" }, fetchImpl });
  assert.strictEqual(id, 42);
  assert.deepStrictEqual(capturedBody.ClassHash, { ClassA: "key1" });
});

test("updateSiteItem posts Hash-nested fields to /api/items/{itemId}/update without siteId in the path", async () => {
  let capturedUrl, capturedBody;
  const fetchImpl = async (url, opts) => { capturedUrl = url; capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 7, StatusCode: 200 }) }; };
  await updateSiteItem({ baseUrl: "https://example.test/", apiKey: "k", itemId: 7, fields: { Date001: "2026-08-24T00:00:00.000Z" }, fetchImpl });
  assert.strictEqual(capturedUrl, "https://example.test/api/items/7/update");
  assert.deepStrictEqual(capturedBody.DateHash, { Date001: "2026-08-24T00:00:00.000Z" });
});

test("throws when StatusCode is not 200", async () => {
  const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 400, Message: "bad" }) });
  await assert.rejects(() => getSiteItems({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl }));
});
  • Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/pleasanterClient.test.js

Expected: FAIL(モジュール未存在)

  • Step 3: 実装

apps/lineworks-user-auth/src/lib/pleasanterClient.js:

"use strict";

// Pleasanter Items APIは分類・日付・説明項目をフラットなキーでなく
// ClassHash/DateHash/DescriptionHashへネストして送る必要がある(org-master-syncで実機確認済み、
// apps/org-master-sync/src/lib/pleasanterClient.js参照)。本アプリ用に必要な関数のみ移植。
const HASH_PREFIXES = [
  ["Description", "DescriptionHash"],
  ["Class", "ClassHash"],
  ["Date", "DateHash"],
];

function toHashPayload(fields) {
  const result = {};
  for (const [key, value] of Object.entries(fields)) {
    const entry = HASH_PREFIXES.find(([prefix]) => key.startsWith(prefix));
    if (entry) {
      const hashName = entry[1];
      if (!result[hashName]) result[hashName] = {};
      result[hashName][key] = value;
    } else {
      result[key] = value;
    }
  }
  return result;
}

function buildUrl(baseUrl, pathname) {
  return baseUrl.replace(/\/$/, "") + pathname;
}

async function callApi({ baseUrl, apiKey, pathname, body, fetchImpl = fetch }) {
  const res = await fetchImpl(buildUrl(baseUrl, pathname), {
    method: "POST",
    headers: { "Content-Type": "application/json; charset=utf-8" },
    body: JSON.stringify({ ApiVersion: "1.1", ApiKey: apiKey, ...body }),
  });
  const data = await res.json();
  if (!res.ok || data.StatusCode !== 200) {
    throw new Error(`${pathname} failed: ${JSON.stringify(data)}`);
  }
  return data;
}

function flattenHashes(item) {
  const { ClassHash, DateHash, DescriptionHash, ...rest } = item;
  return { ...rest, ...ClassHash, ...DateHash, ...DescriptionHash };
}

async function getSiteItems({ baseUrl, apiKey, siteId, fetchImpl = fetch }) {
  const items = [];
  let offset = 0;
  while (true) {
    const data = await callApi({ baseUrl, apiKey, pathname: `/api/items/${siteId}/get`, body: { Offset: offset }, fetchImpl });
    const page = data.Response.Data || [];
    items.push(...page.map(flattenHashes));
    if (page.length === 0 || items.length >= data.Response.TotalCount) break;
    offset += page.length;
  }
  return items;
}

async function createSiteItem({ baseUrl, apiKey, siteId, fields, fetchImpl = fetch }) {
  const data = await callApi({ baseUrl, apiKey, pathname: `/api/items/${siteId}/create`, body: toHashPayload(fields), fetchImpl });
  return data.Id;
}

async function updateSiteItem({ baseUrl, apiKey, itemId, fields, fetchImpl = fetch }) {
  await callApi({ baseUrl, apiKey, pathname: `/api/items/${itemId}/update`, body: toHashPayload(fields), fetchImpl });
}

module.exports = { getSiteItems, createSiteItem, updateSiteItem, toHashPayload };
  • Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/pleasanterClient.test.js

Expected: PASS(5 tests)

  • Step 5: Commit
git add apps/lineworks-user-auth/src/lib/pleasanterClient.js apps/lineworks-user-auth/test/pleasanterClient.test.js
git commit -m "feat(lineworks-user-auth): プリザンターAPIクライアントを移植"

Task 4: 物理列マッピングとトークンレコードストア(tokenRecordStore.js)

Files:

  • Create: apps/lineworks-user-auth/src/config/pleasanterColumns.js
  • Create: apps/lineworks-user-auth/src/lib/tokenRecordStore.js
  • Test: apps/lineworks-user-auth/test/tokenRecordStore.test.js

Interfaces:

  • Consumes: getSiteItems/createSiteItem/updateSiteItem(Task 3)、encrypt/decrypt(Task 2)

  • Produces:

    • findByKey({baseUrl, apiKey, siteId, encryptionKey, key, fetchImpl}) -> Promise<TokenRecord|null>
    • upsert({baseUrl, apiKey, siteId, encryptionKey, key, account, scope, accessToken, refreshToken, expiresAt, fetchImpl, now}) -> Promise<void>
    • touchLastUsed({baseUrl, apiKey, itemId, fetchImpl, now}) -> Promise<void>
    • TokenRecordの形: { itemId, key, account, scope, status, accessToken, refreshToken, expiresAt, refreshedAt, lastUsedAt }(accessToken/refreshTokenは復号済み平文)
  • Step 1: 物理列マッピング定数を書く

apps/lineworks-user-auth/src/config/pleasanterColumns.js:

"use strict";

// プリザンター「LINEWORKS連携トークン」テーブルの物理列マッピング。
// テーブル自体はユーザー側が下記構成で作成する(docs/superpowers/plans/2026-08-24-lineworks-user-auth.md「事前準備」参照)。
module.exports = {
  KEY: "ClassA",
  ACCOUNT: "Class001",
  SCOPE: "Class002",
  STATUS: "Class003",
  ACCESS_TOKEN: "Description001",
  REFRESH_TOKEN: "Description002",
  EXPIRES_AT: "Date001",
  REFRESHED_AT: "Date002",
  LAST_USED_AT: "Date003",
};
  • Step 2: 失敗するテストを書く

apps/lineworks-user-auth/test/tokenRecordStore.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { encrypt } = require("../src/lib/tokenCrypto");
const { findByKey, upsert, touchLastUsed } = require("../src/lib/tokenRecordStore");

const KEY_HEX = crypto.randomBytes(32).toString("hex");
const BASE = { baseUrl: "https://example.test/", apiKey: "k", siteId: 1, encryptionKey: KEY_HEX };

test("findByKey returns null when no item matches the 連携キー", async () => {
  const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) });
  const record = await findByKey({ ...BASE, key: "lineworks-form-sync", fetchImpl });
  assert.strictEqual(record, null);
});

test("findByKey decrypts tokens and maps physical columns to a TokenRecord", async () => {
  const fetchImpl = async () => ({
    ok: true,
    json: async () => ({
      StatusCode: 200,
      Response: {
        Data: [{
          ResultId: 5,
          ClassHash: { ClassA: "lineworks-form-sync", Class001: "svc@example.co.jp", Class002: "form form.read", Class003: "" },
          DescriptionHash: { Description001: encrypt("access-1", KEY_HEX), Description002: encrypt("refresh-1", KEY_HEX) },
          DateHash: { Date001: "2026-08-24T01:00:00.000Z", Date002: "2026-08-24T00:00:00.000Z", Date003: "2026-08-24T00:30:00.000Z" },
        }],
        TotalCount: 1,
      },
    }),
  });
  const record = await findByKey({ ...BASE, key: "lineworks-form-sync", fetchImpl });
  assert.strictEqual(record.itemId, 5);
  assert.strictEqual(record.accessToken, "access-1");
  assert.strictEqual(record.refreshToken, "refresh-1");
  assert.strictEqual(record.expiresAt, "2026-08-24T01:00:00.000Z");
});

test("upsert creates a new item when no existing record for the key (encrypts tokens before sending)", async () => {
  let getCall = 0;
  let capturedCreateBody;
  const fetchImpl = async (url, opts) => {
    if (url.endsWith("/get")) {
      getCall++;
      return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) };
    }
    if (url.endsWith("/create")) {
      capturedCreateBody = JSON.parse(opts.body);
      return { ok: true, json: async () => ({ Id: 9, StatusCode: 200 }) };
    }
    throw new Error(`unexpected url ${url}`);
  };
  await upsert({
    ...BASE, key: "lineworks-form-sync", account: "svc@example.co.jp", scope: "form form.read",
    accessToken: "access-1", refreshToken: "refresh-1", expiresAt: "2026-08-24T01:00:00.000Z",
    fetchImpl, now: () => new Date("2026-08-24T00:00:00.000Z"),
  });
  assert.strictEqual(getCall, 1);
  assert.strictEqual(capturedCreateBody.ClassHash.ClassA, "lineworks-form-sync");
  assert.notStrictEqual(capturedCreateBody.DescriptionHash.Description001, "access-1");
});

test("upsert updates the existing item when a record for the key already exists", async () => {
  let capturedUrl, capturedBody;
  const fetchImpl = async (url, opts) => {
    if (url.endsWith("/get")) {
      return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 5, ClassHash: { ClassA: "lineworks-form-sync" }, DescriptionHash: {}, DateHash: {} }], TotalCount: 1 } }) };
    }
    capturedUrl = url;
    capturedBody = JSON.parse(opts.body);
    return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) };
  };
  await upsert({
    ...BASE, key: "lineworks-form-sync", account: "svc@example.co.jp", scope: "form form.read",
    accessToken: "access-2", refreshToken: "refresh-2", expiresAt: "2026-08-24T02:00:00.000Z",
    fetchImpl, now: () => new Date("2026-08-24T01:00:00.000Z"),
  });
  assert.strictEqual(capturedUrl, "https://example.test/api/items/5/update");
  assert.strictEqual(capturedBody.DateHash.Date002, "2026-08-24T01:00:00.000Z");
});

test("touchLastUsed updates only the last-used-at column", async () => {
  let capturedUrl, capturedBody;
  const fetchImpl = async (url, opts) => { capturedUrl = url; capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) }; };
  await touchLastUsed({ baseUrl: "https://example.test/", apiKey: "k", itemId: 5, fetchImpl, now: () => new Date("2026-08-24T03:00:00.000Z") });
  assert.strictEqual(capturedUrl, "https://example.test/api/items/5/update");
  assert.deepStrictEqual(capturedBody.DateHash, { Date003: "2026-08-24T03:00:00.000Z" });
});
  • Step 3: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/tokenRecordStore.test.js

Expected: FAIL(モジュール未存在)

  • Step 4: 実装

apps/lineworks-user-auth/src/lib/tokenRecordStore.js:

"use strict";
const COLS = require("../config/pleasanterColumns");
const { getSiteItems, createSiteItem, updateSiteItem } = require("./pleasanterClient");
const { encrypt, decrypt } = require("./tokenCrypto");

function toRecord(item, encryptionKey) {
  return {
    itemId: item.ResultId,
    key: item[COLS.KEY],
    account: item[COLS.ACCOUNT],
    scope: item[COLS.SCOPE],
    status: item[COLS.STATUS] || "",
    accessToken: item[COLS.ACCESS_TOKEN] ? decrypt(item[COLS.ACCESS_TOKEN], encryptionKey) : null,
    refreshToken: item[COLS.REFRESH_TOKEN] ? decrypt(item[COLS.REFRESH_TOKEN], encryptionKey) : null,
    expiresAt: item[COLS.EXPIRES_AT] || null,
    refreshedAt: item[COLS.REFRESHED_AT] || null,
    lastUsedAt: item[COLS.LAST_USED_AT] || null,
  };
}

async function findByKey({ baseUrl, apiKey, siteId, encryptionKey, key, fetchImpl = fetch }) {
  const items = await getSiteItems({ baseUrl, apiKey, siteId, fetchImpl });
  const match = items.find((item) => item[COLS.KEY] === key);
  return match ? toRecord(match, encryptionKey) : null;
}

async function upsert({ baseUrl, apiKey, siteId, encryptionKey, key, account, scope, accessToken, refreshToken, expiresAt, fetchImpl = fetch, now = () => new Date() }) {
  const items = await getSiteItems({ baseUrl, apiKey, siteId, fetchImpl });
  const existing = items.find((item) => item[COLS.KEY] === key);
  const fields = {
    [COLS.KEY]: key,
    [COLS.ACCOUNT]: account,
    [COLS.SCOPE]: scope,
    [COLS.ACCESS_TOKEN]: encrypt(accessToken, encryptionKey),
    [COLS.REFRESH_TOKEN]: encrypt(refreshToken, encryptionKey),
    [COLS.EXPIRES_AT]: expiresAt,
    [COLS.REFRESHED_AT]: now().toISOString(),
  };
  if (existing) {
    await updateSiteItem({ baseUrl, apiKey, itemId: existing.ResultId, fields, fetchImpl });
  } else {
    await createSiteItem({ baseUrl, apiKey, siteId, fields, fetchImpl });
  }
}

async function touchLastUsed({ baseUrl, apiKey, itemId, fetchImpl = fetch, now = () => new Date() }) {
  await updateSiteItem({ baseUrl, apiKey, itemId, fields: { [COLS.LAST_USED_AT]: now().toISOString() }, fetchImpl });
}

module.exports = { findByKey, upsert, touchLastUsed };
  • Step 5: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/tokenRecordStore.test.js

Expected: PASS(5 tests)

  • Step 6: 日時フォーマットの実機確認について注記

Date001等へ渡す日時文字列はtoISOString()形式(2026-08-24T01:00:00.000Z)を前提にしているが、プリザンターのDateTime列が実際にどの形式を受け付けるかは未検証。Task 12(事前準備完了後)で実際のテーブルに対して1回だけ疎通確認し、拒否される場合はここ(tokenRecordStore.jsupsert/touchLastUsed)のフォーマット変換のみ調整する。何度も値を変えて試行錯誤しない(feedback_no_trial_and_error_debugging)。

  • Step 7: Commit
git add apps/lineworks-user-auth/src/config/pleasanterColumns.js apps/lineworks-user-auth/src/lib/tokenRecordStore.js apps/lineworks-user-auth/test/tokenRecordStore.test.js
git commit -m "feat(lineworks-user-auth): トークンレコードストアを実装"

Task 5: CSRF対策state生成・検証(oauthState.js)

Files:

  • Create: apps/lineworks-user-auth/src/lib/oauthState.js
  • Test: apps/lineworks-user-auth/test/oauthState.test.js

Interfaces:

  • Produces: createState({key, account, secret, now}) -> stringverifyState({state, secret, maxAgeMs, now}) -> {key, account}|null

  • Step 1: 失敗するテストを書く

apps/lineworks-user-auth/test/oauthState.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const { createState, verifyState } = require("../src/lib/oauthState");

const SECRET = "test-secret";

test("createState then verifyState round-trips key and account", () => {
  const now = () => new Date("2026-08-24T00:00:00.000Z").getTime();
  const state = createState({ key: "lineworks-form-sync", account: "svc@example.co.jp", secret: SECRET, now });
  const data = verifyState({ state, secret: SECRET, now });
  assert.deepStrictEqual(data, { key: "lineworks-form-sync", account: "svc@example.co.jp" });
});

test("verifyState returns null when the signature does not match (tampered state)", () => {
  const state = createState({ key: "lineworks-form-sync", account: "svc@example.co.jp", secret: SECRET });
  const tampered = state.slice(0, -1) + (state.at(-1) === "0" ? "1" : "0");
  assert.strictEqual(verifyState({ state: tampered, secret: SECRET }), null);
});

test("verifyState returns null when the state is older than maxAgeMs", () => {
  const issuedAt = () => new Date("2026-08-24T00:00:00.000Z").getTime();
  const state = createState({ key: "lineworks-form-sync", account: "svc@example.co.jp", secret: SECRET, now: issuedAt });
  const later = () => issuedAt() + 11 * 60 * 1000;
  assert.strictEqual(verifyState({ state, secret: SECRET, maxAgeMs: 10 * 60 * 1000, now: later }), null);
});
  • Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/oauthState.test.js

Expected: FAIL(モジュール未存在)

  • Step 3: 実装

apps/lineworks-user-auth/src/lib/oauthState.js:

"use strict";
const crypto = require("node:crypto");

function base64url(buf) {
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function base64urlDecode(str) {
  return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

function sign(payload, secret) {
  return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}

function timingSafeEqualStrings(a, b) {
  const bufA = Buffer.from(String(a));
  const bufB = Buffer.from(String(b));
  if (bufA.length !== bufB.length) {
    crypto.timingSafeEqual(bufA, bufA);
    return false;
  }
  return crypto.timingSafeEqual(bufA, bufB);
}

function createState({ key, account, secret, now = Date.now }) {
  const payload = JSON.stringify({ key, account, ts: now(), nonce: crypto.randomBytes(8).toString("hex") });
  const b64 = base64url(Buffer.from(payload, "utf8"));
  return `${b64}.${sign(b64, secret)}`;
}

function verifyState({ state, secret, maxAgeMs = 10 * 60 * 1000, now = Date.now }) {
  if (!state || typeof state !== "string") return null;
  const parts = state.split(".");
  if (parts.length !== 2) return null;
  const [b64, signature] = parts;
  if (!timingSafeEqualStrings(signature, sign(b64, secret))) return null;
  let data;
  try {
    data = JSON.parse(base64urlDecode(b64).toString("utf8"));
  } catch {
    return null;
  }
  if (!Number.isFinite(data.ts) || now() - data.ts > maxAgeMs) return null;
  return { key: data.key, account: data.account };
}

module.exports = { createState, verifyState };
  • Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/oauthState.test.js

Expected: PASS(3 tests)

  • Step 5: Commit
git add apps/lineworks-user-auth/src/lib/oauthState.js apps/lineworks-user-auth/test/oauthState.test.js
git commit -m "feat(lineworks-user-auth): OAuth state生成・検証(CSRF対策)を実装"

Task 6: LINEWORKS OAuthクライアント(lineworksOAuth.js)

Files:

  • Create: apps/lineworks-user-auth/src/lib/lineworksOAuth.js
  • Test: apps/lineworks-user-auth/test/lineworksOAuth.test.js

Interfaces:

  • Produces:

    • buildAuthorizeUrl({clientId, redirectUri, scope, state}) -> 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:

const { test } = require("node:test");
const assert = require("node:assert");
const { buildAuthorizeUrl, exchangeCodeForTokens, refreshAccessToken } = require("../src/lib/lineworksOAuth");

test("buildAuthorizeUrl builds the LINEWORKS authorize URL with required query params", () => {
  const url = new URL(buildAuthorizeUrl({
    clientId: "cid", redirectUri: "https://lwauth29.next-hd.net/auth/callback", scope: "form form.read", state: "state-abc",
  }));
  assert.strictEqual(url.origin + url.pathname, "https://auth.worksmobile.com/oauth2/v2.0/authorize");
  assert.strictEqual(url.searchParams.get("client_id"), "cid");
  assert.strictEqual(url.searchParams.get("redirect_uri"), "https://lwauth29.next-hd.net/auth/callback");
  assert.strictEqual(url.searchParams.get("scope"), "form form.read");
  assert.strictEqual(url.searchParams.get("state"), "state-abc");
  assert.strictEqual(url.searchParams.get("response_type"), "code");
});

test("exchangeCodeForTokens posts grant_type=authorization_code and returns camelCase token fields", async () => {
  let capturedBody;
  const fetchImpl = async (url, opts) => {
    capturedBody = opts.body;
    return { ok: true, json: async () => ({ access_token: "at-1", refresh_token: "rt-1", expires_in: 3600 }) };
  };
  const result = await exchangeCodeForTokens({ clientId: "cid", clientSecret: "secret", redirectUri: "https://x/callback", code: "code-1", fetchImpl });
  assert.deepStrictEqual(result, { accessToken: "at-1", refreshToken: "rt-1", expiresIn: 3600 });
  assert.ok(capturedBody.includes("grant_type=authorization_code"));
  assert.ok(capturedBody.includes("code=code-1"));
});

test("refreshAccessToken posts grant_type=refresh_token and returns camelCase token fields", async () => {
  let capturedBody;
  const fetchImpl = async (url, opts) => {
    capturedBody = opts.body;
    return { ok: true, json: async () => ({ access_token: "at-2", refresh_token: "rt-2", expires_in: 3600 }) };
  };
  const result = await refreshAccessToken({ clientId: "cid", clientSecret: "secret", refreshToken: "rt-1", fetchImpl });
  assert.deepStrictEqual(result, { accessToken: "at-2", refreshToken: "rt-2", expiresIn: 3600 });
  assert.ok(capturedBody.includes("grant_type=refresh_token"));
  assert.ok(capturedBody.includes("refresh_token=rt-1"));
});

test("exchangeCodeForTokens throws when the response is not ok", async () => {
  const fetchImpl = async () => ({ ok: false, status: 400, json: async () => ({ error: "invalid_grant" }) });
  await assert.rejects(() => exchangeCodeForTokens({ clientId: "cid", clientSecret: "secret", redirectUri: "https://x/callback", code: "bad", fetchImpl }));
});
  • Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/lineworksOAuth.test.js

Expected: FAIL(モジュール未存在)

  • Step 3: 実装

apps/lineworks-user-auth/src/lib/lineworksOAuth.js:

"use strict";

const AUTHORIZE_URL = "https://auth.worksmobile.com/oauth2/v2.0/authorize";
const TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";

function buildAuthorizeUrl({ clientId, redirectUri, scope, state }) {
  const url = new URL(AUTHORIZE_URL);
  url.searchParams.set("client_id", clientId);
  url.searchParams.set("redirect_uri", redirectUri);
  url.searchParams.set("scope", scope);
  url.searchParams.set("state", state);
  url.searchParams.set("response_type", "code");
  return url.toString();
}

function toTokens(data) {
  return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in };
}

async function postToken(params, fetchImpl) {
  const res = await fetchImpl(TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: params.toString(),
  });
  const data = await res.json();
  if (!res.ok) {
    throw new Error(`LINEWORKS token request failed: ${res.status} ${JSON.stringify(data)}`);
  }
  return toTokens(data);
}

async function exchangeCodeForTokens({ clientId, clientSecret, redirectUri, code, fetchImpl = fetch }) {
  const params = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    client_id: clientId,
    client_secret: clientSecret,
    redirect_uri: redirectUri,
  });
  return postToken(params, fetchImpl);
}

async function refreshAccessToken({ clientId, clientSecret, refreshToken, fetchImpl = fetch }) {
  const params = new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: refreshToken,
    client_id: clientId,
    client_secret: clientSecret,
  });
  return postToken(params, fetchImpl);
}

module.exports = { buildAuthorizeUrl, exchangeCodeForTokens, refreshAccessToken };
  • Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/lineworksOAuth.test.js

Expected: PASS(4 tests)

  • Step 5: Commit
git add apps/lineworks-user-auth/src/lib/lineworksOAuth.js apps/lineworks-user-auth/test/lineworksOAuth.test.js
git commit -m "feat(lineworks-user-auth): LINEWORKS OAuthクライアントを実装"

Task 7: 有効トークン取得ロジック(tokenService.js)

Files:

  • Create: apps/lineworks-user-auth/src/lib/tokenService.js
  • Test: apps/lineworks-user-auth/test/tokenService.test.js

Interfaces:

  • Consumes: findByKey/upsert/touchLastUsed(Task 4)、refreshAccessToken(Task 6)

  • Produces: getValidAccessToken({key, pleasanter, oauth, fetchImpl, now}) -> Promise<string>(有効なaccess tokenを返す。未登録キーはErrorをthrow)

    • pleasanter引数の形: {baseUrl, apiKey, siteId, encryptionKey}
    • oauth引数の形: {clientId, clientSecret}
  • Step 1: 失敗するテストを書く

apps/lineworks-user-auth/test/tokenService.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { encrypt } = require("../src/lib/tokenCrypto");
const { getValidAccessToken } = require("../src/lib/tokenService");

const KEY_HEX = crypto.randomBytes(32).toString("hex");
const PLEASANTER = { baseUrl: "https://example.test/", apiKey: "k", siteId: 1, encryptionKey: KEY_HEX };
const OAUTH = { clientId: "cid", clientSecret: "secret" };

function itemResponse({ expiresAt, accessToken = "access-current", refreshToken = "refresh-current" }) {
  return {
    ok: true,
    json: async () => ({
      StatusCode: 200,
      Response: {
        Data: [{
          ResultId: 5,
          ClassHash: { ClassA: "lineworks-form-sync", Class001: "svc@example.co.jp", Class002: "form form.read" },
          DescriptionHash: { Description001: encrypt(accessToken, KEY_HEX), Description002: encrypt(refreshToken, KEY_HEX) },
          DateHash: { Date001: expiresAt },
        }],
        TotalCount: 1,
      },
    }),
  };
}

test("returns the stored access token as-is when it is still valid (no refresh call made)", async () => {
  const now = () => new Date("2026-08-24T00:30:00.000Z");
  let refreshCalled = false;
  const fetchImpl = async (url) => {
    if (url.endsWith("/get")) return itemResponse({ expiresAt: "2026-08-24T01:00:00.000Z" });
    if (url.includes("auth.worksmobile.com")) { refreshCalled = true; }
    if (url.endsWith("/update")) return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) };
    throw new Error(`unexpected url ${url}`);
  };
  const token = await getValidAccessToken({ key: "lineworks-form-sync", pleasanter: PLEASANTER, oauth: OAUTH, fetchImpl, now });
  assert.strictEqual(token, "access-current");
  assert.strictEqual(refreshCalled, false);
});

test("refreshes and persists a new token when the stored one has expired", async () => {
  const now = () => new Date("2026-08-24T02:00:00.000Z");
  let updateBody;
  const fetchImpl = async (url, opts) => {
    if (url.endsWith("/get")) return itemResponse({ expiresAt: "2026-08-24T01:00:00.000Z" });
    if (url.includes("auth.worksmobile.com")) {
      return { ok: true, json: async () => ({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 }) };
    }
    if (url.endsWith("/update")) { updateBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 5, StatusCode: 200 }) }; }
    throw new Error(`unexpected url ${url}`);
  };
  const token = await getValidAccessToken({ key: "lineworks-form-sync", pleasanter: PLEASANTER, oauth: OAUTH, fetchImpl, now });
  assert.strictEqual(token, "access-new");
  assert.strictEqual(updateBody.DateHash.Date001, "2026-08-24T03:00:00.000Z");
});

test("throws a descriptive error when the key is not registered", async () => {
  const fetchImpl = async (url) => {
    if (url.endsWith("/get")) return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) };
    throw new Error(`unexpected url ${url}`);
  };
  await assert.rejects(
    () => getValidAccessToken({ key: "unknown-key", pleasanter: PLEASANTER, oauth: OAUTH, fetchImpl, now: () => new Date() }),
    /unknown-key/
  );
});
  • Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/tokenService.test.js

Expected: FAIL(モジュール未存在)

  • Step 3: 実装

apps/lineworks-user-auth/src/lib/tokenService.js:

"use strict";
const tokenRecordStore = require("./tokenRecordStore");
const { refreshAccessToken } = require("./lineworksOAuth");

async function getValidAccessToken({ key, pleasanter, oauth, fetchImpl = fetch, now = () => new Date() }) {
  const record = await tokenRecordStore.findByKey({ ...pleasanter, key, fetchImpl });
  if (!record) {
    throw new Error(`連携キー ${key} は未登録です`);
  }

  const isValid = record.expiresAt && new Date(record.expiresAt).getTime() > now().getTime();
  if (isValid) {
    await tokenRecordStore.touchLastUsed({ ...pleasanter, itemId: record.itemId, fetchImpl, now });
    return record.accessToken;
  }

  const refreshed = await refreshAccessToken({
    clientId: oauth.clientId,
    clientSecret: oauth.clientSecret,
    refreshToken: record.refreshToken,
    fetchImpl,
  });
  const expiresAt = new Date(now().getTime() + refreshed.expiresIn * 1000).toISOString();
  await tokenRecordStore.upsert({
    ...pleasanter,
    key: record.key,
    account: record.account,
    scope: record.scope,
    accessToken: refreshed.accessToken,
    refreshToken: refreshed.refreshToken || record.refreshToken,
    expiresAt,
    fetchImpl,
    now,
  });
  return refreshed.accessToken;
}

module.exports = { getValidAccessToken };
  • Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/tokenService.test.js

Expected: PASS(3 tests)

  • Step 5: Commit
git add apps/lineworks-user-auth/src/lib/tokenService.js apps/lineworks-user-auth/test/tokenService.test.js
git commit -m "feat(lineworks-user-auth): 有効トークン取得ロジック(自動リフレッシュ込み)を実装"

Task 8: 消費側実行キー検証(executionKeys.js)

Files:

  • Create: apps/lineworks-user-auth/src/lib/executionKeys.js
  • Test: apps/lineworks-user-auth/test/executionKeys.test.js

Interfaces:

  • Produces: verifyExecutionKey({key, providedKey, configJson}) -> boolean(configJson.envCONSUMER_EXECUTION_KEYSの生文字列、{"連携キー": "実行キー"}形式)

  • Step 1: 失敗するテストを書く

apps/lineworks-user-auth/test/executionKeys.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const { verifyExecutionKey } = require("../src/lib/executionKeys");

const CONFIG = JSON.stringify({ "lineworks-form-sync": "exec-key-abc" });

test("returns true when the provided key matches the configured key for that 連携キー", () => {
  assert.strictEqual(verifyExecutionKey({ key: "lineworks-form-sync", providedKey: "exec-key-abc", configJson: CONFIG }), true);
});

test("returns false when the provided key does not match", () => {
  assert.strictEqual(verifyExecutionKey({ key: "lineworks-form-sync", providedKey: "wrong", configJson: CONFIG }), false);
});

test("returns false when the 連携キー is not present in the config", () => {
  assert.strictEqual(verifyExecutionKey({ key: "unknown-key", providedKey: "exec-key-abc", configJson: CONFIG }), false);
});

test("returns false when providedKey is missing", () => {
  assert.strictEqual(verifyExecutionKey({ key: "lineworks-form-sync", providedKey: undefined, configJson: CONFIG }), false);
});
  • Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/executionKeys.test.js

Expected: FAIL(モジュール未存在)

  • Step 3: 実装

apps/lineworks-user-auth/src/lib/executionKeys.js:

"use strict";
const crypto = require("node:crypto");

function timingSafeEqualStrings(a, b) {
  const bufA = Buffer.from(String(a));
  const bufB = Buffer.from(String(b));
  if (bufA.length !== bufB.length) {
    crypto.timingSafeEqual(bufA, bufA);
    return false;
  }
  return crypto.timingSafeEqual(bufA, bufB);
}

function verifyExecutionKey({ key, providedKey, configJson }) {
  if (!providedKey) return false;
  let config;
  try {
    config = JSON.parse(configJson || "{}");
  } catch {
    return false;
  }
  const expected = config[key];
  if (!expected) return false;
  return timingSafeEqualStrings(providedKey, expected);
}

module.exports = { verifyExecutionKey };
  • Step 4: テスト成功を確認
cd apps/lineworks-user-auth && node --test test/executionKeys.test.js

Expected: PASS(4 tests)

  • Step 5: Commit
git add apps/lineworks-user-auth/src/lib/executionKeys.js apps/lineworks-user-auth/test/executionKeys.test.js
git commit -m "feat(lineworks-user-auth): 消費側実行キー検証を実装"

Task 9: 管理UI認証(adminAuth.js)とHTML(adminView.js)

apps/app-portal/src/adminAuth.jsを移植(アプリ間独立のためコピー)。Cookie Pathを/adminから/authへ変更する。

Files:

  • Create: apps/lineworks-user-auth/src/adminAuth.js
  • Create: apps/lineworks-user-auth/src/adminView.js
  • Test: apps/lineworks-user-auth/test/adminAuth.test.js
  • Test: apps/lineworks-user-auth/test/adminView.test.js

Interfaces:

  • Produces(adminAuth.js): verifyMasterKey(input, masterKey)createSessionToken(secret, now)verifySessionToken(token, secret, now)parseCookies(header)buildSessionCookieHeader(token)buildLogoutCookieHeader()SESSION_COOKIE_NAME

  • Produces(adminView.js): renderLoginPage(errorMessage) -> string(HTML)、renderDashboardPage({records, message}) -> string(HTML、records{key, account, status, expiresAt}[])

  • Step 1: 失敗するテストを書く(adminAuth)

apps/lineworks-user-auth/test/adminAuth.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const { verifyMasterKey, createSessionToken, verifySessionToken, buildSessionCookieHeader, parseCookies, SESSION_COOKIE_NAME } = require("../src/adminAuth");

test("verifyMasterKey returns true only for the exact configured key", () => {
  assert.strictEqual(verifyMasterKey("correct", "correct"), true);
  assert.strictEqual(verifyMasterKey("wrong", "correct"), false);
});

test("createSessionToken then verifySessionToken succeeds within TTL", () => {
  const now = new Date("2026-08-24T00:00:00.000Z").getTime();
  const token = createSessionToken("secret", now);
  assert.strictEqual(verifySessionToken(token, "secret", now + 1000), true);
});

test("verifySessionToken fails after the session TTL has elapsed", () => {
  const now = new Date("2026-08-24T00:00:00.000Z").getTime();
  const token = createSessionToken("secret", now);
  assert.strictEqual(verifySessionToken(token, "secret", now + 25 * 60 * 60 * 1000), false);
});

test("buildSessionCookieHeader scopes the cookie to /auth", () => {
  const header = buildSessionCookieHeader("tok");
  assert.ok(header.includes("Path=/auth"));
  assert.ok(header.startsWith(`${SESSION_COOKIE_NAME}=`));
});

test("parseCookies parses a raw Cookie header into a key-value map", () => {
  assert.deepStrictEqual(parseCookies(`${SESSION_COOKIE_NAME}=abc; other=1`), { [SESSION_COOKIE_NAME]: "abc", other: "1" });
});
  • Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/adminAuth.test.js

Expected: FAIL(モジュール未存在)

  • Step 3: 実装(adminAuth.js、app-portalから移植・Path変更)

apps/lineworks-user-auth/src/adminAuth.js:

"use strict";
const crypto = require("node:crypto");

const SESSION_COOKIE_NAME = "lwauth_admin_session";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;

function timingSafeEqualStrings(a, b) {
  const bufA = Buffer.from(String(a));
  const bufB = Buffer.from(String(b));
  if (bufA.length !== bufB.length) {
    crypto.timingSafeEqual(bufA, bufA);
    return false;
  }
  return crypto.timingSafeEqual(bufA, bufB);
}

function verifyMasterKey(input, masterKey) {
  if (!masterKey || !input) return false;
  return timingSafeEqualStrings(input, masterKey);
}

function sign(payload, secret) {
  return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}

function createSessionToken(secret, now = Date.now()) {
  const payload = String(now);
  return `${payload}.${sign(payload, secret)}`;
}

function verifySessionToken(token, secret, now = Date.now()) {
  if (!token || typeof token !== "string") return false;
  const parts = token.split(".");
  if (parts.length !== 2) return false;
  const [payload, signature] = parts;
  if (!timingSafeEqualStrings(signature, sign(payload, secret))) return false;
  const issuedAt = Number(payload);
  if (!Number.isFinite(issuedAt)) return false;
  return now - issuedAt < SESSION_TTL_MS;
}

function parseCookies(cookieHeader) {
  const result = {};
  if (!cookieHeader) return result;
  cookieHeader.split(";").forEach((pair) => {
    const idx = pair.indexOf("=");
    if (idx === -1) return;
    const key = pair.slice(0, idx).trim();
    const value = pair.slice(idx + 1).trim();
    if (key) result[key] = decodeURIComponent(value);
  });
  return result;
}

function buildSessionCookieHeader(token) {
  return [
    `${SESSION_COOKIE_NAME}=${encodeURIComponent(token)}`,
    "HttpOnly",
    "Secure",
    "Path=/auth",
    "SameSite=Strict",
    `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
  ].join("; ");
}

function buildLogoutCookieHeader() {
  return [`${SESSION_COOKIE_NAME}=`, "HttpOnly", "Secure", "Path=/auth", "SameSite=Strict", "Max-Age=0"].join("; ");
}

module.exports = {
  SESSION_COOKIE_NAME,
  SESSION_TTL_MS,
  verifyMasterKey,
  createSessionToken,
  verifySessionToken,
  parseCookies,
  buildSessionCookieHeader,
  buildLogoutCookieHeader,
};
  • Step 4: テスト成功を確認(adminAuth)
cd apps/lineworks-user-auth && node --test test/adminAuth.test.js

Expected: PASS(5 tests)

  • Step 5: 失敗するテストを書く(adminView)

apps/lineworks-user-auth/test/adminView.test.js:

const { test } = require("node:test");
const assert = require("node:assert");
const { renderLoginPage, renderDashboardPage } = require("../src/adminView");

test("renderLoginPage includes a master key input form", () => {
  const html = renderLoginPage();
  assert.ok(html.includes('name="masterKey"'));
  assert.ok(html.includes("<form"));
});

test("renderLoginPage includes the given error message when provided", () => {
  const html = renderLoginPage("Master Keyが正しくありません");
  assert.ok(html.includes("Master Keyが正しくありません"));
});

test("renderDashboardPage lists each record's key, account, and status", () => {
  const html = renderDashboardPage({
    records: [{ key: "lineworks-form-sync", account: "svc@example.co.jp", status: "有効", expiresAt: "2026-08-24T01:00:00.000Z" }],
    message: null,
  });
  assert.ok(html.includes("lineworks-form-sync"));
  assert.ok(html.includes("svc@example.co.jp"));
  assert.ok(html.includes("有効"));
});

test("renderDashboardPage includes a new-registration form with key and account fields", () => {
  const html = renderDashboardPage({ records: [], message: null });
  assert.ok(html.includes('name="key"'));
  assert.ok(html.includes('name="account"'));
});
  • Step 6: テスト失敗を確認(adminView)
cd apps/lineworks-user-auth && node --test test/adminView.test.js

Expected: FAIL(モジュール未存在)

  • Step 7: 実装(adminView.js)

apps/lineworks-user-auth/src/adminView.js:

"use strict";

function escapeHtml(value) {
  return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&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)
cd apps/lineworks-user-auth && node --test test/adminView.test.js

Expected: PASS(4 tests)

  • Step 9: Commit
git add apps/lineworks-user-auth/src/adminAuth.js apps/lineworks-user-auth/src/adminView.js apps/lineworks-user-auth/test/adminAuth.test.js apps/lineworks-user-auth/test/adminView.test.js
git commit -m "feat(lineworks-user-auth): 管理UI認証とHTML画面を実装"

Task 10: ルーティング統合(src/index.js)

Files:

  • Modify: apps/lineworks-user-auth/src/index.js(Task 1で作成した雛形を全面書き換え)

Interfaces:

  • Consumes: 全ライブラリ(Task 2〜9)
  • Produces: GET /healthGET /tokenGET /auth/loginPOST /auth/loginPOST /auth/logoutGET /authGET /auth/startGET /auth/callback

このタスクはExpressルーティングの配線が中心で、既存コードベース(apps/app-portal/src/index.js)同様にユニットテストは設けず、Task 11でのローカルDocker起動時に手動疎通確認する(app-portalの慣習を踏襲)。

  • Step 1: 実装

apps/lineworks-user-auth/src/index.js:

"use strict";
const express = require("express");
const {
  verifyMasterKey,
  createSessionToken,
  verifySessionToken,
  parseCookies,
  buildSessionCookieHeader,
  buildLogoutCookieHeader,
  SESSION_COOKIE_NAME,
} = require("./adminAuth");
const { renderLoginPage, renderDashboardPage } = require("./adminView");
const { verifyExecutionKey } = require("./lib/executionKeys");
const { getValidAccessToken } = require("./lib/tokenService");
const { findByKey, upsert } = require("./lib/tokenRecordStore");
const { buildAuthorizeUrl, exchangeCodeForTokens } = require("./lib/lineworksOAuth");
const { createState, verifyState } = require("./lib/oauthState");
const { getSiteItems } = require("./lib/pleasanterClient");
const COLS = require("./config/pleasanterColumns");

const app = express();
const PORT = process.env.PORT || 3000;
const MASTER_KEY = process.env.AUTH_MASTER_KEY;
const CONSUMER_EXECUTION_KEYS = process.env.CONSUMER_EXECUTION_KEYS || "{}";

const PLEASANTER = {
  baseUrl: process.env.PLEASANTER_BASE_URL,
  apiKey: process.env.PLEASANTER_API_KEY,
  siteId: Number(process.env.PLEASANTER_TOKEN_SITE_ID),
  encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
};
const OAUTH_APP = {
  clientId: process.env.LW_OAUTH_CLIENT_ID,
  clientSecret: process.env.LW_OAUTH_CLIENT_SECRET,
  redirectUri: process.env.LW_OAUTH_REDIRECT_URI,
  scope: process.env.LW_OAUTH_SCOPE,
};

app.use(express.urlencoded({ extended: false }));

app.get("/health", (req, res) => {
  res.status(200).json({ status: "healthy" });
});

// ---- 消費側システム向けトークン提供API ----
app.get("/token", async (req, res) => {
  const key = req.query.key;
  const providedKey = req.header("X-Execution-Key");
  if (!key || !verifyExecutionKey({ key, providedKey, configJson: CONSUMER_EXECUTION_KEYS })) {
    res.sendStatus(401);
    return;
  }
  try {
    const accessToken = await getValidAccessToken({ key, pleasanter: PLEASANTER, oauth: OAUTH_APP });
    res.json({ accessToken });
  } catch (err) {
    console.error("token fetch failed", err.message);
    res.status(502).json({ error: err.message });
  }
});

// ---- 管理UI(社内限定) ----
function requireAdminSession(req, res, next) {
  const cookies = parseCookies(req.headers.cookie);
  if (!verifySessionToken(cookies[SESSION_COOKIE_NAME], MASTER_KEY)) {
    res.redirect("/auth/login");
    return;
  }
  next();
}

app.get("/auth/login", (req, res) => {
  res.set("Content-Type", "text/html; charset=utf-8").send(renderLoginPage());
});

app.post("/auth/login", (req, res) => {
  if (!verifyMasterKey(req.body.masterKey, MASTER_KEY)) {
    res.set("Content-Type", "text/html; charset=utf-8").send(renderLoginPage("Master Keyが正しくありません"));
    return;
  }
  res.set("Set-Cookie", buildSessionCookieHeader(createSessionToken(MASTER_KEY)));
  res.redirect("/auth");
});

app.post("/auth/logout", (req, res) => {
  res.set("Set-Cookie", buildLogoutCookieHeader());
  res.redirect("/auth/login");
});

app.get("/auth", requireAdminSession, async (req, res) => {
  try {
    const items = await getSiteItems({ baseUrl: PLEASANTER.baseUrl, apiKey: PLEASANTER.apiKey, siteId: PLEASANTER.siteId });
    const records = items.map((item) => ({
      key: item[COLS.KEY],
      account: item[COLS.ACCOUNT],
      status: item[COLS.EXPIRES_AT] && new Date(item[COLS.EXPIRES_AT]).getTime() > Date.now() ? "有効" : "要再認可",
      expiresAt: item[COLS.EXPIRES_AT],
    }));
    res.set("Content-Type", "text/html; charset=utf-8").send(renderDashboardPage({ records, message: null }));
  } catch (err) {
    console.error("dashboard render failed", err.message);
    res.status(500).send("一覧取得に失敗しました");
  }
});

app.get("/auth/start", requireAdminSession, (req, res) => {
  const { key, account } = req.query;
  if (!key || !account) {
    res.status(400).send("連携キーと対象アカウントを指定してください");
    return;
  }
  const state = createState({ key, account, secret: MASTER_KEY });
  res.redirect(buildAuthorizeUrl({ clientId: OAUTH_APP.clientId, redirectUri: OAUTH_APP.redirectUri, scope: OAUTH_APP.scope, state }));
});

app.get("/auth/callback", requireAdminSession, async (req, res) => {
  const data = verifyState({ state: req.query.state, secret: MASTER_KEY });
  if (!data) {
    res.status(400).send("認可リクエストが無効です(期限切れまたは改ざん)。最初からやり直してください");
    return;
  }
  try {
    const tokens = await exchangeCodeForTokens({
      clientId: OAUTH_APP.clientId,
      clientSecret: OAUTH_APP.clientSecret,
      redirectUri: OAUTH_APP.redirectUri,
      code: req.query.code,
    });
    const expiresAt = new Date(Date.now() + tokens.expiresIn * 1000).toISOString();
    await upsert({
      ...PLEASANTER,
      key: data.key,
      account: data.account,
      scope: OAUTH_APP.scope,
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
      expiresAt,
    });
    res.redirect("/auth");
  } catch (err) {
    console.error("oauth callback failed", err.message);
    res.status(502).send("認可コードの交換に失敗しました");
  }
});

app.listen(PORT, () => {
  console.log(`lineworks-user-auth listening on port ${PORT}`);
});
  • Step 2: Commit
git add apps/lineworks-user-auth/src/index.js
git commit -m "feat(lineworks-user-auth): ルーティングを統合(/health /token /auth/*)"

Task 11: ローカルDocker動作確認

Files: なし(既存ファイルの動作確認のみ)

  • Step 1: .envを用意(ダミー値でよい、実際の外部API呼び出しはしない範囲で確認)
cd apps/lineworks-user-auth
cp .env.example .env

.envAUTH_MASTER_KEYTOKEN_ENCRYPTION_KEYだけ実値を入れる(他はダミーで可):

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

出力された値をTOKEN_ENCRYPTION_KEYへ、任意の文字列をAUTH_MASTER_KEYへ設定。

  • Step 2: Dockerでビルド・起動
docker compose -f docker-compose.local.yml up --build
  • Step 3: /health確認

別ターミナルで:

curl -s http://localhost:3000/health

Expected: {"status":"healthy"}

  • Step 4: /auth/login画面確認
curl -s http://localhost:3000/auth/login | grep masterKey

Expected: name="masterKey"を含むHTML断片が出力される

  • Step 5: /tokenが未設定実行キーで401を返すことを確認
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/token?key=lineworks-form-sync"

Expected: 401

  • Step 6: Dockerコンテナを停止
docker compose -f docker-compose.local.yml down
  • Step 7: 全テストを通しで実行
cd apps/lineworks-user-auth && node --test

Expected: 全テストPASS(tokenCrypto/pleasanterClient/tokenRecordStore/oauthState/lineworksOAuth/tokenService/executionKeys/adminAuth/adminView、計29テスト)

このタスクはコード変更を伴わないため、commitは不要。


Task 12: 事前準備の実施とプリザンター疎通確認

このタスクはコード実装ではなく、ユーザー側での準備作業と、実データに対する最小限の疎通確認。

  • Step 1: LINEWORKS Developer ConsoleでOAuthアプリ登録(冒頭「事前準備」1.参照)、client_id/client_secretを控える

  • Step 2: DNS登録(冒頭「事前準備」2.参照)、lwauth29.next-hd.net52.193.142.134

  • Step 3: プリザンターに「LINEWORKS連携トークン」テーブルを作成(冒頭「事前準備」3.の物理列構成で)、SiteId・APIキーを控える

  • Step 4: .envへ本番相当の値を設定し、upsertが実際にItemを作成できることを1回だけ確認する(feedback_no_trial_and_error_debugging、何度も値を変えて試さない)

cd apps/lineworks-user-auth
node --env-file=.env -e "
const { upsert, findByKey } = require('./src/lib/tokenRecordStore');
(async () => {
  await upsert({
    baseUrl: process.env.PLEASANTER_BASE_URL,
    apiKey: process.env.PLEASANTER_API_KEY,
    siteId: Number(process.env.PLEASANTER_TOKEN_SITE_ID),
    encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
    key: 'smoke-test',
    account: 'smoke-test@example.co.jp',
    scope: 'form form.read',
    accessToken: 'smoke-access',
    refreshToken: 'smoke-refresh',
    expiresAt: new Date(Date.now() + 3600000).toISOString(),
  });
  const record = await findByKey({
    baseUrl: process.env.PLEASANTER_BASE_URL,
    apiKey: process.env.PLEASANTER_API_KEY,
    siteId: Number(process.env.PLEASANTER_TOKEN_SITE_ID),
    encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
    key: 'smoke-test',
  });
  console.log('roundtrip ok:', record.accessToken === 'smoke-access');
})();
"

Expected: roundtrip ok: true

日時フォーマットが拒否される場合(「JSONデータが不正です」等のエラー)は、この1回の実機応答を見てtokenRecordStore.jsの日時変換のみ修正し、再度この確認を1回行う(闇雲な試行錯誤はしない)。

  • Step 5: 疎通確認用に作ったsmoke-testレコードをプリザンター側で削除(手動)

  • Step 6: CONSUMER_EXECUTION_KEYSLW_OAUTH_CLIENT_ID等、本番用の値を.env(Dokploy側は後述Task 13で環境変数として設定)へ反映


Task 13: Dokployへのデプロイ

.claude/skills/dokploy-webapp/SKILL.mdの手順に従う。本番操作のため、各コマンド実行前に必ずユーザーへ内容を提示し確認を取る。

  • Step 1: Gitea remoteへpush
git push gitea main
  • Step 2: Dokploy Compose作成(ユーザー確認後に実行)
dokploy compose create \
  --name "lineworks-user-auth" \
  --environmentId "Cm0HjMIFyl11UdIcIGRy8" \
  --composeType "docker-compose" \
  --appName "lineworks-user-auth" \
  --json
  • Step 3: Gitea連携へ切替(ユーザー確認後に実行、<composeId>はStep 2の結果)
dokploy compose update \
  --composeId "<composeId>" \
  --sourceType "gitea" \
  --giteaId "O5-CqLQwVdlzXw3KfmN-8" \
  --giteaOwner "mygit-admin" \
  --giteaRepository "NodeSrv" \
  --giteaBranch "main" \
  --composePath "apps/lineworks-user-auth/docker-compose.yml" \
  --json
  • Step 4: docker-compose.yml(本番用、Traefikラベル付き)を作成

apps/lineworks-user-auth/docker-compose.yml:

services:
  lineworks-user-auth:
    build: .
    expose:
      - 3000
    env_file:
      - .env
    networks:
      - dokploy-network
    labels:
      - traefik.enable=true
      - traefik.http.routers.lineworks-user-auth-web.rule=Host(`lwauth29.next-hd.net`)
      - traefik.http.routers.lineworks-user-auth-web.entrypoints=web
      - traefik.http.routers.lineworks-user-auth-web.middlewares=redirect-to-https
      - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
      - traefik.http.routers.lineworks-user-auth-web.service=lineworks-user-auth-svc
      - traefik.http.routers.lineworks-user-auth-websecure.rule=Host(`lwauth29.next-hd.net`)
      - traefik.http.routers.lineworks-user-auth-websecure.entrypoints=websecure
      - traefik.http.routers.lineworks-user-auth-websecure.tls.certresolver=letsencrypt
      - traefik.http.routers.lineworks-user-auth-websecure.service=lineworks-user-auth-svc
      - traefik.http.services.lineworks-user-auth-svc.loadbalancer.server.port=3000
    restart: unless-stopped

networks:
  dokploy-network:
    external: true

commit:

git add apps/lineworks-user-auth/docker-compose.yml
git commit -m "feat(lineworks-user-auth): 本番用docker-compose.ymlを追加"
git push gitea main
  • Step 5: Dokploy上で環境変数(.env相当)を設定(Dokploy管理画面、またはCLIで。実際のLW_OAUTH_CLIENT_ID等の秘密値をユーザーに確認しながら設定)

  • Step 6: デプロイ実行(ユーザー確認後に実行、<composeId>はStep 2の結果)

dokploy compose deploy --composeId "<composeId>" --title "初回デプロイ" --json
  • Step 7: 外部疎通確認
curl -I https://lwauth29.next-hd.net/health

Expected: HTTP/2 200

  • Step 8: SSH経由でコンテナ状態確認
ssh -i Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
  "sudo docker ps --filter name=lineworks-user-auth"

完了後

このプランの実装が完了したら、[project_lineworks_form_pleasanter_sync]側の実装計画を別途作成する。そちらは本アプリのGET /token?key=lineworks-form-syncを呼ぶだけで済み、OAuth関連のコードは持たない。