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