From 7a45430799d7d372941bd2f34c5939e4af8ba276 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Sat, 2 May 2026 11:47:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(ai-engine):=20ADR-0011=20PR-E2=20?= =?UTF-8?q?=E2=80=94=20OAuthClient=20+=20LoopbackCallbackServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0011 の実装段階 2。issue #28 への対応として、外部 MCP server に対する OAuth 2.1 (Authorization Code + PKCE) クライアントを実装する。本 PR でも OAuth フロー全体は接続されない (PR-E3 で API + UI、PR-E4 で SDK 統合)。 ## 設計判断 - Node 標準のみ (依存追加なし、oauth4webapi 等は使わない) - PKCE は S256 のみ (RFC 7636 推奨) - state の verify は呼び出し側 (PR-E3 の Orchestrator) の責務 - LoopbackCallbackServer は port=0 で OS 採番、host は 127.0.0.1 固定 - token endpoint は application/x-www-form-urlencoded で叩く ## 新規ファイル - packages/ai-engine/src/oauth/oauth-client.ts: PKCE / state / authorization URL / token 交換 / refresh - packages/ai-engine/src/oauth/oauth-client.test.ts: 12 件 - packages/ai-engine/src/oauth/loopback-callback-server.ts: 一時 HTTP server (127.0.0.1, port=0, callback path 限定、HTML escape、timeout、冪等 close) - packages/ai-engine/src/oauth/loopback-callback-server.test.ts: 10 件 - packages/ai-engine/src/oauth/index.ts: 公開 export ## packages/core 拡張 - OAuthProviderConfig.prompt?: string を追加 (Atlassian 用に 'consent')。 ハードコードを避けて将来の prompt 不要 provider を許容する。 - OAUTH_REGISTRY を as const satisfies Readonly<...> に変更 - OAuthProviderConfig 型を core から公開 export ## codex セカンドオピニオン対応 [MEDIUM] close() で pending awaitCallback を reject 発火 (永久 pending リーク防止) [MEDIUM] prompt=consent を OAuthProviderConfig に移して provider 制御化 [LOW] access_token を typeof + 空文字列で明示チェック [LOW] close 中の pending awaitCallback の reject test を追加 pnpm typecheck 4/4 PASS / pnpm test core 99 + storage 97 + ai-engine 235 + frontend 273 = 704 PASS / pnpm lint exit 0。 --- packages/ai-engine/src/oauth/index.ts | 18 ++ .../oauth/loopback-callback-server.test.ts | 117 ++++++++++ .../src/oauth/loopback-callback-server.ts | 157 +++++++++++++ .../ai-engine/src/oauth/oauth-client.test.ts | 218 ++++++++++++++++++ packages/ai-engine/src/oauth/oauth-client.ts | 157 +++++++++++++ packages/core/src/index.ts | 7 +- packages/core/src/oauth/atlassian.ts | 11 +- packages/core/src/oauth/index.ts | 7 +- 8 files changed, 688 insertions(+), 4 deletions(-) create mode 100644 packages/ai-engine/src/oauth/index.ts create mode 100644 packages/ai-engine/src/oauth/loopback-callback-server.test.ts create mode 100644 packages/ai-engine/src/oauth/loopback-callback-server.ts create mode 100644 packages/ai-engine/src/oauth/oauth-client.test.ts create mode 100644 packages/ai-engine/src/oauth/oauth-client.ts diff --git a/packages/ai-engine/src/oauth/index.ts b/packages/ai-engine/src/oauth/index.ts new file mode 100644 index 0000000..a46ea30 --- /dev/null +++ b/packages/ai-engine/src/oauth/index.ts @@ -0,0 +1,18 @@ +export { + type LoopbackCallback, + type LoopbackCallbackHandle, + type StartLoopbackCallbackServerOptions, + startLoopbackCallbackServer, +} from './loopback-callback-server'; +export { + type BuildAuthorizationUrlInput, + buildAuthorizationUrl, + type ExchangeCodeInput, + exchangeCodeForToken, + generateOAuthState, + generatePkcePair, + type PkcePair, + type RefreshTokenInput, + refreshAccessToken, + type TokenExchangeResult, +} from './oauth-client'; diff --git a/packages/ai-engine/src/oauth/loopback-callback-server.test.ts b/packages/ai-engine/src/oauth/loopback-callback-server.test.ts new file mode 100644 index 0000000..5827329 --- /dev/null +++ b/packages/ai-engine/src/oauth/loopback-callback-server.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { startLoopbackCallbackServer } from './loopback-callback-server'; + +describe('startLoopbackCallbackServer', () => { + it('redirectUri は http://127.0.0.1:/callback 形式 (port は OS 採番)', async () => { + const handle = await startLoopbackCallbackServer(); + try { + expect(handle.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); + } finally { + await handle.close(); + } + }); + + it('callback URL を叩くと awaitCallback が code/state で resolve する', async () => { + const handle = await startLoopbackCallbackServer(); + try { + const promise = handle.awaitCallback(); + // ブラウザの redirect 相当を fetch で再現 + const callbackRes = await fetch(`${handle.redirectUri}?code=AAA&state=xyz`); + expect(callbackRes.status).toBe(200); + const got = await promise; + expect(got).toEqual({ code: 'AAA', state: 'xyz' }); + } finally { + await handle.close(); + } + }); + + it('error= 付き callback は awaitCallback を reject + 400 を返す', async () => { + const handle = await startLoopbackCallbackServer(); + try { + const promise = handle.awaitCallback(); + // unhandled rejection 抑止: rejection を先に観測予約しておかないと、 + // Vitest が fetch との race で reject を unhandled として記録する。 + promise.catch(() => {}); + const res = await fetch( + `${handle.redirectUri}?error=access_denied&error_description=user%20canceled`, + ); + expect(res.status).toBe(400); + await expect(promise).rejects.toThrow(/access_denied/); + } finally { + await handle.close(); + } + }); + + it('code/state が無い callback は reject + 400', async () => { + const handle = await startLoopbackCallbackServer(); + try { + const promise = handle.awaitCallback(); + promise.catch(() => {}); + const res = await fetch(`${handle.redirectUri}?code=onlyCode`); + expect(res.status).toBe(400); + await expect(promise).rejects.toThrow(/missing code or state/); + } finally { + await handle.close(); + } + }); + + it('callback path 以外のリクエストは 404 (favicon 等のノイズ対策)', async () => { + const handle = await startLoopbackCallbackServer(); + try { + const res = await fetch(`http://127.0.0.1:${new URL(handle.redirectUri).port}/favicon.ico`); + expect(res.status).toBe(404); + } finally { + await handle.close(); + } + }); + + it('timeout で awaitCallback が reject する', async () => { + const handle = await startLoopbackCallbackServer(); + try { + const start = Date.now(); + await expect(handle.awaitCallback(50)).rejects.toThrow(/timeout/); + // 50ms ちょうどで止まる保証はないが、明らかに長すぎないことだけ確認 + expect(Date.now() - start).toBeLessThan(2000); + } finally { + await handle.close(); + } + }); + + it('close は冪等 (二度呼んでも throw しない)', async () => { + const handle = await startLoopbackCallbackServer(); + await handle.close(); + await expect(handle.close()).resolves.toBeUndefined(); + }); + + it('close() で pending な awaitCallback が reject される (永久 pending リーク防止)', async () => { + const handle = await startLoopbackCallbackServer(); + const promise = handle.awaitCallback(); + promise.catch(() => {}); + await handle.close(); + await expect(promise).rejects.toThrow(/server closed/); + }); + + it('preferredPort 指定時はその port で listen (port=0 は OS 採番)', async () => { + // 0 を指定したときと省略時は同じ挙動 (OS 採番)。 + const a = await startLoopbackCallbackServer({ preferredPort: 0 }); + try { + expect(Number(new URL(a.redirectUri).port)).toBeGreaterThan(0); + } finally { + await a.close(); + } + }); + + it('path カスタマイズで redirect_uri が変わる', async () => { + const handle = await startLoopbackCallbackServer({ path: '/oauth/callback' }); + try { + expect(handle.redirectUri).toMatch(/\/oauth\/callback$/); + const promise = handle.awaitCallback(); + await fetch(`${handle.redirectUri}?code=A&state=S`); + const got = await promise; + expect(got.code).toBe('A'); + } finally { + await handle.close(); + } + }); +}); diff --git a/packages/ai-engine/src/oauth/loopback-callback-server.ts b/packages/ai-engine/src/oauth/loopback-callback-server.ts new file mode 100644 index 0000000..52cef45 --- /dev/null +++ b/packages/ai-engine/src/oauth/loopback-callback-server.ts @@ -0,0 +1,157 @@ +// ADR-0011 PR-E2: OAuth callback URL を loopback IP (127.0.0.1) で受ける一時 HTTP server。 +// +// 設計判断: +// - port は OS 採番 (0 を渡す)。固定 port にすると複数フローや他プロセスとの衝突が発生する。 +// - host は 127.0.0.1 固定。`localhost` だと IPv4/IPv6 どちらに bind するか実装依存で +// redirect_uri と一致しないことがある。 +// - state 検証は本モジュールで行わない (orchestrator 側の責務)。受領した code/state を +// そのまま callback API で返す。 +// - レスポンスは「タブを閉じてください」の最小 HTML。CSRF / XSS 対策で content type を +// text/plain でも良いが、UX を考えて最小 HTML にする。 +// - timeout は呼び出し側で指定 (デフォルト 5 分)。timeout で reject されたあとも close() +// を呼べばリソース解放される。 + +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface LoopbackCallback { + code: string; + state: string; +} + +export interface LoopbackCallbackHandle { + // ブラウザに渡す redirect_uri (例: http://127.0.0.1:54321/callback)。 + redirectUri: string; + // callback の到達を待つ。1 ハンドル 1 回だけ resolve する設計。 + awaitCallback(timeoutMs?: number): Promise; + // server を閉じる。再呼び出しは no-op。 + close(): Promise; +} + +export interface StartLoopbackCallbackServerOptions { + // callback path (default: '/callback') + path?: string; + // 希望 port (default: 0 = OS 採番)。固定 port が必要な provider 設定の場合のみ指定する。 + preferredPort?: number; +} + +export async function startLoopbackCallbackServer( + opts: StartLoopbackCallbackServerOptions = {}, +): Promise { + const callbackPath = opts.path ?? '/callback'; + const port = opts.preferredPort ?? 0; + + let resolveCallback: ((cb: LoopbackCallback) => void) | null = null; + let rejectCallback: ((err: Error) => void) | null = null; + let timeoutHandle: NodeJS.Timeout | null = null; + + const cleanup = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + resolveCallback = null; + rejectCallback = null; + }; + + const server: Server = createServer((req, res) => { + // path が違うものは 404。`favicon.ico` 等のノイズ対策。 + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname !== callbackPath) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + if (error) { + // Provider が OAuth error を返したケース (access_denied 等)。 + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end( + `

