Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI for local users MFA #2979

Merged
merged 22 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/charmbracelet/glamour v0.7.0
github.com/charmbracelet/lipgloss v0.11.0
github.com/client9/gospell v0.0.0-20160306015952-90dfc71015df
github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20230822191820-abc0b42e8715
github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20250205171805-d4709871c4f3
github.com/confluentinc/ccloud-sdk-go-v2/ai v0.1.0
github.com/confluentinc/ccloud-sdk-go-v2/apikeys v0.4.0
github.com/confluentinc/ccloud-sdk-go-v2/billing v0.3.0
Expand Down
98 changes: 2 additions & 96 deletions go.sum

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/login/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,8 @@ func (c *command) getURL(cmd *cobra.Command) (string, error) {
}

func (c *command) saveLoginToKeychain(isCloud bool, url string, credentials *pauth.Credentials) error {
if credentials.IsSSO {
output.ErrPrintln(c.cfg.EnableColor, "The `--save` flag was ignored since SSO credentials are not stored locally.")
if credentials.IsSSO || credentials.IsMFA {
output.ErrPrintln(c.cfg.EnableColor, "The `--save` flag was ignored since SSO or MFA credentials are not stored on keychain.")
return nil
}

Expand Down
9 changes: 5 additions & 4 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func PersistConfluentLoginToConfig(cfg *config.Config, credentials *Credentials,
}

ctxName := GenerateContextName(username, url, caCertPath)
return addOrUpdateContext(cfg, false, credentials, ctxName, url, state, caCertPath, "", save)
return addOrUpdateContext(cfg, false, credentials, ctxName, url, state, caCertPath, "", save, false)
}

func PersistCCloudCredentialsToConfig(config *config.Config, client *ccloudv1.Client, url string, credentials *Credentials, save bool) (string, *ccloudv1.Organization, error) {
Expand All @@ -97,7 +97,7 @@ func PersistCCloudCredentialsToConfig(config *config.Config, client *ccloudv1.Cl

state := getCCloudContextState(credentials.AuthToken, credentials.AuthRefreshToken, user)

if err := addOrUpdateContext(config, true, credentials, ctxName, url, state, "", user.GetOrganization().GetResourceId(), save); err != nil {
if err := addOrUpdateContext(config, true, credentials, ctxName, url, state, "", user.GetOrganization().GetResourceId(), save, credentials.IsMFA); err != nil {
return "", nil, err
}

Expand All @@ -112,7 +112,7 @@ func PersistCCloudCredentialsToConfig(config *config.Config, client *ccloudv1.Cl
return ctx.CurrentEnvironment, user.GetOrganization(), nil
}

func addOrUpdateContext(cfg *config.Config, isCloud bool, credentials *Credentials, ctxName, url string, state *config.ContextState, caCertPath, organizationId string, save bool) error {
func addOrUpdateContext(cfg *config.Config, isCloud bool, credentials *Credentials, ctxName, url string, state *config.ContextState, caCertPath, organizationId string, save, isMFA bool) error {
platform := &config.Platform{
Name: strings.TrimSuffix(strings.TrimPrefix(url, "https://"), "/"),
Server: url,
Expand Down Expand Up @@ -171,8 +171,9 @@ func addOrUpdateContext(cfg *config.Config, isCloud bool, credentials *Credentia
ctx.Credential = credential
ctx.CredentialName = credential.Name
ctx.LastOrgId = organizationId
ctx.IsMFA = isMFA
} else {
if err := cfg.AddContext(ctxName, platform.Name, credential.Name, map[string]*config.KafkaClusterConfig{}, "", state, organizationId, ""); err != nil {
if err := cfg.AddContext(ctxName, platform.Name, credential.Name, map[string]*config.KafkaClusterConfig{}, "", state, organizationId, "", isMFA); err != nil {
return err
}
}
Expand Down
103 changes: 103 additions & 0 deletions pkg/auth/auth_token_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
"strings"
"time"

"github.com/gogo/protobuf/types"
"github.com/pkg/browser"

ccloudv1 "github.com/confluentinc/ccloud-sdk-go-v1-public"
"github.com/confluentinc/mds-sdk-go-public/mdsv1"

"github.com/confluentinc/cli/v4/pkg/auth/mfa"
"github.com/confluentinc/cli/v4/pkg/auth/sso"
"github.com/confluentinc/cli/v4/pkg/errors"
"github.com/confluentinc/cli/v4/pkg/form"
Expand Down Expand Up @@ -40,11 +42,16 @@ func (a *AuthTokenHandlerImpl) GetCCloudTokens(clientFactory CCloudClientFactory
if token, refreshToken, err := a.refreshCCloudSSOToken(client, credentials.AuthRefreshToken, organizationId); err == nil {
return token, refreshToken, nil
}
} else if credentials.IsMFA {
if token, refreshToken, err := a.refreshCCloudMFAToken(client, credentials.AuthRefreshToken, organizationId, credentials.Username); err == nil {
return token, refreshToken, nil
}
} else {
req := &ccloudv1.AuthenticateRequest{
RefreshToken: credentials.AuthRefreshToken,
OrgResourceId: organizationId,
}

if res, err := client.Auth.Login(req); err == nil {
return res.GetToken(), res.GetRefreshToken(), nil
}
Expand All @@ -63,6 +70,17 @@ func (a *AuthTokenHandlerImpl) GetCCloudTokens(clientFactory CCloudClientFactory
return token, refreshToken, err
}

if credentials.IsMFA {
token, refreshToken, err := a.getCCloudMFAToken(client, credentials.Username, organizationId)
if err != nil {
return "", "", err
}

client = clientFactory.JwtHTTPClientFactory(context.Background(), token, url)
err = a.checkMFAEmailMatchesLogin(client, credentials.Username)
tmalikconfluent marked this conversation as resolved.
Show resolved Hide resolved
return token, refreshToken, err
}

client.HttpClient.Timeout = 30 * time.Second
log.CliLogger.Debugf("Making login request for %s for org id %s", credentials.Username, organizationId)

Expand All @@ -76,6 +94,9 @@ func (a *AuthTokenHandlerImpl) GetCCloudTokens(clientFactory CCloudClientFactory
if err != nil {
return "", "", err
}
if res.GetOrganization().GetMfaEnforcedAt() != nil && res.GetUser().GetAuthType() == ccloudv1.AuthType_AUTH_TYPE_LOCAL {
output.Printf(false, "Please be aware that you will be required to enroll in MFA by %s\n", convertDateFormat(res.GetOrganization().GetMfaEnforcedAt()))
}

if utils.IsOrgEndOfFreeTrialSuspended(res.GetOrganization().GetSuspensionStatus()) {
log.CliLogger.Debugf(errors.EndOfFreeTrialErrorMsg, res.GetOrganization().GetSuspensionStatus())
Expand Down Expand Up @@ -112,6 +133,32 @@ func (a *AuthTokenHandlerImpl) getCCloudSSOToken(client *ccloudv1.Client, noBrow
return res.GetToken(), refreshToken, err
}

func (a *AuthTokenHandlerImpl) getCCloudMFAToken(client *ccloudv1.Client, email, organizationId string) (string, string, error) {
connectionName, err := a.getMfaConnectionName(client, email, organizationId)
if err != nil {
return "", "", fmt.Errorf(`unable to obtain MFA info for user "%s: %v"`, email, err)
}
if connectionName == "" {
return "", "", fmt.Errorf(`tried to obtain MFA token for non MFA user "%s"`, email)
}

idToken, refreshToken, err := mfa.Login(client.BaseURL, email, connectionName)
if err != nil {
return "", "", err
}

req := &ccloudv1.AuthenticateRequest{
IdToken: idToken,
OrgResourceId: organizationId,
}
res, err := client.Auth.Login(req)
if err != nil {
return "", "", err
}

return res.GetToken(), refreshToken, err
}

func (a *AuthTokenHandlerImpl) getSsoConnectionName(client *ccloudv1.Client, email, organizationId string) (string, error) {
req := &ccloudv1.GetLoginRealmRequest{
Email: email,
Expand All @@ -128,6 +175,22 @@ func (a *AuthTokenHandlerImpl) getSsoConnectionName(client *ccloudv1.Client, ema
return "", nil
}

func (a *AuthTokenHandlerImpl) getMfaConnectionName(client *ccloudv1.Client, email, organizationId string) (string, error) {
req := &ccloudv1.GetLoginRealmRequest{
Email: email,
ClientId: sso.GetAuth0CCloudClientIdFromBaseUrl(client.BaseURL),
OrgResourceId: organizationId,
}
loginRealmReply, err := client.User.LoginRealm(req)
if err != nil {
return "", err
}
if loginRealmReply.GetMfaRequired() {
return loginRealmReply.GetRealm(), nil
}
return "", nil
}

func (a *AuthTokenHandlerImpl) refreshCCloudSSOToken(client *ccloudv1.Client, refreshToken, organizationId string) (string, string, error) {
idToken, refreshToken, err := sso.RefreshTokens(client.BaseURL, refreshToken)
if err != nil {
Expand All @@ -146,6 +209,24 @@ func (a *AuthTokenHandlerImpl) refreshCCloudSSOToken(client *ccloudv1.Client, re

return res.GetToken(), refreshToken, err
}
func (a *AuthTokenHandlerImpl) refreshCCloudMFAToken(client *ccloudv1.Client, refreshToken, organizationId, email string) (string, string, error) {
idToken, refreshToken, err := mfa.RefreshTokens(client.BaseURL, refreshToken, email)
if err != nil {
return "", "", err
}

req := &ccloudv1.AuthenticateRequest{
IdToken: idToken,
OrgResourceId: organizationId,
}

res, err := login(client, req)
if err != nil {
return "", "", err
}

return res.GetToken(), refreshToken, err
}

func (a *AuthTokenHandlerImpl) GetConfluentToken(mdsClient *mdsv1.APIClient, credentials *Credentials, noBrowser bool) (string, string, error) {
ctx := utils.GetContext()
Expand Down Expand Up @@ -226,6 +307,20 @@ func refreshConfluentToken(mdsClient *mdsv1.APIClient, credentials *Credentials)
return resp.AuthToken, nil
}

func (a *AuthTokenHandlerImpl) checkMFAEmailMatchesLogin(client *ccloudv1.Client, loginEmail string) error {
getMeReply, err := client.Auth.User()
if err != nil {
return err
}
if !strings.EqualFold(getMeReply.GetUser().GetEmail(), loginEmail) {
return errors.NewErrorWithSuggestions(
fmt.Sprintf("expected login credentials for %s but got credentials for %s", loginEmail, getMeReply.GetUser().GetEmail()),
"Please re-login and use the same email at the prompt and in the login portal.",
sgagniere marked this conversation as resolved.
Show resolved Hide resolved
)
}
return nil
}

func (a *AuthTokenHandlerImpl) checkSSOEmailMatchesLogin(client *ccloudv1.Client, loginEmail string) error {
getMeReply, err := client.Auth.User()
if err != nil {
Expand All @@ -247,3 +342,11 @@ func login(client *ccloudv1.Client, req *ccloudv1.AuthenticateRequest) (*ccloudv
return client.Auth.Login(req)
}
}

func convertDateFormat(mfaEnforcedAt *types.Timestamp) string {
tmalikconfluent marked this conversation as resolved.
Show resolved Hide resolved
if mfaEnforcedAt == nil {
return "Invalid Date"
}
date := time.Unix(mfaEnforcedAt.Seconds, int64(mfaEnforcedAt.Nanos)).UTC().Truncate(time.Microsecond)
return date.Format("01/02/2006")
}
31 changes: 31 additions & 0 deletions pkg/auth/auth_token_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package auth

import (
"testing"
"time"

"github.com/gogo/protobuf/types"
"github.com/stretchr/testify/assert"
)

func TestConvertDateFormatToString(t *testing.T) {
t.Run("success", func(t *testing.T) {
date := time.Date(2021, time.June, 16, 12, 0, 0, 0, time.UTC)
timestamp := &types.Timestamp{Seconds: date.Unix()}
actual := convertDateFormat(timestamp)
expected := "06/16/2021"
assert.Equal(t, expected, actual)
})
t.Run("fail", func(t *testing.T) {
date := time.Date(2021, time.April, 16, 12, 0, 0, 0, time.UTC)
timestamp := &types.Timestamp{Seconds: date.Unix()}
actual := convertDateFormat(timestamp)
expected := "06/16/2021"
assert.NotEqual(t, expected, actual)
})
t.Run("fail, nil", func(t *testing.T) {
actual := convertDateFormat(&types.Timestamp{})
expected := "01/01/1970"
assert.Equal(t, expected, actual)
})
}
28 changes: 27 additions & 1 deletion pkg/auth/login_credentials_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Credentials struct {
Username string
Password string
IsSSO bool
IsMFA bool
Salt []byte
Nonce []byte

Expand All @@ -40,7 +41,7 @@ type Credentials struct {
}

func (c *Credentials) IsFullSet() bool {
return c.Username != "" && (c.IsSSO || c.Password != "" || c.AuthRefreshToken != "")
return c.Username != "" && (c.IsSSO || c.IsMFA || c.Password != "" || c.AuthRefreshToken != "")
}

type environmentVariables struct {
Expand Down Expand Up @@ -108,6 +109,9 @@ func (h *LoginCredentialsManagerImpl) getCredentialsFromEnvVarFunc(envVars envir
if h.isSSOUser(email, organizationId) {
log.CliLogger.Debugf("%s=%s belongs to an SSO user.", ConfluentCloudEmail, email)
return &Credentials{Username: email, IsSSO: true}, nil
} else if h.isMFARequired(email, organizationId) {
log.CliLogger.Debugf("%s=%s belongs to an MFA user.", ConfluentCloudEmail, email)
tmalikconfluent marked this conversation as resolved.
Show resolved Hide resolved
return &Credentials{Username: email, IsMFA: true}, nil
}

if password == "" {
Expand Down Expand Up @@ -182,6 +186,7 @@ func (h *LoginCredentialsManagerImpl) GetPrerunCredentialsFromConfig(cfg *config

credentials := &Credentials{
IsSSO: ctx.GetUser().GetAuthType() == ccloudv1.AuthType_AUTH_TYPE_SSO || ctx.GetUser().GetSocialConnection() != "",
IsMFA: ctx.IsMFA,
Username: ctx.GetUser().GetEmail(),
AuthToken: ctx.GetAuthToken(),
AuthRefreshToken: ctx.GetAuthRefreshToken(),
Expand Down Expand Up @@ -245,6 +250,8 @@ func (h *LoginCredentialsManagerImpl) GetCloudCredentialsFromPrompt(organization
if h.isSSOUser(email, organizationId) {
log.CliLogger.Debug("Entered email belongs to an SSO user.")
return &Credentials{Username: email, IsSSO: true}, nil
} else if h.isMFARequired(email, organizationId) {
return &Credentials{Username: email, IsMFA: true}, nil
}
password := h.promptForPassword()
return &Credentials{Username: email, Password: password}, nil
Expand Down Expand Up @@ -278,6 +285,25 @@ func (h *LoginCredentialsManagerImpl) promptForPassword() string {
return f.Responses[passwordField].(string)
}

func (h *LoginCredentialsManagerImpl) isMFARequired(email, organizationId string) bool {
tmalikconfluent marked this conversation as resolved.
Show resolved Hide resolved
if h.client == nil {
return false
}

auth0ClientId := sso.GetAuth0CCloudClientIdFromBaseUrl(h.client.BaseURL)
log.CliLogger.Tracef("h.client.BaseURL: %s", h.client.BaseURL)
log.CliLogger.Tracef("auth0ClientId: %s", auth0ClientId)
req := &ccloudv1.GetLoginRealmRequest{
Email: email,
ClientId: auth0ClientId,
OrgResourceId: organizationId,
}
res, err := h.client.User.LoginRealm(req)
// Fine to ignore non-nil err for this request: e.g. what if this fails due to invalid/malicious
// email, we want to silently continue and give the illusion of password prompt.
return err == nil && res.GetMfaRequired()
tmalikconfluent marked this conversation as resolved.
Show resolved Hide resolved
}

func (h *LoginCredentialsManagerImpl) isSSOUser(email, organizationId string) bool {
if h.client == nil {
return false
Expand Down
12 changes: 10 additions & 2 deletions pkg/auth/login_credentials_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,12 @@ func (suite *LoginCredentialsManagerTestSuite) SetupSuite() {
User: &ccloudv1mock.UserInterface{
LoginRealmFunc: func(req *ccloudv1.GetLoginRealmRequest) (*ccloudv1.GetLoginRealmReply, error) {
if req.Email == "[email protected]" {
return &ccloudv1.GetLoginRealmReply{IsSso: true, Realm: "ccloud-local"}, nil
return &ccloudv1.GetLoginRealmReply{IsSso: true, MfaRequired: false, Realm: "ccloud-local"}, nil
}
return &ccloudv1.GetLoginRealmReply{IsSso: false, Realm: "ccloud-local"}, nil
if req.Email == "[email protected]" {
return &ccloudv1.GetLoginRealmReply{MfaRequired: true, Realm: "ccloud-local"}, nil
}
return &ccloudv1.GetLoginRealmReply{IsSso: false, MfaRequired: false, Realm: "ccloud-local"}, nil
},
},
}
Expand Down Expand Up @@ -107,6 +110,11 @@ func (suite *LoginCredentialsManagerTestSuite) TestGetCCloudCredentialsFromEnvVa
suite.require.NoError(err)
suite.compareCredentials(&Credentials{Username: "[email protected]", IsSSO: true, Password: ""}, creds)

suite.require.NoError(os.Setenv(ConfluentCloudEmail, "[email protected]"))
creds, err = suite.loginCredentialsManager.GetCloudCredentialsFromEnvVar("")()
suite.require.NoError(err)
suite.compareCredentials(&Credentials{Username: "[email protected]", IsMFA: true, Password: ""}, creds)

suite.setCCEnvVars()
creds, err = suite.loginCredentialsManager.GetCloudCredentialsFromEnvVar("")()
suite.require.NoError(err)
Expand Down
Loading