// 外部測試套件:見 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) } }