// oidc_auth_test.go — OIDC handler 與 OIDC-mode middleware 的 unit test。 // // 設計策略:用 mockOIDCProvider 取代真實 IdP(避免 IO、純 Go function call)。 // 這樣測試快且確定性高;真實 IdP 整合留給 OT1(fake server)+ OT2(end-to-end)。 package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/oidc" "visiona-backend/internal/usersession" ) // ---- mockOIDCProvider --------------------------------------------------- // mockOIDCProvider 實作 oidc.Provider,回傳由 test 預先設定的固定值。 // // 比 fake HTTP server 簡單很多:直接控制每個方法的回傳,可注入錯誤情境。 type mockOIDCProvider struct { mu sync.Mutex // AuthorizationURL 行為控制 authURLBase string // 預設 "https://idp.example/authorize" // ExchangeCode 行為控制 exchangeFn func(ctx context.Context, code, verifier string) (*oidc.TokenResponse, error) // VerifyIDToken 行為控制 verifyFn func(ctx context.Context, raw, expectedNonce string) (*oidc.Claims, error) // 記錄呼叫參數供 test assertion 用 gotCode string gotVerifier string gotIDToken string gotNonce string } func (m *mockOIDCProvider) AuthorizationURL(state, nonce, codeChallenge string) string { base := m.authURLBase if base == "" { base = "https://idp.example/authorize" } q := url.Values{} q.Set("state", state) q.Set("nonce", nonce) q.Set("code_challenge", codeChallenge) q.Set("code_challenge_method", "S256") return base + "?" + q.Encode() } func (m *mockOIDCProvider) ExchangeCode(ctx context.Context, code, verifier string) (*oidc.TokenResponse, error) { m.mu.Lock() m.gotCode = code m.gotVerifier = verifier m.mu.Unlock() if m.exchangeFn != nil { return m.exchangeFn(ctx, code, verifier) } return &oidc.TokenResponse{ AccessToken: "access-token-xyz", IDToken: "id-token-xyz", TokenType: "Bearer", ExpiresIn: 3600, }, nil } func (m *mockOIDCProvider) VerifyIDToken(ctx context.Context, raw, expectedNonce string) (*oidc.Claims, error) { m.mu.Lock() m.gotIDToken = raw m.gotNonce = expectedNonce m.mu.Unlock() if m.verifyFn != nil { return m.verifyFn(ctx, raw, expectedNonce) } return &oidc.Claims{ Subject: "user-123", Email: "alice@example.com", Name: "Alice", Nonce: expectedNonce, }, nil } // ---- helper: 建立啟用 OIDC 的測試 Deps + router --------------------------- func newOIDCTestDeps(provider *mockOIDCProvider) Deps { mgr := usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{ Name: "visiona_session", Path: "/", HTTPOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400, SigningKey: []byte("test-secret-32-byte-key-aaaaaaaaaaaa"), }) return Deps{ OIDCProvider: provider, SessionManager: mgr, OIDCPostLoginURL: "http://localhost:3000", } } // newOIDCRouter 建立完整 router(含 public + apiGroup AuthMiddleware)。 func newOIDCRouter(deps Deps) *gin.Engine { r := gin.New() r.Use(RequestIDMiddleware()) // public OIDC routes(必須在 AuthMiddleware 之外) registerOIDCPublicRoutes(r, deps) // /api 群組(含 AuthMiddleware + auth handlers) g := r.Group("/api") g.Use(AuthMiddleware(deps)) registerAuthRoutes(g, deps) return r } // ---- TESTS: oidcLoginHandler -------------------------------------------- // TestOIDCLogin_RedirectsToIdPWithProperParams 驗證 /api/auth/login 會 302 到 IdP, // 並設好 cookie + 在 session 中存好 PKCE state / nonce / verifier。 func TestOIDCLogin_RedirectsToIdPWithProperParams(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/auth/login?return_to=/dashboard", nil) r.ServeHTTP(w, req) require.Equal(t, http.StatusFound, w.Code) loc := w.Header().Get("Location") require.NotEmpty(t, loc) parsed, err := url.Parse(loc) require.NoError(t, err) // 應該有 state / nonce / code_challenge 三個 query q := parsed.Query() assert.NotEmpty(t, q.Get("state")) assert.NotEmpty(t, q.Get("nonce")) assert.NotEmpty(t, q.Get("code_challenge")) assert.Equal(t, "S256", q.Get("code_challenge_method")) // 應該有 Set-Cookie cookies := w.Result().Cookies() require.NotEmpty(t, cookies, "expected Set-Cookie") var sessCookie *http.Cookie for _, c := range cookies { if c.Name == "visiona_session" { sessCookie = c break } } require.NotNil(t, sessCookie, "expected visiona_session cookie") assert.True(t, sessCookie.HttpOnly) assert.Equal(t, http.SameSiteLaxMode, sessCookie.SameSite) } // TestOIDCLogin_SanitizesReturnTo 驗證 open redirect 防護。 func TestOIDCLogin_SanitizesReturnTo(t *testing.T) { tests := []struct { name string raw string want string }{ {"empty", "", ""}, {"normal_path", "/dashboard", "/dashboard"}, {"path_with_query", "/devices?x=1", "/devices?x=1"}, {"absolute_url", "http://evil.example/", ""}, {"protocol_relative", "//evil.example", ""}, {"backslash_trick", "/\\evil.example", ""}, {"missing_leading_slash", "evil", ""}, {"scheme_in_path", "/foo://bar", ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { assert.Equal(t, tc.want, sanitizeReturnTo(tc.raw)) }) } } // ---- TESTS: oidcCallbackHandler ----------------------------------------- // TestOIDCCallback_HappyPath 驗證 callback 完整流程跑通。 func TestOIDCCallback_HappyPath(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) // Step 1: 觸發 login,拿到 state + cookie loginW := httptest.NewRecorder() loginReq := httptest.NewRequest(http.MethodGet, "/api/auth/login?return_to=/devices", nil) r.ServeHTTP(loginW, loginReq) require.Equal(t, http.StatusFound, loginW.Code) loc, _ := url.Parse(loginW.Header().Get("Location")) state := loc.Query().Get("state") require.NotEmpty(t, state) // 提取 cookie cookies := loginW.Result().Cookies() require.NotEmpty(t, cookies) // Step 2: 模擬 IdP 302 回 callback(帶上 cookie + state) cbW := httptest.NewRecorder() cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=auth-code-xyz&state="+url.QueryEscape(state), nil) for _, c := range cookies { cbReq.AddCookie(c) } r.ServeHTTP(cbW, cbReq) // 預期 302 回 frontend require.Equal(t, http.StatusFound, cbW.Code, "body=%s", cbW.Body.String()) redirect := cbW.Header().Get("Location") assert.Equal(t, "http://localhost:3000/devices", redirect) // 驗 mock 收到正確的 code assert.Equal(t, "auth-code-xyz", provider.gotCode) assert.NotEmpty(t, provider.gotVerifier, "verifier should be passed to ExchangeCode") } // TestOIDCCallback_StateMismatch 驗證 state 不符回 400 並清 session。 func TestOIDCCallback_StateMismatch(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) // 先 login 拿 cookie loginW := httptest.NewRecorder() r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)) cookies := loginW.Result().Cookies() // Callback 帶錯的 state cbW := httptest.NewRecorder() cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=xyz&state=wrong-state", nil) for _, c := range cookies { cbReq.AddCookie(c) } r.ServeHTTP(cbW, cbReq) assert.Equal(t, http.StatusBadRequest, cbW.Code) assert.Contains(t, cbW.Body.String(), "state mismatch") } // TestOIDCCallback_NoCookie 驗證沒帶 cookie → 400。 func TestOIDCCallback_NoCookie(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) cbW := httptest.NewRecorder() cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=xyz&state=abc", nil) r.ServeHTTP(cbW, cbReq) assert.Equal(t, http.StatusBadRequest, cbW.Code) assert.Contains(t, cbW.Body.String(), "no pending session") } // TestOIDCCallback_MissingCodeOrState 驗證 missing query 回 400。 func TestOIDCCallback_MissingCodeOrState(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) cbW := httptest.NewRecorder() r.ServeHTTP(cbW, httptest.NewRequest(http.MethodGet, "/api/auth/callback", nil)) assert.Equal(t, http.StatusBadRequest, cbW.Code) assert.Contains(t, cbW.Body.String(), "missing code or state") } // TestOIDCCallback_IdPError 驗證 IdP 回 error param → 400。 func TestOIDCCallback_IdPError(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) cbW := httptest.NewRecorder() r.ServeHTTP(cbW, httptest.NewRequest(http.MethodGet, "/api/auth/callback?error=access_denied&error_description=user_cancelled", nil)) assert.Equal(t, http.StatusBadRequest, cbW.Code) assert.Contains(t, cbW.Body.String(), "access_denied") } // TestOIDCCallback_TokenExchangeInvalidGrant 驗證 invalid_grant → 400。 func TestOIDCCallback_TokenExchangeInvalidGrant(t *testing.T) { provider := &mockOIDCProvider{ exchangeFn: func(ctx context.Context, code, verifier string) (*oidc.TokenResponse, error) { return nil, oidc.ErrInvalidGrant }, } deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) loginW := httptest.NewRecorder() r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)) state := mustExtractStateFromLoginRedirect(t, loginW) cookies := loginW.Result().Cookies() cbW := httptest.NewRecorder() cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=xyz&state="+url.QueryEscape(state), nil) for _, c := range cookies { cbReq.AddCookie(c) } r.ServeHTTP(cbW, cbReq) assert.Equal(t, http.StatusBadRequest, cbW.Code) assert.Contains(t, cbW.Body.String(), "token exchange failed") } // TestOIDCCallback_RotatesSessionID_PreventsFixation 驗證 Fix-A1(session fixation 防護): // // 攻擊情境:攻擊者預先取得一個 pending session cookie(自己跑 /api/auth/login), // 誘騙受害者使用此 cookie 走完 OIDC flow。 // // 防護驗證: // - callback 完成時必須 rotate cookie value(瀏覽器收到的 Set-Cookie 與原 cookie value 不同) // - 用「攻擊者持有的舊 cookie」訪 /api/auth/me 應該 401(pending session 已不存在於 store) // - 用「callback 回傳的新 cookie」訪 /api/auth/me 應該 200(已登入) func TestOIDCCallback_RotatesSessionID_PreventsFixation(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) // 模擬「攻擊者預先取得 pending cookie」 loginW := httptest.NewRecorder() r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)) state := mustExtractStateFromLoginRedirect(t, loginW) attackerCookies := loginW.Result().Cookies() require.NotEmpty(t, attackerCookies) attackerCookieValue := attackerCookies[0].Value // 模擬「受害者用攻擊者的 cookie 走完 callback」 cbW := httptest.NewRecorder() cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=auth-code&state="+url.QueryEscape(state), nil) for _, c := range attackerCookies { cbReq.AddCookie(c) } r.ServeHTTP(cbW, cbReq) require.Equal(t, http.StatusFound, cbW.Code, "callback should succeed; body=%s", cbW.Body.String()) // 驗證 1:callback 必須寫一個新 cookie,且 value 與舊 cookie 不同 newCookies := cbW.Result().Cookies() require.NotEmpty(t, newCookies, "callback must write new Set-Cookie (rotation)") var newSessCookie *http.Cookie for _, c := range newCookies { if c.Name == "visiona_session" { newSessCookie = c break } } require.NotNil(t, newSessCookie, "expected visiona_session cookie after callback") assert.NotEqual(t, attackerCookieValue, newSessCookie.Value, "session fixation: cookie value MUST change after login (rotate session ID)") // 驗證 2:用攻擊者持有的舊 cookie 訪 /me → 401(攻擊者拿不到 victim 帳號) attackerMeW := httptest.NewRecorder() attackerMeReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for _, c := range attackerCookies { attackerMeReq.AddCookie(c) } r.ServeHTTP(attackerMeW, attackerMeReq) assert.Equal(t, http.StatusUnauthorized, attackerMeW.Code, "attacker's old cookie must be rejected after rotation; body=%s", attackerMeW.Body.String()) // 驗證 3:用受害者的新 cookie 訪 /me → 200(合法) victimMeW := httptest.NewRecorder() victimMeReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for _, c := range newCookies { victimMeReq.AddCookie(c) } r.ServeHTTP(victimMeW, victimMeReq) assert.Equal(t, http.StatusOK, victimMeW.Code, "victim's new cookie must be accepted; body=%s", victimMeW.Body.String()) } // TestOIDCCallback_VerifyFails 驗證 id_token 驗證失敗 → 401。 func TestOIDCCallback_VerifyFails(t *testing.T) { provider := &mockOIDCProvider{ verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) { return nil, oidc.ErrInvalidIDToken }, } deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) loginW := httptest.NewRecorder() r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)) state := mustExtractStateFromLoginRedirect(t, loginW) cookies := loginW.Result().Cookies() cbW := httptest.NewRecorder() cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=xyz&state="+url.QueryEscape(state), nil) for _, c := range cookies { cbReq.AddCookie(c) } r.ServeHTTP(cbW, cbReq) assert.Equal(t, http.StatusUnauthorized, cbW.Code) assert.Contains(t, cbW.Body.String(), "id_token verification failed") } // ---- TESTS: AuthMiddleware (OIDC 模式) + /api/auth/me + /api/auth/logout ---- // TestOIDCMiddleware_Allows_AuthenticatedSession 驗證已登入 session 通過 + me 回 user info。 func TestOIDCMiddleware_Allows_AuthenticatedSession(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) // 完整跑一次 login + callback 拿到登入 session cookies := loginAndCallback(t, r, deps, provider) // 訪 /api/auth/me — 應 200 + 帶 user info meW := httptest.NewRecorder() meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for _, c := range cookies { meReq.AddCookie(c) } r.ServeHTTP(meW, meReq) require.Equal(t, http.StatusOK, meW.Code, "body=%s", meW.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(meW.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "user-123", data["user_id"]) assert.Equal(t, "alice@example.com", data["email"]) assert.Equal(t, "Alice", data["name"]) } // TestOIDCMiddleware_Rejects_NoCookie 驗證沒 cookie → 401。 func TestOIDCMiddleware_Rejects_NoCookie(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), "no_session") } // TestOIDCMiddleware_Rejects_PendingSession 驗證 pending session(UserID 空)→ 401。 // // 情境:使用者啟動 login 但還沒走完 callback,只有 pending session cookie // 就直接訪 /api/auth/me — 應該被拒絕,而不是被當已登入。 func TestOIDCMiddleware_Rejects_PendingSession(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) loginW := httptest.NewRecorder() r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)) cookies := loginW.Result().Cookies() meW := httptest.NewRecorder() meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for _, c := range cookies { meReq.AddCookie(c) } r.ServeHTTP(meW, meReq) assert.Equal(t, http.StatusUnauthorized, meW.Code) assert.Contains(t, meW.Body.String(), "session_not_authenticated") } // TestOIDCLogout_ClearsSession 驗證 logout 200 + 清 cookie。 func TestOIDCLogout_ClearsSession(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) cookies := loginAndCallback(t, r, deps, provider) // POST /api/auth/logout logoutW := httptest.NewRecorder() logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) for _, c := range cookies { logoutReq.AddCookie(c) } r.ServeHTTP(logoutW, logoutReq) assert.Equal(t, http.StatusOK, logoutW.Code) // Set-Cookie 應該帶過期 attribute respCookies := logoutW.Result().Cookies() var cleared *http.Cookie for _, c := range respCookies { if c.Name == "visiona_session" { cleared = c break } } require.NotNil(t, cleared, "expected visiona_session clearing cookie") assert.True(t, cleared.MaxAge < 0, "expected MaxAge < 0 to clear cookie") // 之後 /api/auth/me 應該 401 meW := httptest.NewRecorder() meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for _, c := range cookies { meReq.AddCookie(c) } r.ServeHTTP(meW, meReq) assert.Equal(t, http.StatusUnauthorized, meW.Code) } // TestOIDC_LegacyLogin_Returns410 驗證 OIDC 模式下 POST /api/auth/login 回 410。 func TestOIDC_LegacyLogin_Returns410(t *testing.T) { provider := &mockOIDCProvider{} deps := newOIDCTestDeps(provider) r := newOIDCRouter(deps) // POST /api/auth/login 在 OIDC 模式下不支援 — 但會先過 AuthMiddleware // (沒帶 cookie 就 401)。為了測 410 行為,先登入拿 cookie。 cookies := loginAndCallback(t, r, deps, provider) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") for _, c := range cookies { req.AddCookie(c) } r.ServeHTTP(w, req) assert.Equal(t, http.StatusGone, w.Code) assert.Contains(t, w.Body.String(), "GET /api/auth/login") } // TestNewRouterValidate_PanicsWithoutOIDC 驗證 OB5 起 NewRouter 在缺 OIDC 依賴時 panic。 func TestNewRouterValidate_PanicsWithoutOIDC(t *testing.T) { t.Run("no provider, no manager", func(t *testing.T) { assert.Panics(t, func() { (&Deps{}).validate() }, "缺兩個 OIDC 依賴應 panic") }) t.Run("only provider", func(t *testing.T) { assert.Panics(t, func() { (&Deps{OIDCProvider: &mockOIDCProvider{}}).validate() }, "缺 SessionManager 應 panic") }) t.Run("only manager", func(t *testing.T) { // 這裡的 SigningKey 長度必須 ≥ 32 bytes(usersession.MinSigningKeyBytes),否則 NewManager 會 panic。 d := &Deps{SessionManager: usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{SigningKey: []byte("test-key-test-key-test-key-1234!")})} assert.Panics(t, func() { d.validate() }, "缺 OIDCProvider 應 panic") }) t.Run("both set passes", func(t *testing.T) { d := newOIDCTestDeps(&mockOIDCProvider{}) assert.NotPanics(t, func() { d.validate() }) }) } // ---- helper: 共用的 login + callback 流程 -------------------------------- // loginAndCallback 跑完整 login → callback 流程,回傳已登入 session 的 cookie。 // // Fix-A1(session fixation 防護)後:callback 完成時會 rotate session ID,cookie 會被改寫。 // 因此優先回傳 callback 後的 Set-Cookie;若 callback 沒寫新 cookie(理論上不應該) // 才 fallback 用 login 階段的 cookie。 func loginAndCallback(t *testing.T, r *gin.Engine, deps Deps, _ *mockOIDCProvider) []*http.Cookie { t.Helper() loginW := httptest.NewRecorder() r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login?return_to=/", nil)) require.Equal(t, http.StatusFound, loginW.Code) state := mustExtractStateFromLoginRedirect(t, loginW) loginCookies := loginW.Result().Cookies() cbW := httptest.NewRecorder() cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=auth-code&state="+url.QueryEscape(state), nil) for _, c := range loginCookies { cbReq.AddCookie(c) } r.ServeHTTP(cbW, cbReq) require.Equal(t, http.StatusFound, cbW.Code, "callback failed: %s", cbW.Body.String()) // callback 完成後的 Set-Cookie 是 rotation 後的新 cookie;用它做後續請求。 cbCookies := cbW.Result().Cookies() if len(cbCookies) > 0 { return cbCookies } return loginCookies } // mustExtractStateFromLoginRedirect 從 login redirect 的 Location 取出 state。 func mustExtractStateFromLoginRedirect(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() loc, err := url.Parse(w.Header().Get("Location")) require.NoError(t, err) state := loc.Query().Get("state") require.NotEmpty(t, state) return state }