fix(auth): promote 換 token 改用 client_secret_post(修 MC 401 invalid_client)

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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-06-24 23:07:10 +08:00
parent 0cae1b1130
commit f3f0cf1c40
2 changed files with 30 additions and 47 deletions

View File

@ -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);
});

View File

@ -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 headerMember CenterOpenIddict
* 只接受這種送法 `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=<scope>``audience=<aud>`Auth0 / 多數 IdP 慣例
* `client_id``client_secret``scope=<scope>``audience=<aud>`
* - 預期回應 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 <base64>`
*/
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<string>}
*/
async _fetchToken(scope, config) {
// client 認證採 client_secret_postclient_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 headerMC 只接受 client_secret_post。
};
const controller = new AbortController();
@ -456,7 +451,6 @@ module.exports = {
// 測試用內部
_internals: {
buildBasicAuthHeader,
parseTokenResponse,
tryParseOauthErrorBody,
singleton,