Skip to content

Commit 77d6522

Browse files
feat(cli): named login profiles for multi-organization work
Switching organizations meant logging out and back in. The CLI stored one session per account, keyed by email in the keyring, and a session token is scoped to a single organization, so a second login destroyed the first. Working across tenants in parallel meant exporting tokens into env vars or .env files. A profile is now one login: an account on one instance, plus the organization it uses by default. Each profile has its own keyring entry, so sessions coexist, and selecting a profile selects the account, instance and organization together. The organization is a field of the profile rather than part of its identity. --org and INFISICAL_ORG retarget a single command by name, slug or id, and the organization-scoped token is cached per organization in the keyring, so the switch costs one exchange and nothing thereafter. Changing the profile's default is `profile set-org` (also reachable as `org switch`). Which profile a command uses is decided by --profile, then INFISICAL_PROFILE, then a bound directory, then the machine default. An explicit override wins over a bound directory, and says so, so that a binding which did not apply is explained rather than silently ignored. Those last three each get their own verb, so all of them are discoverable from `profile --help`: profile use <name> the default for this machine profile pin <name> this terminal only, via eval profile bind [name] [path] a directory and everything under it Sub-organizations are handled throughout: they appear nested in `org list`, `--org` resolves them by name, slug or id, and a profile scoped to one reports it as "Acme / Research" rather than as the root organization it would otherwise be indistinguishable from. Organizations that require MFA prompt during `profile new` and `profile set-org`, which perform their own exchange; `--org` on an ordinary command cannot prompt, so it fails with a message pointing at the command that can. Commands added: profile list | current | new | use | pin | unpin | bind | unbind | set-org | delete org list | switch logout Session handling. Sessions continue to expire at JWT_AUTH_LIFETIME, with expiry sending the user back through login, unchanged from today. Renewal via the stored refresh token stays unimplemented on purpose: the server rotates the refresh token on every refresh and treats a stale one as theft by revoking the session, which several CLI processes sharing one vault entry cannot coordinate safely. The token is also no longer written to the vault, since nothing read it and storing it only widens what a stolen vault yields. `logout` revokes server-side, and so do `profile delete` and `reset`. Because the server keys sessions by user, IP and user agent, several profiles for one account on one machine share a session, so a session another profile still uses is left intact and only local credentials are removed. Integration with existing commands: `init` uses the profile's organization instead of asking again and offers to bind the directory; `user switch` operates on profiles; `vault set` clears them. An explicit --domain now beats a profile's saved domain instead of being silently overridden, `user update domain` only repoints profiles that were on the instance being changed rather than every profile sharing an email, and `reset` removes every stored session instead of orphaning all but the active one. Hardening from review: profile names are shell-quoted where pin prints an export, since a derived name comes from a server-supplied email and would otherwise run as a command under eval; organization selectors match by id, then slug, then name, with ambiguity rejected, so an organization named after another's id cannot be selected in its place; logout authenticates revocation with any live token rather than only the profile's own, which previously let a cached organization token survive locally deleted credentials; a profile's session is refused rather than sent when an explicit --domain names a different instance; `user update domain` selects a profile rather than an account, so profiles sharing an email and instance for different organizations are not moved together, and the moved profile's session is cleared, before the new instance is recorded, since a session that outlived the change would be sent there; server-supplied names are stripped of control characters before reaching a terminal; and the legacy login pointer is published only for email-named profiles, so an older binary cannot load one profile's token while aimed at another's instance. Migration is lazy and requires no re-login. Legacy loggedInUserEmail and loggedInUsers entries become profiles named after the account email, which is also the legacy keyring key, so existing sessions keep working untouched, and those fields stay in sync with the active profile for older binaries and scripts that read them. Single-profile users see no change in behavior. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e1e829c commit 77d6522

21 files changed

Lines changed: 3310 additions & 261 deletions

packages/api/api.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const (
7777
operationCallGetCertificateBundle = "CallGetCertificateBundle"
7878
operationCallRenewCertificate = "CallRenewCertificate"
7979
operationCallGetCertificateRequest = "CallGetCertificateRequest"
80+
operationCallRevokeUserSession = "CallRevokeUserSession"
8081
)
8182

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

