// oidc_e2e_test.go — OIDC BFF end-to-end 整合測試。 // // OB5(2026-04-26)起 OIDC 是唯一認證路徑、setupFixture 預設就 wire 好 fake OIDC, // 因此本檔案不再用 build tag 隔離 — 屬於主測試套件的一部分。 // // 涵蓋情境: // - Happy path:login → IdP → callback → me → logout // - State mismatch(CSRF 防護) // - Invalid nonce(replay 攻擊) // - Token exchange 失敗(IdP 不可達) // - Pairing token 綁到 OIDC sub(oidc-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"), "必帶 state(CSRF 防護)") assert.NotEmpty(t, q.Get("nonce"), "必帶 nonce(replay 防護)") 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 / 503(IdP 不可達)— 重點是不 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 正確改成 sub,pairing 會繼續用「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 token,B 應該看不到。 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") } // 額外直接驗 store:Alice 名下確實有,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 一連串 302(login → 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 cookie;jar=%+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