ken_nogi/lineworks-sync/lineworks-anythingllm-auth.md
Kenichiro NOGI ed33892f08 chore: 作業中の変更を整理しコミット(複数プロジェクト分)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:09:50 +09:00

281 lines
16 KiB
Markdown

# lineworks-anythingllm.js 認証処理 抜粋ドキュメント
`lineworks-anythingllm.js` のうち、LINE WORKS 掲示板の**本文記事を読み込む**ために必要な認証処理だけを抜き出したドキュメント。添付ファイルダウンロード時のリダイレクト認証(`downloadAttachment`)は本文記事の読み込みには不要なため対象外。
## 1. 認証方式の概要
LINE WORKS の Service Account 認証(JWT Bearer Grant)。Node.js 標準の `crypto` モジュールで JWT を自前署名し、OAuth2 のトークンエンドポイントにアクセストークンを要求する。外部ライブラリ不使用。
流れ:
1. JWT アサーション(ヘッダ+ペイロード+RS256署名)を自前生成する。
2. トークンエンドポイントに `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` でPOSTし、アクセストークンを取得する。
3. 取得したアクセストークンを `Authorization: Bearer <token>` として各Board APIリクエストに付与する。
4. アクセストークンの有効期限(1時間)に対し、45分経過ごとに自動再取得する。
## 2. 必要な環境変数(認証関連のみ)
`lineworks-anythingllm.js:20-26`
| 変数名 | 既定値 | 用途 |
|---|---|---|
| `LW_CLIENT_ID` | コード内既定値あり | LINE WORKS APIクライアントID |
| `LW_CLIENT_SECRET` | コード内既定値あり | クライアントシークレット |
| `LW_SERVICE_ACCOUNT` | コード内既定値あり | Service Accountのメールアドレス(JWTの`sub`) |
| `LW_PRIVATE_KEY` | — | 秘密鍵の中身を直接渡す場合(未設定時は`LW_PRIVATE_KEY_FILE`を使用) |
| `LW_PRIVATE_KEY_FILE` | `./private_20260307184804.key` | 秘密鍵ファイルのパス |
| `LW_SCOPE` | `board board.read` | 掲示板本文読み取りに必要なスコープ。`board`だけではアクセス不可な場合があるため両方指定 |
## 3. 定数・エンドポイント
`lineworks-anythingllm.js:127-147`
```js
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
const LW_API_BASE_URL = "https://www.worksapis.com/v1.0";
const PRIVATE_KEY_FILE = process.env.LW_PRIVATE_KEY_FILE
? path.resolve(process.env.LW_PRIVATE_KEY_FILE)
: path.join(__dirname, "private_20260307184804.key");
const LW_CLIENT_ID = process.env.LW_CLIENT_ID || "tre8J_Tk8RblfsMSyZRh";
const LW_CLIENT_SECRET = process.env.LW_CLIENT_SECRET || "O5_R5gcHxg";
const LW_SERVICE_ACCOUNT = process.env.LW_SERVICE_ACCOUNT || "wh4k8.serviceaccount@nexthd.jp";
const LW_SCOPE = process.env.LW_SCOPE || "board board.read";
const LW_PRIVATE_KEY = (process.env.LW_PRIVATE_KEY && process.env.LW_PRIVATE_KEY.trim())
? process.env.LW_PRIVATE_KEY
: (fs.existsSync(PRIVATE_KEY_FILE) ? fs.readFileSync(PRIVATE_KEY_FILE, "utf8") : "");
```
秘密鍵は環境変数`LW_PRIVATE_KEY`優先、無ければファイル`PRIVATE_KEY_FILE`から読み込む。
## 4. 事前チェック `ensureRequiredEnvForAuth`
`lineworks-anythingllm.js:149-162`
`LW_CLIENT_ID` / `LW_CLIENT_SECRET` / `LW_SERVICE_ACCOUNT` / `LW_PRIVATE_KEY` のいずれかが欠けている場合にエラーを投げる。秘密鍵が未設定かつファイルも存在しない場合は、ファイルパスを含めたエラーメッセージを出す。
```js
function ensureRequiredEnvForAuth() {
const missing = [];
if (!LW_CLIENT_ID) missing.push("LW_CLIENT_ID");
if (!LW_CLIENT_SECRET) missing.push("LW_CLIENT_SECRET");
if (!LW_SERVICE_ACCOUNT) missing.push("LW_SERVICE_ACCOUNT");
if (!LW_PRIVATE_KEY) missing.push("LW_PRIVATE_KEY");
if (missing.length > 0) {
if (missing.includes("LW_PRIVATE_KEY") && !fs.existsSync(PRIVATE_KEY_FILE)) {
throw new Error(`秘密鍵が見つかりません: ${PRIVATE_KEY_FILE}`);
}
throw new Error(`環境変数が不足しています: ${missing.join(", ")}`);
}
}
```
## 5. JWTアサーション生成 `createJwtAssertion`
`lineworks-anythingllm.js:164-199`
Base64URLエンコード(パディング`=`除去、`+`→`-`、`/`→`_`)は `base64UrlEncode` で共通化。
```js
function base64UrlEncode(value) {
return Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function createJwtAssertion() {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iss: LW_CLIENT_ID,
sub: LW_SERVICE_ACCOUNT,
aud: LW_TOKEN_URL,
iat: now,
exp: now + 300,
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signer = crypto.createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
const signature = signer
.sign(LW_PRIVATE_KEY)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
return `${signingInput}.${signature}`;
}
```
要点:
- `alg: RS256` / `typ: JWT` の固定ヘッダ。
- `iss`=クライアントID、`sub`=Service Accountメール、`aud`=トークンエンドポイントURL。
- `exp`は`iat`から300秒(5分)後。有効期限が短いアサーションを都度生成する。
- 署名は`crypto.createSign("RSA-SHA256")`で秘密鍵(`LW_PRIVATE_KEY`)を使い、`ヘッダ.ペイロード`部分に対して行う。
## 6. アクセストークン取得 `getAccessToken`
`lineworks-anythingllm.js:201-230`
```js
async function getAccessToken(scope = LW_SCOPE) {
ensureRequiredEnvForAuth();
const assertion = createJwtAssertion();
const form = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
client_id: LW_CLIENT_ID,
client_secret: LW_CLIENT_SECRET,
scope,
});
const response = await lwFetch(LW_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`アクセストークン取得失敗: ${response.status} ${detail}`);
}
const data = await response.json();
if (!data.access_token) {
throw new Error("アクセストークン取得失敗: access_token が返却されませんでした");
}
return data.access_token;
}
```
`ensureRequiredEnvForAuth`で事前チェック後、JWTアサーションを`assertion`パラメータに乗せて`LW_TOKEN_URL`にPOSTする。リクエストは`lwFetch`(スロットリング+429リトライ付きの共通fetchラッパー、`lineworks-anythingllm.js:101-123`)経由。レスポンスの`access_token`を返す。
## 7. トークン自動更新
`lineworks-anythingllm.js:237-251`
アクセストークンの有効期限は1時間。掲示板の全件同期など長時間実行時に途中失効(401)するのを防ぐため、45分経過ごとに自動再取得する仕組み。
```js
const TOKEN_REFRESH_INTERVAL_MS = 45 * 60 * 1000; // 45分
function createAuthState(initialToken) {
return { token: initialToken, obtainedAt: Date.now() };
}
async function ensureFreshToken(authState) {
const elapsed = Date.now() - authState.obtainedAt;
if (elapsed > TOKEN_REFRESH_INTERVAL_MS) {
console.log(" [トークン更新] 経過時間が長いためアクセストークンを再取得します...");
authState.token = await getAccessToken();
authState.obtainedAt = Date.now();
}
return authState.token;
}
```
- `createAuthState`: 初回取得済みトークンと取得時刻を保持する状態オブジェクトを作る。
- `ensureFreshToken`: 経過時間が45分を超えていれば`getAccessToken()`を呼び直し、`authState`を更新する。掲示板・投稿を1件処理するたびに呼び出される(下記8章参照)。
## 8. 本文記事取得APIでの認証ヘッダ付与
掲示板本文を読み込む各APIは、取得したアクセストークンを`Authorization: Bearer`ヘッダに付けて呼び出す。パターンは共通。
**掲示板一覧取得** `fetchAllBoards`(`lineworks-anythingllm.js:258-294`)
```js
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
```
**投稿一覧取得** `fetchAllPosts`(`lineworks-anythingllm.js:319-354`)
```js
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
```
**投稿詳細(本文)取得** `fetchPostDetail`(`lineworks-anythingllm.js:356-375`)
```js
async function fetchPostDetail(accessToken, boardId, postId) {
const url = `${LW_API_BASE_URL}/boards/${boardId}/posts/${postId}`;
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
// ...
}
```
`syncCommand`(`lineworks-anythingllm.js:842`〜)内では、掲示板ループ・投稿ループそれぞれの先頭で`ensureFreshToken(authState)`を呼び、常に有効なトークンを取得してから上記3関数に渡している。
## 9. まとめ(呼び出し順)
1. `syncCommand`開始時に`getAccessToken()`で初回トークン取得 → `createAuthState`で状態保持。
2. 掲示板ごとに`ensureFreshToken`でトークン鮮度確認 → `fetchAllPosts`で投稿一覧取得。
3. 投稿ごとに`ensureFreshToken`でトークン鮮度確認 → `fetchPostDetail`で本文取得。
4. 45分経過していれば`ensureFreshToken`内部で`getAccessToken()`が再実行され、JWTアサーションから作り直される。
## 10. LINE WORKS Developer Console側の事前設定(Redirect URL)
コード上には現れないが、認証が動作するための前提としてLINE WORKS Developer Console側でAPIクライアントに対しRedirect URLの設定が必要。
- 対象箇所: LINE WORKS Developer Console →該当のAPIクライアント(`LW_CLIENT_ID`に対応するクライアント) → 認証設定(OAuth設定)
- 本スクリプトが使う認証方式はJWT Bearer Grant(Service Account認証、`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`)であり、Authorization Code Grantのようにブラウザ経由でRedirect URLへ実際にコールバックされるわけではない。
- ただしLINE WORKS Developer Console上、APIクライアント作成・編集画面ではRedirect URLが必須入力項目になっており、ここが未設定/不正だとアクセストークン取得(`getAccessToken`)がエラーになる場合がある。
- 値そのものはこの認証フローでは使用されないため、到達可能なURLである必要はない(プレースホルダー的なURLで登録して問題ない運用が一般的)。ただし登録自体は必須。
- 設定・確認手順:
1. LINE WORKS Developer Console(https://developers.worksmobile.com/jp/console/) にログイン。
2. 対象のAPIクライアントを開く。
3. 「Redirect URL」欄が未設定の場合は任意のURLを1件登録して保存する(過去の運用実績値: `https://example.com/callback`)。
4. `LW_SCOPE`(既定 `board board.read`)がクライアントに許可されているスコープに含まれているかも合わせて確認する。
## 11. 補足: 初回の個人ユーザー認証(要確認事項)
**この節は運用上の経験則であり、手順の詳細は未確定。実際に掲示板APIが401/403で失敗する場合の調査観点として記載する。**
- 過去の経験として、Redirect URLに`https://example.com/callback`を設定しただけでなく、Service Account(JWT Bearer Grant)のトークン取得とは別に、**個人ユーザーアカウントによるOAuth2認可コードフローの同意を一度通す**操作をしないと、`board`スコープでの掲示板記事の読み込みが実際には通らなかった、という認識がある。
- 具体的な操作手順(どのURLにアクセスしたか、どのアカウント権限が必要だったか等)は未確認。今回の調査ではここまでの裏取りができていない。
- もし本スクリプトで掲示板本文取得(`fetchAllBoards`/`fetchAllPosts`/`fetchPostDetail`)が権限エラーになる場合は、以下を疑って調査する:
1. LINE WORKS Developer Consoleで対象APIクライアントに対し、`board`/`board.read`スコープの**管理者による認可(同意)**が実際に完了しているか。
2. 完了していない場合、LINE WORKS公式のOAuth2認可コードフロー仕様(https://developers.worksmobile.com/jp/docs/auth-oauth)に従い、ブラウザで認可URLにアクセスし、対象アカウントでログイン・同意する操作が必要になる可能性がある。
3. この同意操作の再現手順が判明次第、本節を具体的な手順書として更新すること。
## 12. 実機テスト結果(2026-07-30実施)
上記11章の「Service Accountのみでは401/403になる」という記憶を検証するため、`test-board-posts.ps1`を使い、Service Account(JWT Bearer Grant)のアクセストークンのみで実際に掲示板記事を読み込めるか実機テストした。
**テスト対象**: board `4080000000758656266`(全社掲示板(通達)、`board-list.csv`でflag=1の対象)
**テスト1: 投稿一覧取得** `GET /v1.0/boards/{boardId}/posts?count=3`
**200 OK**。3件の投稿(タイトル・投稿者・日時等のメタ情報)を取得できた。
**テスト2: 投稿詳細(本文)取得** `GET /v1.0/boards/{boardId}/posts/{postId}`
**200 OK**。`body`(HTML)・`plainTextBody`(プレーンテキスト)フィールドを含む記事本文そのものを取得できた。401/403は発生しなかった。
**結論**:
- 今回使用したAPIクライアントでは、Service Account単体のJWT Bearer Grantだけで掲示板の投稿一覧・本文詳細どちらも問題なく取得できた。個人ユーザー認証の追加操作は不要だった。
- ただし、テストに使われたクライアントは`.env`で設定された`LW_CLIENT_ID=b6UHrJEXVKbcwL8AVdCO` / `LW_SERVICE_ACCOUNT=v4s3m.serviceaccount@next-hd.co.jp`(秘密鍵: `private_20260709122316.key`)であり、`lineworks-anythingllm.js`コード内に書かれているデフォルト値(`LW_CLIENT_ID=tre8J_Tk8RblfsMSyZRh` / `LW_SERVICE_ACCOUNT=wh4k8.serviceaccount@nexthd.jp`、鍵: `private_20260307184804.key`)とは別物の**異なるAPIクライアント**。
- 今回テストしたクライアントは、Developer Console側で`board`/`board.read`スコープの認可(同意)がすでに完了済みの状態だった可能性が高い。ユーザーの記憶にある「Service Accountのみでは401/403だった」という経験は、このクライアントの**初回セットアップ時(認可未完了状態)**、またはコード内デフォルト値の**別クライアント(`tre8J_Tk8RblfsMSyZRh`)**での出来事である可能性がある。
- 結果として、今回の実機テストでは11章の権限不足エラーを再現できなかった。11章に記載した「Redirect URL設定+個人ユーザー認証が必須」という運用手順自体は否定されたわけではなく、**現在使用中のクライアントは認可済みのため症状が出ていないだけ**という可能性が高い。未認可の新規クライアントでの再現確認は未実施。
- **Redirect URL(`https://example.com/callback`)の位置づけ**: JWT Bearer Grant(`getAccessToken`)の実行そのものではこの値は一切参照されず、その意味では「無意味」に見える。しかし OAuth2 の仕様上、認可コードグラントの `authorize` リクエストで渡す `redirect_uri` は Developer Console 登録値と一致している必要がある。11章の記憶が事実なら、初回に `board`/`board.read` スコープの同意を通した際、この登録済み Redirect URL を `redirect_uri` として使って一度だけ認可コードフローを実行した可能性が高い。つまり「今動いている理由」ではなく「そもそも動く状態(スコープ同意済み)を一度作るために必要だった値」であり、今後スコープを追加したり別クライアントを新規作成する際には再び必要になる。登録自体は無駄ではない。