Repository: firebase/firebase-admin-go
Pull request #414 (2020-12-18 17:21:05)

feat(auth): Recognize `FIREBASE_AUTH_EMULATOR_HOST` environment variable
If `FIREBASE_AUTH_EMULATOR_HOST` is set, auth API request will be sent to the host according to the environment variable.

### Discussion

#409 

~~Reflecting the [comment on the issue](https://github.com/firebase/firebase-admin-go/issues/409#issuecomment-743111881), the behavior of signers and token verifiers are unchanged.~~

Now the signer is replaced to `emulatedSigner`, and token verifiers panic when the environment variable is set. https://github.com/firebase/firebase-admin-go/pull/414#issuecomment-754931543 https://github.com/firebase/firebase-admin-go/pull/414#issuecomment-758166352


samtstern: General approach LGTM but assigning to @hiranya911 for Go / Admin SDK review.
dgpc: @hiranya911 - as a user waiting for this feature, my 2c worth is that although token verification emulation is necessary for complete end-to-end testing, making it possible to talk to the emulator at all in this manner is still useful to release as-is, since it unblocks testing of a variety of workflows
Michaelhobo: > @hiranya911 - as a user waiting for this feature, my 2c worth is that although token verification emulation is necessary for complete end-to-end testing, making it possible to talk to the emulator at all in this manner is still useful to release as-is, since it unblocks testing of a variety of workflows

Agreed, this would be a huge win for me.
yuchenshi: I'd propose a middle ground where we release a version first, where everything works but token verification panics with "not implemented" for now when the env var is set. This should ensure that SDK don't call production APIs in emulator mode and also minimize any confusion caused by the SDK verifying signatures using production public keys.
samtstern: @yuchenshi I like that idea!  SGTM
hiranya911: Yeah, I like that too. Any chance we can get this PR updated to incorporate that change?
maku693: Thank you for review! I'll update this PR this weekend.
maku693: @yuchenshi Is the point of the change is to prevent calling production APIs in emulator mode?
If so, not just the token verifier but also the signer and the tenant manager should panic at API calls, correct?
yuchenshi: For `createCustomToken` etc., when `FIREBASE_AUTH_EMULATOR_HOST` is set, the SDK should create unsigned tokens (i.e. the signature part being an empty string), which are in turn accepted by the Auth Emulator. In other words, signer should be short-circuited and there should not be any outgoing network call at all (no `signBlob` etc.).

Auth Emulator doesn't support multi-tenancy (at least for now) and I'm indifferent whether the SDK should panic or forward them to the Auth Emulator which rejects such requests. I'm fine as long as they won't reach production.
maku693: Ah, I understand. I'll update not to sign tokens.

Patch:
diff --git a/auth/auth.go b/auth/auth.go
index e134d31..50e69d5 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"os"
 	"strings"
 	"time"
 
@@ -28,9 +29,11 @@ import (
 )
 
 const (
-	authErrorCode    = "authErrorCode"
-	firebaseAudience = "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"
-	oneHourInSeconds = 3600
+	authErrorCode      = "authErrorCode"
+	emulatorHostEnvVar = "FIREBASE_AUTH_EMULATOR_HOST"
+	defaultAuthURL     = "https://identitytoolkit.googleapis.com"
+	firebaseAudience   = "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"
+	oneHourInSeconds   = 3600
 
 	// SDK-generated error codes
 	idTokenRevoked       = "ID_TOKEN_REVOKED"
@@ -58,18 +61,27 @@ type Client struct {
 // Auth service through firebase.App.
 func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error) {
 	var (
-		signer cryptoSigner
-		err    error
+		isEmulator bool
+		signer     cryptoSigner
+		err        error
 	)
 
-	creds, _ := transport.Creds(ctx, conf.Opts...)
+	authEmulatorHost := os.Getenv(emulatorHostEnvVar)
+	if authEmulatorHost != "" {
+		isEmulator = true
+		signer = emulatedSigner{}
+	}
+
+	if signer == nil {
+		creds, _ := transport.Creds(ctx, conf.Opts...)
 
-	// Initialize a signer by following the go/firebase-admin-sign protocol.
-	if creds != nil && len(creds.JSON) > 0 {
-		// If the SDK was initialized with a service account, use it to sign bytes.
-		signer, err = signerFromCreds(creds.JSON)
-		if err != nil && err != errNotAServiceAcct {
-			return nil, err
+		// Initialize a signer by following the go/firebase-admin-sign protocol.
+		if creds != nil && len(creds.JSON) > 0 {
+			// If the SDK was initialized with a service account, use it to sign bytes.
+			signer, err = signerFromCreds(creds.JSON)
+			if err != nil && err != errNotAServiceAcct {
+				return nil, err
+			}
 		}
 	}
 
@@ -91,12 +103,12 @@ func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error)
 		}
 	}
 
-	idTokenVerifier, err := newIDTokenVerifier(ctx, conf.ProjectID)
+	idTokenVerifier, err := newIDTokenVerifier(ctx, conf.ProjectID, isEmulator)
 	if err != nil {
 		return nil, err
 	}
 
-	cookieVerifier, err := newSessionCookieVerifier(ctx, conf.ProjectID)
+	cookieVerifier, err := newSessionCookieVerifier(ctx, conf.ProjectID, isEmulator)
 	if err != nil {
 		return nil, err
 	}
@@ -112,9 +124,20 @@ func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error)
 		internal.WithHeader("X-Client-Version", fmt.Sprintf("Go/Admin/%s", conf.Version)),
 	}
 