認証エラー

${escapeHtml(error)}: ${escapeHtml(errorDescription ?? '')}

`, + ); + const reject = rejectCallback; + cleanup(); + reject?.(new Error(`OAuth callback error: ${error} ${errorDescription ?? ''}`)); + return; + } + + if (!code || !state) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end( + '

認証エラー

code または state が見つかりません。

', + ); + const reject = rejectCallback; + cleanup(); + reject?.(new Error('OAuth callback missing code or state')); + return; + } + + // 成功レスポンス。ブラウザに「タブを閉じて Tally に戻ってください」と促す。 + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end( + '

認証完了

このタブを閉じて Tally に戻ってください。

', + ); + const resolve = resolveCallback; + cleanup(); + resolve?.({ code, state }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + + const addr = server.address() as AddressInfo; + const redirectUri = `http://127.0.0.1:${addr.port}${callbackPath}`; + + let closed = false; + + return { + redirectUri, + async awaitCallback(timeoutMs = 5 * 60 * 1000): Promise { + return new Promise((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + const r = rejectCallback; + cleanup(); + r?.(new Error(`OAuth callback timeout after ${timeoutMs}ms`)); + }, timeoutMs); + } + }); + }, + async close(): Promise { + if (closed) return; + closed = true; + // cleanup() より前に pending な awaitCallback を reject 発火させる + // (cleanup() は rejectCallback を null 化するだけで reject を呼ばない)。 + // これを先にしないと、ユーザーキャンセル等で close() された際に + // 既存の awaitCallback Promise が永遠に settle せずリークする (CR Major)。 + const reject = rejectCallback; + cleanup(); + reject?.(new Error('OAuth callback server closed before callback received')); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +} + +// 最小 HTML エスケープ (provider が返す error 文言を表示する用)。 +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/ai-engine/src/oauth/oauth-client.test.ts b/packages/ai-engine/src/oauth/oauth-client.test.ts new file mode 100644 index 0000000..fd8bb58 --- /dev/null +++ b/packages/ai-engine/src/oauth/oauth-client.test.ts @@ -0,0 +1,218 @@ +import { ATLASSIAN_CLOUD_OAUTH } from '@tally/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + buildAuthorizationUrl, + exchangeCodeForToken, + generateOAuthState, + generatePkcePair, + refreshAccessToken, +} from './oauth-client'; + +describe('generatePkcePair', () => { + it('code_verifier は RFC 7636 §4.1 の長さ要件 (43-128 文字) を満たす', () => { + for (let i = 0; i < 5; i++) { + const { codeVerifier } = generatePkcePair(); + expect(codeVerifier.length).toBeGreaterThanOrEqual(43); + expect(codeVerifier.length).toBeLessThanOrEqual(128); + } + }); + + it('code_challenge は code_verifier の SHA-256 を base64url した値', async () => { + const { codeVerifier, codeChallenge } = generatePkcePair(); + // 自前で再計算して一致を確認 + const { createHash } = await import('node:crypto'); + const expected = createHash('sha256').update(codeVerifier).digest('base64url'); + expect(codeChallenge).toBe(expected); + }); + + it('呼び出しごとに別の値を返す (entropy 確認の最小限)', () => { + const a = generatePkcePair(); + const b = generatePkcePair(); + expect(a.codeVerifier).not.toBe(b.codeVerifier); + }); +}); + +describe('generateOAuthState', () => { + it('呼び出しごとに別の値を返す', () => { + const a = generateOAuthState(); + const b = generateOAuthState(); + expect(a).not.toBe(b); + expect(a.length).toBeGreaterThan(0); + }); +}); + +describe('buildAuthorizationUrl', () => { + it('Atlassian の authorize URL に必須 PKCE / state / scope を載せる', () => { + const url = new URL( + buildAuthorizationUrl({ + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid-abc', + redirectUri: 'http://127.0.0.1:54321/callback', + scopes: ['read:jira-work', 'offline_access'], + state: 'state-xyz', + codeChallenge: 'challenge-abc', + }), + ); + expect(url.origin + url.pathname).toBe(ATLASSIAN_CLOUD_OAUTH.authorizationEndpoint); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('client_id')).toBe('cid-abc'); + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:54321/callback'); + expect(url.searchParams.get('scope')).toBe('read:jira-work offline_access'); + expect(url.searchParams.get('state')).toBe('state-xyz'); + expect(url.searchParams.get('code_challenge')).toBe('challenge-abc'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + // refresh_token を確実に得るため毎回 consent + expect(url.searchParams.get('prompt')).toBe('consent'); + }); + + it('provider.audience がある場合は audience を付ける (Atlassian)', () => { + const url = new URL( + buildAuthorizationUrl({ + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + redirectUri: 'http://127.0.0.1:1/cb', + scopes: ['s'], + state: 's', + codeChallenge: 'c', + }), + ); + expect(url.searchParams.get('audience')).toBe('api.atlassian.com'); + }); + + it('provider.audience が無い場合は audience パラメータを付けない', () => { + // audience を除いた provider config を作る (exactOptionalPropertyTypes で undefined 代入を避ける)。 + const { audience: _audience, ...noAudienceProvider } = ATLASSIAN_CLOUD_OAUTH; + const url = new URL( + buildAuthorizationUrl({ + provider: noAudienceProvider, + clientId: 'cid', + redirectUri: 'http://127.0.0.1:1/cb', + scopes: ['s'], + state: 's', + codeChallenge: 'c', + }), + ); + expect(url.searchParams.has('audience')).toBe(false); + }); +}); + +describe('exchangeCodeForToken / refreshAccessToken', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('exchangeCodeForToken: token endpoint に form-encoded で POST し、camelCase で返す', async () => { + const fetchMock = vi.fn(async () => { + return new Response( + JSON.stringify({ + access_token: 'a-tok', + refresh_token: 'r-tok', + expires_in: 3600, + scope: 'read:jira-work offline_access', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await exchangeCodeForToken({ + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + code: 'auth-code', + redirectUri: 'http://127.0.0.1:54321/callback', + codeVerifier: 'verifier-xyz', + }); + + expect(result.accessToken).toBe('a-tok'); + expect(result.refreshToken).toBe('r-tok'); + expect(result.expiresIn).toBe(3600); + expect(result.scope).toBe('read:jira-work offline_access'); + expect(result.tokenType).toBe('Bearer'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error('fetch not called'); + const [endpoint, init] = call; + expect(endpoint).toBe(ATLASSIAN_CLOUD_OAUTH.tokenEndpoint); + expect(init?.method).toBe('POST'); + const headers = init?.headers as Record; + expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('auth-code'); + expect(body.get('code_verifier')).toBe('verifier-xyz'); + expect(body.get('redirect_uri')).toBe('http://127.0.0.1:54321/callback'); + expect(body.get('client_id')).toBe('cid'); + }); + + it('exchangeCodeForToken: token_type が無いレスポンスは Bearer に default', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ access_token: 'a' }), { status: 200 })), + ); + const result = await exchangeCodeForToken({ + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + code: 'c', + redirectUri: 'http://127.0.0.1:1/cb', + codeVerifier: 'v', + }); + expect(result.tokenType).toBe('Bearer'); + }); + + it('exchangeCodeForToken: 4xx/5xx は throw', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('invalid_grant', { status: 400 })), + ); + await expect( + exchangeCodeForToken({ + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + code: 'c', + redirectUri: 'http://127.0.0.1:1/cb', + codeVerifier: 'v', + }), + ).rejects.toThrow(/token endpoint failed.*400/); + }); + + it('exchangeCodeForToken: access_token 欠落は throw (provider バグの早期検出)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({}), { status: 200 })), + ); + await expect( + exchangeCodeForToken({ + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + code: 'c', + redirectUri: 'http://127.0.0.1:1/cb', + codeVerifier: 'v', + }), + ).rejects.toThrow(/missing access_token/); + }); + + it('refreshAccessToken: grant_type=refresh_token と refresh_token を form で送る', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ access_token: 'new-tok' }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAccessToken({ + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + refreshToken: 'old-refresh', + }); + expect(result.accessToken).toBe('new-tok'); + + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error('fetch not called'); + const init = call[1]; + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('old-refresh'); + expect(body.get('client_id')).toBe('cid'); + }); +}); diff --git a/packages/ai-engine/src/oauth/oauth-client.ts b/packages/ai-engine/src/oauth/oauth-client.ts new file mode 100644 index 0000000..25c8d37 --- /dev/null +++ b/packages/ai-engine/src/oauth/oauth-client.ts @@ -0,0 +1,157 @@ +// ADR-0011 PR-E2: 外部 MCP server に対する OAuth 2.1 (Authorization Code + PKCE) クライアント。 +// Node 標準のみで実装する (依存追加を避ける)。 +// +// 設計判断: +// - PKCE は S256 のみサポート (RFC 7636 推奨)。plain は実装しない。 +// - state は Tally 側で生成 + verify する (CSRF + 偽 callback 防止)。 +// - state / code_verifier / refresh_token を返さず、呼び出し側 (PR-E3 の Orchestrator) が +// 管理する。本モジュールは純粋関数とネットワーク I/O のみに集中する。 +// - token endpoint は application/x-www-form-urlencoded で叩く (RFC 6749 §4.1.3)。 + +import { createHash, randomBytes } from 'node:crypto'; + +import type { OAuthProviderConfig } from '@tally/core'; + +// PKCE pair。code_verifier はクライアント側で保持し、token 交換時に渡す。 +// code_challenge は authorization request の URL に乗せる。 +export interface PkcePair { + codeVerifier: string; + codeChallenge: string; +} + +// RFC 7636 §4.1: code_verifier は 43-128 文字の URL-safe random。 +// 32 byte の random を base64url すると 43 文字になり下限を満たす。 +export function generatePkcePair(): PkcePair { + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); + return { codeVerifier, codeChallenge }; +} + +// state は CSRF 防止用の opaque random。authorization request に乗せて、callback で +// 一致を verify する。長すぎると URL が膨れるので 16 byte で十分 (entropy 128 bit)。 +export function generateOAuthState(): string { + return randomBytes(16).toString('base64url'); +} + +export interface BuildAuthorizationUrlInput { + provider: OAuthProviderConfig; + clientId: string; + redirectUri: string; + scopes: readonly string[]; + state: string; + codeChallenge: string; +} + +// Authorization URL を組み立てる (Authorization Code + PKCE フロー)。 +// Atlassian は audience が必須なので provider.audience があれば付ける。 +// prompt=consent を付けて refresh_token を確実に取得する (provider 依存だが大半で有効)。 +export function buildAuthorizationUrl(input: BuildAuthorizationUrlInput): string { + const url = new URL(input.provider.authorizationEndpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', input.clientId); + url.searchParams.set('redirect_uri', input.redirectUri); + url.searchParams.set('scope', input.scopes.join(' ')); + url.searchParams.set('state', input.state); + url.searchParams.set('code_challenge', input.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + if (input.provider.audience) { + url.searchParams.set('audience', input.provider.audience); + } + // prompt は provider 設定 (例: Atlassian は 'consent') で指定された場合のみ付ける。 + // ハードコードすると prompt 不可 / 別値必須の provider を将来追加した際に詰む。 + if (input.provider.prompt) { + url.searchParams.set('prompt', input.provider.prompt); + } + return url.toString(); +} + +// Token endpoint からの response。snake_case のまま受け、呼び出し側が camelCase に変換する。 +interface TokenEndpointResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; +} + +// 呼び出し側に返す形 (camelCase)。 +export interface TokenExchangeResult { + accessToken: string; + refreshToken?: string; + // Token endpoint レスポンスの expires_in (秒)。expiresAt 化は呼び出し側で行う。 + expiresIn?: number; + // 実際に許可された scope (space 区切り、provider が絞った場合に把握)。 + scope?: string; + tokenType: string; +} + +export interface ExchangeCodeInput { + provider: OAuthProviderConfig; + clientId: string; + code: string; + redirectUri: string; + codeVerifier: string; +} + +// Authorization code を access_token に交換する (RFC 6749 §4.1.3 + RFC 7636)。 +// public client (PKCE 経由) なので client_secret は使わない。 +export async function exchangeCodeForToken(input: ExchangeCodeInput): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: input.clientId, + code: input.code, + redirect_uri: input.redirectUri, + code_verifier: input.codeVerifier, + }); + return await postTokenEndpoint(input.provider.tokenEndpoint, body); +} + +export interface RefreshTokenInput { + provider: OAuthProviderConfig; + clientId: string; + refreshToken: string; +} + +// Refresh token を使って access_token を更新する (RFC 6749 §6)。 +// 一部 provider は refresh 時に新しい refresh_token を返すので、戻り値の refreshToken を +// 必ず token store に書き戻す必要がある (rotation policy)。 +export async function refreshAccessToken(input: RefreshTokenInput): Promise { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: input.clientId, + refresh_token: input.refreshToken, + }); + return await postTokenEndpoint(input.provider.tokenEndpoint, body); +} + +async function postTokenEndpoint( + endpoint: string, + body: URLSearchParams, +): Promise { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + }); + if (!res.ok) { + // エラー本文には機密が含まれる可能性が低いが、念のため最初の 512 文字に切る。 + const text = (await res.text().catch(() => '')).slice(0, 512); + throw new Error(`token endpoint failed (${res.status}): ${text}`); + } + const json = (await res.json()) as TokenEndpointResponse; + if (typeof json.access_token !== 'string' || json.access_token === '') { + throw new Error('token endpoint response missing access_token'); + } + // exactOptionalPropertyTypes 下では `?: string` に `string | undefined` を直接 assign + // できないので、各 optional は値が存在するときだけ object に乗せる (spread 経由)。 + return { + accessToken: json.access_token, + ...(json.refresh_token !== undefined ? { refreshToken: json.refresh_token } : {}), + ...(json.expires_in !== undefined ? { expiresIn: json.expires_in } : {}), + ...(json.scope !== undefined ? { scope: json.scope } : {}), + tokenType: json.token_type ?? 'Bearer', + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e2134a..8eff236 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,7 +15,12 @@ export type { StoryProgress } from './logic/story'; export { computeStoryProgress, isStoryComplete } from './logic/story'; export type { EdgeMeta, NodeMeta } from './meta'; export { EDGE_META, NODE_META } from './meta'; -export { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY, type OAuthKind } from './oauth'; +export { + ATLASSIAN_CLOUD_OAUTH, + OAUTH_REGISTRY, + type OAuthKind, + type OAuthProviderConfig, +} from './oauth'; export type { Codebase, McpOAuthConfig, McpOAuthToken, McpServerConfig } from './schema'; export { ChatBlockSchema, diff --git a/packages/core/src/oauth/atlassian.ts b/packages/core/src/oauth/atlassian.ts index 1b28449..a6894c9 100644 --- a/packages/core/src/oauth/atlassian.ts +++ b/packages/core/src/oauth/atlassian.ts @@ -19,6 +19,10 @@ export interface OAuthProviderConfig { // requested scopes 未指定時の default。refresh_token に必要な scope (例: offline_access) // はここに含める想定。 defaultScopes: readonly string[]; + // authorization request に付ける prompt パラメータ (Atlassian / Auth0 で 'consent' を + // 指定すると refresh_token を確実に得られる)。provider が prompt を受け付けない場合は + // undefined にして送らない。`buildAuthorizationUrl` ではここに値があるときだけ url に乗せる。 + prompt?: string; } export const ATLASSIAN_CLOUD_OAUTH: OAuthProviderConfig = { @@ -32,11 +36,14 @@ export const ATLASSIAN_CLOUD_OAUTH: OAuthProviderConfig = { // でユーザーが追加指定する。 // refresh_token を得るには `offline_access` が必須。 defaultScopes: ['read:jira-work', 'read:jira-user', 'offline_access'], + // Atlassian は再ログイン時 (=既存 grant あり) に refresh_token が返らないことがあるため、 + // 毎回 consent を要求して確実に refresh_token を得る。 + prompt: 'consent', }; // kind ごとの OAuth endpoint registry。kind が増えたらここにエントリを追加する。 -export const OAUTH_REGISTRY: Readonly> = { +export const OAUTH_REGISTRY = { atlassian: ATLASSIAN_CLOUD_OAUTH, -}; +} as const satisfies Readonly>; export type OAuthKind = keyof typeof OAUTH_REGISTRY; diff --git a/packages/core/src/oauth/index.ts b/packages/core/src/oauth/index.ts index bd28e23..a407d58 100644 --- a/packages/core/src/oauth/index.ts +++ b/packages/core/src/oauth/index.ts @@ -1 +1,6 @@ -export { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY, type OAuthKind } from './atlassian'; +export { + ATLASSIAN_CLOUD_OAUTH, + OAUTH_REGISTRY, + type OAuthKind, + type OAuthProviderConfig, +} from './atlassian'; From d9ecb2e9cd5f8e8021f53a934ca05915ba90c0b9 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Sat, 2 May 2026 11:53:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(ai-engine):=20awaitCallback=20=E3=81=AE?= =?UTF-8?q?=E5=A4=9A=E9=87=8D=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=20/=20cl?= =?UTF-8?q?ose=20=E5=BE=8C=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E3=81=A7=20?= =?UTF-8?q?throw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (PR #30) Major 1 件対応。 awaitCallback() は内部で resolveCallback / rejectCallback を上書きする実装 だったため、同 handle で 2 回以上呼ぶと先行 Promise が未解決のまま残って リークしていた。close() 後の呼び出しも server 閉鎖済みで callback は届かず 永久 pending になっていた。 `awaitStarted` フラグと既存の `closed` フラグを冒頭で確認し、いずれかに 該当すれば即時 throw する。1 ハンドル 1 回呼出 + close 後不可の規約を 明示的に強制する。 test 2 件追加: 多重呼び出しで throw / close 後呼び出しで throw。 pnpm typecheck 4/4 PASS / pnpm test ai-engine 237 PASS / pnpm lint exit 0。 --- .../src/oauth/loopback-callback-server.test.ts | 17 +++++++++++++++++ .../src/oauth/loopback-callback-server.ts | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/ai-engine/src/oauth/loopback-callback-server.test.ts b/packages/ai-engine/src/oauth/loopback-callback-server.test.ts index 5827329..f41a608 100644 --- a/packages/ai-engine/src/oauth/loopback-callback-server.test.ts +++ b/packages/ai-engine/src/oauth/loopback-callback-server.test.ts @@ -92,6 +92,23 @@ describe('startLoopbackCallbackServer', () => { await expect(promise).rejects.toThrow(/server closed/); }); + it('awaitCallback の多重呼び出しは throw (Promise 上書きでリーク防止)', async () => { + const handle = await startLoopbackCallbackServer(); + try { + const first = handle.awaitCallback(); + first.catch(() => {}); + await expect(handle.awaitCallback()).rejects.toThrow(/can only be called once/); + } finally { + await handle.close(); + } + }); + + it('close 後の awaitCallback は throw (callback は永遠に届かないため即時失敗)', async () => { + const handle = await startLoopbackCallbackServer(); + await handle.close(); + await expect(handle.awaitCallback()).rejects.toThrow(/already closed/); + }); + it('preferredPort 指定時はその port で listen (port=0 は OS 採番)', async () => { // 0 を指定したときと省略時は同じ挙動 (OS 採番)。 const a = await startLoopbackCallbackServer({ preferredPort: 0 }); diff --git a/packages/ai-engine/src/oauth/loopback-callback-server.ts b/packages/ai-engine/src/oauth/loopback-callback-server.ts index 52cef45..0bc2451 100644 --- a/packages/ai-engine/src/oauth/loopback-callback-server.ts +++ b/packages/ai-engine/src/oauth/loopback-callback-server.ts @@ -113,10 +113,22 @@ export async function startLoopbackCallbackServer( const redirectUri = `http://127.0.0.1:${addr.port}${callbackPath}`; let closed = false; + // 1 ハンドル 1 回だけ awaitCallback を許す。多重呼び出しは先行 Promise が + // 未解決のまま resolveCallback/rejectCallback を上書きされてリークするため + // 明示的に弾く (CR Major)。close 後の呼び出しも server が閉じている以上 + // callback は届かないので即時失敗にする。 + let awaitStarted = false; return { redirectUri, async awaitCallback(timeoutMs = 5 * 60 * 1000): Promise { + if (closed) { + throw new Error('OAuth callback server is already closed'); + } + if (awaitStarted) { + throw new Error('awaitCallback can only be called once per handle'); + } + awaitStarted = true; return new Promise((resolve, reject) => { resolveCallback = resolve; rejectCallback = reject;