Files
alterminal/internal/oidc/helpers_test.go
T
2026-10-03 10:44:29 +08:00

322 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 外部測試套件(與 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)
}