Repository: firebase/firebase-admin-go
Pull request #548 (2023-03-23 21:55:16)

feat(auth): Add TOTP support in Project and Tenant config
Basing changes off dev here.
Original PR can be found [here](https://github.com/firebase/firebase-admin-go/pull/542).



Patch:
diff --git a/auth/auth.go b/auth/auth.go
index b6505a2..d629961 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -148,11 +148,13 @@ func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error)
 	userManagementEndpoint := idToolkitV1Endpoint
 	providerConfigEndpoint := idToolkitV2Endpoint
 	tenantMgtEndpoint := idToolkitV2Endpoint
+	projectMgtEndpoint := idToolkitV2Endpoint
 
 	base := &baseClient{
 		userManagementEndpoint: userManagementEndpoint,
 		providerConfigEndpoint: providerConfigEndpoint,
 		tenantMgtEndpoint:      tenantMgtEndpoint,
+		projectMgtEndpoint:     projectMgtEndpoint,
 		projectID:              conf.ProjectID,
 		httpClient:             hc,
 		idTokenVerifier:        idTokenVerifier,
@@ -274,6 +276,7 @@ type baseClient struct {
 	userManagementEndpoint string
 	providerConfigEndpoint string
 	tenantMgtEndpoint      string
+	projectMgtEndpoint     string
 	projectID              string
 	tenantID               string
 	httpClient             *internal.HTTPClient
diff --git a/auth/multi_factor_config_mgt.go b/auth/multi_factor_config_mgt.go
new file mode 100644
index 0000000..d01e2f4
--- /dev/null
+++ b/auth/multi_factor_config_mgt.go
@@ -0,0 +1,89 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+	"fmt"
+)
+
+// ProviderConfig represents a multi-factor auth provider configuration.
+// Currently, only TOTP is supported.
+type ProviderConfig struct {
+	// The state of multi-factor configuration, whether it's enabled or disabled.
+	State MultiFactorConfigState `json:"state"`
+	// TOTPProviderConfig holds the TOTP (time-based one-time password) configuration that is used in second factor authentication.
+	TOTPProviderConfig *TOTPProviderConfig `json:"totpProviderConfig,omitempty"`
+}
+
+// TOTPProviderConfig represents configuration settings for TOTP second factor auth.
+type TOTPProviderConfig struct {
+	// The number of adjacent intervals used by TOTP.
+	AdjacentIntervals int `json:"adjacentIntervals,omitempty"`
+}
+
+// MultiFactorConfigState represents whether the multi-factor configuration is enabled or disabled.
+type MultiFactorConfigState string
+
+// These constants represent the possible values for the MultiFactorConfigState type.
+const (
+	Enabled  MultiFactorConfigState = "ENABLED"
+	Disabled MultiFactorConfigState = "DISABLED"
+)
+
+// MultiFactorConfig represents a multi-factor configuration for a tenant or project.
+// This can be used to define whether multi-factor authentication is enabled or disabled and the list of second factor challenges that are supported.
+type MultiFactorConfig struct {
+	// A slice of pointers to ProviderConfig structs, each outlining the specific second factor authorization method.
+	ProviderConfigs []*ProviderConfig `json:"providerConfigs,omitempty"`
+}
+
+func (mfa *MultiFactorConfig) validate() error {
+	if mfa == nil {
+		return nil
+	}
+	if len(mfa.ProviderConfigs) == 0 {
+		return fmt.Errorf("\"ProviderConfigs\" must be a non-empty array of type \"ProviderConfig\"s")
+	}
+	for _, providerConfig := range mfa.ProviderConfigs {
+		if providerConfig == nil {
+			return fmt.Errorf("\"ProviderConfigs\" must be a non-empty array of type \"ProviderConfig\"s")
+		}
+		if err := providerConfig.validate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (pvc *ProviderConfig) validate() error {
+	if pvc.State == "" && pvc.TOTPProviderConfig == nil {
+		return fmt.Errorf("\"ProviderConfig\" must be defined")
+	}
+	state := string(pvc.State)
+	if state != string(Enabled) && state != string(Disabled) {
+		return fmt.Errorf("\"ProviderConfig.State\" must be 'Enabled' or 'Disabled'")
+	}
+	return pvc.TOTPProviderConfig.validate()
+}
+
+func (tpvc *TOTPProviderConfig) validate() error {
+	if tpvc == nil {
+		return fmt.Errorf("\"TOTPProviderConfig\" must be defined")
+	}
+	if !(tpvc.AdjacentIntervals >= 1 && tpvc.AdjacentIntervals <= 10) {
+		return fmt.Errorf("\"AdjacentIntervals\" must be an integer between 1 and 10 (inclusive)")
+	}
+	return nil
+}
diff --git a/auth/project_config_mgt.go b/auth/project_config_mgt.go
new file mode 100644
index 0000000..5ae739c
--- /dev/null
+++ b/auth/project_config_mgt.go
@@ -0,0 +1,112 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"firebase.google.com/go/v4/internal"
+)
+
+// ProjectConfig represents the properties to update on the provided project config.
+type ProjectConfig struct {
+	MultiFactorConfig *MultiFactorConfig `json:"mfa,omitEmpty"`
+}
+
+func (base *baseClient) GetProjectConfig(ctx context.Context) (*ProjectConfig, error) {
+	req := &internal.Request{
+		Method: http.MethodGet,
+		URL:    "/config",
+	}
+	var result ProjectConfig
+	if _, err := base.makeRequest(ctx, req, &result); err != nil {
+		return nil, err
+	}
+	return &result, nil
+}
+
+func (base *baseClient) UpdateProjectConfig(ctx context.Context, projectConfig *ProjectConfigToUpdate) (*ProjectConfig, error) {
+	if projectConfig == nil {
+		return nil, errors.New("project config must not be nil")
+	}
+	if err := projectConfig.validate(); err != nil {
+		return nil, err
+	}
+	mask := projectConfig.params.UpdateMask()
+	if len(mask) == 0 {
+		return nil, errors.New("no parameters specified in the update request")
+	}
+	req := &internal.Request{
+		Method: http.MethodPatch,
+		URL:    "/config",
+		Body:   internal.NewJSONEntity(projectConfig.params),
+		Opts: []internal.HTTPOption{
+			internal.WithQueryParam("updateMask", strings.Join(mask, ",")),
+		},
+	}
+	var result ProjectConfig
+	if _, err := base.makeRequest(ctx, req, &result); err != nil {
+		return nil, err
+	}
+	return &result, nil
+}
+
+// ProjectConfigToUpdate represents the options used to update the current project.
+type ProjectConfigToUpdate struct {
+	params nestedMap
+}
+
+const (
+	multiFactorConfigProjectKey = "mfa"
+)
+
+// MultiFactorConfig configures the project's multi-factor settings
+func (pc *ProjectConfigToUpdate) MultiFactorConfig(multiFactorConfig MultiFactorConfig) *ProjectConfigToUpdate {
+	return pc.set(multiFactorConfigProjectKey, multiFactorConfig)
+}
+
+func (pc *ProjectConfigToUpdate) set(key string, value interface{}) *ProjectConfigToUpdate {
+	pc.ensureParams().Set(key, value)
+	return pc
+}
+
+func (pc *ProjectConfigToUpdate) ensureParams() nestedMap {
+	if pc.params == nil {
+		pc.params = make(nestedMap)
+	}
+	return pc.params
+}
+
+func (pc *ProjectConfigToUpdate) validate() error {
+	req := make(map[string]interface{})
+	for k, v := range pc.params {
+		req[k] = v
+	}
+	val, ok := req[multiFactorConfigProjectKey]
+	if ok {
+		multiFactorConfig, ok := val.(MultiFactorConfig)
+		if !ok {
+			return fmt.Errorf("invalid type for MultiFactorConfig: %s", req[multiFactorConfigProjectKey])
+		}
+		if err := multiFactorConfig.validate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/auth/tenant_mgt.go b/auth/tenant_mgt.go
index 9212a8e..0e3ab20 100644
--- a/auth/tenant_mgt.go
+++ b/auth/tenant_mgt.go
@@ -43,11 +43,12 @@ import (
 // All other settings of a tenant will also be inherited. These will need to be managed from the
 // Cloud Console UI.
 type Tenant struct {
-	ID                    string `json:"name"`
-	DisplayName           string `json:"displayName"`
-	AllowPasswordSignUp   bool   `json:"allowPasswordSignup"`
-	EnableEmailLinkSignIn bool   `json:"enableEmailLinkSignin"`
-	EnableAnonymousUsers  bool   `json:"enableAnonymousUser"`
+	ID                    string             `json:"name"`
+	DisplayName           string             `json:"displayName"`
+	AllowPasswordSignUp   bool               `json:"allowPasswordSignup"`
+	EnableEmailLinkSignIn bool               `json:"enableEmailLinkSignin"`
+	EnableAnonymousUsers  bool               `json:"enableAnonymousUser"`
+	MultiFactorConfig     *MultiFactorConfig `json:"mfaConfig"`
 }
 
 // TenantClient is used for managing users, configuring SAML/OIDC providers, and generating email
@@ -126,7 +127,9 @@ func (tm *TenantManager) CreateTenant(ctx context.Context, tenant *TenantToCreat
 	if tenant == nil {
 		return nil, errors.New("tenant must not be nil")
 	}
-
+	if err := tenant.validate(); err != nil {
+		return nil, err
+	}
 	req := &internal.Request{
 		Method: http.MethodPost,
 		URL:    "/tenants",
@@ -149,7 +152,9 @@ func (tm *TenantManager) UpdateTenant(ctx context.Context, tenantID string, tena
 	if tenant == nil {
 		return nil, errors.New("tenant must not be nil")
 	}
-
+	if err := tenant.validate(); err != nil {
+		return nil, err
+	}
 	mask := tenant.params.UpdateMask()
 	if len(mask) == 0 {
 		return nil, errors.New("no parameters specified in the update request")
@@ -214,10 +219,11 @@ func (tm *TenantManager) makeRequest(ctx context.Context, req *internal.Request,
 }
 
 const (
-	tenantDisplayNameKey     = "displayName"
-	allowPasswordSignUpKey   = "allowPasswordSignup"
-	enableEmailLinkSignInKey = "enableEmailLinkSignin"
-	enableAnonymousUser      = "enableAnonymousUser"
+	tenantDisplayNameKey       = "displayName"
+	allowPasswordSignUpKey     = "allowPasswordSignup"
+	enableEmailLinkSignInKey   = "enableEmailLinkSignin"
+	enableAnonymousUser        = "enableAnonymousUser"
+	multiFactorConfigTenantKey = "mfaConfig"
 )
 
 // TenantToCreate represents the options used to create a new tenant.
@@ -247,6 +253,11 @@ func (t *TenantToCreate) EnableAnonymousUsers(enable bool) *TenantToCreate {
 	return t.set(enableAnonymousUser, enable)
 }
 
+// MultiFactorConfig configures the tenant's multi-factor settings
+func (t *TenantToCreate) MultiFactorConfig(multiFactorConfig MultiFactorConfig) *TenantToCreate {
+	return t.set(multiFactorConfigTenantKey, multiFactorConfig)
+}
+
 func (t *TenantToCreate) set(key string, value interface{}) *TenantToCreate {
 	t.ensureParams().Set(key, value)
 	return t
@@ -259,6 +270,23 @@ func (t *TenantToCreate) ensureParams() nestedMap {
 
 	return t.params
 }
+func (t *TenantToCreate) validate() error {
+	req := make(map[string]interface{})
+	for k, v := range t.params {
+		req[k] = v
+	}
+	val, ok := req[multiFactorConfigTenantKey]
+	if ok {
+		multiFactorConfig, ok := val.(MultiFactorConfig)
+		if !ok {
+			return fmt.Errorf("invalid type for MultiFactorConfig: %s", req[multiFactorConfigProjectKey])
+		}
+		if err := multiFactorConfig.validate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
 
 // TenantToUpdate represents the options used to update an existing tenant.
 type TenantToUpdate struct {
@@ -287,6 +315,11 @@ func (t *TenantToUpdate) EnableAnonymousUsers(enable bool) *TenantToUpdate {
 	return t.set(enableAnonymousUser, enable)
 }
 
+// MultiFactorConfig configures the tenant's multi-factor settings
+func (t *TenantToUpdate) MultiFactorConfig(multiFactorConfig MultiFactorConfig) *TenantToUpdate {
+	return t.set(multiFactorConfigTenantKey, multiFactorConfig)
+}
+
 func (t *TenantToUpdate) set(key string, value interface{}) *TenantToUpdate {
 	if t.params == nil {
 		t.params = make(nestedMap)
@@ -296,6 +329,24 @@ func (t *TenantToUpdate) set(key string, value interface{}) *TenantToUpdate {
 	return t
 }
 
+func (t *TenantToUpdate) validate() error {
+	req := make(map[string]interface{})
+	for k, v := range t.params {
+		req[k] = v
+	}
+	val, ok := req[multiFactorConfigTenantKey]
+	if ok {
+		multiFactorConfig, ok := val.(MultiFactorConfig)
+		if !ok {
+			return fmt.Errorf("invalid type for MultiFactorConfig: %s", req[multiFactorConfigProjectKey])
+		}
+		if err := multiFactorConfig.validate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // TenantIterator is an iterator over tenants.
 type TenantIterator struct {
 	tm       *TenantManager


Test patch:
diff --git a/auth/multi_factor_config_mgt_test.go b/auth/multi_factor_config_mgt_test.go
new file mode 100644
index 0000000..ebbef4e
--- /dev/null
+++ b/auth/multi_factor_config_mgt_test.go
@@ -0,0 +1,110 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+	"testing"
+)
+
+func TestMultiFactorConfig(t *testing.T) {
+	mfa := MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{{
+			State: Disabled,
+			TOTPProviderConfig: &TOTPProviderConfig{
+				AdjacentIntervals: 5,
+			},
+		}},
+	}
+	if err := mfa.validate(); err != nil {
+		t.Errorf("MultiFactorConfig not valid")
+	}
+}
+func TestMultiFactorConfigNoProviderConfigs(t *testing.T) {
+	mfa := MultiFactorConfig{}
+	want := "\"ProviderConfigs\" must be a non-empty array of type \"ProviderConfig\"s"
+	if err := mfa.validate(); err.Error() != want {
+		t.Errorf("MultiFactorConfig.validate(nil) = %v, want = %q", err, want)
+	}
+}
+
+func TestMultiFactorConfigNilProviderConfigs(t *testing.T) {
+	mfa := MultiFactorConfig{
+		ProviderConfigs: nil,
+	}
+	want := "\"ProviderConfigs\" must be a non-empty array of type \"ProviderConfig\"s"
+	if err := mfa.validate(); err.Error() != want {
+		t.Errorf("MultiFactorConfig.validate(nil) = %v, want = %q", err, want)
+	}
+}
+
+func TestMultiFactorConfigNilProviderConfig(t *testing.T) {
+	mfa := MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{nil},
+	}
+	want := "\"ProviderConfigs\" must be a non-empty array of type \"ProviderConfig\"s"
+	if err := mfa.validate(); err.Error() != want {
+		t.Errorf("MultiFactorConfig.validate(nil) = %v, want = %q", err, want)
+	}
+}
+
+func TestMultiFactorConfigUndefinedProviderConfig(t *testing.T) {
+	mfa := MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{{}},
+	}
+	want := "\"ProviderConfig\" must be defined"
+	if err := mfa.validate(); err.Error() != want {
+		t.Errorf("MultiFactorConfig.validate(nil) = %v, want = %q", err, want)
+	}
+}
+
+func TestMultiFactorConfigInvalidProviderConfigState(t *testing.T) {
+	mfa := MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{{
+			State: "invalid",
+		}},
+	}
+	want := "\"ProviderConfig.State\" must be 'Enabled' or 'Disabled'"
+	if err := mfa.validate(); err.Error() != want {
+		t.Errorf("MultiFactorConfig.validate(nil) = %v, want = %q", err, want)
+	}
+}
+
+func TestMultiFactorConfigNilTOTPProviderConfig(t *testing.T) {
+	mfa := MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{{
+			State:              Disabled,
+			TOTPProviderConfig: nil,
+		}},
+	}
+	want := "\"TOTPProviderConfig\" must be defined"
+	if err := mfa.validate(); err.Error() != want {
+		t.Errorf("MultiFactorConfig.validate(nil) = %v, want = %q", err, want)
+	}
+}
+
+func TestMultiFactorConfigInvalidAdjacentIntervals(t *testing.T) {
+	mfa := MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{{
+			State: Disabled,
+			TOTPProviderConfig: &TOTPProviderConfig{
+				AdjacentIntervals: 11,
+			},
+		}},
+	}
+	want := "\"AdjacentIntervals\" must be an integer between 1 and 10 (inclusive)"
+	if err := mfa.validate(); err.Error() != want {
+		t.Errorf("MultiFactorConfig.validate(nil) = %v, want = %q", err, want)
+	}
+}
diff --git a/auth/project_config_mgt_test.go b/auth/project_config_mgt_test.go
new file mode 100644
index 0000000..ec0fa75
--- /dev/null
+++ b/auth/project_config_mgt_test.go
@@ -0,0 +1,143 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package auth
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"reflect"
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+const projectConfigResponse = `{
+	"mfa": {
+		"providerConfigs": [
+			{
+				"state":"ENABLED",
+				"totpProviderConfig":{
+					"adjacentIntervals":5
+				}
+			}
+		]
+	}
+}`
+
+var testProjectConfig = &ProjectConfig{
+	MultiFactorConfig: &MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{
+			{
+				State: Enabled,
+				TOTPProviderConfig: &TOTPProviderConfig{
+					AdjacentIntervals: 5,
+				},
+			},
+		},
+	},
+}
+
+func TestGetProjectConfig(t *testing.T) {
+	s := echoServer([]byte(projectConfigResponse), t)
+	defer s.Close()
+
+	client := s.Client
+	projectConfig, err := client.GetProjectConfig(context.Background())
+
+	if err != nil {
+		t.Errorf("GetProjectConfig() = %v", err)
+	}
+	if !reflect.DeepEqual(projectConfig, testProjectConfig) {
+		t.Errorf("GetProjectConfig() = %#v, want = %#v", projectConfig, testProjectConfig)
+	}
+}
+
+func TestUpdateProjectConfig(t *testing.T) {
+	s := echoServer([]byte(projectConfigResponse), t)
+	defer s.Close()
+
+	client := s.Client
+	options := (&ProjectConfigToUpdate{}).
+		MultiFactorConfig(*testProjectConfig.MultiFactorConfig)
+	projectConfig, err := client.UpdateProjectConfig(context.Background(), options)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !reflect.DeepEqual(projectConfig, testProjectConfig) {
+		t.Errorf("UpdateProjectConfig() = %#v; want = %#v", projectConfig, testProjectConfig)
+	}
+	wantBody := map[string]interface{}{
+		"mfa": map[string]interface{}{
+			"providerConfigs": []interface{}{
+				map[string]interface{}{
+					"state": "ENABLED",
+					"totpProviderConfig": map[string]interface{}{
+						"adjacentIntervals": float64(5),
+					},
+				},
+			},
+		},
+	}
+	wantMask := []string{"mfa"}
+	if err := checkUpdateProjectConfigRequest(s, wantBody, wantMask); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestUpdateProjectNilOptions(t *testing.T) {
+	base := &baseClient{}
+	want := "project config must not be nil"
+	if _, err := base.UpdateProjectConfig(context.Background(), nil); err == nil || err.Error() != want {
+		t.Errorf("UpdateProject(nil) = %v, want = %q", err, want)
+	}
+}
+
+func checkUpdateProjectConfigRequest(s *mockAuthServer, wantBody interface{}, wantMask []string) error {
+	req := s.Req[0]
+	if req.Method != http.MethodPatch {
+		return fmt.Errorf("UpdateProjectConfig() Method = %q; want = %q", req.Method, http.MethodPatch)
+	}
+
+	wantURL := "/projects/mock-project-id/config"
+	if req.URL.Path != wantURL {
+		return fmt.Errorf("UpdateProjectConfig() URL = %q; want = %q", req.URL.Path, wantURL)
+	}
+
+	queryParam := req.URL.Query().Get("updateMask")
+	mask := strings.Split(queryParam, ",")
+	sort.Strings(mask)
+	if !reflect.DeepEqual(mask, wantMask) {
+		return fmt.Errorf("UpdateProjectConfig() Query = %#v; want = %#v", mask, wantMask)
+	}
+
+	var body map[string]interface{}
+	if err := json.Unmarshal(s.Rbody, &body); err != nil {
+		return err
+	}
+
+	if diff := cmp.Diff(body, wantBody); diff != "" {
+		fmt.Printf("UpdateProjectConfig() diff = %s", diff)
+	}
+
+	if !reflect.DeepEqual(body, wantBody) {
+		return fmt.Errorf("UpdateProjectConfig() Body = %#v; want = %#v", body, wantBody)
+	}
+
+	return nil
+}
diff --git a/auth/tenant_mgt_test.go b/auth/tenant_mgt_test.go
index 1e9e2b9..77c2685 100644
--- a/auth/tenant_mgt_test.go
+++ b/auth/tenant_mgt_test.go
@@ -1085,7 +1085,17 @@ const tenantResponse = `{
     "displayName": "Test Tenant",
     "allowPasswordSignup": true,
     "enableEmailLinkSignin": true,
-    "enableAnonymousUser": true
+    "enableAnonymousUser": true,
+	"mfaConfig": {
+		"providerConfigs": [
+			{
+				"state":"ENABLED",
+				"totpProviderConfig":{
+					"adjacentIntervals":5
+				}
+			}
+		]
+	}
 }`
 
 const tenantResponse2 = `{
@@ -1093,7 +1103,17 @@ const tenantResponse2 = `{
     "displayName": "Test Tenant 2",
     "allowPasswordSignup": true,
     "enableEmailLinkSignin": true,
-    "enableAnonymousUser": true
+    "enableAnonymousUser": true,
+	"mfaConfig": {
+		"providerConfigs": [
+			{
+				"state":"ENABLED",
+				"totpProviderConfig":{
+					"adjacentIntervals":5
+				}
+			}
+		]
+	}
 }`
 
 const tenantNotFoundResponse = `{
@@ -1108,6 +1128,16 @@ var testTenant = &Tenant{
 	AllowPasswordSignUp:   true,
 	EnableEmailLinkSignIn: true,
 	EnableAnonymousUsers:  true,
+	MultiFactorConfig: &MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{
+			{
+				State: Enabled,
+				TOTPProviderConfig: &TOTPProviderConfig{
+					AdjacentIntervals: 5,
+				},
+			},
+		},
+	},
 }
 
 var testTenant2 = &Tenant{
@@ -1116,6 +1146,16 @@ var testTenant2 = &Tenant{
 	AllowPasswordSignUp:   true,
 	EnableEmailLinkSignIn: true,
 	EnableAnonymousUsers:  true,
+	MultiFactorConfig: &MultiFactorConfig{
+		ProviderConfigs: []*ProviderConfig{
+			{
+				State: Enabled,
+				TOTPProviderConfig: &TOTPProviderConfig{
+					AdjacentIntervals: 5,
+				},
+			},
+		},
+	},
 }
 
 func TestTenant(t *testing.T) {
@@ -1182,7 +1222,8 @@ func TestCreateTenant(t *testing.T) {
 		DisplayName(testTenant.DisplayName).
 		AllowPasswordSignUp(testTenant.AllowPasswordSignUp).
 		EnableEmailLinkSignIn(testTenant.EnableEmailLinkSignIn).
-		EnableAnonymousUsers(testTenant.EnableAnonymousUsers)
+		EnableAnonymousUsers(testTenant.EnableAnonymousUsers).
+		MultiFactorConfig(*testTenant.MultiFactorConfig)
 	tenant, err := client.TenantManager.CreateTenant(context.Background(), options)
 	if err != nil {
 		t.Fatal(err)
@@ -1197,6 +1238,16 @@ func TestCreateTenant(t *testing.T) {
 		"allowPasswordSignup":   testTenant.AllowPasswordSignUp,
 		"enableEmailLinkSignin": testTenant.EnableEmailLinkSignIn,
 		"enableAnonymousUser":   testTenant.EnableAnonymousUsers,
+		"mfaConfig": map[string]interface{}{
+			"providerConfigs": []interface{}{
+				map[string]interface{}{
+					"state": "ENABLED",
+					"totpProviderConfig": map[string]interface{}{
+						"adjacentIntervals": float64(5),
+					},
+				},
+			},
+		},
 	}
 	if err := checkCreateTenantRequest(s, wantBody); err != nil {
 		t.Fatal(err)
@@ -1283,7 +1334,8 @@ func TestUpdateTenant(t *testing.T) {
 		DisplayName(testTenant.DisplayName).
 		AllowPasswordSignUp(testTenant.AllowPasswordSignUp).
 		EnableEmailLinkSignIn(testTenant.EnableEmailLinkSignIn).
-		EnableAnonymousUsers(testTenant.EnableAnonymousUsers)
+		EnableAnonymousUsers(testTenant.EnableAnonymousUsers).
+		MultiFactorConfig(*testTenant.MultiFactorConfig)
 	tenant, err := client.TenantManager.UpdateTenant(context.Background(), "tenantID", options)
 	if err != nil {
 		t.Fatal(err)
@@ -1298,8 +1350,18 @@ func TestUpdateTenant(t *testing.T) {
 		"allowPasswordSignup":   testTenant.AllowPasswordSignUp,
 		"enableEmailLinkSignin": testTenant.EnableEmailLinkSignIn,
 		"enableAnonymousUser":   testTenant.EnableAnonymousUsers,
+		"mfaConfig": map[string]interface{}{
+			"providerConfigs": []interface{}{
+				map[string]interface{}{
+					"state": "ENABLED",
+					"totpProviderConfig": map[string]interface{}{
+						"adjacentIntervals": float64(5),
+					},
+				},
+			},
+		},
 	}
-	wantMask := []string{"allowPasswordSignup", "displayName", "enableAnonymousUser", "enableEmailLinkSignin"}
+	wantMask := []string{"allowPasswordSignup", "displayName", "enableAnonymousUser", "enableEmailLinkSignin", "mfaConfig"}
 	if err := checkUpdateTenantRequest(s, wantBody, wantMask); err != nil {
 		t.Fatal(err)
 	}
diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go
index aa0f465..943810c 100644
--- a/auth/user_mgt_test.go
+++ b/auth/user_mgt_test.go
@@ -2289,6 +2289,7 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer {
 	authClient.baseClient.userManagementEndpoint = s.Srv.URL
 	authClient.baseClient.providerConfigEndpoint = s.Srv.URL
 	authClient.TenantManager.endpoint = s.Srv.URL
+	authClient.baseClient.projectMgtEndpoint = s.Srv.URL
 	s.Client = authClient
 	return &s
 }
diff --git a/integration/auth/project_config_mgt_test.go b/integration/auth/project_config_mgt_test.go
new file mode 100644
index 0000000..5028400
--- /dev/null
+++ b/integration/auth/project_config_mgt_test.go
@@ -0,0 +1,61 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package auth
+
+import (
+	"context"
+	"reflect"
+	"testing"
+
+	"firebase.google.com/go/v4/auth"
+)
+
+func TestProjectConfig(t *testing.T) {
+	mfaObject := &auth.MultiFactorConfig{
+		ProviderConfigs: []*auth.ProviderConfig{
+			{
+				State: auth.Enabled,
+				TOTPProviderConfig: &auth.TOTPProviderConfig{
+					AdjacentIntervals: 5,
+				},
+			},
+		},
+	}
+	want := &auth.ProjectConfig{
+		MultiFactorConfig: mfaObject,
+	}
+	t.Run("UpdateProjectConfig()", func(t *testing.T) {
+		mfaConfigReq := *want.MultiFactorConfig
+		req := (&auth.ProjectConfigToUpdate{}).
+			MultiFactorConfig(mfaConfigReq)
+		projectConfig, err := client.UpdateProjectConfig(context.Background(), req)
+		if err != nil {
+			t.Fatalf("UpdateProjectConfig() = %v", err)
+		}
+		if !reflect.DeepEqual(projectConfig, want) {
+			t.Errorf("UpdateProjectConfig() = %#v; want = %#v", projectConfig, want)
+		}
+	})
+
+	t.Run("GetProjectConfig()", func(t *testing.T) {
+		project, err := client.GetProjectConfig(context.Background())
+		if err != nil {
+			t.Fatalf("GetProjectConfig() = %v", err)
+		}
+
+		if !reflect.DeepEqual(project, want) {
+			t.Errorf("GetProjectConfig() = %v; want = %#v", project, want)
+		}
+	})
+}
diff --git a/integration/auth/tenant_mgt_test.go b/integration/auth/tenant_mgt_test.go
index 190a29f..8682cb3 100644
--- a/integration/auth/tenant_mgt_test.go
+++ b/integration/auth/tenant_mgt_test.go
@@ -27,18 +27,30 @@ import (
 )
 
 func TestTenantManager(t *testing.T) {
+	mfaObject := &auth.MultiFactorConfig{
+		ProviderConfigs: []*auth.ProviderConfig{
+			{
+				State: auth.Enabled,
+				TOTPProviderConfig: &auth.TOTPProviderConfig{
+					AdjacentIntervals: 5,
+				},
+			},
+		},
+	}
 	want := &auth.Tenant{
 		DisplayName:           "admin-go-tenant",
 		AllowPasswordSignUp:   true,
 		EnableEmailLinkSignIn: true,
 		EnableAnonymousUsers:  true,
+		MultiFactorConfig:     mfaObject,
 	}
 
 	req := (&auth.TenantToCreate{}).
 		DisplayName("admin-go-tenant").
 		AllowPasswordSignUp(true).
 		EnableEmailLinkSignIn(true).
-		EnableAnonymousUsers(true)
+		EnableAnonymousUsers(true).
+		MultiFactorConfig(*mfaObject)
 	created, err := client.TenantManager.CreateTenant(context.Background(), req)
 	if err != nil {
 		t.Fatalf("CreateTenant() = %v", err)
@@ -126,18 +138,30 @@ func TestTenantManager(t *testing.T) {
 	})
 
 	t.Run("UpdateTenant()", func(t *testing.T) {
+		mfaObject := &auth.MultiFactorConfig{
+			ProviderConfigs: []*auth.ProviderConfig{
+				{
+					State: auth.Enabled,
+					TOTPProviderConfig: &auth.TOTPProviderConfig{
+						AdjacentIntervals: 5,
+					},
+				},
+			},
+		}
 		want = &auth.Tenant{
 			ID:                    id,
 			DisplayName:           "updated-go-tenant",
 			AllowPasswordSignUp:   false,
 			EnableEmailLinkSignIn: false,
 			EnableAnonymousUsers:  false,
+			MultiFactorConfig:     mfaObject,
 		}
 		req := (&auth.TenantToUpdate{}).
 			DisplayName("updated-go-tenant").
 			AllowPasswordSignUp(false).
 			EnableEmailLinkSignIn(false).
-			EnableAnonymousUsers(false)
+			EnableAnonymousUsers(false).
+			MultiFactorConfig(*mfaObject)
 		tenant, err := client.TenantManager.UpdateTenant(context.Background(), id, req)
 		if err != nil {
 			t.Fatalf("UpdateTenant() = %v", err)