diff --git a/README.md b/README.md index 044ab43..d225e9a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,18 @@ pnpm dev または `examples/sample-project` をプロジェクトディレクトリとして読み込むとデモが確認できる。 +### 外部 MCP (Atlassian) の OAuth セットアップ + +ADR-0011 で Tally は OAuth 2.1 フローをプロセス内で完結させる設計に統一した。Atlassian MCP を使う場合は以下の手順: + +1. **OAuth client を Atlassian で発行**: developer.atlassian.com → OAuth 2.0 (3LO) アプリを新規作成。redirect URI は loopback (`http://127.0.0.1:/callback`) を登録する。Atlassian の developer console は redirect URI を完全一致で検証するため、Tally を一度起動して認証ボタンを押すと表示される **実際のポート番号** をコピーして登録する必要がある (例: `http://127.0.0.1:54801/callback`)。今後 Atlassian が「loopback を port 任意で許可」する仕様改定 (RFC 8252) に追従するまでの暫定運用。詳細は [Atlassian OAuth 2.1 docs](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) を参照 +2. **Tally の Project Settings を開く** (歯車アイコン) → 「MCP サーバーを追加」 +3. **id / name / url / OAuth Client ID** を入力して **保存** (id は `atlassian` を推奨。`mcp____*` の wildcard が AI tool 名に展開されるため) +4. 保存後に同じ行に表示される **「🔓 認証 (新規タブ)」ボタン** をクリック → 別タブで Atlassian の認可画面が開く → 承認すると自動でカードが「認証済」に切り替わる +5. Chat で `@JIRA EPIC-1` のように外部 MCP ツールを呼べるようになる + +トークン期限は `buildMcpServers` が透過的に refresh する (5 分以内に切れる場合 `refresh_token` を使って自動更新)。refresh が失敗した場合 (token revoked 等) は再度 Settings から認証する必要がある。token は `/oauth/.yaml` に file mode 600 で保存される (ADR-0011)。 + ## ドキュメント 実装に着手する前に、最低でも以下を読んでください。 diff --git a/docs/adr/0011-tally-managed-oauth-flow.md b/docs/adr/0011-tally-managed-oauth-flow.md index 2d0de68..38cfc93 100644 --- a/docs/adr/0011-tally-managed-oauth-flow.md +++ b/docs/adr/0011-tally-managed-oauth-flow.md @@ -1,7 +1,7 @@ # ADR-0011: 外部 MCP サーバーの OAuth 2.1 フローを Tally 側で管理する - **日付**: 2026-05-02 -- **ステータス**: Proposed +- **ステータス**: Accepted (PR-E5 merge をもって確定。PR-E1 〜 PR-E5 で実装) ## コンテキスト @@ -123,17 +123,18 @@ token expiry が近ければ `OAuthClient.refresh` を呼んでから注入す これにより chat-runner は OAuth を完全に意識しないシンプルな構造に戻る。 -## 実装段階 (PR 分割案) +## 実装段階 (PR 分割実績) -| PR | 範囲 | 規模 | +| PR | 範囲 | 状態 | |---|---|---| -| **PR-E1** | core/schema.ts に oauth 設定追加、token store ファイル形式定義 + storage パッケージ実装 | 小 | -| **PR-E2** | OAuthClient (PKCE / token 交換 / refresh) + LoopbackCallbackServer 実装 | 中 | -| **PR-E3** | API endpoint (`POST /api/mcp//oauth/start` 等) + UI 統合 (`AuthRequestCard` の改修) | 中 | -| **PR-E4** | `buildMcpServers` に token 注入、`ChatRunner` から OAuth 関連削除 (auth-detector / handleAuthToolResult / runOAuthCallback) | 中 | -| **PR-E5** | E2E テスト (real OAuth flow を mock したシナリオ)、ドキュメント整備 | 中 | +| **PR-E1** (#29) | core/schema.ts に oauth 設定追加、token store ファイル形式定義 + storage パッケージ実装 | ✅ Merged | +| **PR-E2** (#30) | OAuthClient (PKCE / token 交換 / refresh) + LoopbackCallbackServer 実装 | ✅ Merged | +| **PR-E3a** (#31) | OAuthFlowOrchestrator (Route Handler から呼べる singleton state + runId guard / preempt 防御) | ✅ Merged | +| **PR-E3b** (#32) | Route Handler `/api/projects/[id]/mcp/[mcpServerId]/oauth` (POST/GET/DELETE) + AuthRequestCard を Route Handler 駆動に改修 (paste UX 廃止) | ✅ Merged | +| **PR-E4** (#33) | `buildMcpServers` に token 注入、`ChatRunner` から OAuth 関連削除 (auth-detector / handleAuthToolResult / runOAuthCallback / chat_auth_request event)、AuthRequestCard を chat → project settings に移管 | ✅ Merged | +| **PR-E5** | `buildMcpServers` の expiry 近接 token 自動 refresh、E2E テスト、docs 整備 | 🚧 (本 PR) | -各 PR は独立に merge 可能な設計にする (PR-E4 は PR-E1 〜 E3 がすべて main に入った後)。 +PR-E3 は当初 1 PR の予定だったが diff 量が大きくなり orchestrator (E3a) と Route Handler+UI (E3b) に分割した。各 PR は独立に merge 可能。 ## 影響 @@ -166,6 +167,13 @@ PoC / 個人開発段階のため **後方互換は不要**。具体的には: - PR #21 (merged): OAuth 2.1 pivot - ADR-0006: Claude Code OAuth for Agent SDK (Tally プロセス全体の OAuth) -## 補足: 暫定処置 +## 補足: 暫定処置 (実装中) ADR-0011 の実装が完了するまでは PR-C (long-lived 統合) のままで運用する。CodeRabbit の指摘は「設計トレードオフ + ADR で trace」として受容している。 + +## 完了後の確定事項 (PR-E5 時点) + +- Token refresh は `buildMcpServers` が透過的に処理する。`expiresAt - now ≤ 5 min` で `refreshToken` があれば `refreshAccessToken` を呼び、新 access_token を header に注入し、新 token を `FileSystemOAuthStore` に書き戻す。refresh 失敗 (refresh_token revoked 等) は header 無しで構築 → MCP 401 → UI 側 AuthRequestCard で再認証 +- 旧 `auth_request` ChatBlock / `chat_auth_request` ChatEvent / `oauth_callback` WS message / `runOAuthCallback` メソッド / `auth-detector.ts` はすべて削除済み (PR-E4) +- AuthRequestCard は `packages/frontend/src/components/mcp/auth-request-card.tsx` に独立し、project settings 画面の MCP server 行から呼び出される +- 永続化済み + clientId 入力済 + 編集なし のときだけ Connect ボタンが描画される (`isOAuthConnectable`)。未保存変更があれば「先に保存」と促す diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts index ffe5b6a..4fedc20 100644 --- a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts +++ b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts @@ -1,24 +1,28 @@ -import type { McpOAuthToken } from '@tally/core'; +import { ATLASSIAN_CLOUD_OAUTH, type McpOAuthToken } from '@tally/core'; import type { OAuthStore } from '@tally/storage'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildMcpServers } from './build-mcp-servers'; -// PR-E4 の token 注入を検証するためのテスト用 OAuthStore。 -// `read(id)` が指定 map から token を返す簡易実装。 -function makeOAuthStore(map: Record): OAuthStore { +// PR-E4 の token 注入 + PR-E5 の refresh 検証用 OAuthStore モック。 +// write は内部 map を更新するので refresh 後の永続化を assert できる。 +function makeOAuthStore(initial: Record): OAuthStore & { + current: Record; +} { + const current = { ...initial }; return { + current, async read(id: string) { - return map[id] ?? null; + return current[id] ?? null; }, - async write(_token) { - // テストでは write は使わない。 + async write(token: McpOAuthToken) { + current[token.mcpServerId] = token; }, - async delete(_id: string) { - // 同上。 + async delete(id: string) { + delete current[id]; }, async list() { - return Object.keys(map); + return Object.keys(current); }, }; } @@ -33,6 +37,11 @@ const baseAtlassianConfig = { }; describe('buildMcpServers', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', async () => { const result = await buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, @@ -178,4 +187,173 @@ describe('buildMcpServers', () => { expect(second.headers).toBeUndefined(); expect(result.allowedTools).toEqual(['mcp__tally__*', 'mcp__first__*', 'mcp__second__*']); }); + + // PR-E5: refresh 自動化の検証。expiresAt が REFRESH_BUFFER (5 分) 以内 + refreshToken あり + // → token endpoint を呼び、新 access_token で header を構築 + store に書き戻す。 + describe('PR-E5: token refresh on expiry', () => { + it('expiry 直前 + refreshToken あり → refresh して新 access_token を注入 + store に書き戻し', async () => { + // expiresAt = now + 1 min (REFRESH_BUFFER の 5 分以内 → refresh 対象) + const aboutToExpire = new Date(Date.now() + 60_000).toISOString(); + const fetchMock = vi.fn(async (url: string | URL) => { + const u = typeof url === 'string' ? url : url.toString(); + if (u === ATLASSIAN_CLOUD_OAUTH.tokenEndpoint) { + return new Response( + JSON.stringify({ + access_token: 'new-tok', + refresh_token: 'new-refresh', + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:jira-work offline_access', + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch: ${u}`); + }); + vi.stubGlobal('fetch', fetchMock); + + const store = makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'old-tok', + refreshToken: 'r-old', + acquiredAt: new Date(Date.now() - 3540_000).toISOString(), + expiresAt: aboutToExpire, + tokenType: 'Bearer', + }, + }); + + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: store, + }); + + // header は新 access_token + const atlassian = result.mcpServers.atlassian as { headers?: Record }; + expect(atlassian.headers).toEqual({ Authorization: 'Bearer new-tok' }); + // token endpoint が 1 回呼ばれている (= refresh) + expect(fetchMock).toHaveBeenCalledTimes(1); + // store に新 token が書き戻されている + const persisted = store.current.atlassian; + expect(persisted?.accessToken).toBe('new-tok'); + expect(persisted?.refreshToken).toBe('new-refresh'); + expect(persisted?.scopes).toEqual(['read:jira-work', 'offline_access']); + }); + + it('refresh response が新 refresh_token を返さない場合は旧 refresh_token を保持 (rotate 無し provider)', async () => { + const aboutToExpire = new Date(Date.now() + 60_000).toISOString(); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + access_token: 'rotated-access', + expires_in: 3600, + token_type: 'Bearer', + // refresh_token 未返却 (= rotate 無し) + }), + { status: 200 }, + ), + ), + ); + const store = makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'old', + refreshToken: 'kept-refresh', + acquiredAt: new Date(Date.now() - 3540_000).toISOString(), + expiresAt: aboutToExpire, + tokenType: 'Bearer', + scopes: ['read:jira-work'], + }, + }); + + await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: store, + }); + + expect(store.current.atlassian?.refreshToken).toBe('kept-refresh'); + // refresh が scope を返さなかったので元の scopes が維持される + expect(store.current.atlassian?.scopes).toEqual(['read:jira-work']); + }); + + it('refresh 失敗 (token endpoint が 4xx) → 過去 token は null 扱い、header 無し', async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('invalid_grant', { status: 400 })), + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'expired', + refreshToken: 'revoked', + acquiredAt: '2020-01-01T00:00:00Z', + expiresAt: past, + tokenType: 'Bearer', + }, + }), + }); + + const atlassian = result.mcpServers.atlassian as { headers?: unknown }; + expect(atlassian.headers).toBeUndefined(); + // 詳細は server log + expect(warnSpy.mock.calls.some((c) => /token refresh failed/.test(c.join(' ')))).toBe(true); + }); + + it('refreshToken が無い & expired → null (header 無し)、token endpoint は呼ばない', async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'old', + // refreshToken 無し + acquiredAt: '2020-01-01T00:00:00Z', + expiresAt: past, + tokenType: 'Bearer', + }, + }), + }); + const atlassian = result.mcpServers.atlassian as { headers?: unknown }; + expect(atlassian.headers).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('expiresAt が REFRESH_BUFFER より遠ければ refresh しない (毎ターン refresh しない)', async () => { + const farFuture = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); // 6 時間後 + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'fresh-tok', + refreshToken: 'r', + acquiredAt: new Date().toISOString(), + expiresAt: farFuture, + tokenType: 'Bearer', + }, + }), + }); + const atlassian = result.mcpServers.atlassian as { headers?: Record }; + expect(atlassian.headers).toEqual({ Authorization: 'Bearer fresh-tok' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.ts b/packages/ai-engine/src/mcp/build-mcp-servers.ts index c3bb83f..d6898f2 100644 --- a/packages/ai-engine/src/mcp/build-mcp-servers.ts +++ b/packages/ai-engine/src/mcp/build-mcp-servers.ts @@ -1,6 +1,13 @@ -import type { McpServerConfig } from '@tally/core'; +import { + type McpOAuthToken, + type McpServerConfig, + OAUTH_REGISTRY, + type OAuthKind, +} from '@tally/core'; import type { OAuthStore } from '@tally/storage'; +import { refreshAccessToken } from '../oauth/oauth-client'; + // SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 // chat-runner / agent-runner が共通で使える shape にする。 // @@ -10,7 +17,16 @@ import type { OAuthStore } from '@tally/storage'; // 場合は header 無しで construct する → MCP server 側が 401 を返し、UI 側は AuthRequestCard // (project settings) 経由で認証フローを走らせる想定。 // +// PR-E5: expiry が近い (REFRESH_BUFFER_MS 以内) または既に過去なら、refresh_token があれば +// transparent に refresh して store に書き戻す。refresh 失敗 (refresh_token 失効等) や +// refresh_token が無い場合は token null 扱いで header を付けない → MCP 側 401 → UI 再認証。 +// // allowedTools は wildcard `mcp____*` (Spike 0b 確認済、Claude Code 2.1.117+ サポート)。 + +// expiresAt - now が このバッファ以内なら refresh を試行する。5 分の余裕を見ておけば +// 通信遅延 + tool 呼び出し中の expiry を防げる。短すぎると毎ターン refresh する羽目になる。 +const REFRESH_BUFFER_MS = 5 * 60 * 1000; + export interface BuildMcpServersInput { // createSdkMcpServer で組み立てた Tally MCP。ここでは opaque。 tallyMcp: unknown; @@ -32,15 +48,8 @@ export async function buildMcpServers(input: BuildMcpServersInput): Promise = { tally: tallyMcp }; const allowedTools: string[] = ['mcp__tally__*']; - const now = Date.now(); for (const cfg of configs) { - const token = await oauthStore.read(cfg.id); - // codex Major 対応: expiresAt が過去なら token は null 扱い。期限切れを注入すると - // MCP サーバーが 401 を返し、AI ループの tool_result に紛れて UI には認証問題が - // 通知されないため。期限切れ検知時は header 無しで構築 → MCP 側 401 → UI 側は - // project settings の AuthRequestCard で再認証を促す (PR-E5 で refresh 自動化予定)。 - const expired = token?.expiresAt !== undefined && Date.parse(token.expiresAt) <= now; - const usable = token && !expired ? token : null; + const usable = await loadUsableToken(cfg, oauthStore); const headers: Record = {}; if (usable) headers.Authorization = `${usable.tokenType} ${usable.accessToken}`; mcpServers[cfg.id] = { @@ -53,3 +62,86 @@ export async function buildMcpServers(input: BuildMcpServersInput): Promise { + const token = await oauthStore.read(cfg.id); + if (!token) return null; + + const now = Date.now(); + const expiresAtMs = token.expiresAt !== undefined ? Date.parse(token.expiresAt) : undefined; + const expiresSoon = expiresAtMs !== undefined && expiresAtMs - now <= REFRESH_BUFFER_MS; + const expired = expiresAtMs !== undefined && expiresAtMs <= now; + + // expiresAt 不明 (provider が expires_in を返さなかった) は注入してみる: MCP 側 401 で + // 検知することになるが、不明なら盲目的に短期間 refresh するより素朴な方が予測しやすい。 + if (!expiresSoon) return token; + + // refresh_token が無いなら refresh 不能。token が完全に過去なら null 返し (header 無し)、 + // まだ有効期限内 (expiresSoon だが expired ではない) ならそのまま注入して 1 回使う。 + if (!token.refreshToken) { + return expired ? null : token; + } + + // kind が registry に無い provider は refresh 経路を持たないので fallback する。 + const kind = cfg.kind as OAuthKind; + const provider = OAUTH_REGISTRY[kind]; + if (!provider) { + return expired ? null : token; + } + + try { + const refreshed = await refreshAccessToken({ + provider, + clientId: cfg.oauth.clientId, + refreshToken: token.refreshToken, + }); + // CR Major 対応 (codex): expires_in は token endpoint レスポンス受領時を起点に計算する。 + // refresh の HTTP ラウンドトリップ後に Date.now() を取り直さないと、最初の `now` から + // 数秒早く期限切れに見える (バッファに収まる範囲だが、厳密性のため再取得)。 + const issuedAt = Date.now(); + const acquiredAt = new Date(issuedAt).toISOString(); + const newExpiresAt = + refreshed.expiresIn !== undefined + ? new Date(issuedAt + refreshed.expiresIn * 1000).toISOString() + : undefined; + const scopesParsed = refreshed.scope?.split(/\s+/).filter(Boolean); + // 一部 provider は refresh 時に新 refresh_token を返さない (rotate 無し)。その場合は + // 旧 refresh_token をそのまま保持する (RFC 6749 §6 互換)。 + const newRefresh = refreshed.refreshToken ?? token.refreshToken; + const updated: McpOAuthToken = { + mcpServerId: cfg.id, + accessToken: refreshed.accessToken, + refreshToken: newRefresh, + acquiredAt, + ...(newExpiresAt !== undefined ? { expiresAt: newExpiresAt } : {}), + ...(scopesParsed && scopesParsed.length > 0 + ? { scopes: scopesParsed } + : token.scopes + ? { scopes: token.scopes } + : {}), + tokenType: refreshed.tokenType, + }; + // CR Major 対応 (codex): 同一プロジェクトで chat-runner と agent-runner が + // 並走したときに refresh が二重発火する race がある。MVP は単一ユーザー前提 + // なので last-write-wins で許容しているが、将来 multi-tenant 化するときは + // refresh 中の他 caller を直列化する mutex が必要 (FileSystemOAuthStore 自体は + // tmp→rename でアトミック書き込み)。 + await oauthStore.write(updated); + return updated; + } catch (err) { + // refresh 失敗 (refresh_token 失効 / revoked / network 失敗 等)。詳細は server log。 + // 過去 token は捨てる方針: 注入すると MCP 401 → AI tool 失敗で UX が悪い。null 返しで + // header 無し → MCP 401 (直接) → UI 側 AuthRequestCard で再認証を促す。 + console.warn( + `[build-mcp-servers] token refresh failed for ${cfg.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + return expired ? null : token; + } +} diff --git a/packages/ai-engine/src/oauth/oauth-e2e.test.ts b/packages/ai-engine/src/oauth/oauth-e2e.test.ts new file mode 100644 index 0000000..3984eca --- /dev/null +++ b/packages/ai-engine/src/oauth/oauth-e2e.test.ts @@ -0,0 +1,169 @@ +// ADR-0011 PR-E5: OAuth フロー全体の E2E テスト。 +// PR-E1 〜 PR-E4 で実装した部品を統合した「authorize → token 交換 → store 永続化 → +// buildMcpServers が header に注入」のシナリオを 1 本通す。 +// +// 実 OAuth provider は使えないので token endpoint を mock し、それ以外 (loopback callback への +// fetch) は real fetch に流す。OAuthFlowOrchestrator の test と同じ手法。 +// +// 検証する範囲: +// 1. orchestrator が authorize URL を発行 +// 2. ブラウザ相当の loopback callback fetch +// 3. orchestrator が token endpoint を叩いて token を取得 +// 4. FileSystemOAuthStore に YAML 永続化 +// 5. buildMcpServers が同 store から token を読み Authorization header を組み立てる +// 6. token 期限が近づいたら refresh して store に書き戻す + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { ATLASSIAN_CLOUD_OAUTH } from '@tally/core'; +import { FileSystemOAuthStore } from '@tally/storage'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildMcpServers } from '../mcp/build-mcp-servers'; +import { __resetAllFlowsForTest, awaitOAuthFlowSettled, startOAuthFlow } from './index'; + +function makeProjectDir(): string { + return mkdtempSync(path.join(tmpdir(), 'tally-oauth-e2e-')); +} + +const PROJECT_ID = 'pE5'; +const ATLASSIAN_CONFIG = { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian' as const, + url: 'https://api.atlassian.com/mcp', + oauth: { clientId: 'cid-e2e' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, +}; + +describe('OAuth E2E (ADR-0011 PR-E5)', () => { + beforeEach(async () => { + await __resetAllFlowsForTest(); + }); + afterEach(async () => { + vi.unstubAllGlobals(); + await __resetAllFlowsForTest(); + }); + + it('authorize → callback → token store → buildMcpServers が Authorization header を注入する', async () => { + const projectDir = makeProjectDir(); + try { + // token endpoint だけ mock、loopback callback への fetch は real に流す。 + // (oauth-flow-orchestrator.test.ts と同じパターン) + const realFetch = globalThis.fetch.bind(globalThis); + vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => { + const u = typeof input === 'string' ? input : input.toString(); + if (u === ATLASSIAN_CLOUD_OAUTH.tokenEndpoint) { + return new Response( + JSON.stringify({ + access_token: 'e2e-access', + refresh_token: 'e2e-refresh', + expires_in: 3600, + scope: 'read:jira-work offline_access', + token_type: 'Bearer', + }), + { status: 200 }, + ); + } + return await realFetch(input, init); + }); + + // 1. orchestrator を start + const { authorizationUrl } = await startOAuthFlow({ + projectId: PROJECT_ID, + mcpServerId: 'atlassian', + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: ATLASSIAN_CONFIG.oauth.clientId, + projectDir, + }); + expect(authorizationUrl).toMatch(/^https:\/\/auth\.atlassian\.com\/authorize\?/); + + // 2. ブラウザ相当: redirect_uri に callback を投げる + const url = new URL(authorizationUrl); + const redirectUri = url.searchParams.get('redirect_uri'); + const state = url.searchParams.get('state'); + if (!redirectUri || !state) throw new Error('invalid auth URL'); + const cbRes = await fetch(`${redirectUri}?code=AAA&state=${encodeURIComponent(state)}`); + expect(cbRes.status).toBe(200); + + // 3. orchestrator の bg promise が settle するまで待つ + await awaitOAuthFlowSettled(PROJECT_ID, 'atlassian'); + + // 4. token store に永続化されていること + const store = new FileSystemOAuthStore(projectDir); + const persisted = await store.read('atlassian'); + expect(persisted?.accessToken).toBe('e2e-access'); + expect(persisted?.refreshToken).toBe('e2e-refresh'); + expect(persisted?.tokenType).toBe('Bearer'); + + // 5. buildMcpServers が同 store から読んで Authorization header を組み立てる + const built = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ATLASSIAN_CONFIG], + oauthStore: store, + }); + const atlassianMcp = built.mcpServers.atlassian as { + type: string; + url: string; + headers?: Record; + }; + expect(atlassianMcp.headers).toEqual({ Authorization: 'Bearer e2e-access' }); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('期限切れ間近の token は buildMcpServers 経由で transparent に refresh される', async () => { + const projectDir = makeProjectDir(); + try { + // 直接 token store に「期限切れ間近」の token を書く (ユーザーが過去に認証済の状態を再現)。 + const store = new FileSystemOAuthStore(projectDir); + const aboutToExpire = new Date(Date.now() + 60_000).toISOString(); + await store.write({ + mcpServerId: 'atlassian', + accessToken: 'old-access', + refreshToken: 'old-refresh', + acquiredAt: new Date(Date.now() - 3540_000).toISOString(), + expiresAt: aboutToExpire, + tokenType: 'Bearer', + scopes: ['read:jira-work'], + }); + + // refresh 用 token endpoint を mock + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + access_token: 'refreshed-access', + refresh_token: 'rotated-refresh', + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:jira-work offline_access', + }), + { status: 200 }, + ), + ), + ); + + // buildMcpServers が refresh を発火させ、新 access_token を header に乗せる + const built = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ATLASSIAN_CONFIG], + oauthStore: store, + }); + const atlassianMcp = built.mcpServers.atlassian as { headers?: Record }; + expect(atlassianMcp.headers).toEqual({ Authorization: 'Bearer refreshed-access' }); + + // store にも書き戻されている (rotation が反映される) + const persisted = await store.read('atlassian'); + expect(persisted?.accessToken).toBe('refreshed-access'); + expect(persisted?.refreshToken).toBe('rotated-refresh'); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); +});