Skip to content
Open
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
20 changes: 20 additions & 0 deletions packages/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const (
operationCallGetCertificateBundle = "CallGetCertificateBundle"
operationCallRenewCertificate = "CallRenewCertificate"
operationCallGetCertificateRequest = "CallGetCertificateRequest"
operationCallRevokeUserSession = "CallRevokeUserSession"
)

var ErrNotFound = errors.New("resource not found")
Expand Down Expand Up @@ -160,6 +161,25 @@ func CallLoginV3(httpClient *resty.Client, request GetLoginV3Request) (GetLoginV
return loginV3Response, nil
}

// CallRevokeUserSession revokes a single server-side login session by its id
// (the tokenVersionId claim carried in every session JWT).
func CallRevokeUserSession(httpClient *resty.Client, sessionID string) error {
response, err := httpClient.
R().
SetHeader("User-Agent", USER_AGENT).
Delete(fmt.Sprintf("%v/v2/users/me/sessions/%v", config.INFISICAL_URL, url.PathEscape(sessionID)))

if err != nil {
return NewGenericRequestError(operationCallRevokeUserSession, err)
}

if response.IsError() {
return NewAPIErrorWithResponse(operationCallRevokeUserSession, response, nil)
}

return nil
}

func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) (*VerifyMfaTokenResponse, *VerifyMfaTokenErrorResponse, error) {
var verifyMfaTokenResponse VerifyMfaTokenResponse
var responseError VerifyMfaTokenErrorResponse
Expand Down
144 changes: 92 additions & 52 deletions packages/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cmd
import (
"encoding/json"
"fmt"
"os"

"github.com/Infisical/infisical-merge/packages/api"
"github.com/Infisical/infisical-merge/packages/config"
Expand Down Expand Up @@ -57,64 +58,66 @@ var initCmd = &cobra.Command{
}
httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken)

selectedOrgID, selectedSubOrgName, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email)
if err != nil {
util.HandleError(err, "Unable to select organization")
}
// The profile already carries an organization (and --org can retarget it
// for this command), so don't ask again. Only fall back to the picker
// when the profile has no organization recorded, which happens for
// sessions migrated from a CLI that predates profiles.
selectedOrgID := userCreds.OrganizationID
var selectedSubOrgName *string

tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID})
if tokenResponse.MfaEnabled {
i := 1
for i < 6 {
mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod)
if selectedOrgID == "" {
pickedOrgID, pickedSubOrgName, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email)
if err != nil {
util.HandleError(err, "Unable to select organization")
}
selectedSubOrgName = pickedSubOrgName

httpClient, err := util.GetRestyClientWithCustomHeaders()
if err != nil {
util.HandleError(err, "Unable to get resty client with custom headers")
}
httpClient.SetAuthToken(tokenResponse.Token)
verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{
Email: userCreds.UserCredentials.Email,
MFAToken: mfaVerifyCode,
MFAMethod: tokenResponse.MfaMethod,
})
if requestError != nil {
util.HandleError(err)
break
} else if mfaErrorResponse != nil {
if mfaErrorResponse.Context.Code == "mfa_invalid" {
msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i)
util.PrintlnStderr(msg)
if i == 5 {
util.PrintErrorMessageAndExit("No tries left, please try again in a bit")
break
}
}

if mfaErrorResponse.Context.Code == "mfa_expired" {
util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again")
break
}
i++
} else {
httpClient.SetAuthToken(verifyMFAresponse.Token)
tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID})
break
}
newSessionToken, err := selectOrganizationToken(userCreds.UserCredentials.JTWToken, userCreds.UserCredentials.Email, pickedOrgID)
if err != nil {
util.HandleError(err, "Unable to select organization")
}
}

if err != nil {
util.HandleError(err, "Unable to select organization")
}
// The session token is now scoped to the selected organization; record
// it on the profile this invocation resolved to so later commands in
// this project don't have to ask again.
userCreds.UserCredentials.JTWToken = newSessionToken
orgID, subOrgID := util.ParseTokenOrgClaims(newSessionToken)
if orgID == "" {
orgID = pickedOrgID
}
selectedOrgID = orgID

