Skip to content

Commit

Permalink
feat(auth): Add TOTP support in Project and Tenant config (#548)
Browse files Browse the repository at this point in the history
* feat(auth): Add TOTP support in Project and Tenant config

* Documentation nits

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>

* Code cleanup

* Fix unit test url

---------

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>
  • Loading branch information
pragatimodi and kevinthecheung authored Mar 29, 2023
1 parent a2babaf commit 5779279
Show file tree
Hide file tree
Showing 10 changed files with 674 additions and 18 deletions.
3 changes: 3 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -274,6 +276,7 @@ type baseClient struct {
userManagementEndpoint string
providerConfigEndpoint string
tenantMgtEndpoint string
projectMgtEndpoint string
projectID string
tenantID string
httpClient *internal.HTTPClient
Expand Down
89 changes: 89 additions & 0 deletions auth/multi_factor_config_mgt.go
Original file line number Diff line number Diff line change
@@ -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
}
110 changes: 110 additions & 0 deletions auth/multi_factor_config_mgt_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
112 changes: 112 additions & 0 deletions auth/project_config_mgt.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 5779279

Please sign in to comment.