jim800121chen d8a9517c9d feat(task-scheduler): Phase 0.8b — API key auth + /result endpoint
Auth pillar 從 OAuth 2.0 resource server 改成 pre-shared API key
(visionA ↔ converter 1:1 internal trust)。新增 GET /api/v1/jobs/:id/result
streaming endpoint 給 visionA backend 中轉 NEF 下載。

Phase A(auth 切換):
- 新增 apiKeyMiddleware(constant-time compare、tokenFingerprint、4 audit events)
- 砍 OAuth middleware + JWKS(保留 oauthClient 供 promote → FAA 使用)
- 4 個 endpoint 換掛 requireApiKey
- 加 TRUST_PROXY env + Express trust proxy 設定(forensic source_ip)

Phase B(/result endpoint):
- streaming NEF download with 5min timeout + concurrent cap 10
- Two-tier rate limit(burst 5/10s + sustained 20/min)
- Bandwidth quota(1 GB/hr + 6 GB/24hr)by token_fingerprint
- Range header silently ignored + Accept-Ranges: none
- filename quote-escape + RFC 5987 fallback + sanitize
- 8 個 /result audit events(forensic 完整)

設計演進記錄:docs/TODO-visionA-integration-v2.md(5/2 OAuth → 5/16 API key
→ 5/16 download via converter;對應 visionA repo ADR-015/016)

Tests: 597 → 666 (+69)、29 suites all pass
Security: APPROVE WITH CONDITIONS(單 instance 部署、6 新 env、24hr 監控)
npm audit: 3 vuln → 0(transitive AWS SDK xml chain)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:47:28 +08:00

12 KiB
Raw Blame History

Auth 設計Phase 0.8b

scopevisionA → converter 的對外 API authconverter → FAA 的 promote auth。

狀態Phase 0.8b 重寫 — visionA → converter 改用 API keyconverter → FAA 仍走 OAuth client_credentials。

配套design-doc.md §3.2 / §3.3、security.md Trust Boundary 章節。

