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 误配了 firebase,provider=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 }