+	baseURL := defaultAuthURL
+	if isEmulator {
+		baseURL = fmt.Sprintf("http://%s/identitytoolkit.googleapis.com", authEmulatorHost)
+	}
+	idToolkitV1Endpoint := fmt.Sprintf("%s/v1", baseURL)
+	idToolkitV2Beta1Endpoint := fmt.Sprintf("%s/v2beta1", baseURL)
+	userManagementEndpoint := idToolkitV1Endpoint
+	providerConfigEndpoint := idToolkitV2Beta1Endpoint
+	tenantMgtEndpoint := idToolkitV2Beta1Endpoint
+
 	base := &baseClient{
-		userManagementEndpoint: idToolkitV1Endpoint,
+		userManagementEndpoint: userManagementEndpoint,
 		providerConfigEndpoint: providerConfigEndpoint,
+		tenantMgtEndpoint:      tenantMgtEndpoint,
 		projectID:              conf.ProjectID,
 		httpClient:             hc,
 		idTokenVerifier:        idTokenVerifier,
@@ -177,7 +200,7 @@ func (c *baseClient) CustomTokenWithClaims(ctx context.Context, uid string, devC
 
 	now := c.clock.Now().Unix()
 	info := &jwtInfo{
-		header: jwtHeader{Algorithm: "RS256", Type: "JWT"},
+		header: jwtHeader{Algorithm: c.signer.Algorithm(), Type: "JWT"},
 		payload: &customToken{
 			Iss:      iss,
 			Sub:      iss,
@@ -234,6 +257,7 @@ type FirebaseInfo struct {
 type baseClient struct {
 	userManagementEndpoint string
 	providerConfigEndpoint string
+	tenantMgtEndpoint      string
 	projectID              string
 	tenantID               string
 	httpClient             *internal.HTTPClient
diff --git a/auth/provider_config.go b/auth/provider_config.go
index 496e991..17a941d 100644
--- a/auth/provider_config.go
+++ b/auth/provider_config.go
@@ -28,8 +28,6 @@ import (
 )
 
 const (
-	providerConfigEndpoint = "https://identitytoolkit.googleapis.com/v2beta1"
-
 	maxConfigs = 100
 
 	idpEntityIDKey = "idpConfig.idpEntityId"
diff --git a/auth/tenant_mgt.go b/auth/tenant_mgt.go
index f35c6d7..802926e 100644
--- a/auth/tenant_mgt.go
+++ b/auth/tenant_mgt.go
@@ -26,10 +26,6 @@ import (
 	"google.golang.org/api/iterator"
 )
 
-const (
-	tenantMgtEndpoint = "https://identitytoolkit.googleapis.com/v2beta1"
-)
-
 // Tenant represents a tenant in a multi-tenant application.
 //
 // Multi-tenancy support requires Google Cloud's Identity Platform (GCIP). To learn more about GCIP,
@@ -88,7 +84,7 @@ type TenantManager struct {
 func newTenantManager(client *internal.HTTPClient, conf *internal.AuthConfig, base *baseClient) *TenantManager {
 	return &TenantManager{
 		base:       base,
-		endpoint:   tenantMgtEndpoint,
+		endpoint:   base.tenantMgtEndpoint,
 		projectID:  conf.ProjectID,
 		httpClient: client,
 	}
diff --git a/auth/token_generator.go b/auth/token_generator.go
index ca19892..0e18b8c 100644
--- a/auth/token_generator.go
+++ b/auth/token_generator.go
@@ -33,6 +33,13 @@ import (
 	"firebase.google.com/go/v4/internal"
 )
 
+const (
+	algorithmNone  = "none"
+	algorithmRS256 = "RS256"
+
+	emulatorEmail = "firebase-auth-emulator@example.com"
+)
+
 type jwtHeader struct {
 	Algorithm string `json:"alg"`
 	Type      string `json:"typ"`
@@ -88,6 +95,7 @@ type serviceAccount struct {
 
 // cryptoSigner is used to cryptographically sign data, and query the identity of the signer.
 type cryptoSigner interface {
+	Algorithm() string
 	Sign(context.Context, []byte) ([]byte, error)
 	Email(context.Context) (string, error)
 }
@@ -133,6 +141,10 @@ func newServiceAccountSigner(sa serviceAccount) (*serviceAccountSigner, error) {
 	}, nil
 }
 
+func (s serviceAccountSigner) Algorithm() string {
+	return algorithmRS256
+}
+
 func (s serviceAccountSigner) Sign(ctx context.Context, b []byte) ([]byte, error) {
 	hash := sha256.New()
 	hash.Write(b)
@@ -173,6 +185,10 @@ func newIAMSigner(ctx context.Context, config *internal.AuthConfig) (*iamSigner,
 	}, nil
 }
 
+func (s iamSigner) Algorithm() string {
+	return algorithmRS256
+}
+
 func (s iamSigner) Sign(ctx context.Context, b []byte) ([]byte, error) {
 	account, err := s.Email(ctx)
 	if err != nil {
@@ -245,3 +261,17 @@ func (s iamSigner) callMetadataService(ctx context.Context) (string, error) {
 
 	return result, nil
 }
+
+type emulatedSigner struct{}
+
+func (s emulatedSigner) Algorithm() string {
+	return algorithmNone
+}
+
+func (s emulatedSigner) Email(context.Context) (string, error) {
+	return emulatorEmail, nil
+}
+
+func (s emulatedSigner) Sign(context.Context, []byte) ([]byte, error) {
+	return []byte(""), nil
+}
diff --git a/auth/token_verifier.go b/auth/token_verifier.go
index d91f64d..3e83a8e 100644
--- a/auth/token_verifier.go
+++ b/auth/token_verifier.go
@@ -100,9 +100,10 @@ type tokenVerifier struct {
 	expiredTokenCode  string
 	keySource         keySource
 	clock             internal.Clock
+	isEmulator        bool
 }
 
-func newIDTokenVerifier(ctx context.Context, projectID string) (*tokenVerifier, error) {
+func newIDTokenVerifier(ctx context.Context, projectID string, isEmulator bool) (*tokenVerifier, error) {
 	noAuthHTTPClient, _, err := transport.NewHTTPClient(ctx, option.WithoutAuthentication())
 	if err != nil {
 		return nil, err
@@ -118,10 +119,11 @@ func newIDTokenVerifier(ctx context.Context, projectID string) (*tokenVerifier,
 		expiredTokenCode:  idTokenExpired,
 		keySource:         newHTTPKeySource(idTokenCertURL, noAuthHTTPClient),
 		clock:             internal.SystemClock,
+		isEmulator:        isEmulator,
 	}, nil
 }
 
-func newSessionCookieVerifier(ctx context.Context, projectID string) (*tokenVerifier, error) {
+func newSessionCookieVerifier(ctx context.Context, projectID string, isEmulator bool) (*tokenVerifier, error) {
 	noAuthHTTPClient, _, err := transport.NewHTTPClient(ctx, option.WithoutAuthentication())
 	if err != nil {
 		return nil, err
@@ -137,6 +139,7 @@ func newSessionCookieVerifier(ctx context.Context, projectID string) (*tokenVeri
 		expiredTokenCode:  sessionCookieExpired,
 		keySource:         newHTTPKeySource(sessionCookieCertURL, noAuthHTTPClient),
 		clock:             internal.SystemClock,
+		isEmulator:        isEmulator,
 	}, nil
 }
 
@@ -154,6 +157,10 @@ func newSessionCookieVerifier(ctx context.Context, projectID string) (*tokenVeri
 // If any of the above conditions are not met, an error is returned. Otherwise a pointer to a
 // decoded Token is returned.
 func (tv *tokenVerifier) VerifyToken(ctx context.Context, token string) (*Token, error) {
+	if tv.isEmulator {
+		panic("not implemented")
+	}
+
 	if tv.projectID == "" {
 		// Configuration error.
 		return nil, errors.New("project id not available")
diff --git a/auth/user_mgt.go b/auth/user_mgt.go
index 5c84ed8..7bb5442 100644
--- a/auth/user_mgt.go
+++ b/auth/user_mgt.go
@@ -30,9 +30,8 @@ import (
 )
 
 const (
-	maxLenPayloadCC     = 1000
-	defaultProviderID   = "firebase"
-	idToolkitV1Endpoint = "https://identitytoolkit.googleapis.com/v1"
+	maxLenPayloadCC   = 1000
+	defaultProviderID = "firebase"
 
 	// Maximum number of users allowed to batch get at a time.
 	maxGetAccountsBatchSize = 100


Test patch:
diff --git a/auth/auth_test.go b/auth/auth_test.go
index cecea7b..51bb395 100644
--- a/auth/auth_test.go
+++ b/auth/auth_test.go
@@ -34,9 +34,11 @@ import (
 )
 
 const (
-	credEnvVar    = "GOOGLE_APPLICATION_CREDENTIALS"
-	testProjectID = "mock-project-id"
-	testVersion   = "test-version"
+	credEnvVar                      = "GOOGLE_APPLICATION_CREDENTIALS"
+	testProjectID                   = "mock-project-id"
+	testVersion                     = "test-version"
+	defaultIDToolkitV1Endpoint      = "https://identitytoolkit.googleapis.com/v1"
+	defaultIDToolkitV2Beta1Endpoint = "https://identitytoolkit.googleapis.com/v2beta1"
 )
 
 var (
@@ -280,6 +282,38 @@ func TestNewClientExplicitNoAuth(t *testing.T) {
 	}
 }
 
+func TestNewClientEmulatorHostEnvVar(t *testing.T) {
+	emulatorHost := "localhost:9099"
+	idToolkitV1Endpoint := "http://localhost:9099/identitytoolkit.googleapis.com/v1"
+	idToolkitV2Beta1Endpoint := "http://localhost:9099/identitytoolkit.googleapis.com/v2beta1"
+
+	os.Setenv(emulatorHostEnvVar, emulatorHost)
+	defer os.Unsetenv(emulatorHostEnvVar)
+
+	conf := &internal.AuthConfig{
+		Opts: []option.ClientOption{
+			option.WithoutAuthentication(),
+		},
+	}
+	client, err := NewClient(context.Background(), conf)
+	if err != nil {
+		t.Fatal(err)
+	}
+	baseClient := client.baseClient
+	if baseClient.userManagementEndpoint != idToolkitV1Endpoint {
+		t.Errorf("baseClient.userManagementEndpoint = %q; want = %q", baseClient.userManagementEndpoint, idToolkitV1Endpoint)
+	}
+	if baseClient.providerConfigEndpoint != idToolkitV2Beta1Endpoint {
+		t.Errorf("baseClient.providerConfigEndpoint = %q; want = %q", baseClient.providerConfigEndpoint, idToolkitV2Beta1Endpoint)
+	}
+	if baseClient.tenantMgtEndpoint != idToolkitV2Beta1Endpoint {
+		t.Errorf("baseClient.tenantMgtEndpoint = %q; want = %q", baseClient.tenantMgtEndpoint, idToolkitV2Beta1Endpoint)
+	}
+	if _, ok := baseClient.signer.(emulatedSigner); !ok {
+		t.Errorf("baseClient.signer = %#v; want = %#v", baseClient.signer, emulatedSigner{})
+	}
+}
+
 func TestCustomToken(t *testing.T) {
 	client := &Client{
 		baseClient: &baseClient{
@@ -663,6 +697,27 @@ func TestVerifyIDTokenWithNoProjectID(t *testing.T) {
 	}
 }
 
+func TestVerifyIDTokenInEmulatorMode(t *testing.T) {
+	os.Setenv(emulatorHostEnvVar, "localhost:9099")
+	defer os.Unsetenv(emulatorHostEnvVar)
+
+	conf := &internal.AuthConfig{
+		ProjectID: "",
+		Opts:      optsWithTokenSource,
+	}
+	c, err := NewClient(context.Background(), conf)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	defer func() {
+		if r := recover(); r == nil {
+			t.Error("VeridyIDToken() didn't panic")
+		}
+	}()
+	c.VerifyIDToken(context.Background(), testIDToken)
+}
+
 func TestCustomTokenVerification(t *testing.T) {
 	client := &Client{
 		baseClient: &baseClient{
@@ -682,7 +737,7 @@ func TestCustomTokenVerification(t *testing.T) {
 }
 
 func TestCertificateRequestError(t *testing.T) {
-	tv, err := newIDTokenVerifier(context.Background(), testProjectID)
+	tv, err := newIDTokenVerifier(context.Background(), testProjectID, false)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -1022,7 +1077,7 @@ func signerForTests(ctx context.Context) (cryptoSigner, error) {
 }
 
 func idTokenVerifierForTests(ctx context.Context) (*tokenVerifier, error) {
-	tv, err := newIDTokenVerifier(ctx, testProjectID)
+	tv, err := newIDTokenVerifier(ctx, testProjectID, false)
 	if err != nil {
 		return nil, err
 	}
@@ -1036,7 +1091,7 @@ func idTokenVerifierForTests(ctx context.Context) (*tokenVerifier, error) {
 }
 
 func cookieVerifierForTests(ctx context.Context) (*tokenVerifier, error) {
-	tv, err := newSessionCookieVerifier(ctx, testProjectID)
+	tv, err := newSessionCookieVerifier(ctx, testProjectID, false)
 	if err != nil {
 		return nil, err
 	}
@@ -1163,15 +1218,18 @@ func checkCookieVerifier(tv *tokenVerifier, projectID string) error {
 }
 
 func checkBaseClient(client *Client, wantProjectID string) error {
-	umc := client.baseClient
-	if umc.userManagementEndpoint != idToolkitV1Endpoint {
-		return fmt.Errorf("userManagementEndpoint = %q; want = %q", umc.userManagementEndpoint, idToolkitV1Endpoint)
+	baseClient := client.baseClient
+	if baseClient.userManagementEndpoint != defaultIDToolkitV1Endpoint {
+		return fmt.Errorf("userManagementEndpoint = %q; want = %q", baseClient.userManagementEndpoint, defaultIDToolkitV1Endpoint)
+	}
+	if baseClient.providerConfigEndpoint != defaultIDToolkitV2Beta1Endpoint {
+		return fmt.Errorf("providerConfigEndpoint = %q; want = %q", baseClient.providerConfigEndpoint, defaultIDToolkitV2Beta1Endpoint)
 	}
-	if umc.providerConfigEndpoint != providerConfigEndpoint {
-		return fmt.Errorf("providerConfigEndpoint = %q; want = %q", umc.providerConfigEndpoint, providerConfigEndpoint)
+	if baseClient.tenantMgtEndpoint != defaultIDToolkitV2Beta1Endpoint {
+		return fmt.Errorf("providerConfigEndpoint = %q; want = %q", baseClient.providerConfigEndpoint, defaultIDToolkitV2Beta1Endpoint)
 	}
-	if umc.projectID != wantProjectID {
-		return fmt.Errorf("projectID = %q; want = %q", umc.projectID, wantProjectID)
+	if baseClient.projectID != wantProjectID {
+		return fmt.Errorf("projectID = %q; want = %q", baseClient.projectID, wantProjectID)
 	}
 
 	req, err := http.NewRequest(http.MethodGet, "https://firebase.google.com", nil)
@@ -1179,7 +1237,7 @@ func checkBaseClient(client *Client, wantProjectID string) error {
 		return err
 	}
 
-	for _, opt := range umc.httpClient.Opts {
+	for _, opt := range baseClient.httpClient.Opts {
 		opt(req)
 	}
 	version := req.Header.Get("X-Client-Version")
diff --git a/auth/token_generator_test.go b/auth/token_generator_test.go
index 2f49b09..c80c4de 100644
--- a/auth/token_generator_test.go
+++ b/auth/token_generator_test.go
@@ -103,6 +103,10 @@ func TestServiceAccountSigner(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
+	algorithm := signer.Algorithm()
+	if algorithm != algorithmRS256 {
+		t.Errorf("Algorithm() = %q; want = %q", algorithm, algorithmRS256)
+	}
 	email, err := signer.Email(context.Background())
 	if email != sa.ClientEmail || err != nil {
 		t.Errorf("Email() = (%q, %v); want = (%q, nil)", email, err, sa.ClientEmail)
@@ -123,6 +127,11 @@ func TestIAMSigner(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
+
+	algorithm := signer.Algorithm()
+	if algorithm != algorithmRS256 {
+		t.Errorf("Algorithm() = %q; want = %q", algorithm, algorithmRS256)
+	}
 	email, err := signer.Email(ctx)
 	if email != conf.ServiceAccountID || err != nil {
 		t.Errorf("Email() = (%q, %v); want = (%q, nil)", email, err, conf.ServiceAccountID)
@@ -265,10 +274,40 @@ func TestIAMSignerNoMetadataService(t *testing.T) {
 	}
 }
 
+func TestEmulatedSigner(t *testing.T) {
+	signer := emulatedSigner{}
+
+	algorithm := signer.Algorithm()
+	if algorithm != algorithmNone {
+		t.Errorf("Algorithm() = %q; want = %q", algorithm, algorithmNone)
+	}
+
+	email, err := signer.Email(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if email != emulatorEmail {
+		t.Errorf("Email() = %q; want = %q", email, emulatorEmail)
+	}
+
+	wantSignature := ""
+	sign, err := signer.Sign(context.Background(), []byte("test"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(sign) != wantSignature {
+		t.Errorf("Sign() = %q; want = %q", string(sign), wantSignature)
+	}
+}
+
 type mockSigner struct {
 	err error
 }
 
+func (s *mockSigner) Algorithm() string {
+	return ""
+}
+
 func (s *mockSigner) Email(ctx context.Context) (string, error) {
 	return "", nil
 }
diff --git a/auth/token_verifier_test.go b/auth/token_verifier_test.go
index f226fd3..b60aa11 100644
--- a/auth/token_verifier_test.go
+++ b/auth/token_verifier_test.go
@@ -28,7 +28,7 @@ import (
 )
 
 func TestNewIDTokenVerifier(t *testing.T) {
-	tv, err := newIDTokenVerifier(context.Background(), testProjectID)
+	tv, err := newIDTokenVerifier(context.Background(), testProjectID, true)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -48,6 +48,9 @@ func TestNewIDTokenVerifier(t *testing.T) {
 	if ks.KeyURI != idTokenCertURL {
 		t.Errorf("tokenVerifier.certURL = %q; want = %q", ks.KeyURI, idTokenCertURL)
 	}
+	if tv.isEmulator != true {
+		t.Errorf("tokenVerifier.isEmulator = %t; want = %t", tv.isEmulator, true)
+	}
 }
 
 func TestHTTPKeySource(t *testing.T) {