設計演進visionA repo adr-015-server-to-server-api-key.md v2.1(為什麼用 API key


1. visionA → ConverterAPI key middleware

1.1 設計概要

  • HeaderAuthorization: Bearer <CONVERTER_API_KEY>(重用既有 Bearer 格式)
  • 比對crypto.timingSafeEqual constant-time compare防 timing attack
  • 長度64 hex charsopenssl rand -hex 32
  • 失敗行為401 invalid_token + 主動 socket.destroy()(沿用 OAuth middleware 的 M2 行為)
  • req.auth shape:通過後設定固定值(無 scope check

1.2 Middleware 介面

// src/auth/apiKeyMiddleware.js
function requireApiKey(deps = {}) {
  // deps.expectedApiKey 可注入測試;正式環境 lazy load from config
  return function apiKeyMiddleware(req, res, next) { ... };
}

使用方式(取代既有 requireAuth(scope)

const { requireApiKey } = require('../../auth/apiKeyMiddleware');

// 取代 requireAuth(config.converter.scopeWrite)
router.post('/jobs', requireApiKey(), perClientLimiter, handler);
router.get('/jobs', requireApiKey(), perClientLimiter, handler);
router.get('/jobs/:id', requireApiKey(), perClientLimiter, handler);
router.post('/jobs/:id/promote', requireApiKey(), perClientLimiter, handler);
router.get('/jobs/:id/result', requireApiKey(), perClientLimiter, handler);

1.3 req.auth shape通過後

req.auth = {
  sub: 'visionA-service',
  clientId: 'visionA-service',
  tenantId: null,
  scopes: ['converter:job.write', 'converter:job.read'],  // implicit full access
  raw: { authType: 'api_key' },
};

為什麼這樣設計

  • clientId 固定值讓既有 per-client rate limiter / log infra 無需修改
  • scopes 列兩個值是「兼容性 placeholder」下游 handler 不會再 checkmiddleware 已不做 scope check
  • raw.authType: 'api_key' 給 log / metrics 分類用,未來如果加回 OAuth 可從這個欄位區分

1.4 失敗情境

情境 HTTP error.code 訊息
Missing Authorization header 401 invalid_token 缺少或格式錯誤的 Authorization header需為 Bearer
Authorization 不是 Bearer 格式 401 invalid_token 同上
Token 為空字串 401 invalid_token 同上
Token 與 CONVERTER_API_KEY 不符constant-time compare 401 invalid_token API key 驗證失敗
CONVERTER_API_KEY env 未設定fail-fast 503 service_unavailable API key not configured
任何未預期 exception 401 invalid_token API key 驗證失敗(兜底,避免 5xx 洩漏內部細節)

1.5 Constant-time compare 實作

function constantTimeEquals(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string') return false;
  const bufA = Buffer.from(a, 'utf8');
  const bufB = Buffer.from(b, 'utf8');
  if (bufA.length !== bufB.length) return false;  // 必須先比長度timingSafeEqual 在長度不同時 throw
  return crypto.timingSafeEqual(bufA, bufB);
}

注意

  • 長度先比是必要的(timingSafeEqual 在長度不同時會 throw RangeError
  • 長度本身不算 secretkey 長度為公開資訊,本專案固定 64 chars
  • 比較完整 byte不可截短

1.6 Destroy socket 行為M2 沿用)

對齊既有 auth/middleware.jssendAuthError

  1. Connection: close header
  2. res.status(401).json({ error: {...} }) 寫 response
  3. res.once('finish', () => req.socket.destroy()) 在 response 寫完後主動斷線

為什麼401 後 client 可能還在繼續上傳 500MB bodyNode 會持續往 socket buffer 灌資料。destroy socket 防止這個情境吃光記憶體 / 頻寬。

1.7 Fail-fast 行為CONVERTER_API_KEY 未設定)

if (!expected || expected === '') {
  // log 一次(不印 key
  console.error(JSON.stringify({
    level: 'ERROR',
    action: 'auth.api_key.not_configured',
    message: 'CONVERTER_API_KEY env not set; rejecting all requests',
  }));
  // 503 拒絕,不要 silently allow
  return sendApiKeyError(req, res, 503, 'service_unavailable', 'API key not configured');
}

為什麼不 throw / process.exit

  • 不想啟動時就 throwWeb UI legacy 路徑也跑在同 process、應該還能用
  • 但對外 API 必須擋403 / 503 比 silently allow 安全)

1.8 Log 規則

場景 log level 欄位
啟動時 API key 已設定 INFO action: 'config.api_key_enabled'api_key_length(不印 key 本身)
啟動時 API key 未設定 WARN action: 'config.api_key_not_set'
Middleware 收到 request 但 API key 未配置 ERROR action: 'auth.api_key.not_configured'
Middleware 驗證失敗 (不 log 個別失敗,避免 log injection計入 metrics 即可)
Middleware 驗證成功 (不 log下游 handler 會 log request
Middleware 兜底 exception ERROR action: 'auth.api_key.unexpected_error'error_message 截短 100 chars

絕不 log

  • API key 內容(含 expected 或 received 任何一邊)
  • Authorization header 完整內容
  • token / secret 字串

2. Converter → FAAOAuth client_credentials保留不動

2.1 範圍

Promote 流程(POST /api/v1/jobs/:id/promoteConverter 以自己的身分取 files:upload.write token、PUT 結果檔到 FAA。Phase 0.8b 完全不動

詳細 client 行為見既有 apps/task-scheduler/src/auth/oauthClient.js保留),本節僅記架構決策。

2.2 設定

環境變數 用途 Phase 0.8b 狀態
MEMBER_CENTER_TOKEN_URL MC token endpoint 保留
KNERON_CONVERTER_CLIENT_ID Converter 作為 client 的 ID 保留
KNERON_CONVERTER_CLIENT_SECRET Converter client secret 保留
FILE_ACCESS_AGENT_AUDIENCE FAA 的 audience取 token 時用) 保留
FILE_ACCESS_AGENT_BASE_URL FAA API base URL 保留
PROMOTE_TIMEOUT_MS FAA PUT timeout 保留
OAUTH_TOKEN_REFRESH_SKEW_MS Cache token 距 expiresAt 多少 ms 主動 refresh 保留
OAUTH_TOKEN_TIMEOUT_MS 取 token 的網路 timeout 保留

2.3 Client 行為(沿用既有)

  • grant_type=client_credentials
  • Authorization: Basic base64(client_id:client_secret)RFC 6749 §2.3.1
  • scope=files:upload.writeaudience=<FAA aud>
  • Token cacheper-scopedistance to expiresAt > refreshSkewMs預設 60s算 valid
  • In-flight Promise dedup同 scope 並發只發一次 request
  • AbortController timeout預設 10s
  • 錯誤分類:OAuthClientError4xx不重試/ OAuthServerError5xx可重試/ OAuthTimeoutError(網路 / timeout可重試
  • FAA 回 401 → invalidate(scope) + retry 一次;仍 401 → 503 auth_service_unavailable

3. 砍除清單Phase 0.8b 移除)

檔案 / 模組 處理
src/auth/middleware.jsOAuth resource server
src/auth/jwks.js
src/auth/middleware.test.js
src/auth/jwks.test.js
src/auth/oauthClient.js 保留promote 用)
src/auth/oauthClient.test.js 保留
src/config.js 內:MEMBER_CENTER_ISSUER / MEMBER_CENTER_JWKS_URL / KNERON_CONVERTER_AUDIENCE / JWKS_* / JWT_CLOCK_TOLERANCE_SEC
src/config.js 內:MEMBER_CENTER_TOKEN_URL / KNERON_CONVERTER_CLIENT_* / FILE_ACCESS_AGENT_* / PROMOTE_TIMEOUT_MS / OAUTH_* 保留
src/config.js 新增:CONVERTER_API_KEY 新增
.env.example 移除 OAuth resource server 段、新增 CONVERTER_API_KEY= placeholder
README.md auth 章節OAuth → API key
docs/openapi.yaml security schemeOAuth → bearer / api_key

