從 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>
390 lines
15 KiB
Go
390 lines
15 KiB
Go
// 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
|