visionA/visionA-backend/internal/api/oidc_auth_test.go
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

605 lines
20 KiB
Go
Raw 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_auth_test.go — OIDC handler 與 OIDC-mode middleware 的 unit test。
//
// 設計策略:用 mockOIDCProvider 取代真實 IdP避免 IO、純 Go function call
// 這樣測試快且確定性高;真實 IdP 整合留給 OT1fake server+ OT2end-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-A1session fixation 防護):
//
// 攻擊情境:攻擊者預先取得一個 pending session cookie自己跑 /api/auth/login
// 誘騙受害者使用此 cookie 走完 OIDC flow。
//
// 防護驗證:
// - callback 完成時必須 rotate cookie value瀏覽器收到的 Set-Cookie 與原 cookie value 不同)
// - 用「攻擊者持有的舊 cookie」訪 /api/auth/me 應該 401pending 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())
// 驗證 1callback 必須寫一個新 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 sessionUserID 空)→ 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 bytesusersession.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-A1session fixation 防護callback 完成時會 rotate session IDcookie 會被改寫。
// 因此優先回傳 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
}