// set the config jwt token to the new token
userCreds.UserCredentials.JTWToken = tokenResponse.Token
err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials)
httpClient.SetAuthToken(tokenResponse.Token)
updatedProfile := userCreds.Profile
updatedProfile.OrganizationID = orgID
updatedProfile.SubOrganizationID = subOrgID
updatedProfile.OrganizationName = util.OrgDisplayName(newSessionToken, orgID, subOrgID)

if err != nil {
util.HandleError(err, "Unable to store your user credentials")
// Only move the global default when this invocation was using it; a
// terminal pinned via env var, flag, or directory scope must not switch
// other terminals.
makeActive := userCreds.ProfileSource == util.ProfileSourceDefault
err = util.PersistLoginProfile(updatedProfile, &userCreds.UserCredentials, makeActive)
httpClient.SetAuthToken(newSessionToken)

if err != nil {
util.HandleError(err, "Unable to store your user credentials")
}
} else {
orgDisplay := userCreds.OrganizationName
if orgDisplay == "" {
orgDisplay = selectedOrgID
}
util.PrintlnStderr(fmt.Sprintf("Using organization %s from profile '%s'. Pass --org to pick a different one.", orgDisplay, userCreds.ProfileName))

// An --org override is per command, so a project linked under it would
// not resolve on later runs that use the profile's default.
if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.OrganizationID {
profileOrg := userCreds.Profile.OrganizationName
if profileOrg == "" {
profileOrg = userCreds.Profile.OrganizationID
}
util.PrintWarning(fmt.Sprintf("Profile '%s' defaults to organization %s, so later commands here will not find this project unless you pass --org again. Run [infisical profile set-org %s] to make it the default.", userCreds.ProfileName, profileOrg, orgDisplay))
}
}

workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient)
Expand All @@ -140,11 +143,48 @@ var initCmd = &cobra.Command{
util.HandleError(err)
}

offerDirectoryProfileBinding(userCreds.ProfileName)

Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION))

},
}

// offerDirectoryProfileBinding asks (only when multiple profiles exist)
// whether this directory should always use the profile init just ran with, so
// commands run here pick the right tenant without flags or env vars.
func offerDirectoryProfileBinding(profileName string) {
configFile, err := util.GetMigratedConfigFile()
if err != nil || profileName == "" || len(configFile.Profiles) < 2 {
return
}

cwd, err := os.Getwd()
if err != nil {
return
}

if boundProfile, _, ok := util.FindGoverningDirectoryProfile(configFile, cwd); ok && boundProfile == profileName {
return
}

prompt := promptui.Select{
Label: fmt.Sprintf("Bind this directory to profile '%s'? Commands run here will then select it automatically. Select[Yes/No]", profileName),
Items: []string{"No", "Yes"},
}
_, result, err := prompt.Run()
if err != nil || result != "Yes" {
return
}

util.SetDirectoryProfile(&configFile, cwd, profileName)
if err := util.WriteConfigFile(&configFile); err != nil {
util.PrintWarning(fmt.Sprintf("Unable to save the directory profile binding [err=%s]", err))
return
}
util.PrintlnStderr(fmt.Sprintf("Directory %s now uses profile '%s'. Manage bindings with [infisical profile bind] and [infisical profile unbind].", cwd, profileName))
}

func init() {
RootCmd.AddCommand(initCmd)
}
Expand Down
60 changes: 51 additions & 9 deletions packages/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,17 @@ var loginCmd = &cobra.Command{
}

currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
// if the key can't be found or there is an error getting current credentials from key ring, allow them to override
if err != nil && (strings.Contains(err.Error(), "we couldn't find your logged in details")) {
// if the key can't be found, the selected profile doesn't exist yet, or
// there is an error getting current credentials from key ring, allow them to override
if err != nil && (errors.Is(err, util.ErrProfileNotFound) || errors.Is(err, util.ErrProfileDomainMismatch) || strings.Contains(err.Error(), "we couldn't find your logged in details")) {
log.Debug().Err(err)
} else if err != nil {
util.HandleError(err)
}

if currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 {
// When a profile is explicitly targeted (flag or env var), the login is
// a deliberate write to that profile; skip the add/override menu.
if config.INFISICAL_PROFILE_OVERRIDE == "" && currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 {
shouldOverride, err := userLoginMenu(currentLoggedInUserDetails.UserCredentials.Email)
if err != nil {
util.HandleError(err)
Expand Down Expand Up @@ -227,7 +230,40 @@ var loginCmd = &cobra.Command{
cliDefaultLogin(&userCredentialsToBeStored, email, password, organizationId)
}

err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored)
orgID, subOrgID := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken)
orgName := util.OrgDisplayName(userCredentialsToBeStored.JTWToken, orgID, subOrgID)

existingConfig, err := util.GetMigratedConfigFile()
if err != nil {
util.HandleError(err, "Unable to read the Infisical config file")
}

profileName := config.INFISICAL_PROFILE_OVERRIDE
if profileName == "" {
profileName = util.DeriveProfileName(existingConfig, userCredentialsToBeStored.Email, config.INFISICAL_URL, orgID, orgName)
} else if err := util.ValidateProfileName(profileName); err != nil {
util.HandleError(err)
}

if existingProfile, found := util.FindProfile(existingConfig, profileName); found && existingProfile.Email != userCredentialsToBeStored.Email {
util.PrintWarning(fmt.Sprintf("Profile '%s' previously stored the session for %s and now stores the session for %s.", profileName, existingProfile.Email, userCredentialsToBeStored.Email))
}

// An explicitly targeted login (--profile flag or INFISICAL_PROFILE) is a
// scoped write: it must not move the global default out from under other
// terminals that rely on it. This also keeps expired-session renewals
// (which re-exec login with --profile) from stealing the default.
// Untargeted logins keep the familiar "last login wins" behavior.
makeActive := config.INFISICAL_PROFILE_OVERRIDE == ""

err = util.PersistLoginProfile(models.Profile{
Name: profileName,
Email: userCredentialsToBeStored.Email,
Domain: config.INFISICAL_URL,
OrganizationID: orgID,
OrganizationName: orgName,
SubOrganizationID: subOrgID,
}, &userCredentialsToBeStored, makeActive)
if err != nil {
log.Error().Msgf("Unable to store your credentials in system vault")
log.Error().Msgf("\nTo trouble shoot further, read https://infisical.com/docs/cli/faq")
Expand All @@ -236,11 +272,6 @@ var loginCmd = &cobra.Command{
util.HandleError(err)
}

err = util.WriteInitalConfig(&userCredentialsToBeStored)
if err != nil {
util.HandleError(err, "Unable to write write to Infisical Config file. Please try again")
}

// Identify the user in PostHog and alias the anonymous machine ID
// so that pre-login CLI events are merged into the same person record.
// This call is idempotent (gated on LastIdentifiedEmail in the config),
Expand All @@ -267,6 +298,17 @@ var loginCmd = &cobra.Command{
boldWhite.Printf(">>>> Welcome to Infisical!")
boldWhite.Printf(" You are now logged in as %v <<<< \n", userCredentialsToBeStored.Email)

if profileName != userCredentialsToBeStored.Email {
orgDetail := ""
if orgName != "" {
orgDetail = fmt.Sprintf(" (org %s)", orgName)
}
util.PrintlnStderr(fmt.Sprintf("Session saved to profile '%s'%s. Select it with --profile %s or INFISICAL_PROFILE=%s.", profileName, orgDetail, profileName, profileName))
}
if configAfterLogin, err := util.GetConfigFile(); err == nil && configAfterLogin.ActiveProfile != "" && configAfterLogin.ActiveProfile != profileName {
util.PrintlnStderr(fmt.Sprintf("Your default profile remains '%s'; terminals using it are unaffected. Run [infisical profile use %s] to make '%s' the default.", configAfterLogin.ActiveProfile, profileName, profileName))
}

plainBold := color.New(color.Bold)

plainBold.Println("\nQuick links")
Expand Down
Loading
Loading