jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

390 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// oidc_e2e_test.go — OIDC BFF end-to-end 整合測試。
//
// OB52026-04-26起 OIDC 是唯一認證路徑、setupFixture 預設就 wire 好 fake OIDC
// 因此本檔案不再用 build tag 隔離 — 屬於主測試套件的一部分。
//
// 涵蓋情境:
// - Happy pathlogin → IdP → callback → me → logout
// - State mismatchCSRF 防護)
// - Invalid noncereplay 攻擊)
// - Token exchange 失敗IdP 不可達)
// - Pairing token 綁到 OIDC suboidc-tdd.md §9 關鍵驗證)
// - 多 user isolation兩 user 各自的 token 不混淆)
//
// # 對齊文件
//
// - .autoflow/04-architecture/oidc-tdd.md §3 BFF Flow 詳細時序圖
// - .autoflow/04-architecture/oidc-tdd.md §9 Pairing 流程確認 user binding 仍正確
// - .autoflow/04-architecture/adr/adr-010-oidc-bff.md
// - .autoflow/04-architecture/adr/adr-011-supersede-adr-005.md
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/auth"
"visiona-backend/internal/oidctest"
)
// ──────────────────────────────────────────────────────────────
// E2E TEST CASES
// ──────────────────────────────────────────────────────────────
// TestOIDCE2E_FullLoginFlow 是 OIDC e2e 的核心 happy path 測試。
//
// 完整流程:
//
// 1. GET /api/auth/login → 302 to fakeOIDC /authorize
// 2. (sim) GET fakeOIDC /authorize → 302 to backend /api/auth/callback?code=...&state=...
// 3. GET /api/auth/callback → backend 完成 token exchange + 建 cookie session → 302 to PostLoginURL
// 4. GET /api/auth/me → 200 + 預期 user_id (= OIDC sub)
// 5. POST /api/auth/logout → 200 + clear cookie
// 6. GET /api/auth/me → 401
func TestOIDCE2E_FullLoginFlow(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
// 預先設定 fake server 下一個 /token 簽出來的 id_token claims
const wantSub = "sub-oidc-e2e-001"
const wantEmail = "alice@innovedus.com"
const wantName = "Alice OIDC"
f.fakeOIDC.SetNextIDTokenClaims(map[string]any{
"sub": wantSub,
"email": wantEmail,
"name": wantName,
})
client := newCookieClient(t)
// ─── 1. GET /api/auth/login → 302 to fake IdP /authorize ───
loc1 := getExpect302(t, client, f.apiServer.URL+"/api/auth/login")
require.True(t, strings.HasPrefix(loc1, f.fakeOIDC.URL+"/authorize"),
"login 應 302 to fake IdP /authorize得 %s", loc1)
// 驗 backend 帶的 query 參數符合 OIDC spec
authorizeURL, err := url.Parse(loc1)
require.NoError(t, err)
q := authorizeURL.Query()
assert.Equal(t, "code", q.Get("response_type"))
assert.Equal(t, fixtureOIDCClientID, q.Get("client_id"))
assert.NotEmpty(t, q.Get("state"), "必帶 stateCSRF 防護)")
assert.NotEmpty(t, q.Get("nonce"), "必帶 noncereplay 防護)")
assert.NotEmpty(t, q.Get("code_challenge"), "必帶 PKCE challenge")
assert.Equal(t, "S256", q.Get("code_challenge_method"))
// ─── 2. 模擬使用者「登入並同意」→ fake IdP 回 callback URL ───
callbackURL := f.fakeOIDC.SimulateAuthorizationFlow(t, loc1)
// ─── 3. GET callback → backend 換 token + 建 session → 302 to PostLoginURL ───
loc2 := getExpect302(t, client, callbackURL)
assert.NotEmpty(t, loc2, "callback 應 302 to PostLoginURL")
// 驗 cookie 已 set
assertHasSessionCookie(t, client, f.apiServer.URL)
// ─── 4. GET /api/auth/me → 200 + 預期 user_id ───
meResp := getJSON(t, client, f.apiServer.URL+"/api/auth/me")
require.Equal(t, http.StatusOK, meResp.status, "body=%v", meResp.body)
data := meResp.body["data"].(map[string]any)
assert.Equal(t, wantSub, data["user_id"], "user_id 應為 OIDC sub")
assert.Equal(t, wantEmail, data["email"])
// ─── 5. POST /api/auth/logout ───
logoutReq, _ := http.NewRequest(http.MethodPost, f.apiServer.URL+"/api/auth/logout", nil)
logoutResp, err := client.Do(logoutReq)
require.NoError(t, err)
logoutResp.Body.Close()
assert.True(t, logoutResp.StatusCode == http.StatusNoContent || logoutResp.StatusCode == http.StatusOK,
"logout 應為 204 或 200得 %d", logoutResp.StatusCode)
// ─── 6. GET /api/auth/me 應 401 ───
meResp2 := getJSON(t, client, f.apiServer.URL+"/api/auth/me")
assert.Equal(t, http.StatusUnauthorized, meResp2.status,
"logout 後 /api/auth/me 應回 401")
}
// TestOIDCE2E_StateMismatch 驗 callback 收到的 state 與 pending session 不符 → 4xx。
//
// 真實攻擊場景:攻擊者在 victim 的 browser 上塞自己的 state企圖讓 victim 用攻擊者
// 的帳號登入CSRF。這個 test 確保 BFF 真的有比 state。
func TestOIDCE2E_StateMismatch(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
client := newCookieClient(t)
// 1. /login → 取 authorize URL
loc1 := getExpect302(t, client, f.apiServer.URL+"/api/auth/login")
// 2. 模擬 IdP redirect 但「篡改 state」
cb := f.fakeOIDC.SimulateAuthorizationFlow(t, loc1)
tampered := tamperState(t, cb, "evil-state-not-the-one-backend-stored")
// 3. backend 應拒絕
resp, err := client.Get(tampered)
require.NoError(t, err)
defer resp.Body.Close()
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 500,
"state mismatch 應回 4xx得 %d", resp.StatusCode)
}
// TestOIDCE2E_InvalidNonce 驗 id_token 的 nonce 與 backend 期待的不符 → 認證失敗。
//
// 模擬 replay 攻擊:攻擊者拿到一個其他登入流程的 id_token企圖用它通過驗證。
func TestOIDCE2E_InvalidNonce(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
// 把 id_token 的 nonce 故意覆寫成「跟 authorize 收到的不同」
f.fakeOIDC.SetNextIDTokenClaims(map[string]any{
"sub": "sub-replay-attempt",
"nonce": "this-is-a-stale-or-stolen-nonce",
})
client := newCookieClient(t)
loc1 := getExpect302(t, client, f.apiServer.URL+"/api/auth/login")
cb := f.fakeOIDC.SimulateAuthorizationFlow(t, loc1)
resp, err := client.Get(cb)
require.NoError(t, err)
defer resp.Body.Close()
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 500,
"nonce mismatch 應回 4xx得 %d", resp.StatusCode)
}
// TestOIDCE2E_TokenExchangeFails 驗 IdP 5xx 時 backend 優雅 fail 而非 panic / 500。
//
// 提前關掉 fake server 模擬 IdP 不可達。
func TestOIDCE2E_TokenExchangeFails(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
client := newCookieClient(t)
loc1 := getExpect302(t, client, f.apiServer.URL+"/api/auth/login")
cb := f.fakeOIDC.SimulateAuthorizationFlow(t, loc1)
// 在 callback 發送之前把 fake IdP 關掉模擬「token endpoint 連不上」
f.fakeOIDC.Close()
resp, err := client.Get(cb)
require.NoError(t, err)
defer resp.Body.Close()
// 預期 backend 回 502 / 503IdP 不可達)— 重點是不 panic
assert.True(t, resp.StatusCode >= 500 && resp.StatusCode < 600,
"IdP 不可達應回 5xx得 %d", resp.StatusCode)
}
// TestOIDCE2E_PairingTokenBindsToOIDCUser 是本任務最關鍵的測試oidc-tdd.md §9
//
// 驗證OIDC 登入完成後,使用者建立的 Pairing Token 綁定的 user_id
// **是 OIDC sub**(不再是 StaticAuthProvider 的「demo-user」
//
// 為什麼關鍵oidc-tdd.md §9 承諾「Pairing 流程零影響」— 但 user_id 從
// 「demo-user」變成「OIDC sub」這個改動會穿透到 PairingStore如果沒把
// UserContext.UserID 正確改成 subpairing 會繼續用「demo-user」綁所有人
// 多用戶上線時會直接災難性混亂(一個人的 device 連到別人的帳號上)。
func TestOIDCE2E_PairingTokenBindsToOIDCUser(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
const wantSub = "sub-pairing-binding-test-001"
f.fakeOIDC.SetNextIDTokenClaims(map[string]any{
"sub": wantSub,
"email": "pairing-test@innovedus.com",
"name": "Pairing Test",
})
client := newCookieClient(t)
// 1. 走完整 OIDC 登入
loc1 := getExpect302(t, client, f.apiServer.URL+"/api/auth/login")
cb := f.fakeOIDC.SimulateAuthorizationFlow(t, loc1)
getExpect302(t, client, cb)
assertHasSessionCookie(t, client, f.apiServer.URL)
// 2. 確認 /me 回 OIDC sub
meResp := getJSON(t, client, f.apiServer.URL+"/api/auth/me")
require.Equal(t, http.StatusOK, meResp.status)
assert.Equal(t, wantSub,
meResp.body["data"].(map[string]any)["user_id"],
"前置OIDC sub 應正確注入 UserContext")
// 3. 建立 Pairing Token走 AuthMiddleware
tokResp := postJSON(t, client, f.apiServer.URL+"/api/pairing/token", nil)
require.Equal(t, http.StatusOK, tokResp.status, "body=%v", tokResp.body)
pairingToken := tokResp.body["data"].(map[string]any)["token"].(string)
require.True(t, auth.IsValidPairingToken(pairingToken),
"應為合法 pairing token%s", pairingToken)
// 4. **核心斷言**:用 PairingStore Validate 檢查綁定的 user_id 是 OIDC sub。
//
// 從 fixture 取出 PairingStore 直接驗OB5 起 testFixture 已 expose pairingStore 欄位)。
tokInfo, err := f.pairingStore.Validate(context.Background(), pairingToken)
require.NoError(t, err, "pairing token 應仍可驗證")
assert.Equal(t, wantSub, tokInfo.UserID,
"pairing token 應綁到 OIDC sub不再是 demo-user"+
"若失敗代表 OB3 沒把 UserContext.UserID 設為 OIDC sub"+
"或 PairingStore.Create 沒收到正確的 user_id")
}
// TestOIDCE2E_MultiUserIsolation 確保兩個 OIDC 使用者建立的 pairing token 不會混淆。
//
// 從 oidc-tdd.md §9 的角度看這是「demo-user → OIDC sub」遷移後最容易藏的 bug
// 兩個 user A / B 各自登入A 建一個 pairing tokenB 應該看不到。
func TestOIDCE2E_MultiUserIsolation(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
// ─ Alice ─
clientA := f.AuthenticatedClient(t, "user-alice", "alice@x.com")
// Alice 建一個 pairing token
tokRespA := postJSON(t, clientA, f.apiServer.URL+"/api/pairing/token", nil)
require.Equal(t, http.StatusOK, tokRespA.status)
pairingA := tokRespA.body["data"].(map[string]any)["token"].(string)
require.True(t, auth.IsValidPairingToken(pairingA))
// ─ Bob ─
clientB := f.AuthenticatedClient(t, "user-bob", "bob@x.com")
// Bob 列出自己的 tokens —— 不應該看到 Alice 的
listResp := getJSON(t, clientB, f.apiServer.URL+"/api/pairing/tokens")
require.Equal(t, http.StatusOK, listResp.status)
bobTokens, _ := listResp.body["data"].([]any)
for _, raw := range bobTokens {
tok := raw.(map[string]any)
// list 回的是 token_prefix前 12 字元),對比 Alice token 的 prefix
prefix, _ := tok["token_prefix"].(string)
assert.NotEqual(t, pairingA[:len(prefix)], prefix,
"Bob 的 token 列表不應包含 Alice 的 token prefix")
}
// 額外直接驗 storeAlice 名下確實有Bob 名下沒有
aliceTokens, err := f.pairingStore.List(context.Background(), "user-alice")
require.NoError(t, err)
assert.NotEmpty(t, aliceTokens, "Alice 名下應有 pairing token")
bobStoreTokens, err := f.pairingStore.List(context.Background(), "user-bob")
require.NoError(t, err)
for _, tok := range bobStoreTokens {
assert.NotEqual(t, "user-alice", tok.UserID,
"Bob 名下的 token UserID 不應為 user-alice")
}
}
// ──────────────────────────────────────────────────────────────
// HTTP CLIENT HELPERS
// ──────────────────────────────────────────────────────────────
// newCookieClient 回傳一個會記 cookie 但「不自動跟隨 redirect」的 http.Client。
//
// 不自動 redirect 是必要的BFF flow 一連串 302login → IdP authorize → callback
// → PostLoginURL要由我們自己一段一段控制才能在中間 assert 每一步的 status / Location。
func newCookieClient(t *testing.T) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
require.NoError(t, err)
return &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 10 * time.Second,
}
}
// getExpect302 GET 一個 URL 並斷言它回 302回傳 Location header。
func getExpect302(t *testing.T, client *http.Client, target string) string {
t.Helper()
resp, err := client.Get(target)
require.NoError(t, err, "GET %s", target)
defer resp.Body.Close()
require.Truef(t, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther,
"預期 302/303得 %d (%s)", resp.StatusCode, target)
loc := resp.Header.Get("Location")
require.NotEmpty(t, loc, "Location header 應非空 (%s)", target)
return loc
}
type jsonResp struct {
status int
body map[string]any
}
func getJSON(t *testing.T, client *http.Client, target string) jsonResp {
t.Helper()
resp, err := client.Get(target)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
out := jsonResp{status: resp.StatusCode, body: map[string]any{}}
if len(body) > 0 {
_ = json.Unmarshal(body, &out.body)
}
return out
}
func postJSON(t *testing.T, client *http.Client, target string, body io.Reader) jsonResp {
t.Helper()
contentType := "application/json"
if body == nil {
body = bytes.NewReader(nil)
contentType = ""
}
req, err := http.NewRequest(http.MethodPost, target, body)
require.NoError(t, err)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
out := jsonResp{status: resp.StatusCode, body: map[string]any{}}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &out.body)
}
return out
}
// assertHasSessionCookie 驗 cookie jar 含 visiona_session cookie。
func assertHasSessionCookie(t *testing.T, client *http.Client, baseURL string) {
t.Helper()
u, err := url.Parse(baseURL)
require.NoError(t, err)
for _, c := range client.Jar.Cookies(u) {
if c.Name == "visiona_session" {
require.NotEmpty(t, c.Value, "visiona_session cookie 應有值")
return
}
}
t.Fatalf("未找到 visiona_session cookiejar=%+v", client.Jar.Cookies(u))
}
// tamperState 把 callback URL 的 state 換成另一個值(模擬攻擊者)。
func tamperState(t *testing.T, callbackURL, newState string) string {
t.Helper()
u, err := url.Parse(callbackURL)
require.NoError(t, err)
q := u.Query()
q.Set("state", newState)
u.RawQuery = q.Encode()
return u.String()
}
// 確保 oidctest 一定 import 到(避免未來 helper 改動時被 lint 掉)。
var _ = oidctest.WithClientCredentials