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:
parent
0cae1b1130
commit
f3f0cf1c40
@ -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);
|
||||
});
|
||||
|
||||
@ -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=<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_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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user