229 lines
7.9 KiB
Go
229 lines
7.9 KiB
Go
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
|
||
}
|