2026-05-12 09:53:20 +08:00

229 lines
7.9 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.

package auth_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"maps"
"math/big"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"hyapp/pkg/xerr"
authservice "hyapp/services/user-service/internal/service/auth"
)
func TestFirebaseThirdPartyVerifierAcceptsGoogleFirebaseIDToken(t *testing.T) {
// Firebase verifier 必须从已签名 ID token 中抽取 sub/uid而不是信任客户端直传 account id。
now := time.Unix(2000, 0)
privateKey, certPEM := firebaseTestKeyPair(t, now)
certsServer := firebaseCertsServer(t, "kid-1", certPEM)
verifier := authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
ProjectID: "hyapp-test",
AllowedSignInProviders: []string{"google.com"},
CertsURL: certsServer.URL,
Now: func() time.Time { return now },
})
token := signFirebaseTestToken(t, privateKey, "kid-1", map[string]any{
"iss": "https://securetoken.google.com/hyapp-test",
"aud": "hyapp-test",
"sub": "firebase-uid-1",
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
"auth_time": now.Add(-time.Minute).Unix(),
"firebase": map[string]any{"sign_in_provider": "google.com"},
})
profile, err := verifier.Verify(context.Background(), "firebase", token)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if profile.ProviderSubject != "firebase-uid-1" {
t.Fatalf("provider_subject must come from Firebase sub: %+v", profile)
}
}
func TestFirebaseThirdPartyVerifierRejectsInvalidClaims(t *testing.T) {
// AUTH_FAILED 分支必须覆盖 provider、audience、登录方式和过期时间避免身份枚举和配置泄漏。
now := time.Unix(2000, 0)
privateKey, certPEM := firebaseTestKeyPair(t, now)
certsServer := firebaseCertsServer(t, "kid-1", certPEM)
verifier := authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
ProjectID: "hyapp-test",
AllowedSignInProviders: []string{"google.com"},
CertsURL: certsServer.URL,
Now: func() time.Time { return now },
})
validClaims := map[string]any{
"iss": "https://securetoken.google.com/hyapp-test",
"aud": "hyapp-test",
"sub": "firebase-uid-1",
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
"auth_time": now.Add(-time.Minute).Unix(),
"firebase": map[string]any{"sign_in_provider": "google.com"},
}
cases := []struct {
name string
provider string
mutate func(map[string]any)
}{
{
name: "wrong provider",
provider: "google",
},
{
name: "wrong audience",
provider: "firebase",
mutate: func(claims map[string]any) {
claims["aud"] = "other-project"
},
},
{
name: "wrong sign in provider",
provider: "firebase",
mutate: func(claims map[string]any) {
claims["firebase"] = map[string]any{"sign_in_provider": "password"}
},
},
{
name: "expired token",
provider: "firebase",
mutate: func(claims map[string]any) {
claims["exp"] = now.Add(-time.Minute).Unix()
},
},
{
name: "empty subject",
provider: "firebase",
mutate: func(claims map[string]any) {
claims["sub"] = ""
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claims := cloneFirebaseClaims(validClaims)
if tc.mutate != nil {
tc.mutate(claims)
}
token := signFirebaseTestToken(t, privateKey, "kid-1", claims)
_, err := verifier.Verify(context.Background(), tc.provider, token)
if !xerr.IsCode(err, xerr.AuthFailed) {
t.Fatalf("expected AUTH_FAILED, got %v", err)
}
})
}
}
func TestFirebaseThirdPartyVerifierRejectsUnknownKidWithoutRefreshingValidCache(t *testing.T) {
// 缓存有效期内的未知 kid 只能本地拒绝,不能让随机 token 放大成 Firebase 证书端点请求。
now := time.Unix(2000, 0)
privateKey, certPEM := firebaseTestKeyPair(t, now)
var fetches atomic.Int32
certsServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
fetches.Add(1)
writer.Header().Set("Cache-Control", "public, max-age=3600")
_ = json.NewEncoder(writer).Encode(map[string]string{"kid-1": certPEM})
}))
t.Cleanup(certsServer.Close)
verifier := authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
ProjectID: "hyapp-test",
AllowedSignInProviders: []string{"google.com"},
CertsURL: certsServer.URL,
Now: func() time.Time { return now },
})
claims := map[string]any{
"iss": "https://securetoken.google.com/hyapp-test",
"aud": "hyapp-test",
"sub": "firebase-uid-1",
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
"auth_time": now.Add(-time.Minute).Unix(),
"firebase": map[string]any{"sign_in_provider": "google.com"},
}
if _, err := verifier.Verify(context.Background(), "firebase", signFirebaseTestToken(t, privateKey, "kid-1", claims)); err != nil {
t.Fatalf("initial valid token should warm cache: %v", err)
}
if fetches.Load() != 1 {
t.Fatalf("valid token should fetch certs once, got %d", fetches.Load())
}
_, err := verifier.Verify(context.Background(), "firebase", signFirebaseTestToken(t, privateKey, "random-kid", claims))
if !xerr.IsCode(err, xerr.AuthFailed) {
t.Fatalf("expected AUTH_FAILED for unknown kid, got %v", err)
}
if fetches.Load() != 1 {
t.Fatalf("unknown kid must not refresh valid cert cache, got fetches=%d", fetches.Load())
}
}
func TestRoutedThirdPartyVerifierNeverRoutesFirebaseToStaticVerifier(t *testing.T) {
// 即使 static allowlist 误配了 firebaseprovider=firebase 也必须走真实 Firebase verifier 并 fail-closed。
verifier := authservice.NewRoutedThirdPartyVerifier(nil, authservice.NewStaticThirdPartyVerifier([]string{"firebase", "wechat"}))
if _, err := verifier.Verify(context.Background(), "firebase", "client-supplied-subject"); !xerr.IsCode(err, xerr.AuthFailed) {
t.Fatalf("firebase must not fall back to static verifier, got %v", err)
}
profile, err := verifier.Verify(context.Background(), "wechat", "mock-openid")
if err != nil || profile.ProviderSubject != "mock-openid" {
t.Fatalf("non-firebase mock provider should still use fallback: profile=%+v err=%v", profile, err)
}
}
func firebaseTestKeyPair(t *testing.T, now time.Time) (*rsa.PrivateKey, string) {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key failed: %v", err)
}
certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(time.Hour),
}, &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(time.Hour),
}, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatalf("create certificate failed: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
return privateKey, string(certPEM)
}
func firebaseCertsServer(t *testing.T, kid string, certPEM string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Cache-Control", "public, max-age=3600")
_ = json.NewEncoder(writer).Encode(map[string]string{kid: certPEM})
}))
t.Cleanup(server.Close)
return server
}
func signFirebaseTestToken(t *testing.T, privateKey *rsa.PrivateKey, kid string, claims map[string]any) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims))
token.Header["kid"] = kid
signed, err := token.SignedString(privateKey)
if err != nil {
t.Fatalf("sign firebase token failed: %v", err)
}
return signed
}
func cloneFirebaseClaims(input map[string]any) map[string]any {
result := make(map[string]any, len(input))
maps.Copy(result, input)
return result
}