從 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>
251 lines
8.0 KiB
Go
251 lines
8.0 KiB
Go
package usersession
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// testKey 是測試用的 32 byte HMAC key。
|
||
var testKey = []byte("test-key-test-key-test-key-1234!") // 32 bytes
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// Encode / Decode roundtrip
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
func TestEncodeDecode_Roundtrip(t *testing.T) {
|
||
sid := "abc123XYZ_-test"
|
||
encoded := EncodeCookieValue(sid, testKey)
|
||
if !strings.Contains(encoded, ".") {
|
||
t.Fatalf("encoded should contain separator '.', got %q", encoded)
|
||
}
|
||
got, err := DecodeCookieValue(encoded, testKey)
|
||
if err != nil {
|
||
t.Fatalf("Decode: %v", err)
|
||
}
|
||
if got != sid {
|
||
t.Fatalf("roundtrip mismatch: got %q want %q", got, sid)
|
||
}
|
||
}
|
||
|
||
func TestDecode_TamperedSessionID(t *testing.T) {
|
||
encoded := EncodeCookieValue("realsid", testKey)
|
||
parts := strings.SplitN(encoded, ".", 2)
|
||
tampered := "fakesid." + parts[1]
|
||
_, err := DecodeCookieValue(tampered, testKey)
|
||
if !errors.Is(err, ErrSignatureMismatch) {
|
||
t.Fatalf("expected ErrSignatureMismatch when sessionID tampered, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDecode_TamperedSignature(t *testing.T) {
|
||
encoded := EncodeCookieValue("realsid", testKey)
|
||
parts := strings.SplitN(encoded, ".", 2)
|
||
// 換個簽章(不同長度的 base64url 也會失敗)
|
||
tampered := parts[0] + ".AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||
_, err := DecodeCookieValue(tampered, testKey)
|
||
if !errors.Is(err, ErrSignatureMismatch) {
|
||
t.Fatalf("expected ErrSignatureMismatch, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDecode_DifferentKeyFails(t *testing.T) {
|
||
encoded := EncodeCookieValue("sid", testKey)
|
||
other := []byte("other-key-other-key-other-key-1!")
|
||
_, err := DecodeCookieValue(encoded, other)
|
||
if !errors.Is(err, ErrSignatureMismatch) {
|
||
t.Fatalf("expected ErrSignatureMismatch with different key, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDecode_MalformedValues(t *testing.T) {
|
||
cases := map[string]string{
|
||
"empty": "",
|
||
"no separator": "noseparator",
|
||
"only sep": ".",
|
||
"empty sid": ".sigonly",
|
||
"empty sig": "sidonly.",
|
||
}
|
||
for name, val := range cases {
|
||
t.Run(name, func(t *testing.T) {
|
||
_, err := DecodeCookieValue(val, testKey)
|
||
if !errors.Is(err, ErrInvalidCookie) {
|
||
t.Fatalf("%s: expected ErrInvalidCookie, got %v", name, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// WriteCookie / ReadCookie / ClearCookie via httptest
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
func newCookieCfg() CookieConfig {
|
||
return CookieConfig{
|
||
Name: DefaultCookieName,
|
||
Path: "/",
|
||
HTTPOnly: true,
|
||
Secure: false,
|
||
SameSite: http.SameSiteLaxMode,
|
||
MaxAge: 86400,
|
||
SigningKey: testKey,
|
||
}
|
||
}
|
||
|
||
func TestWriteAndReadCookie_Roundtrip(t *testing.T) {
|
||
cfg := newCookieCfg()
|
||
sid := "session-abc-123"
|
||
|
||
w := httptest.NewRecorder()
|
||
if err := WriteCookie(w, cfg, sid); err != nil {
|
||
t.Fatalf("WriteCookie: %v", err)
|
||
}
|
||
|
||
// 從 response 取出 Set-Cookie,做成 request cookie 模擬 browser 回傳
|
||
resp := w.Result()
|
||
cookies := resp.Cookies()
|
||
if len(cookies) != 1 {
|
||
t.Fatalf("expected 1 Set-Cookie, got %d", len(cookies))
|
||
}
|
||
c := cookies[0]
|
||
if c.Name != DefaultCookieName {
|
||
t.Fatalf("cookie name: got %q want %q", c.Name, DefaultCookieName)
|
||
}
|
||
if !c.HttpOnly {
|
||
t.Fatalf("HttpOnly should be true")
|
||
}
|
||
if c.SameSite != http.SameSiteLaxMode {
|
||
t.Fatalf("SameSite should be Lax")
|
||
}
|
||
if c.MaxAge != 86400 {
|
||
t.Fatalf("MaxAge: got %d want 86400", c.MaxAge)
|
||
}
|
||
|
||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
r.AddCookie(c)
|
||
got, ok := ReadCookie(r, cfg)
|
||
if !ok {
|
||
t.Fatalf("ReadCookie: ok=false")
|
||
}
|
||
if got != sid {
|
||
t.Fatalf("ReadCookie: got %q want %q", got, sid)
|
||
}
|
||
}
|
||
|
||
func TestReadCookie_NoCookie(t *testing.T) {
|
||
cfg := newCookieCfg()
|
||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
if _, ok := ReadCookie(r, cfg); ok {
|
||
t.Fatalf("ReadCookie should return ok=false when no cookie present")
|
||
}
|
||
}
|
||
|
||
func TestReadCookie_TamperedValue(t *testing.T) {
|
||
cfg := newCookieCfg()
|
||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
// 故意放一個簽章錯的 cookie
|
||
r.AddCookie(&http.Cookie{Name: cfg.Name, Value: "tampered.sig"})
|
||
if _, ok := ReadCookie(r, cfg); ok {
|
||
t.Fatalf("ReadCookie should return ok=false for tampered cookie")
|
||
}
|
||
}
|
||
|
||
func TestClearCookie_SetsExpiration(t *testing.T) {
|
||
cfg := newCookieCfg()
|
||
w := httptest.NewRecorder()
|
||
|
||
ClearCookie(w, cfg)
|
||
cookies := w.Result().Cookies()
|
||
if len(cookies) != 1 {
|
||
t.Fatalf("expected 1 Set-Cookie, got %d", len(cookies))
|
||
}
|
||
c := cookies[0]
|
||
if c.MaxAge >= 0 {
|
||
t.Fatalf("ClearCookie MaxAge should be < 0, got %d", c.MaxAge)
|
||
}
|
||
if c.Value != "" {
|
||
t.Fatalf("ClearCookie value should be empty, got %q", c.Value)
|
||
}
|
||
if c.Name != cfg.Name || c.Path != cfg.Path {
|
||
t.Fatalf("ClearCookie must use same Name/Path; got name=%q path=%q", c.Name, c.Path)
|
||
}
|
||
}
|
||
|
||
func TestClearCookie_BrowserCannotRead(t *testing.T) {
|
||
cfg := newCookieCfg()
|
||
|
||
// 1. WriteCookie → 得到一個 cookie
|
||
w1 := httptest.NewRecorder()
|
||
if err := WriteCookie(w1, cfg, "sid-X"); err != nil {
|
||
t.Fatalf("WriteCookie: %v", err)
|
||
}
|
||
original := w1.Result().Cookies()[0]
|
||
|
||
// 2. ClearCookie → 得到一個 expiration cookie
|
||
w2 := httptest.NewRecorder()
|
||
ClearCookie(w2, cfg)
|
||
expirationCookie := w2.Result().Cookies()[0]
|
||
|
||
// 3. 模擬 browser 在收到 expiration cookie 後立刻發 request — 此時應該沒有 cookie
|
||
// (這裡無法直接模擬 browser 的 cookie jar 邏輯,但能驗證 expiration cookie 內容是空的、
|
||
// 若 browser 真的把它存下來,後續 ReadCookie 會失敗)。
|
||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
r.AddCookie(&http.Cookie{Name: expirationCookie.Name, Value: expirationCookie.Value})
|
||
if _, ok := ReadCookie(r, cfg); ok {
|
||
t.Fatalf("after ClearCookie, ReadCookie of cleared value must fail")
|
||
}
|
||
// sanity check:原本的 cookie 仍然能讀(驗證 ReadCookie 本身沒壞)
|
||
r2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
r2.AddCookie(original)
|
||
if _, ok := ReadCookie(r2, cfg); !ok {
|
||
t.Fatalf("baseline: original cookie should still read OK")
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// CookieConfig validation
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
func TestWriteCookie_MissingSigningKey(t *testing.T) {
|
||
cfg := CookieConfig{} // SigningKey 為 nil
|
||
w := httptest.NewRecorder()
|
||
err := WriteCookie(w, cfg, "sid")
|
||
if !errors.Is(err, ErrInvalidConfig) {
|
||
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestWriteCookie_EmptySessionID(t *testing.T) {
|
||
cfg := newCookieCfg()
|
||
w := httptest.NewRecorder()
|
||
err := WriteCookie(w, cfg, "")
|
||
if !errors.Is(err, ErrInvalidCookie) {
|
||
t.Fatalf("expected ErrInvalidCookie, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestReadCookie_MissingSigningKey(t *testing.T) {
|
||
cfg := CookieConfig{} // SigningKey 為 nil
|
||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
r.AddCookie(&http.Cookie{Name: DefaultCookieName, Value: "anything.thing"})
|
||
if _, ok := ReadCookie(r, cfg); ok {
|
||
t.Fatalf("ReadCookie should fail when SigningKey missing")
|
||
}
|
||
}
|
||
|
||
func TestCookieConfig_Defaults(t *testing.T) {
|
||
cfg := CookieConfig{SigningKey: testKey} // 其他欄位都用預設
|
||
|
||
if cfg.resolvedName() != DefaultCookieName {
|
||
t.Fatalf("resolvedName default mismatch")
|
||
}
|
||
if cfg.resolvedPath() != "/" {
|
||
t.Fatalf("resolvedPath default should be '/'")
|
||
}
|
||
if cfg.resolvedSameSite() != http.SameSiteLaxMode {
|
||
t.Fatalf("resolvedSameSite default should be Lax")
|
||
}
|
||
}
|