// 外部測試套件(與 jwks_test.go 同理):oidc 模型由 store 遷移,內部 // 測試套件匯入 testdb 會形成循環。 package oidc_test import ( "crypto" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "gorm.io/gorm" "alterminal/internal/application" "alterminal/internal/auth" "alterminal/internal/jwk" "alterminal/internal/oidc" "alterminal/internal/testdb" ) // testIssuer 為測試用 issuer(與本機 http 開發環境一致)。 const testIssuer = "http://localhost:8080" // testEnv 打包端點整合測試的共用物件:簽章金鑰、使用者與 Session、 // 一個機密式與一個公開式應用程式。 type testEnv struct { db *gorm.DB key *jwk.SigningKey user *auth.User session *auth.Session app *application.Application // 機密式:openid profile email offline_access secret string // app 的 client secret 明文 pub *application.Application // 公開式:預設 grant 與 scope } // newTestEnv 建立測試環境(含所有相依資料列)。 func newTestEnv(t *testing.T) *testEnv { t.Helper() db := testdb.New(t) key := mustNewKey(t, false) if err := db.Create(key).Error; err != nil { t.Fatal("建立測試金鑰: ", err) } user := &auth.User{ Username: "oidc-test", Email: "oidc-test@example.com", Name: "測試使用者", EmailVerified: true, } if err := db.Create(user).Error; err != nil { t.Fatal("建立測試使用者: ", err) } session, err := auth.CreateSession(db, user.ID) if err != nil { t.Fatal("建立測試 Session: ", err) } app, secret, err := application.NewApplication( "機密式測試應用", application.ClientConfidential, []string{"https://rp.example/callback"}, []application.GrantType{application.GrantAuthorizationCode, application.GrantRefreshToken}, "openid profile email offline_access", ) if err != nil { t.Fatal("建立測試應用程式: ", err) } if err := db.Create(app).Error; err != nil { t.Fatal("建立測試應用程式: ", err) } pub, _, err := application.NewApplication( "公開式測試應用", application.ClientPublic, []string{"http://localhost:3000/cb"}, nil, "", // fill 會補預設值:grant 僅 authorization_code、scope 為 openid profile email ) if err != nil { t.Fatal("建立公開式測試應用: ", err) } if err := db.Create(pub).Error; err != nil { t.Fatal("建立公開式測試應用: ", err) } return &testEnv{db: db, key: key, user: user, session: session, app: app, secret: secret, pub: pub} } // sessionCookie 回傳環境使用者的 Session Cookie。 func (e *testEnv) sessionCookie() *http.Cookie { return &http.Cookie{Name: auth.CookieName, Value: e.session.ID} } // authorizeQuery 組出對指定應用程式的授權請求 query(redirect URI 取 // 第一個註冊值)。challenge 為空時不帶 PKCE 參數。 func authorizeQuery(app *application.Application, scope, state, nonce, challenge string) string { v := url.Values{} v.Set("response_type", "code") v.Set("client_id", app.ClientID) v.Set("redirect_uri", app.RedirectURIs[0]) v.Set("scope", scope) if state != "" { v.Set("state", state) } if nonce != "" { v.Set("nonce", nonce) } if challenge != "" { v.Set("code_challenge", challenge) v.Set("code_challenge_method", "S256") } return v.Encode() } // getAuthorize 對 GET /authorize 發出請求(可選帶 Cookie)並回傳記錄器。 func getAuthorize(h http.HandlerFunc, query string, cookies ...*http.Cookie) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/authorize?"+query, nil) for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h(rec, req) return rec } // csrfCookieOf 從回應的 Set-Cookie 取得輪替後的 CSRF token。 func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, c := range rec.Result().Cookies() { if c.Name == auth.CSRFCookieName { return c } } t.Fatal("回應未設定 CSRF Cookie") return nil } // hiddenFieldValue 由表單頁 HTML 取出指定隱藏欄位的 value( simplistic // 剖析,僅供測試使用)。 func hiddenFieldValue(t *testing.T, body, name string) string { t.Helper() marker := `name="` + name + `" value="` i := strings.Index(body, marker) if i < 0 { t.Fatalf("頁面缺少隱藏欄位 %s", name) } rest := body[i+len(marker):] return rest[:strings.Index(rest, `"`)] } // postAuthorize 送出同意頁決定(帶原始授權參數與 CSRF),回傳記錄器。 func postAuthorize(h http.HandlerFunc, query, decision string, cookies ...*http.Cookie) *httptest.ResponseRecorder { form, err := url.ParseQuery(query) // 正確解碼一次,Encode 時再編碼 if err != nil { panic(err) } form.Set("decision", decision) var csrf string for _, c := range cookies { if c.Name == auth.CSRFCookieName { csrf = c.Value } } form.Set("csrf_token", csrf) req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h(rec, req) return rec } // consentAllow 走授權流程取得授權碼,回傳 redirect Location 與其中的 // code:scope 已同意過時直接使用靜默通過的 302,否則顯示同意頁後按 // 同意。登入 Session 由 e 提供。 func consentAllow(t *testing.T, e *testEnv, query string) (*url.URL, string) { t.Helper() h := oidc.AuthorizeHandler(e.db) rec := getAuthorize(h, query, e.sessionCookie()) if rec.Code == http.StatusFound { loc := redirectLocation(t, rec) if code := loc.Query().Get("code"); code != "" { return loc, code // 已同意,靜默通過 } t.Fatalf("未預期的 302(無 code): %s", loc) } if rec.Code != http.StatusOK { t.Fatalf("同意頁 status = %d, want 200, body = %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "授權存取") { t.Fatalf("應顯示同意頁: %s", rec.Body.String()) } csrf := csrfCookieOf(t, rec) rec = postAuthorize(h, query, "allow", e.sessionCookie(), csrf) if rec.Code != http.StatusFound { t.Fatalf("同意後 status = %d, want 302, body = %s", rec.Code, rec.Body.String()) } loc := redirectLocation(t, rec) code := loc.Query().Get("code") if code == "" { t.Fatalf("redirect URI 缺少 code: %s", loc) } return loc, code } // redirectLocation 解析 302/303 回應的 Location 標頭。 func redirectLocation(t *testing.T, rec *httptest.ResponseRecorder) *url.URL { t.Helper() raw := rec.Header().Get("Location") u, err := url.Parse(raw) if err != nil { t.Fatalf("解析 Location %q: %v", raw, err) } return u } // postToken 對 POST /token 送出表單;basicID/basicSecret 非空時改用 // HTTP Basic 認證。 func postToken(h http.HandlerFunc, form url.Values, basicID, basicSecret string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if basicID != "" { req.SetBasicAuth(basicID, basicSecret) } rec := httptest.NewRecorder() h(rec, req) return rec } // tokenErrorBody 解析 token 錯誤回應。 type tokenErrorBody struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` } // decodeTokenError 解析錯誤回應 JSON。 func decodeTokenError(t *testing.T, rec *httptest.ResponseRecorder) tokenErrorBody { t.Helper() var e tokenErrorBody if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { t.Fatalf("解析錯誤回應: %v, body = %s", err, rec.Body.String()) } return e } // jwtParts 拆解 JWT 三段。 func jwtParts(t *testing.T, token string) (header, payload []byte) { t.Helper() parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("JWT 應有三段: %s", token) } var err error if header, err = base64.RawURLEncoding.DecodeString(parts[0]); err != nil { t.Fatalf("解碼 header: %v", err) } if payload, err = base64.RawURLEncoding.DecodeString(parts[1]); err != nil { t.Fatalf("解碼 payload: %v", err) } return header, payload } // forgeJWT 以指定金鑰與自訂 header/claims 造出 JWT(供負面測試: // alg 混淆、過期 claims 等)。 func forgeJWT(t *testing.T, key *jwk.SigningKey, header map[string]string, claims any) string { t.Helper() priv, err := key.PrivateKey() if err != nil { t.Fatal(err) } hb, err := json.Marshal(header) if err != nil { t.Fatal(err) } pb, err := json.Marshal(claims) if err != nil { t.Fatal(err) } signingInput := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(pb) digest := sha256.Sum256([]byte(signingInput)) sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:]) if err != nil { t.Fatal(err) } return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) } // idTokenClaims 為測試斷言用的 ID token claims。 type idTokenClaims struct { Iss string `json:"iss"` Sub string `json:"sub"` Aud string `json:"aud"` Exp int64 `json:"exp"` Iat int64 `json:"iat"` AuthTime int64 `json:"auth_time"` Nonce string `json:"nonce"` Name string `json:"name"` Email string `json:"email"` EmailVerf *bool `json:"email_verified"` } // userInfoBody 為測試斷言用的 /userinfo 回應。 type userInfoBody struct { Sub string `json:"sub"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` Email string `json:"email"` EmailVerified *bool `json:"email_verified"` } // subjectOf 回傳使用者 ID 的字串形式(與正式碼的 sub 生成一致)。 func subjectOf(id uint) string { return fmt.Sprintf("%d", id) }