從 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>
93 lines
3.4 KiB
Go
93 lines
3.4 KiB
Go
// oidc_test_helper_test.go — OB5 起測試共用的 OIDC 認證 helper。
|
||
//
|
||
// 為什麼需要這份:OB5 移除 StaticAuth 之後,所有走 AuthMiddleware 的 integration test
|
||
// 都必須先走完 OIDC login flow 拿 cookie。把這段樣板抽成 helper 讓每個 test
|
||
// 不必重複「fake OIDC server + login + callback + 取 cookie」這 30 行程式碼。
|
||
//
|
||
// 設計選擇:
|
||
// - 同個 oidctest.NewServer 在 fixture 整個生命週期共用 — 多個 client 各自登入即可
|
||
// - AuthenticatedClient 拿 cookie 後就和原本的 http.DefaultClient 行為一致,
|
||
// 之後每次打 /api/* 都自動帶 cookie,handler 看到的 UserContext = 預先 set 的 sub
|
||
// - 不暴露 fake OIDC URL 給 test caller — caller 透過 fixture method 操作即可
|
||
|
||
package main
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// AuthenticatedClient 走完整 OIDC login flow,回傳已帶 visiona_session cookie 的 *http.Client。
|
||
//
|
||
// userID / email:simulate 登入後 backend session 裡會記錄的 OIDC sub / email。
|
||
// 同個 fixture 可以呼叫多次(不同 userID),各自拿到獨立的 cookie jar,模擬多 user。
|
||
//
|
||
// 任何步驟出錯直接 t.Fatalf — caller 不必檢 err。
|
||
//
|
||
// 注意:回傳的 client 「會自動跟 redirect」(CheckRedirect=nil),方便 caller 直接打
|
||
// /api/* 端點不用自己處理 302。如果你需要做 BFF flow 的 step-by-step assert,請改用
|
||
// oidc_e2e_test.go 裡的 newCookieClient。
|
||
func (f *testFixture) AuthenticatedClient(t *testing.T, userID, email string) *http.Client {
|
||
t.Helper()
|
||
|
||
if f.fakeOIDC == nil {
|
||
t.Fatalf("AuthenticatedClient: fixture.fakeOIDC is nil — fixture wasn't built with setupFixture (which now wires fake OIDC)")
|
||
}
|
||
|
||
// 設定下一輪 ExchangeCode 的 id_token claims
|
||
f.fakeOIDC.SetNextIDTokenClaims(map[string]any{
|
||
"sub": userID,
|
||
"email": email,
|
||
"name": userID, // 簡化:name 與 sub 一致,測試夠用
|
||
})
|
||
|
||
jar, err := cookiejar.New(nil)
|
||
require.NoError(t, err)
|
||
// login flow 期間用「不自動 redirect」客戶端控制 302 step-by-step
|
||
flowClient := &http.Client{
|
||
Jar: jar,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
return http.ErrUseLastResponse
|
||
},
|
||
Timeout: 10 * time.Second,
|
||
}
|
||
|
||
// Step 1: GET /api/auth/login → 應 302 to fake OIDC /authorize
|
||
loc := getExpect302(t, flowClient, f.apiServer.URL+"/api/auth/login")
|
||
require.True(t, strings.HasPrefix(loc, f.fakeOIDC.URL+"/authorize"),
|
||
"login 應 302 to fake OIDC /authorize,得 %s", loc)
|
||
|
||
// Step 2: 模擬 IdP 同意登入 → 拿 callback URL
|
||
callbackURL := f.fakeOIDC.SimulateAuthorizationFlow(t, loc)
|
||
|
||
// Step 3: GET callback → backend 完成 token exchange + 寫 cookie session → 302 to PostLoginURL
|
||
_ = getExpect302(t, flowClient, callbackURL)
|
||
|
||
// 驗 cookie 已 set
|
||
u, err := url.Parse(f.apiServer.URL)
|
||
require.NoError(t, err)
|
||
cookies := flowClient.Jar.Cookies(u)
|
||
var sessCookie *http.Cookie
|
||
for _, c := range cookies {
|
||
if c.Name == "visiona_session" {
|
||
sessCookie = c
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, sessCookie, "expected visiona_session cookie after callback")
|
||
require.NotEmpty(t, sessCookie.Value, "visiona_session cookie 應有值")
|
||
|
||
// 回傳一個共用同個 cookie jar、但會自動跟 redirect 的 client,
|
||
// 讓 caller 寫 client.Get/Post 不必處理 302。
|
||
return &http.Client{
|
||
Jar: jar,
|
||
Timeout: 30 * time.Second,
|
||
}
|
||
}
|