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
+112
View File
@@ -0,0 +1,112 @@
// 外部測試套件:見 jwks_test.go 開頭說明。
package oidc_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"alterminal/internal/oidc"
)
// IssueAccessToken 簽出的 token 應可由 VerifyAccessToken 驗回,且 claims
// 正確(iss/sub/aud/scope)。
func TestAccessTokenRoundTrip(t *testing.T) {
e := newTestEnv(t)
token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid profile")
if err != nil {
t.Fatal("IssueAccessToken: ", err)
}
claims, err := oidc.VerifyAccessToken(e.db, testIssuer, token)
if err != nil {
t.Fatal("VerifyAccessToken: ", err)
}
if claims.Iss != testIssuer {
t.Errorf("iss = %q, want %q", claims.Iss, testIssuer)
}
if claims.Sub != subjectOf(e.user.ID) {
t.Errorf("sub = %q, want %q", claims.Sub, subjectOf(e.user.ID))
}
if claims.Aud != e.app.ClientID || claims.ClientID != e.app.ClientID {
t.Errorf("aud/client_id = %q/%q, want %q", claims.Aud, claims.ClientID, e.app.ClientID)
}
if claims.Scope != "openid profile" {
t.Errorf("scope = %q", claims.Scope)
}
// header 應含正確的 alg 與 kid(RP 以 kid 對應 JWKS)。
header, _ := jwtParts(t, token)
var h struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(header, &h); err != nil {
t.Fatal("解析 header: ", err)
}
if h.Alg != "RS256" || h.Kid != e.key.Kid || h.Typ != "JWT" {
t.Errorf("header = %+v", h)
}
}
// 各種無效 token 都應回 ErrInvalidToken,不洩漏細節。
func TestVerifyAccessTokenRejectsInvalid(t *testing.T) {
e := newTestEnv(t)
good, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid")
if err != nil {
t.Fatal(err)
}
rs256Header := func(kid string) map[string]string {
return map[string]string{"alg": "RS256", "kid": kid, "typ": "JWT"}
}
// 竄改 payload:改動第一個字元後以原簽章送出,驗章應失敗。
parts := strings.Split(good, ".")
payloadBytes := []byte(parts[1])
if payloadBytes[0] == 'e' {
payloadBytes[0] = 'e' + 1
} else {
payloadBytes[0] = 'e'
}
parts[1] = string(payloadBytes)
tampered := strings.Join(parts, ".")
tests := []struct {
name string
token string
}{
{"非 JWT 結構", "not-a-jwt"},
{"alg=none(演算法混淆)", forgeJWT(t, e.key, map[string]string{"alg": "none", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
{"alg=HS256", forgeJWT(t, e.key, map[string]string{"alg": "HS256", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
{"kid 不存在", forgeJWT(t, e.key, rs256Header("unknown-kid"), map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
{"issuer 不符", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": "https://other.example", "exp": time.Now().Add(time.Hour).Unix()})},
{"已過期", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": testIssuer, "exp": time.Now().Add(-time.Minute).Unix()})},
{"竄改 payload", tampered},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := oidc.VerifyAccessToken(e.db, testIssuer, tt.token)
if !errors.Is(err, oidc.ErrInvalidToken) {
t.Fatalf("err = %v, want ErrInvalidToken", err)
}
})
}
}
// 已退休金鑰簽發的 token 在效期內仍應驗證成功(輪替過渡期,JWKS 同步
// 發佈新舊金鑰的模型)。
func TestVerifyAccessTokenRetiredKey(t *testing.T) {
e := newTestEnv(t)
token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid")
if err != nil {
t.Fatal(err)
}
now := time.Now()
if err := e.db.Model(e.key).Update("retired_at", now).Error; err != nil {
t.Fatal("退休金鑰: ", err)
}
if _, err := oidc.VerifyAccessToken(e.db, testIssuer, token); err != nil {
t.Fatalf("退休金鑰在效期內仍應可驗證: %v", err)
}
}