從 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>
605 lines
20 KiB
Go
605 lines
20 KiB
Go
// 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
|
||
}
|