164+
// CallRevokeUserSession revokes a single server-side login session by its id
165+
// (the tokenVersionId claim carried in every session JWT).
166+
func CallRevokeUserSession(httpClient *resty.Client, sessionID string) error {
167+
response, err := httpClient.
168+
R().
169+
SetHeader("User-Agent", USER_AGENT).
170+
Delete(fmt.Sprintf("%v/v2/users/me/sessions/%v", config.INFISICAL_URL, url.PathEscape(sessionID)))
171+
172+
if err != nil {
173+
return NewGenericRequestError(operationCallRevokeUserSession, err)
174+
}
175+
176+
if response.IsError() {
177+
return NewAPIErrorWithResponse(operationCallRevokeUserSession, response, nil)
178+
}
179+
180+
return nil
181+
}
182+
163183
func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) (*VerifyMfaTokenResponse, *VerifyMfaTokenErrorResponse, error) {
164184
var verifyMfaTokenResponse VerifyMfaTokenResponse
165185
var responseError VerifyMfaTokenErrorResponse

packages/cmd/init.go

Lines changed: 92 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package cmd
66
import (
77
"encoding/json"
88
"fmt"
9+
"os"
910

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

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

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

71-
httpClient, err := util.GetRestyClientWithCustomHeaders()
72-
if err != nil {
73-
util.HandleError(err, "Unable to get resty client with custom headers")
74-
}
75-
httpClient.SetAuthToken(tokenResponse.Token)
76-
verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{
77-
Email: userCreds.UserCredentials.Email,
78-
MFAToken: mfaVerifyCode,
79-
MFAMethod: tokenResponse.MfaMethod,
80-
})
81-
if requestError != nil {
82-
util.HandleError(err)
83-
break
84-
} else if mfaErrorResponse != nil {
85-
if mfaErrorResponse.Context.Code == "mfa_invalid" {
86-
msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i)
87-
util.PrintlnStderr(msg)
88-
if i == 5 {
89-
util.PrintErrorMessageAndExit("No tries left, please try again in a bit")
90-
break
91-
}
92-
}
93-
94-
if mfaErrorResponse.Context.Code == "mfa_expired" {
95-
util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again")
96-
break
97-
}
98-
i++
99-
} else {
100-
httpClient.SetAuthToken(verifyMFAresponse.Token)
101-
tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID})
102-
break
103-
}
75+
newSessionToken, err := selectOrganizationToken(userCreds.UserCredentials.JTWToken, userCreds.UserCredentials.Email, pickedOrgID)
76+
if err != nil {
77+
util.HandleError(err, "Unable to select organization")
10478
}
105-
}
10679

107-
if err != nil {
108-
util.HandleError(err, "Unable to select organization")
109-
}
80+
// The session token is now scoped to the selected organization; record
81+
// it on the profile this invocation resolved to so later commands in
82+
// this project don't have to ask again.
83+
userCreds.UserCredentials.JTWToken = newSessionToken
84+
orgID, subOrgID := util.ParseTokenOrgClaims(newSessionToken)
85+
if orgID == "" {
86+
orgID = pickedOrgID
87+
}
88+
selectedOrgID = orgID
11089

111-
// set the config jwt token to the new token
112-
userCreds.UserCredentials.JTWToken = tokenResponse.Token
113-
err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials)
114-
httpClient.SetAuthToken(tokenResponse.Token)
90+
updatedProfile := userCreds.Profile
91+
updatedProfile.OrganizationID = orgID
92+
updatedProfile.SubOrganizationID = subOrgID
93+
updatedProfile.OrganizationName = util.OrgDisplayName(newSessionToken, orgID, subOrgID)
11594

116-
if err != nil {
117-
util.HandleError(err, "Unable to store your user credentials")
95+
// Only move the global default when this invocation was using it; a
96+
// terminal pinned via env var, flag, or directory scope must not switch
97+
// other terminals.
98+
makeActive := userCreds.ProfileSource == util.ProfileSourceDefault
99+
err = util.PersistLoginProfile(updatedProfile, &userCreds.UserCredentials, makeActive)
100+
httpClient.SetAuthToken(newSessionToken)
101+
102+
if err != nil {
103+
util.HandleError(err, "Unable to store your user credentials")
104+
}
105+
} else {
106+
orgDisplay := userCreds.OrganizationName
107+
if orgDisplay == "" {
108+
orgDisplay = selectedOrgID
109+
}
110+
util.PrintlnStderr(fmt.Sprintf("Using organization %s from profile '%s'. Pass --org to pick a different one.", orgDisplay, userCreds.ProfileName))
111+
112+
// An --org override is per command, so a project linked under it would
113+
// not resolve on later runs that use the profile's default.
114+
if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.OrganizationID {
115+
profileOrg := userCreds.Profile.OrganizationName
116+
if profileOrg == "" {
117+
profileOrg = userCreds.Profile.OrganizationID
118+
}
119+
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))
120+
}
118121
}
119122

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

146+
offerDirectoryProfileBinding(userCreds.ProfileName)
147+
143148
Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION))
144149

145150
},
146151
}
147152

153+
// offerDirectoryProfileBinding asks (only when multiple profiles exist)
154+
// whether this directory should always use the profile init just ran with, so
155+
// commands run here pick the right tenant without flags or env vars.
156+
func offerDirectoryProfileBinding(profileName string) {
157+
configFile, err := util.GetMigratedConfigFile()
158+
if err != nil || profileName == "" || len(configFile.Profiles) < 2 {
159+
return
160+
}
161+
162+
cwd, err := os.Getwd()
163+
if err != nil {
164+
return
165+
}
166+
167+
if boundProfile, _, ok := util.FindGoverningDirectoryProfile(configFile, cwd); ok && boundProfile == profileName {
168+
return
169+
}
170+
171+
prompt := promptui.Select{
172+
Label: fmt.Sprintf("Bind this directory to profile '%s'? Commands run here will then select it automatically. Select[Yes/No]", profileName),
173+
Items: []string{"No", "Yes"},
174+
}
175+
_, result, err := prompt.Run()
176+
if err != nil || result != "Yes" {
177+
return
178+
}
179+
180+
util.SetDirectoryProfile(&configFile, cwd, profileName)
181+
if err := util.WriteConfigFile(&configFile); err != nil {
182+
util.PrintWarning(fmt.Sprintf("Unable to save the directory profile binding [err=%s]", err))
183+
return
184+
}
185+
util.PrintlnStderr(fmt.Sprintf("Directory %s now uses profile '%s'. Manage bindings with [infisical profile bind] and [infisical profile unbind].", cwd, profileName))
186+
}
187+
148188
func init() {
149189
RootCmd.AddCommand(initCmd)
150190
}