3.1 砍除的 unit test 範圍

  • JWT 過期 / 簽章錯 / aud 錯 / iss 錯 / kid 不存在 / scope 不足 / tenant_mismatch
  • JWKS cache hit / miss / cooldown / 演算法 pin
  • 既有 routes/v1/jobs.test.js 內驗 401 / 403 的部分 → 改測 API key 401

3.2 加入的 unit test 範圍

  • API key middleware
    • Happy path正確 key → next() + req.auth 正確)
    • Missing Authorization header → 401
    • Authorization 非 Bearer 格式 → 401
    • Token 為空 → 401
    • Token 不符 → 401constant-time 比對行為驗證 — 不同 prefix 仍須完成比對)
    • API key 未設定env 缺)→ 503
    • destroy socket 行為response 寫完後 socket 確實被關)

4. CONVERTER_API_KEY 管理

4.1 產生

openssl rand -hex 32
# 輸出 64 hex chars128 bits 安全強度,遠超 NIST 推薦的 80 bits

4.2 部署位置

環境 位置
dev apps/task-scheduler/.envgitignored
stage docker-compose env / k8s secret
prod docker secret / k8s secret / cloud secrets manager

4.3 雙端對齊

  • visionA .env.stageVISIONA_CONVERTER_API_KEY=<same string>
  • converter .envCONVERTER_API_KEY=<same string>
  • 兩端必須完全相同字串

4.4 Rotation 流程

  1. 雙端各自 stop deployment或允許短暫 401 期)
  2. openssl rand -hex 32 產新 key
  3. 更新雙端 .env
  4. converter 先 redeploy接受新 key
  5. visionA 後 redeploy用新 key call
  6. 驗證:curl -H "Authorization: Bearer <NEW_KEY>" https://converter.../api/v1/health(雖然 /health 無 auth但用其他 endpoint 驗)

極小停機< 1 分鐘)做法:暫時讓 converter 接受新舊兩把 keymiddleware 拓展成 array comparevisionA 切到新 key再砍舊 key。Phase 0.8b 不實作此優化(接受短暫 401

4.5 外洩處理

  • 立即 rotate 雙端 key
  • 檢視 audit log「在 rotation 前是否有可疑請求」(用 request_id + user_id 追蹤)
  • 若有 anomalous activity同 client_id 短期內 100+ 不同 user_id通報

5. 與既有 promote 流程的關係

visionA-backend → converter:
  POST /api/v1/jobs/:id/promote
  Authorization: Bearer <CONVERTER_API_KEY>    ← API key
  ↓
  converter requireApiKey() middleware 過
  ↓
  converter promote handler:
    1. 讀 job from Redis
    2. status === 'COMPLETED' ?
    3. for each target:
       a. minio.headObject(sourceKey)
       b. oauthClient.getServiceToken('files:upload.write')  ← OAuth client保留
       c. faaClient.putFile(targetKey, stream, ...)
  ↓
  回 200 + { promoted: [...] }

API key 只在 converter 對外那一層converter 內部對 FAA 仍是 OAuth client_credentials。


6. 安全性檢查清單Phase 0.8b

  • crypto.timingSafeEqual constant-time compare
  • 長度先比避免 throw
  • 不 log key 內容(含 expected / received
  • Fail-fastenv 未設定不要 silently allow
  • Destroy socket 行為對齊既有 OAuth middleware
  • req.auth shape 對齊下游 handler 預期
  • OAuth clientpromote程式碼完全不動
  • Secret 不進 git.env 已在 .gitignore但 Sec C1 history 仍待 rewrite
  • Log 結構化、不含 secret
  • Backend 實作時驗收tests cover 上述全部情境
  • Reviewer 驗收grep CONVERTER_API_KEY 不出現在任何 log statement