first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+321
View File
@@ -0,0 +1,321 @@
// 外部測試套件(與 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)
}