接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過, 讓登入/換帳號可用;另補真轉檔服務的整合 e2e。 OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過): - email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發 sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫 - prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關) - logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用 隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。 config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地) 真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集): - real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result) - real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS) 交接檔(給對應團隊根治): - mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory) - converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth 全程 Reviewer 審查 + 對 stage 真環境驗證。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
801 lines
28 KiB
Go
801 lines
28 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/user"
|
||
"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: email fallback (放寬 email 必填) -----------------------------
|
||
|
||
// recordingUserStore 是攔截 Upsert 的測試 user.Store,記錄最後一次寫入的 User,
|
||
// 供 assertion 驗證 provision 進 DB 的 email(真 email vs fallback placeholder)。
|
||
type recordingUserStore struct {
|
||
mu sync.Mutex
|
||
lastUpsert *user.User
|
||
}
|
||
|
||
func (s *recordingUserStore) Upsert(ctx context.Context, in *user.User) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
cp := *in
|
||
s.lastUpsert = &cp
|
||
return nil
|
||
}
|
||
|
||
func (s *recordingUserStore) Get(ctx context.Context, id string) (*user.User, error) {
|
||
return nil, user.ErrNotFound
|
||
}
|
||
|
||
func (s *recordingUserStore) last() *user.User {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.lastUpsert
|
||
}
|
||
|
||
// runCallbackWithProvider 跑完整 login + callback,回傳 callback 的 recorder。
|
||
func runCallbackWithProvider(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
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=auth-code&state="+url.QueryEscape(state), nil)
|
||
for _, c := range cookies {
|
||
cbReq.AddCookie(c)
|
||
}
|
||
r.ServeHTTP(cbW, cbReq)
|
||
return cbW
|
||
}
|
||
|
||
// TestOIDCCallback_EmailMissing_UsesFallback 驗證 email claim 為空時:
|
||
// - callback 仍 provision 成功(302、非 500)
|
||
// - 寫進 user store 的 email 是 "<sub>@noemail.visiona.local" placeholder(含 sub、unique)
|
||
func TestOIDCCallback_EmailMissing_UsesFallback(t *testing.T) {
|
||
provider := &mockOIDCProvider{
|
||
verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) {
|
||
// 模擬 MC 現況:只有 sub / name,沒有 email
|
||
return &oidc.Claims{Subject: "sub-uuid-1", Email: "", Name: "Bob", Nonce: nonce}, nil
|
||
},
|
||
}
|
||
store := &recordingUserStore{}
|
||
deps := newOIDCTestDeps(provider)
|
||
deps.UserStore = store
|
||
r := newOIDCRouter(deps)
|
||
|
||
cbW := runCallbackWithProvider(t, r)
|
||
|
||
require.Equal(t, http.StatusFound, cbW.Code,
|
||
"login should succeed even without email claim; body=%s", cbW.Body.String())
|
||
|
||
got := store.last()
|
||
require.NotNil(t, got, "Upsert should have been called")
|
||
assert.Equal(t, "sub-uuid-1", got.ID)
|
||
assert.Equal(t, "sub-uuid-1@noemail.visiona.local", got.Email,
|
||
"empty email claim must be provisioned with sub-based fallback placeholder")
|
||
assert.True(t, isFallbackEmail(got.Email), "provisioned email must be flagged as fallback")
|
||
}
|
||
|
||
// TestOIDCCallback_EmailPresent_UsesRealEmail 驗證 email claim 非空時用真 email、不套 fallback。
|
||
// (未來 MC 修好發 email 後的行為。)
|
||
func TestOIDCCallback_EmailPresent_UsesRealEmail(t *testing.T) {
|
||
provider := &mockOIDCProvider{
|
||
verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) {
|
||
return &oidc.Claims{Subject: "sub-uuid-2", Email: "real@example.com", Name: "Carol", Nonce: nonce}, nil
|
||
},
|
||
}
|
||
store := &recordingUserStore{}
|
||
deps := newOIDCTestDeps(provider)
|
||
deps.UserStore = store
|
||
r := newOIDCRouter(deps)
|
||
|
||
cbW := runCallbackWithProvider(t, r)
|
||
|
||
require.Equal(t, http.StatusFound, cbW.Code, "body=%s", cbW.Body.String())
|
||
|
||
got := store.last()
|
||
require.NotNil(t, got)
|
||
assert.Equal(t, "real@example.com", got.Email, "non-empty email claim must be used as-is")
|
||
assert.False(t, isFallbackEmail(got.Email), "real email must not be flagged as fallback")
|
||
}
|
||
|
||
// TestResolveProvisionEmail 驗證 fallback email 計算邏輯(格式正確、含 sub、unique-by-sub)。
|
||
func TestResolveProvisionEmail(t *testing.T) {
|
||
t.Run("empty_email_uses_sub_fallback", func(t *testing.T) {
|
||
email, isFallback := resolveProvisionEmail("abc-123", "")
|
||
assert.Equal(t, "abc-123@noemail.visiona.local", email)
|
||
assert.True(t, isFallback)
|
||
assert.True(t, isFallbackEmail(email))
|
||
})
|
||
t.Run("real_email_passthrough", func(t *testing.T) {
|
||
email, isFallback := resolveProvisionEmail("abc-123", "x@y.com")
|
||
assert.Equal(t, "x@y.com", email)
|
||
assert.False(t, isFallback)
|
||
assert.False(t, isFallbackEmail(email))
|
||
})
|
||
t.Run("fallback_is_unique_per_sub", func(t *testing.T) {
|
||
e1, _ := resolveProvisionEmail("sub-A", "")
|
||
e2, _ := resolveProvisionEmail("sub-B", "")
|
||
assert.NotEqual(t, e1, e2, "different subs must yield different fallback emails (no unique-index clash)")
|
||
})
|
||
}
|
||
|
||
// ---- 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)
|
||
}
|
||
|
||
// TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset 驗證未設定 OIDCLogoutURL 時,
|
||
// logout response 不含 idp_logout 欄位(向下相容,只清本地 session)。
|
||
func TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset(t *testing.T) {
|
||
provider := &mockOIDCProvider{}
|
||
deps := newOIDCTestDeps(provider)
|
||
// 不設 OIDCLogoutURL(預設空字串)
|
||
require.Empty(t, deps.OIDCLogoutURL)
|
||
r := newOIDCRouter(deps)
|
||
|
||
cookies := loginAndCallback(t, r, deps, provider)
|
||
|
||
logoutW := httptest.NewRecorder()
|
||
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||
for _, c := range cookies {
|
||
logoutReq.AddCookie(c)
|
||
}
|
||
r.ServeHTTP(logoutW, logoutReq)
|
||
require.Equal(t, http.StatusOK, logoutW.Code)
|
||
|
||
// data.success == true 且不含 idp_logout
|
||
var env struct {
|
||
Success bool `json:"success"`
|
||
Data struct {
|
||
Success bool `json:"success"`
|
||
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
|
||
} `json:"data"`
|
||
}
|
||
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
|
||
assert.True(t, env.Data.Success)
|
||
assert.Nil(t, env.Data.IDPLogout, "未設定 OIDCLogoutURL 時不應回 idp_logout")
|
||
// 原始 JSON 不應出現 idp_logout key(omitempty 驗證)
|
||
assert.NotContains(t, logoutW.Body.String(), "idp_logout")
|
||
}
|
||
|
||
// TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet 驗證設定 OIDCLogoutURL 後,
|
||
// logout response 含 idp_logout(URL + Method=GET),且仍清本地 session。
|
||
func TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet(t *testing.T) {
|
||
provider := &mockOIDCProvider{}
|
||
deps := newOIDCTestDeps(provider)
|
||
// MC Web :7880 logout 入口(不是 :7850 Api)。
|
||
const mcLogoutURL = "https://stage-9527.innovedus.com:7880/account/logout"
|
||
deps.OIDCLogoutURL = mcLogoutURL
|
||
r := newOIDCRouter(deps)
|
||
|
||
cookies := loginAndCallback(t, r, deps, provider)
|
||
|
||
logoutW := httptest.NewRecorder()
|
||
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||
for _, c := range cookies {
|
||
logoutReq.AddCookie(c)
|
||
}
|
||
r.ServeHTTP(logoutW, logoutReq)
|
||
require.Equal(t, http.StatusOK, logoutW.Code)
|
||
|
||
var env struct {
|
||
Data struct {
|
||
Success bool `json:"success"`
|
||
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
|
||
} `json:"data"`
|
||
}
|
||
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
|
||
assert.True(t, env.Data.Success)
|
||
require.NotNil(t, env.Data.IDPLogout, "設定 OIDCLogoutURL 後應回 idp_logout")
|
||
assert.Equal(t, mcLogoutURL, env.Data.IDPLogout.URL)
|
||
assert.Equal(t, http.MethodGet, env.Data.IDPLogout.Method)
|
||
|
||
// 本地 session 仍要被清掉(清 cookie)
|
||
var cleared *http.Cookie
|
||
for _, c := range logoutW.Result().Cookies() {
|
||
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")
|
||
}
|
||
|
||
// 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
|
||
}
|