packages/cmd/login.go

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,17 @@ var loginCmd = &cobra.Command{
134134
}
135135

136136
currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
137-
// if the key can't be found or there is an error getting current credentials from key ring, allow them to override
138-
if err != nil && (strings.Contains(err.Error(), "we couldn't find your logged in details")) {
137+
// if the key can't be found, the selected profile doesn't exist yet, or
138+
// there is an error getting current credentials from key ring, allow them to override
139+
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")) {
139140
log.Debug().Err(err)
140141
} else if err != nil {
141142
util.HandleError(err)
142143
}
143144

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

230-
err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored)
233+
orgID, subOrgID := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken)
234+
orgName := util.OrgDisplayName(userCredentialsToBeStored.JTWToken, orgID, subOrgID)
235+
236+
existingConfig, err := util.GetMigratedConfigFile()
237+
if err != nil {
238+
util.HandleError(err, "Unable to read the Infisical config file")
239+
}
240+
241+
profileName := config.INFISICAL_PROFILE_OVERRIDE
242+
if profileName == "" {
243+
profileName = util.DeriveProfileName(existingConfig, userCredentialsToBeStored.Email, config.INFISICAL_URL, orgID, orgName)
244+
} else if err := util.ValidateProfileName(profileName); err != nil {
245+
util.HandleError(err)
246+
}
247+
248+
if existingProfile, found := util.FindProfile(existingConfig, profileName); found && existingProfile.Email != userCredentialsToBeStored.Email {
249+
util.PrintWarning(fmt.Sprintf("Profile '%s' previously stored the session for %s and now stores the session for %s.", profileName, existingProfile.Email, userCredentialsToBeStored.Email))
250+
}
251+
252+
// An explicitly targeted login (--profile flag or INFISICAL_PROFILE) is a
253+
// scoped write: it must not move the global default out from under other
254+
// terminals that rely on it. This also keeps expired-session renewals
255+
// (which re-exec login with --profile) from stealing the default.
256+
// Untargeted logins keep the familiar "last login wins" behavior.
257+
makeActive := config.INFISICAL_PROFILE_OVERRIDE == ""
258+
259+
err = util.PersistLoginProfile(models.Profile{
260+
Name: profileName,
261+
Email: userCredentialsToBeStored.Email,
262+
Domain: config.INFISICAL_URL,
263+
OrganizationID: orgID,
264+
OrganizationName: orgName,
265+
SubOrganizationID: subOrgID,
266+
}, &userCredentialsToBeStored, makeActive)
231267
if err != nil {
232268
log.Error().Msgf("Unable to store your credentials in system vault")
233269
log.Error().Msgf("\nTo trouble shoot further, read https://infisical.com/docs/cli/faq")
@@ -236,11 +272,6 @@ var loginCmd = &cobra.Command{
236272
util.HandleError(err)
237273
}
238274

239-
err = util.WriteInitalConfig(&userCredentialsToBeStored)
240-
if err != nil {
241-
util.HandleError(err, "Unable to write write to Infisical Config file. Please try again")
242-
}
243-
244275
// Identify the user in PostHog and alias the anonymous machine ID
245276
// so that pre-login CLI events are merged into the same person record.
246277
// This call is idempotent (gated on LastIdentifiedEmail in the config),
@@ -267,6 +298,17 @@ var loginCmd = &cobra.Command{
267298
boldWhite.Printf(">>>> Welcome to Infisical!")
268299
boldWhite.Printf(" You are now logged in as %v <<<< \n", userCredentialsToBeStored.Email)
269300

301+
if profileName != userCredentialsToBeStored.Email {
302+
orgDetail := ""
303+
if orgName != "" {
304+
orgDetail = fmt.Sprintf(" (org %s)", orgName)
305+
}
306+
util.PrintlnStderr(fmt.Sprintf("Session saved to profile '%s'%s. Select it with --profile %s or INFISICAL_PROFILE=%s.", profileName, orgDetail, profileName, profileName))
307+
}
308+
if configAfterLogin, err := util.GetConfigFile(); err == nil && configAfterLogin.ActiveProfile != "" && configAfterLogin.ActiveProfile != profileName {
309+
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))
310+
}
311+
270312
plainBold := color.New(color.Bold)
271313

272314
plainBold.Println("\nQuick links")

0 commit comments

Comments
 (0)