From f3f0cf1c403cfded80d2413def98a7b68107a9d1 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Wed, 24 Jun 2026 23:07:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20promote=20=E6=8F=9B=20token=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20client=5Fsecret=5Fpost=EF=BC=88=E4=BF=AE?= =?UTF-8?q?=20MC=20401=20invalid=5Fclient=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promote 流程的 OAuth client 去 Member Center(OpenIddict)換 service token 時 用 HTTP Basic Auth 送 client 憑證,但 MC 只接受 client_secret_post(憑證放 form body),導致 MC 回 401 invalid_client → promote 拿不到 token 去 FAA PUT → 對 visionA 回 500。根因由 visionA 端實測定位(交接檔 converter-promote-oauth-handoff.md)。 改 apps/task-scheduler/src/auth/oauthClient.js: - client_id/client_secret 從 Authorization Basic header 移進 form body(既有 body 上新增、保留 grant_type/scope/audience) - 移除 Authorization header 與 buildBasicAuthHeader() + _internals export - 檔頭 design 註解更新為 client_secret_post,避免被改回 Basic 安全約束維持:client_secret 僅進必要 body、絕不進任何 log(既有 "secret never in log" 測試跑全路徑 grep 驗證、現變成 body 帶 secret 情境的鎖)。 test:oauthClient.test.js 改斷言鎖「headers 無 Authorization + body 含 client_id/ client_secret」防回退。scheduler 全套件 666 pass。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/auth/__tests__/oauthClient.test.js | 43 +++++++------------ apps/task-scheduler/src/auth/oauthClient.js | 34 ++++++--------- 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/apps/task-scheduler/src/auth/__tests__/oauthClient.test.js b/apps/task-scheduler/src/auth/__tests__/oauthClient.test.js index 35564ab..1451fb4 100644 --- a/apps/task-scheduler/src/auth/__tests__/oauthClient.test.js +++ b/apps/task-scheduler/src/auth/__tests__/oauthClient.test.js @@ -183,7 +183,7 @@ describe('getServiceToken — happy path & cache', () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it('uses HTTP Basic auth header (not body) for client credentials', async () => { + it('uses client_secret_post (form body, no Authorization header) for client credentials', async () => { const fetch = makeMockFetch(() => makeJsonResponse(200, tokenSuccessBody())); const client = new OAuthClient({ fetch, @@ -194,20 +194,19 @@ describe('getServiceToken — happy path & cache', () => { const init = fetch.calls[0].init; expect(init.method).toBe('POST'); expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); - expect(init.headers.Authorization).toMatch(/^Basic /); - const expected = Buffer.from( - `${TEST_CLIENT_ID}:${TEST_CLIENT_SECRET}`, - 'utf8' - ).toString('base64'); - expect(init.headers.Authorization).toBe(`Basic ${expected}`); + // 不可送 Basic auth header — MC 只接受 client_secret_post,送 Basic 會被拒。 + // 鎖住這次的修正,避免被改回 Basic。 + expect(init.headers.Authorization).toBeUndefined(); - // body 必須不含 client_secret + // client_id / client_secret 必須在 form body expect(typeof init.body).toBe('string'); - expect(init.body).not.toContain(TEST_CLIENT_SECRET); - expect(init.body).toContain('grant_type=client_credentials'); - expect(init.body).toContain('scope=files%3Aupload.write'); - expect(init.body).toContain(`audience=${TEST_FAA_AUDIENCE}`); + const params = new URLSearchParams(init.body); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('client_id')).toBe(TEST_CLIENT_ID); + expect(params.get('client_secret')).toBe(TEST_CLIENT_SECRET); + expect(params.get('scope')).toBe('files:upload.write'); + expect(params.get('audience')).toBe(TEST_FAA_AUDIENCE); }); it('refreshes when cached token is within refreshSkewMs of expiry', async () => { @@ -780,12 +779,6 @@ describe('SECURITY: client_secret never appears in any log', () => { // ---------------------------------------------------------------------------- describe('_internals helpers', () => { - it('buildBasicAuthHeader produces RFC 7617 base64 form', () => { - const h = _internals.buildBasicAuthHeader('alice', 'open sesame'); - // base64 of "alice:open sesame" = "YWxpY2U6b3BlbiBzZXNhbWU=" - expect(h).toBe('Basic YWxpY2U6b3BlbiBzZXNhbWU='); - }); - it('parseTokenResponse handles minimal valid payload', () => { const p = _internals.parseTokenResponse({ access_token: 'a', @@ -881,19 +874,15 @@ describe('integration with real HTTP server', () => { expect(tok).toBe('integration-token'); expect(captured).not.toBeNull(); expect(captured.headers['content-type']).toBe('application/x-www-form-urlencoded'); - expect(captured.headers.authorization).toMatch(/^Basic /); - const expectedBasic = Buffer.from( - `${TEST_CLIENT_ID}:${TEST_CLIENT_SECRET}`, - 'utf8' - ).toString('base64'); - expect(captured.headers.authorization).toBe(`Basic ${expectedBasic}`); - - // body 內不能含 client_secret - expect(captured.body).not.toContain(TEST_CLIENT_SECRET); + // client_secret_post:不送 Authorization header + expect(captured.headers.authorization).toBeUndefined(); + // client_id / client_secret 在 form body const params = new URLSearchParams(captured.body); expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('client_id')).toBe(TEST_CLIENT_ID); + expect(params.get('client_secret')).toBe(TEST_CLIENT_SECRET); expect(params.get('scope')).toBe('files:upload.write'); expect(params.get('audience')).toBe(TEST_FAA_AUDIENCE); }); diff --git a/apps/task-scheduler/src/auth/oauthClient.js b/apps/task-scheduler/src/auth/oauthClient.js index 3d7a721..7e3352d 100644 --- a/apps/task-scheduler/src/auth/oauthClient.js +++ b/apps/task-scheduler/src/auth/oauthClient.js @@ -17,15 +17,19 @@ * 7. **絕不**將 client_secret / token 內容寫入 log * * 通信規格(對齊 TDD §2.4 / §5.2 / RFC 6749 §4.4 + §2.3.1): - * - 使用 HTTP Basic auth header `Authorization: Basic base64(client_id:client_secret)` - * (RFC 6749 §2.3.1 推薦,比 body 傳 secret 安全;token endpoint 通常都接受) + * - client 認證採 **`client_secret_post`**:`client_id` / `client_secret` 放在 + * POST form body(不是 HTTP Basic auth header)。Member Center(OpenIddict) + * 只接受這種送法,回 `Basic` header 會被拒為 `401 invalid_client`。 + * (RFC 6749 §2.3.1 把 `client_secret_post` 列為允許的 client 認證方式之一。) * - body: `application/x-www-form-urlencoded`,含 `grant_type=client_credentials`、 - * `scope=`、`audience=`(Auth0 / 多數 IdP 慣例) + * `client_id`、`client_secret`、`scope=`、`audience=`。 * - 預期回應 JSON:`{ access_token, token_type, expires_in }` * * 安全注意: - * - 任何 log 都不得包含 `client_secret`、Authorization header 內容、access_token - * - 錯誤訊息只揭露 status + 標準 error_code(如 `invalid_client`),不揭露 server 端細節 + * - `client_secret` 雖然放進 body,但**絕不**得出現在任何 log + * (URLSearchParams 字串、body 變數、錯誤訊息都不可被 log 出來)。 + * - 任何 log 都不得包含 `client_secret`、access_token。 + * - 錯誤訊息只揭露 status + 標準 error_code(如 `invalid_client`),不揭露 server 端細節。 */ 'use strict'; @@ -81,19 +85,6 @@ class OAuthTimeoutError extends OAuthError { // 內部 helpers // ---------------------------------------------------------------------------- -/** - * 把 client_id / client_secret 編碼成 Basic auth header value。 - * - * @param {string} clientId - * @param {string} clientSecret - * @returns {string} - `Basic ` - */ -function buildBasicAuthHeader(clientId, clientSecret) { - const raw = `${clientId}:${clientSecret}`; - // Buffer.from(...).toString('base64') 是 Node 標準做法;不依賴 deprecated `btoa` - return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`; -} - /** * 從 fetch Response 嘗試解析 OAuth 標準錯誤 JSON: * `{ "error": "invalid_client", "error_description": "..." }` @@ -296,8 +287,12 @@ class OAuthClient { * @returns {Promise} */ async _fetchToken(scope, config) { + // client 認證採 client_secret_post:client_id / client_secret 放在 form body。 + // 注意:body 含 client_secret,**絕不**可被 log 出來(見檔頭安全注意)。 const body = new URLSearchParams({ grant_type: 'client_credentials', + client_id: config.clientId, + client_secret: config.clientSecret, scope, audience: config.faaAudience, }).toString(); @@ -305,7 +300,7 @@ class OAuthClient { const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', - Authorization: buildBasicAuthHeader(config.clientId, config.clientSecret), + // 不送 Authorization Basic header;MC 只接受 client_secret_post。 }; const controller = new AbortController(); @@ -456,7 +451,6 @@ module.exports = { // 測試用內部 _internals: { - buildBasicAuthHeader, parseTokenResponse, tryParseOauthErrorBody, singleton,