From eae0b2ad3f74e1b4d3f8108bcf53aee863e68f79 Mon Sep 17 00:00:00 2001 From: Thiago Araujo da Silva Date: Tue, 19 May 2026 23:59:57 -0300 Subject: [PATCH 1/7] add login status command --- packages/cmd/login_status.go | 259 +++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 packages/cmd/login_status.go diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go new file mode 100644 index 00000000..572380eb --- /dev/null +++ b/packages/cmd/login_status.go @@ -0,0 +1,259 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var loginStatusCmd = &cobra.Command{ + Use: "status", + Short: "View the current authentication status", + Long: "Reports whether the CLI is authenticated to Infisical and, when available, the organization the active session is scoped to.", + DisableFlagsInUseLine: true, + Example: "infisical login status", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + jsonOutput, _ := cmd.Flags().GetBool("json") + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + if strings.Contains(err.Error(), "we couldn't find your logged in details") { + printNotAuthenticated(jsonOutput) + os.Exit(1) + } + util.HandleError(err, "[infisical login status]: Unable to read logged-in user details") + } + + if !loggedInUserDetails.IsUserLoggedIn { + printNotAuthenticated(jsonOutput) + os.Exit(1) + } + + domain := strings.TrimSuffix(config.INFISICAL_URL, "/api") + token := loggedInUserDetails.UserCredentials.JTWToken + claims, claimsErr := decodeTokenClaims(token) + + var resolved resolvedLoginOrgNames + if claimsErr == nil && claims.OrganizationID != "" { + resolved = resolveLoggedInOrgNames(token, claims.OrganizationID, claims.SubOrganizationID) + } + + if jsonOutput { + out := loginStatusJSONOutput{Domain: domain} + if claimsErr == nil { + if claims.Exp != 0 { + out.Token = &loginStatusTokenJSON{Exp: claims.Exp} + } + if claims.OrganizationID != "" || resolved.Organization != "" { + out.Organization = &loginStatusOrgJSON{ + ID: claims.OrganizationID, + Name: resolved.Organization, + } + } + if claims.SubOrganizationID != "" || resolved.SubOrganization != "" { + out.SubOrganization = &loginStatusOrgJSON{ + ID: claims.SubOrganizationID, + Name: resolved.SubOrganization, + } + } + } + if err := writeLoginStatusJSON(out); err != nil { + util.HandleError(err, "[infisical login status]: Unable to encode JSON output") + } + if loggedInUserDetails.LoginExpired { + os.Exit(1) + } + return + } + + bold := color.New(color.Bold) + green := color.New(color.FgGreen).Add(color.Bold) + red := color.New(color.FgRed).Add(color.Bold) + + util.PrintlnStdout(bold.Sprint(domain)) + + if loggedInUserDetails.LoginExpired { + util.PrintfStdout(" %s Logged in as %s (session expired)\n", red.Sprint("x"), bold.Sprint(loggedInUserDetails.UserCredentials.Email)) + util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") + os.Exit(1) + } + + util.PrintfStdout(" %s Logged in as %s\n", green.Sprint("✓"), bold.Sprint(loggedInUserDetails.UserCredentials.Email)) + + if claimsErr == nil && claims.Exp != 0 { + printStatusItem("Token", fmt.Sprintf("true (expires %s)", formatExpiry(time.Unix(claims.Exp, 0)))) + } else { + printStatusItem("Token", "true") + } + + if claimsErr != nil { + log.Debug().Err(claimsErr).Msg("login status: unable to decode token payload") + printStatusItem("Organization", "unknown (could not parse token)") + return + } + + if claims.OrganizationID == "" { + printStatusItem("Organization", "none (token is not scoped to an organization)") + return + } + + if resolved.Organization != "" { + printStatusItem("Organization", fmt.Sprintf("%s (%s)", resolved.Organization, claims.OrganizationID)) + } else { + printStatusItem("Organization", claims.OrganizationID) + } + + if claims.SubOrganizationID != "" { + if resolved.SubOrganization != "" { + printStatusItem("Sub-organization", fmt.Sprintf("%s (%s)", resolved.SubOrganization, claims.SubOrganizationID)) + } else { + printStatusItem("Sub-organization", claims.SubOrganizationID) + } + } + }, +} + +type loginStatusJSONOutput struct { + Domain string `json:"domain,omitempty"` + Token *loginStatusTokenJSON `json:"token,omitempty"` + Organization *loginStatusOrgJSON `json:"organization,omitempty"` + SubOrganization *loginStatusOrgJSON `json:"sub_organization,omitempty"` +} + +type loginStatusTokenJSON struct { + Exp int64 `json:"exp,omitempty"` +} + +type loginStatusOrgJSON struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +func writeLoginStatusJSON(out loginStatusJSONOutput) error { + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + util.PrintlnStdout(string(data)) + return nil +} + +func printStatusItem(key, value string) { + bold := color.New(color.Bold) + util.PrintfStdout(" - %s: %s\n", key, bold.Sprint(value)) +} + +type loginTokenClaims struct { + OrganizationID string `json:"organizationId"` + SubOrganizationID string `json:"subOrganizationId"` + Exp int64 `json:"exp"` +} + +func decodeTokenClaims(token string) (loginTokenClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return loginTokenClaims{}, fmt.Errorf("invalid token format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return loginTokenClaims{}, err + } + var claims loginTokenClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return loginTokenClaims{}, err + } + return claims, nil +} + +func formatExpiry(expiresAt time.Time) string { + remaining := time.Until(expiresAt) + if remaining <= 0 { + return "expired" + } + hours := int(remaining.Hours()) + if hours >= 24 { + days := hours / 24 + return fmt.Sprintf("in %dd %dh", days, hours%24) + } + if hours > 0 { + return fmt.Sprintf("in %dh %dm", hours, int(remaining.Minutes())%60) + } + return fmt.Sprintf("in %dm", int(remaining.Minutes())) +} + +type resolvedLoginOrgNames struct { + Organization string + SubOrganization string +} + +func resolveLoggedInOrgNames(token, orgID, subOrgID string) resolvedLoginOrgNames { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + log.Debug().Err(err).Msg("login status: unable to build http client") + return resolvedLoginOrgNames{} + } + httpClient.SetAuthToken(token) + + var result resolvedLoginOrgNames + + if subOrgID != "" { + if resp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil { + for _, org := range resp.Organizations { + if org.ID == orgID { + result.Organization = org.Name + for _, sub := range org.SubOrganizations { + if sub.ID == subOrgID { + result.SubOrganization = sub.Name + break + } + } + break + } + } + } else { + log.Debug().Err(err).Msg("login status: failed to fetch orgs with sub-orgs") + } + } + + if result.Organization == "" { + if resp, err := api.CallGetAllOrganizations(httpClient); err == nil { + for _, org := range resp.Organizations { + if org.ID == orgID { + result.Organization = org.Name + break + } + } + } else { + log.Debug().Err(err).Msg("login status: failed to fetch organizations") + } + } + + return result +} + +func printNotAuthenticated(jsonOutput bool) { + if jsonOutput { + if err := writeLoginStatusJSON(loginStatusJSONOutput{}); err != nil { + util.HandleError(err, "[infisical login status]: Unable to encode JSON output") + } + return + } + red := color.New(color.FgRed).Add(color.Bold) + util.PrintfStdout("%s You are not authenticated.\nRun `infisical login` to log in.\n", red.Sprint("x")) +} + +func init() { + loginStatusCmd.Flags().Bool("json", false, "Output the login status as JSON") + loginCmd.AddCommand(loginStatusCmd) +} From 14b19ba00a6adb57f104082563f932c11d0554c5 Mon Sep 17 00:00:00 2001 From: Thiago Araujo da Silva Date: Wed, 20 May 2026 00:13:43 -0300 Subject: [PATCH 2/7] refactor to make file more concise and add test --- packages/cmd/login_status.go | 280 +++++++++++++++++------------- packages/cmd/login_status_test.go | 125 +++++++++++++ packages/util/credentials.go | 4 +- 3 files changed, 292 insertions(+), 117 deletions(-) create mode 100644 packages/cmd/login_status_test.go diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index 572380eb..2c429643 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -1,8 +1,8 @@ package cmd import ( - "encoding/base64" "encoding/json" + "errors" "fmt" "os" "strings" @@ -12,10 +12,23 @@ import ( "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" + jwt "github.com/golang-jwt/jwt/v5" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) +const ( + statusAuthenticated = "authenticated" + statusExpired = "expired" + statusUnauthenticated = "unauthenticated" +) + +var ( + boldStyle = color.New(color.Bold) + greenStyle = color.New(color.FgGreen, color.Bold) + redStyle = color.New(color.FgRed, color.Bold) +) + var loginStatusCmd = &cobra.Command{ Use: "status", Short: "View the current authentication status", @@ -23,112 +36,73 @@ var loginStatusCmd = &cobra.Command{ DisableFlagsInUseLine: true, Example: "infisical login status", Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - jsonOutput, _ := cmd.Flags().GetBool("json") - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) - if err != nil { - if strings.Contains(err.Error(), "we couldn't find your logged in details") { - printNotAuthenticated(jsonOutput) - os.Exit(1) - } - util.HandleError(err, "[infisical login status]: Unable to read logged-in user details") - } - - if !loggedInUserDetails.IsUserLoggedIn { - printNotAuthenticated(jsonOutput) - os.Exit(1) - } - - domain := strings.TrimSuffix(config.INFISICAL_URL, "/api") - token := loggedInUserDetails.UserCredentials.JTWToken - claims, claimsErr := decodeTokenClaims(token) + Run: runLoginStatus, +} - var resolved resolvedLoginOrgNames - if claimsErr == nil && claims.OrganizationID != "" { - resolved = resolveLoggedInOrgNames(token, claims.OrganizationID, claims.SubOrganizationID) - } +func runLoginStatus(cmd *cobra.Command, args []string) { + jsonOutput, _ := cmd.Flags().GetBool("json") - if jsonOutput { - out := loginStatusJSONOutput{Domain: domain} - if claimsErr == nil { - if claims.Exp != 0 { - out.Token = &loginStatusTokenJSON{Exp: claims.Exp} - } - if claims.OrganizationID != "" || resolved.Organization != "" { - out.Organization = &loginStatusOrgJSON{ - ID: claims.OrganizationID, - Name: resolved.Organization, - } - } - if claims.SubOrganizationID != "" || resolved.SubOrganization != "" { - out.SubOrganization = &loginStatusOrgJSON{ - ID: claims.SubOrganizationID, - Name: resolved.SubOrganization, - } - } - } - if err := writeLoginStatusJSON(out); err != nil { - util.HandleError(err, "[infisical login status]: Unable to encode JSON output") - } - if loggedInUserDetails.LoginExpired { - os.Exit(1) - } - return + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + if errors.Is(err, util.ErrUserNotLoggedIn) { + renderNotAuthenticated(jsonOutput) + os.Exit(1) } + util.HandleError(err, "Unable to read logged-in user details") + } - bold := color.New(color.Bold) - green := color.New(color.FgGreen).Add(color.Bold) - red := color.New(color.FgRed).Add(color.Bold) - - util.PrintlnStdout(bold.Sprint(domain)) + if !loggedInUserDetails.IsUserLoggedIn { + renderNotAuthenticated(jsonOutput) + os.Exit(1) + } - if loggedInUserDetails.LoginExpired { - util.PrintfStdout(" %s Logged in as %s (session expired)\n", red.Sprint("x"), bold.Sprint(loggedInUserDetails.UserCredentials.Email)) - util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") - os.Exit(1) - } + // INFISICAL_URL may be reassigned by GetCurrentLoggedInUserDetails(true) above + // to point at the logged-in user's domain — read it after the call. + domain := strings.TrimSuffix(config.INFISICAL_URL, "/api") - util.PrintfStdout(" %s Logged in as %s\n", green.Sprint("✓"), bold.Sprint(loggedInUserDetails.UserCredentials.Email)) + token := loggedInUserDetails.UserCredentials.JTWToken + claims, claimsErr := parseLoginJWTClaims(token) - if claimsErr == nil && claims.Exp != 0 { - printStatusItem("Token", fmt.Sprintf("true (expires %s)", formatExpiry(time.Unix(claims.Exp, 0)))) - } else { - printStatusItem("Token", "true") - } + var resolved resolvedLoginOrgNames + if claimsErr == nil && claims.OrganizationID != "" { + resolved = resolveLoggedInOrgNames(token, claims.OrganizationID, claims.SubOrganizationID) + } - if claimsErr != nil { - log.Debug().Err(claimsErr).Msg("login status: unable to decode token payload") - printStatusItem("Organization", "unknown (could not parse token)") - return - } + ctx := loginStatusContext{ + domain: domain, + loggedInUser: loggedInUserDetails, + claims: claims, + claimsErr: claimsErr, + resolved: resolved, + } - if claims.OrganizationID == "" { - printStatusItem("Organization", "none (token is not scoped to an organization)") - return + if jsonOutput { + if err := writeLoginStatusJSON(buildJSONOutput(ctx)); err != nil { + util.HandleError(err, "Unable to encode JSON output") } + } else { + renderHuman(ctx) + } - if resolved.Organization != "" { - printStatusItem("Organization", fmt.Sprintf("%s (%s)", resolved.Organization, claims.OrganizationID)) - } else { - printStatusItem("Organization", claims.OrganizationID) - } + if loggedInUserDetails.LoginExpired { + os.Exit(1) + } +} - if claims.SubOrganizationID != "" { - if resolved.SubOrganization != "" { - printStatusItem("Sub-organization", fmt.Sprintf("%s (%s)", resolved.SubOrganization, claims.SubOrganizationID)) - } else { - printStatusItem("Sub-organization", claims.SubOrganizationID) - } - } - }, +type loginStatusContext struct { + domain string + loggedInUser util.LoggedInUserDetails + claims loginTokenClaims + claimsErr error + resolved resolvedLoginOrgNames } type loginStatusJSONOutput struct { + Status string `json:"status"` Domain string `json:"domain,omitempty"` Token *loginStatusTokenJSON `json:"token,omitempty"` Organization *loginStatusOrgJSON `json:"organization,omitempty"` - SubOrganization *loginStatusOrgJSON `json:"sub_organization,omitempty"` + SubOrganization *loginStatusOrgJSON `json:"subOrganization,omitempty"` } type loginStatusTokenJSON struct { @@ -140,6 +114,37 @@ type loginStatusOrgJSON struct { Name string `json:"name,omitempty"` } +func buildJSONOutput(ctx loginStatusContext) loginStatusJSONOutput { + out := loginStatusJSONOutput{ + Domain: ctx.domain, + Status: statusAuthenticated, + } + if ctx.loggedInUser.LoginExpired { + out.Status = statusExpired + } + + if ctx.claimsErr != nil { + return out + } + + if ctx.claims.ExpiresAt != nil { + out.Token = &loginStatusTokenJSON{Exp: ctx.claims.ExpiresAt.Unix()} + } + if ctx.claims.OrganizationID != "" || ctx.resolved.Organization != "" { + out.Organization = &loginStatusOrgJSON{ + ID: ctx.claims.OrganizationID, + Name: ctx.resolved.Organization, + } + } + if ctx.claims.SubOrganizationID != "" || ctx.resolved.SubOrganization != "" { + out.SubOrganization = &loginStatusOrgJSON{ + ID: ctx.claims.SubOrganizationID, + Name: ctx.resolved.SubOrganization, + } + } + return out +} + func writeLoginStatusJSON(out loginStatusJSONOutput) error { data, err := json.MarshalIndent(out, "", " ") if err != nil { @@ -149,28 +154,69 @@ func writeLoginStatusJSON(out loginStatusJSONOutput) error { return nil } +func renderHuman(ctx loginStatusContext) { + util.PrintlnStdout(boldStyle.Sprint(ctx.domain)) + + if ctx.loggedInUser.LoginExpired { + util.PrintfStdout(" %s Logged in as %s (session expired)\n", + redStyle.Sprint("x"), boldStyle.Sprint(ctx.loggedInUser.UserCredentials.Email)) + util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") + return + } + + util.PrintfStdout(" %s Logged in as %s\n", + greenStyle.Sprint("✓"), boldStyle.Sprint(ctx.loggedInUser.UserCredentials.Email)) + + printStatusItem("Token", tokenStatusLine(ctx.claims, ctx.claimsErr)) + printStatusItem("Organization", orgStatusLine(ctx.claims, ctx.claimsErr, ctx.resolved)) + + if ctx.claimsErr == nil && ctx.claims.SubOrganizationID != "" { + printStatusItem("Sub-organization", subOrgStatusLine(ctx.claims, ctx.resolved)) + } +} + +func tokenStatusLine(claims loginTokenClaims, claimsErr error) string { + if claimsErr == nil && claims.ExpiresAt != nil { + return fmt.Sprintf("true (expires %s)", formatExpiry(claims.ExpiresAt.Time)) + } + return "true" +} + +func orgStatusLine(claims loginTokenClaims, claimsErr error, resolved resolvedLoginOrgNames) string { + if claimsErr != nil { + log.Debug().Err(claimsErr).Msg("login status: unable to decode token payload") + return "unknown (could not parse token)" + } + if claims.OrganizationID == "" { + return "none (token is not scoped to an organization)" + } + if resolved.Organization != "" { + return fmt.Sprintf("%s (%s)", resolved.Organization, claims.OrganizationID) + } + return claims.OrganizationID +} + +func subOrgStatusLine(claims loginTokenClaims, resolved resolvedLoginOrgNames) string { + if resolved.SubOrganization != "" { + return fmt.Sprintf("%s (%s)", resolved.SubOrganization, claims.SubOrganizationID) + } + return claims.SubOrganizationID +} + func printStatusItem(key, value string) { - bold := color.New(color.Bold) - util.PrintfStdout(" - %s: %s\n", key, bold.Sprint(value)) + util.PrintfStdout(" - %s: %s\n", key, boldStyle.Sprint(value)) } type loginTokenClaims struct { OrganizationID string `json:"organizationId"` SubOrganizationID string `json:"subOrganizationId"` - Exp int64 `json:"exp"` + jwt.RegisteredClaims } -func decodeTokenClaims(token string) (loginTokenClaims, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return loginTokenClaims{}, fmt.Errorf("invalid token format") - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return loginTokenClaims{}, err - } +func parseLoginJWTClaims(token string) (loginTokenClaims, error) { var claims loginTokenClaims - if err := json.Unmarshal(payload, &claims); err != nil { + parser := jwt.NewParser() + if _, _, err := parser.ParseUnverified(token, &claims); err != nil { return loginTokenClaims{}, err } return claims, nil @@ -206,27 +252,30 @@ func resolveLoggedInOrgNames(token, orgID, subOrgID string) resolvedLoginOrgName httpClient.SetAuthToken(token) var result resolvedLoginOrgNames + subOrgEndpointSucceeded := false if subOrgID != "" { if resp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil { + subOrgEndpointSucceeded = true for _, org := range resp.Organizations { - if org.ID == orgID { - result.Organization = org.Name - for _, sub := range org.SubOrganizations { - if sub.ID == subOrgID { - result.SubOrganization = sub.Name - break - } + if org.ID != orgID { + continue + } + result.Organization = org.Name + for _, sub := range org.SubOrganizations { + if sub.ID == subOrgID { + result.SubOrganization = sub.Name + break } - break } + break } } else { log.Debug().Err(err).Msg("login status: failed to fetch orgs with sub-orgs") } } - if result.Organization == "" { + if !subOrgEndpointSucceeded { if resp, err := api.CallGetAllOrganizations(httpClient); err == nil { for _, org := range resp.Organizations { if org.ID == orgID { @@ -242,15 +291,14 @@ func resolveLoggedInOrgNames(token, orgID, subOrgID string) resolvedLoginOrgName return result } -func printNotAuthenticated(jsonOutput bool) { +func renderNotAuthenticated(jsonOutput bool) { if jsonOutput { - if err := writeLoginStatusJSON(loginStatusJSONOutput{}); err != nil { - util.HandleError(err, "[infisical login status]: Unable to encode JSON output") + if err := writeLoginStatusJSON(loginStatusJSONOutput{Status: statusUnauthenticated}); err != nil { + util.HandleError(err, "Unable to encode JSON output") } return } - red := color.New(color.FgRed).Add(color.Bold) - util.PrintfStdout("%s You are not authenticated.\nRun `infisical login` to log in.\n", red.Sprint("x")) + util.PrintfStdout("%s You are not authenticated.\nRun `infisical login` to log in.\n", redStyle.Sprint("x")) } func init() { diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go new file mode 100644 index 00000000..9d95ed8d --- /dev/null +++ b/packages/cmd/login_status_test.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestFormatExpiry(t *testing.T) { + now := time.Now() + + cases := []struct { + name string + expiresAt time.Time + wantPrefix string + wantExact string + }{ + { + name: "already expired", + expiresAt: now.Add(-1 * time.Minute), + wantExact: "expired", + }, + { + name: "exactly at now is expired", + expiresAt: now, + wantExact: "expired", + }, + { + name: "minutes only", + expiresAt: now.Add(15 * time.Minute), + wantPrefix: "in ", + }, + { + name: "hours and minutes", + expiresAt: now.Add(5*time.Hour + 30*time.Minute), + wantPrefix: "in 5h ", + }, + { + name: "days and hours", + expiresAt: now.Add(50 * time.Hour), + wantPrefix: "in 2d ", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatExpiry(tc.expiresAt) + if tc.wantExact != "" { + if got != tc.wantExact { + t.Errorf("formatExpiry(%v) = %q, want %q", tc.expiresAt, got, tc.wantExact) + } + return + } + if !strings.HasPrefix(got, tc.wantPrefix) { + t.Errorf("formatExpiry(%v) = %q, want prefix %q", tc.expiresAt, got, tc.wantPrefix) + } + }) + } +} + +func TestParseLoginJWTClaims(t *testing.T) { + t.Run("happy path with org and sub-org", func(t *testing.T) { + exp := time.Now().Add(time.Hour).Unix() + token := makeUnsignedJWT(t, map[string]any{ + "organizationId": "org-1", + "subOrganizationId": "sub-1", + "exp": exp, + }) + + claims, err := parseLoginJWTClaims(token) + if err != nil { + t.Fatalf("parseLoginJWTClaims: unexpected error: %v", err) + } + if claims.OrganizationID != "org-1" { + t.Errorf("OrganizationID = %q, want %q", claims.OrganizationID, "org-1") + } + if claims.SubOrganizationID != "sub-1" { + t.Errorf("SubOrganizationID = %q, want %q", claims.SubOrganizationID, "sub-1") + } + if claims.ExpiresAt == nil || claims.ExpiresAt.Unix() != exp { + t.Errorf("ExpiresAt = %v, want unix %d", claims.ExpiresAt, exp) + } + }) + + t.Run("token without organization claims still parses", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "exp": time.Now().Add(time.Hour).Unix(), + }) + claims, err := parseLoginJWTClaims(token) + if err != nil { + t.Fatalf("parseLoginJWTClaims: unexpected error: %v", err) + } + if claims.OrganizationID != "" { + t.Errorf("OrganizationID = %q, want empty", claims.OrganizationID) + } + if claims.SubOrganizationID != "" { + t.Errorf("SubOrganizationID = %q, want empty", claims.SubOrganizationID) + } + }) + + t.Run("malformed token returns error", func(t *testing.T) { + if _, err := parseLoginJWTClaims("not-a-jwt"); err == nil { + t.Errorf("parseLoginJWTClaims(%q) = nil error, want error", "not-a-jwt") + } + }) + + t.Run("non-base64 payload returns error", func(t *testing.T) { + if _, err := parseLoginJWTClaims("aaa.!!!.ccc"); err == nil { + t.Errorf("parseLoginJWTClaims with bad payload = nil error, want error") + } + }) +} + +func makeUnsignedJWT(t *testing.T, claims map[string]any) string { + t.Helper() + headerJSON, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"}) + payloadJSON, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + enc := base64.RawURLEncoding + return enc.EncodeToString(headerJSON) + "." + enc.EncodeToString(payloadJSON) + "." +} diff --git a/packages/util/credentials.go b/packages/util/credentials.go index 68a17cdf..194f9327 100644 --- a/packages/util/credentials.go +++ b/packages/util/credentials.go @@ -19,6 +19,8 @@ type LoggedInUserDetails struct { UserCredentials models.UserCredentials } +var ErrUserNotLoggedIn = errors.New("we couldn't find your logged in details, try running [infisical login] then try again") + func StoreUserCredsInKeyRing(userCred *models.UserCredentials) error { userCredMarshalled, err := json.Marshal(userCred) if err != nil { @@ -69,7 +71,7 @@ func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails userCreds, err := GetUserCredsFromKeyRing(configFile.LoggedInUserEmail) if err != nil { if strings.Contains(err.Error(), "credentials not found in system keyring") { - return LoggedInUserDetails{}, errors.New("we couldn't find your logged in details, try running [infisical login] then try again") + return LoggedInUserDetails{}, ErrUserNotLoggedIn } else { return LoggedInUserDetails{}, fmt.Errorf("failed to fetch credentials from keyring because [err=%s]", err) } From 8412467bada84d21ded0188b87421a2d578a2220 Mon Sep 17 00:00:00 2001 From: Thiago Araujo da Silva Date: Wed, 20 May 2026 22:54:15 -0300 Subject: [PATCH 3/7] add machine identity support --- packages/cmd/login_status.go | 419 +++++++++++++++++++++--------- packages/cmd/login_status_test.go | 82 ++++++ 2 files changed, 372 insertions(+), 129 deletions(-) diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index 2c429643..98522fb4 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" @@ -18,9 +17,12 @@ import ( ) const ( - statusAuthenticated = "authenticated" - statusExpired = "expired" - statusUnauthenticated = "unauthenticated" + statusAuthenticated = "authenticated" + statusExpired = "expired" + + principalKindUser = "user" + principalKindMachineIdentity = "machine-identity" + principalKindServiceToken = "service-token" ) var ( @@ -39,70 +41,169 @@ var loginStatusCmd = &cobra.Command{ Run: runLoginStatus, } +const ( + authMethodLoginLabel = "login" + authMethodServiceTokenLabel = "service-token" + + tokenSourceLoginSession = "infisical login (keyring)" +) + func runLoginStatus(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) - if err != nil { - if errors.Is(err, util.ErrUserNotLoggedIn) { - renderNotAuthenticated(jsonOutput) + // --token is a one-off inspection: we only render that token's status and + // skip the user-session check entirely. Mirrors how `--token` overrides + // session-based auth in every other CLI command. We require --domain + // alongside --token so the displayed domain unambiguously matches the + // instance the inspected token belongs to (rather than silently defaulting). + if flagToken, _ := cmd.Flags().GetString("token"); strings.TrimSpace(flagToken) != "" { + if !cmd.Flags().Changed("domain") { + util.PrintErrorMessageAndExit("--token requires --domain to be set so the status reflects the correct Infisical instance") + } + ctx := buildMachineIdentityContext(strings.TrimSpace(flagToken), "--token flag", + strings.TrimSuffix(config.INFISICAL_URL, "/api")) + emitLoginStatus([]loginStatusContext{ctx}, jsonOutput) + if isContextExpired(ctx) { os.Exit(1) } + return + } + + var sessions []loginStatusContext + + // Capture the API URL BEFORE GetCurrentLoggedInUserDetails(true) overwrites + // it with the logged-in user's domain — this is the domain a machine + // identity token would actually authenticate against. + machineIdentityDomain := strings.TrimSuffix(config.INFISICAL_URL, "/api") + + if token, source, ok := detectMachineIdentityEnvToken(); ok { + sessions = append(sessions, buildMachineIdentityContext(token, source, machineIdentityDomain)) + } + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil && !errors.Is(err, util.ErrUserNotLoggedIn) { util.HandleError(err, "Unable to read logged-in user details") } + if loggedInUserDetails.IsUserLoggedIn { + userDomain := strings.TrimSuffix(config.INFISICAL_URL, "/api") + sessions = append(sessions, buildUserContext(loggedInUserDetails, userDomain)) + } - if !loggedInUserDetails.IsUserLoggedIn { + if len(sessions) == 0 { renderNotAuthenticated(jsonOutput) os.Exit(1) } - // INFISICAL_URL may be reassigned by GetCurrentLoggedInUserDetails(true) above - // to point at the logged-in user's domain — read it after the call. - domain := strings.TrimSuffix(config.INFISICAL_URL, "/api") + emitLoginStatus(sessions, jsonOutput) - token := loggedInUserDetails.UserCredentials.JTWToken - claims, claimsErr := parseLoginJWTClaims(token) - - var resolved resolvedLoginOrgNames - if claimsErr == nil && claims.OrganizationID != "" { - resolved = resolveLoggedInOrgNames(token, claims.OrganizationID, claims.SubOrganizationID) + for _, s := range sessions { + if isContextExpired(s) { + os.Exit(1) + } } +} - ctx := loginStatusContext{ +func buildUserContext(details util.LoggedInUserDetails, domain string) loginStatusContext { + claims, claimsErr := parseLoginJWTClaims(details.UserCredentials.JTWToken) + return loginStatusContext{ + kind: principalKindUser, domain: domain, - loggedInUser: loggedInUserDetails, + loggedInUser: details, claims: claims, claimsErr: claimsErr, - resolved: resolved, + } +} + +func buildMachineIdentityContext(token, source, domain string) loginStatusContext { + // Service tokens (`st..` format) are opaque — no JWT to decode. + if strings.HasPrefix(token, "st.") { + return loginStatusContext{ + kind: principalKindServiceToken, + domain: domain, + tokenSource: source, + } } + claims, claimsErr := parseLoginJWTClaims(token) + expired := claimsErr == nil && claims.ExpiresAt != nil && !claims.ExpiresAt.After(time.Now()) + + return loginStatusContext{ + kind: principalKindMachineIdentity, + domain: domain, + tokenSource: source, + claims: claims, + claimsErr: claimsErr, + expired: expired, + } +} + +func isContextExpired(ctx loginStatusContext) bool { + switch ctx.kind { + case principalKindUser: + return ctx.loggedInUser.LoginExpired + case principalKindMachineIdentity: + return ctx.expired + } + return false +} + +func emitLoginStatus(sessions []loginStatusContext, jsonOutput bool) { if jsonOutput { - if err := writeLoginStatusJSON(buildJSONOutput(ctx)); err != nil { + if err := writeLoginStatusJSON(buildJSONOutput(sessions)); err != nil { util.HandleError(err, "Unable to encode JSON output") } - } else { - renderHuman(ctx) + return + } + for i, s := range sessions { + if i > 0 { + util.PrintlnStdout("") + } + renderHuman(s) } +} - if loggedInUserDetails.LoginExpired { - os.Exit(1) +// detectMachineIdentityEnvToken returns the machine-identity / service-token +// credential exported in the environment, mirroring the precedence used by +// util.GetInfisicalToken. The legacy `TOKEN` gateway variable is intentionally +// omitted here because its name collides with too many unrelated tools. +func detectMachineIdentityEnvToken() (token, source string, ok bool) { + candidates := []string{ + util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, + util.INFISICAL_TOKEN_NAME, + } + for _, name := range candidates { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v, fmt.Sprintf("%s environment variable", name), true + } } + return "", "", false } type loginStatusContext struct { + kind string domain string - loggedInUser util.LoggedInUserDetails + loggedInUser util.LoggedInUserDetails // populated when kind == principalKindUser + tokenSource string // populated for machine-identity / service-token + expired bool // populated for machine-identity claims loginTokenClaims claimsErr error - resolved resolvedLoginOrgNames } type loginStatusJSONOutput struct { - Status string `json:"status"` - Domain string `json:"domain,omitempty"` - Token *loginStatusTokenJSON `json:"token,omitempty"` - Organization *loginStatusOrgJSON `json:"organization,omitempty"` - SubOrganization *loginStatusOrgJSON `json:"subOrganization,omitempty"` + Sessions []loginStatusSessionJSON `json:"sessions"` +} + +type loginStatusSessionJSON struct { + PrincipalType string `json:"principalType,omitempty"` + Status string `json:"status,omitempty"` + Domain string `json:"domain,omitempty"` + Email string `json:"email,omitempty"` + AuthMethod string `json:"authMethod,omitempty"` + TokenSource string `json:"tokenSource,omitempty"` + Identity *loginStatusIdentityJSON `json:"identity,omitempty"` + Token *loginStatusTokenJSON `json:"token,omitempty"` + Organization *loginStatusOrgJSON `json:"organization,omitempty"` + SubOrganization *loginStatusOrgJSON `json:"subOrganization,omitempty"` } type loginStatusTokenJSON struct { @@ -110,39 +211,72 @@ type loginStatusTokenJSON struct { } type loginStatusOrgJSON struct { + ID string `json:"id,omitempty"` +} + +type loginStatusIdentityJSON struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` } -func buildJSONOutput(ctx loginStatusContext) loginStatusJSONOutput { - out := loginStatusJSONOutput{ - Domain: ctx.domain, - Status: statusAuthenticated, - } - if ctx.loggedInUser.LoginExpired { - out.Status = statusExpired +func buildJSONOutput(sessions []loginStatusContext) loginStatusJSONOutput { + out := loginStatusJSONOutput{Sessions: make([]loginStatusSessionJSON, 0, len(sessions))} + for _, ctx := range sessions { + out.Sessions = append(out.Sessions, buildSessionJSON(ctx)) } + return out +} - if ctx.claimsErr != nil { - return out +func buildSessionJSON(ctx loginStatusContext) loginStatusSessionJSON { + session := loginStatusSessionJSON{ + PrincipalType: ctx.kind, + Domain: ctx.domain, + AuthMethod: authMethodLabel(ctx), + TokenSource: tokenSourceLabel(ctx), + Status: statusAuthenticated, } - if ctx.claims.ExpiresAt != nil { - out.Token = &loginStatusTokenJSON{Exp: ctx.claims.ExpiresAt.Unix()} - } - if ctx.claims.OrganizationID != "" || ctx.resolved.Organization != "" { - out.Organization = &loginStatusOrgJSON{ - ID: ctx.claims.OrganizationID, - Name: ctx.resolved.Organization, + switch ctx.kind { + case principalKindUser: + session.Email = ctx.loggedInUser.UserCredentials.Email + if ctx.loggedInUser.LoginExpired { + session.Status = statusExpired } - } - if ctx.claims.SubOrganizationID != "" || ctx.resolved.SubOrganization != "" { - out.SubOrganization = &loginStatusOrgJSON{ - ID: ctx.claims.SubOrganizationID, - Name: ctx.resolved.SubOrganization, + if ctx.claimsErr != nil { + return session + } + if ctx.claims.ExpiresAt != nil { + session.Token = &loginStatusTokenJSON{Exp: ctx.claims.ExpiresAt.Unix()} + } + if ctx.claims.OrganizationID != "" { + session.Organization = &loginStatusOrgJSON{ID: ctx.claims.OrganizationID} + } + if ctx.claims.SubOrganizationID != "" { + session.SubOrganization = &loginStatusOrgJSON{ID: ctx.claims.SubOrganizationID} + } + + case principalKindMachineIdentity: + if ctx.expired { + session.Status = statusExpired + } + if ctx.claimsErr != nil { + return session + } + if ctx.claims.IdentityID != "" || ctx.claims.IdentityName != "" { + session.Identity = &loginStatusIdentityJSON{ + ID: ctx.claims.IdentityID, + Name: ctx.claims.IdentityName, + } + } + if ctx.claims.ExpiresAt != nil { + session.Token = &loginStatusTokenJSON{Exp: ctx.claims.ExpiresAt.Unix()} + } + if ctx.claims.OrgID != "" { + session.Organization = &loginStatusOrgJSON{ID: ctx.claims.OrgID} } } - return out + + return session } func writeLoginStatusJSON(out loginStatusJSONOutput) error { @@ -155,24 +289,94 @@ func writeLoginStatusJSON(out loginStatusJSONOutput) error { } func renderHuman(ctx loginStatusContext) { - util.PrintlnStdout(boldStyle.Sprint(ctx.domain)) - - if ctx.loggedInUser.LoginExpired { - util.PrintfStdout(" %s Logged in as %s (session expired)\n", - redStyle.Sprint("x"), boldStyle.Sprint(ctx.loggedInUser.UserCredentials.Email)) - util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") + label := principalLabel(ctx) + expired := isContextExpired(ctx) + + if expired { + util.PrintfStdout("%s Authenticated as %s (expired)\n", + redStyle.Sprint("x"), boldStyle.Sprint(label)) + if ctx.domain != "" { + printStatusItem("Domain", ctx.domain) + } + switch ctx.kind { + case principalKindUser: + util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") + case principalKindMachineIdentity: + util.PrintlnStdout(" - Refresh your machine identity access token and re-export it.") + } return } - util.PrintfStdout(" %s Logged in as %s\n", - greenStyle.Sprint("✓"), boldStyle.Sprint(ctx.loggedInUser.UserCredentials.Email)) + util.PrintfStdout("%s Authenticated as %s\n", + greenStyle.Sprint("✓"), boldStyle.Sprint(label)) + + if ctx.domain != "" { + printStatusItem("Domain", ctx.domain) + } + if method := authMethodLabel(ctx); method != "" { + printStatusItem("Auth method", method) + } + if ctx.kind == principalKindMachineIdentity && ctx.claimsErr == nil && ctx.claims.IdentityID != "" { + printStatusItem("Identity", ctx.claims.IdentityID) + } + if source := tokenSourceLabel(ctx); source != "" { + printStatusItem("Token source", source) + } + if ctx.kind != principalKindServiceToken { + printStatusItem("Token", tokenStatusLine(ctx.claims, ctx.claimsErr)) + } + if org := organizationLineFor(ctx); org != "" { + printStatusItem("Organization", org) + } + if ctx.kind == principalKindUser && ctx.claimsErr == nil && ctx.claims.SubOrganizationID != "" { + printStatusItem("Sub-organization", ctx.claims.SubOrganizationID) + } +} - printStatusItem("Token", tokenStatusLine(ctx.claims, ctx.claimsErr)) - printStatusItem("Organization", orgStatusLine(ctx.claims, ctx.claimsErr, ctx.resolved)) +func principalLabel(ctx loginStatusContext) string { + switch ctx.kind { + case principalKindUser: + return ctx.loggedInUser.UserCredentials.Email + case principalKindMachineIdentity: + if ctx.claimsErr == nil && ctx.claims.IdentityName != "" { + return ctx.claims.IdentityName + } + return "machine identity" + case principalKindServiceToken: + return "service token" + } + return "" +} - if ctx.claimsErr == nil && ctx.claims.SubOrganizationID != "" { - printStatusItem("Sub-organization", subOrgStatusLine(ctx.claims, ctx.resolved)) +func authMethodLabel(ctx loginStatusContext) string { + switch ctx.kind { + case principalKindUser: + return authMethodLoginLabel + case principalKindMachineIdentity: + if ctx.claimsErr == nil { + return ctx.claims.AuthMethod + } + case principalKindServiceToken: + return authMethodServiceTokenLabel } + return "" +} + +func tokenSourceLabel(ctx loginStatusContext) string { + if ctx.kind == principalKindUser { + return tokenSourceLoginSession + } + return ctx.tokenSource +} + +func organizationLineFor(ctx loginStatusContext) string { + switch ctx.kind { + case principalKindUser: + return orgStatusLine(ctx.claims, ctx.claimsErr) + case principalKindMachineIdentity: + return machineIdentityOrgStatusLine(ctx.claims, ctx.claimsErr) + } + return "" } func tokenStatusLine(claims loginTokenClaims, claimsErr error) string { @@ -182,7 +386,7 @@ func tokenStatusLine(claims loginTokenClaims, claimsErr error) string { return "true" } -func orgStatusLine(claims loginTokenClaims, claimsErr error, resolved resolvedLoginOrgNames) string { +func orgStatusLine(claims loginTokenClaims, claimsErr error) string { if claimsErr != nil { log.Debug().Err(claimsErr).Msg("login status: unable to decode token payload") return "unknown (could not parse token)" @@ -190,17 +394,18 @@ func orgStatusLine(claims loginTokenClaims, claimsErr error, resolved resolvedLo if claims.OrganizationID == "" { return "none (token is not scoped to an organization)" } - if resolved.Organization != "" { - return fmt.Sprintf("%s (%s)", resolved.Organization, claims.OrganizationID) - } return claims.OrganizationID } -func subOrgStatusLine(claims loginTokenClaims, resolved resolvedLoginOrgNames) string { - if resolved.SubOrganization != "" { - return fmt.Sprintf("%s (%s)", resolved.SubOrganization, claims.SubOrganizationID) +func machineIdentityOrgStatusLine(claims loginTokenClaims, claimsErr error) string { + if claimsErr != nil { + log.Debug().Err(claimsErr).Msg("login status: unable to decode machine identity token") + return "unknown (could not parse token)" + } + if claims.OrgID == "" { + return "none (token is not scoped to an organization)" } - return claims.SubOrganizationID + return claims.OrgID } func printStatusItem(key, value string) { @@ -208,8 +413,16 @@ func printStatusItem(key, value string) { } type loginTokenClaims struct { + // User session JWT claims OrganizationID string `json:"organizationId"` SubOrganizationID string `json:"subOrganizationId"` + + // Machine identity access token JWT claims + IdentityID string `json:"identityId"` + IdentityName string `json:"identityName"` + AuthMethod string `json:"authMethod"` + OrgID string `json:"orgId"` + jwt.RegisteredClaims } @@ -238,62 +451,9 @@ func formatExpiry(expiresAt time.Time) string { return fmt.Sprintf("in %dm", int(remaining.Minutes())) } -type resolvedLoginOrgNames struct { - Organization string - SubOrganization string -} - -func resolveLoggedInOrgNames(token, orgID, subOrgID string) resolvedLoginOrgNames { - httpClient, err := util.GetRestyClientWithCustomHeaders() - if err != nil { - log.Debug().Err(err).Msg("login status: unable to build http client") - return resolvedLoginOrgNames{} - } - httpClient.SetAuthToken(token) - - var result resolvedLoginOrgNames - subOrgEndpointSucceeded := false - - if subOrgID != "" { - if resp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil { - subOrgEndpointSucceeded = true - for _, org := range resp.Organizations { - if org.ID != orgID { - continue - } - result.Organization = org.Name - for _, sub := range org.SubOrganizations { - if sub.ID == subOrgID { - result.SubOrganization = sub.Name - break - } - } - break - } - } else { - log.Debug().Err(err).Msg("login status: failed to fetch orgs with sub-orgs") - } - } - - if !subOrgEndpointSucceeded { - if resp, err := api.CallGetAllOrganizations(httpClient); err == nil { - for _, org := range resp.Organizations { - if org.ID == orgID { - result.Organization = org.Name - break - } - } - } else { - log.Debug().Err(err).Msg("login status: failed to fetch organizations") - } - } - - return result -} - func renderNotAuthenticated(jsonOutput bool) { if jsonOutput { - if err := writeLoginStatusJSON(loginStatusJSONOutput{Status: statusUnauthenticated}); err != nil { + if err := writeLoginStatusJSON(loginStatusJSONOutput{Sessions: []loginStatusSessionJSON{}}); err != nil { util.HandleError(err, "Unable to encode JSON output") } return @@ -303,5 +463,6 @@ func renderNotAuthenticated(jsonOutput bool) { func init() { loginStatusCmd.Flags().Bool("json", false, "Output the login status as JSON") + loginStatusCmd.Flags().String("token", "", "Inspect this machine identity access token instead of the active session or environment variables") loginCmd.AddCommand(loginStatusCmd) } diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go index 9d95ed8d..807a9213 100644 --- a/packages/cmd/login_status_test.go +++ b/packages/cmd/login_status_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/Infisical/infisical-merge/packages/util" ) func TestFormatExpiry(t *testing.T) { @@ -84,6 +86,34 @@ func TestParseLoginJWTClaims(t *testing.T) { } }) + t.Run("machine identity claims parse", func(t *testing.T) { + exp := time.Now().Add(time.Hour).Unix() + token := makeUnsignedJWT(t, map[string]any{ + "identityId": "id-123", + "identityName": "my-ci-bot", + "authMethod": "universal-auth", + "orgId": "org-1", + "exp": exp, + }) + + claims, err := parseLoginJWTClaims(token) + if err != nil { + t.Fatalf("parseLoginJWTClaims: unexpected error: %v", err) + } + if claims.IdentityID != "id-123" { + t.Errorf("IdentityID = %q, want %q", claims.IdentityID, "id-123") + } + if claims.IdentityName != "my-ci-bot" { + t.Errorf("IdentityName = %q, want %q", claims.IdentityName, "my-ci-bot") + } + if claims.AuthMethod != "universal-auth" { + t.Errorf("AuthMethod = %q, want %q", claims.AuthMethod, "universal-auth") + } + if claims.OrgID != "org-1" { + t.Errorf("OrgID = %q, want %q", claims.OrgID, "org-1") + } + }) + t.Run("token without organization claims still parses", func(t *testing.T) { token := makeUnsignedJWT(t, map[string]any{ "exp": time.Now().Add(time.Hour).Unix(), @@ -113,6 +143,58 @@ func TestParseLoginJWTClaims(t *testing.T) { }) } +func TestDetectMachineIdentityEnvToken(t *testing.T) { + t.Run("no env vars set", func(t *testing.T) { + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "") + t.Setenv(util.INFISICAL_TOKEN_NAME, "") + + if _, _, ok := detectMachineIdentityEnvToken(); ok { + t.Errorf("detectMachineIdentityEnvToken() = ok, want !ok when no env vars set") + } + }) + + t.Run("universal-auth access token takes precedence", func(t *testing.T) { + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "ua-token") + t.Setenv(util.INFISICAL_TOKEN_NAME, "should-be-ignored") + + token, source, ok := detectMachineIdentityEnvToken() + if !ok { + t.Fatalf("detectMachineIdentityEnvToken() = !ok, want ok") + } + if token != "ua-token" { + t.Errorf("token = %q, want %q", token, "ua-token") + } + if !strings.Contains(source, util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME) { + t.Errorf("source = %q, want it to contain %q", source, util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME) + } + }) + + t.Run("falls back to INFISICAL_TOKEN", func(t *testing.T) { + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "") + t.Setenv(util.INFISICAL_TOKEN_NAME, "st.abc.def") + + token, source, ok := detectMachineIdentityEnvToken() + if !ok { + t.Fatalf("detectMachineIdentityEnvToken() = !ok, want ok") + } + if token != "st.abc.def" { + t.Errorf("token = %q, want %q", token, "st.abc.def") + } + if !strings.Contains(source, util.INFISICAL_TOKEN_NAME) { + t.Errorf("source = %q, want it to contain %q", source, util.INFISICAL_TOKEN_NAME) + } + }) + + t.Run("whitespace-only env value is ignored", func(t *testing.T) { + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, " ") + t.Setenv(util.INFISICAL_TOKEN_NAME, "") + + if _, _, ok := detectMachineIdentityEnvToken(); ok { + t.Errorf("detectMachineIdentityEnvToken() = ok for whitespace-only value, want !ok") + } + }) +} + func makeUnsignedJWT(t *testing.T, claims map[string]any) string { t.Helper() headerJSON, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"}) From 10da9d15ea18ef0d273e59e88f374996a4244540 Mon Sep 17 00:00:00 2001 From: Thiago Araujo da Silva Date: Thu, 21 May 2026 12:05:27 -0300 Subject: [PATCH 4/7] validate identities on backend --- packages/cmd/login_status.go | 247 +++++++++++++++++++++++------- packages/cmd/login_status_test.go | 143 +++++++++++++++++ 2 files changed, 334 insertions(+), 56 deletions(-) diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index 98522fb4..ed58e6cc 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -4,10 +4,12 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "os" "strings" "time" + "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" @@ -19,10 +21,18 @@ import ( const ( statusAuthenticated = "authenticated" statusExpired = "expired" + statusRejected = "rejected" principalKindUser = "user" principalKindMachineIdentity = "machine-identity" principalKindServiceToken = "service-token" + + verifyStateVerified = "verified" + verifyStateRejected = "rejected" + verifyStateUnknown = "unknown" + verifyStateSkipped = "skipped" + + verifyTimeout = 10 * time.Second ) var ( @@ -51,19 +61,18 @@ const ( func runLoginStatus(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") - // --token is a one-off inspection: we only render that token's status and - // skip the user-session check entirely. Mirrors how `--token` overrides - // session-based auth in every other CLI command. We require --domain - // alongside --token so the displayed domain unambiguously matches the - // instance the inspected token belongs to (rather than silently defaulting). + // --token is a one-off inspection if flagToken, _ := cmd.Flags().GetString("token"); strings.TrimSpace(flagToken) != "" { if !cmd.Flags().Changed("domain") { - util.PrintErrorMessageAndExit("--token requires --domain to be set so the status reflects the correct Infisical instance") + if _, envSet := os.LookupEnv("INFISICAL_API_URL"); !envSet { + util.PrintErrorMessageAndExit("--token requires --domain (or INFISICAL_API_URL) to be set so the status reflects the correct Infisical instance") + } } ctx := buildMachineIdentityContext(strings.TrimSpace(flagToken), "--token flag", strings.TrimSuffix(config.INFISICAL_URL, "/api")) + ctx.verification = verifySession(ctx) emitLoginStatus([]loginStatusContext{ctx}, jsonOutput) - if isContextExpired(ctx) { + if shouldExitWithError(ctx) { os.Exit(1) } return @@ -71,9 +80,6 @@ func runLoginStatus(cmd *cobra.Command, args []string) { var sessions []loginStatusContext - // Capture the API URL BEFORE GetCurrentLoggedInUserDetails(true) overwrites - // it with the logged-in user's domain — this is the domain a machine - // identity token would actually authenticate against. machineIdentityDomain := strings.TrimSuffix(config.INFISICAL_URL, "/api") if token, source, ok := detectMachineIdentityEnvToken(); ok { @@ -94,10 +100,14 @@ func runLoginStatus(cmd *cobra.Command, args []string) { os.Exit(1) } + for i := range sessions { + sessions[i].verification = verifySession(sessions[i]) + } + emitLoginStatus(sessions, jsonOutput) for _, s := range sessions { - if isContextExpired(s) { + if shouldExitWithError(s) { os.Exit(1) } } @@ -109,6 +119,7 @@ func buildUserContext(details util.LoggedInUserDetails, domain string) loginStat kind: principalKindUser, domain: domain, loggedInUser: details, + rawToken: details.UserCredentials.JTWToken, claims: claims, claimsErr: claimsErr, } @@ -120,6 +131,7 @@ func buildMachineIdentityContext(token, source, domain string) loginStatusContex return loginStatusContext{ kind: principalKindServiceToken, domain: domain, + rawToken: token, tokenSource: source, } } @@ -130,6 +142,7 @@ func buildMachineIdentityContext(token, source, domain string) loginStatusContex return loginStatusContext{ kind: principalKindMachineIdentity, domain: domain, + rawToken: token, tokenSource: source, claims: claims, claimsErr: claimsErr, @@ -147,6 +160,21 @@ func isContextExpired(ctx loginStatusContext) bool { return false } +func contextStatus(ctx loginStatusContext) string { + if isContextExpired(ctx) { + return statusExpired + } + if ctx.verification.state == verifyStateRejected { + return statusRejected + } + return statusAuthenticated +} + +func shouldExitWithError(ctx loginStatusContext) bool { + s := contextStatus(ctx) + return s == statusExpired || s == statusRejected +} + func emitLoginStatus(sessions []loginStatusContext, jsonOutput bool) { if jsonOutput { if err := writeLoginStatusJSON(buildJSONOutput(sessions)); err != nil { @@ -183,10 +211,17 @@ type loginStatusContext struct { kind string domain string loggedInUser util.LoggedInUserDetails // populated when kind == principalKindUser + rawToken string // bearer credential used for backend verification tokenSource string // populated for machine-identity / service-token expired bool // populated for machine-identity claims loginTokenClaims claimsErr error + verification verificationResult +} + +type verificationResult struct { + state string + reason string } type loginStatusJSONOutput struct { @@ -194,31 +229,33 @@ type loginStatusJSONOutput struct { } type loginStatusSessionJSON struct { - PrincipalType string `json:"principalType,omitempty"` - Status string `json:"status,omitempty"` - Domain string `json:"domain,omitempty"` - Email string `json:"email,omitempty"` - AuthMethod string `json:"authMethod,omitempty"` - TokenSource string `json:"tokenSource,omitempty"` - Identity *loginStatusIdentityJSON `json:"identity,omitempty"` - Token *loginStatusTokenJSON `json:"token,omitempty"` - Organization *loginStatusOrgJSON `json:"organization,omitempty"` - SubOrganization *loginStatusOrgJSON `json:"subOrganization,omitempty"` + PrincipalType string `json:"principalType,omitempty"` + Status string `json:"status,omitempty"` + Domain string `json:"domain,omitempty"` + Email string `json:"email,omitempty"` + AuthMethod string `json:"authMethod,omitempty"` + TokenSource string `json:"tokenSource,omitempty"` + Identity *loginStatusIdentityJSON `json:"identity,omitempty"` + Token *loginStatusTokenJSON `json:"token,omitempty"` + Organization *string `json:"organization,omitempty"` + SubOrganization *string `json:"subOrganization,omitempty"` + Verification *loginStatusVerificationJSON `json:"verification,omitempty"` } type loginStatusTokenJSON struct { Exp int64 `json:"exp,omitempty"` } -type loginStatusOrgJSON struct { - ID string `json:"id,omitempty"` -} - type loginStatusIdentityJSON struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` } +type loginStatusVerificationJSON struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` +} + func buildJSONOutput(sessions []loginStatusContext) loginStatusJSONOutput { out := loginStatusJSONOutput{Sessions: make([]loginStatusSessionJSON, 0, len(sessions))} for _, ctx := range sessions { @@ -233,15 +270,13 @@ func buildSessionJSON(ctx loginStatusContext) loginStatusSessionJSON { Domain: ctx.domain, AuthMethod: authMethodLabel(ctx), TokenSource: tokenSourceLabel(ctx), - Status: statusAuthenticated, + Status: contextStatus(ctx), + Verification: verificationJSON(ctx.verification), } switch ctx.kind { case principalKindUser: session.Email = ctx.loggedInUser.UserCredentials.Email - if ctx.loggedInUser.LoginExpired { - session.Status = statusExpired - } if ctx.claimsErr != nil { return session } @@ -249,16 +284,13 @@ func buildSessionJSON(ctx loginStatusContext) loginStatusSessionJSON { session.Token = &loginStatusTokenJSON{Exp: ctx.claims.ExpiresAt.Unix()} } if ctx.claims.OrganizationID != "" { - session.Organization = &loginStatusOrgJSON{ID: ctx.claims.OrganizationID} + session.Organization = &ctx.claims.OrganizationID } if ctx.claims.SubOrganizationID != "" { - session.SubOrganization = &loginStatusOrgJSON{ID: ctx.claims.SubOrganizationID} + session.SubOrganization = &ctx.claims.SubOrganizationID } case principalKindMachineIdentity: - if ctx.expired { - session.Status = statusExpired - } if ctx.claimsErr != nil { return session } @@ -272,13 +304,20 @@ func buildSessionJSON(ctx loginStatusContext) loginStatusSessionJSON { session.Token = &loginStatusTokenJSON{Exp: ctx.claims.ExpiresAt.Unix()} } if ctx.claims.OrgID != "" { - session.Organization = &loginStatusOrgJSON{ID: ctx.claims.OrgID} + session.Organization = &ctx.claims.OrgID } } return session } +func verificationJSON(v verificationResult) *loginStatusVerificationJSON { + if v.state == "" { + return nil + } + return &loginStatusVerificationJSON{State: v.state, Reason: v.reason} +} + func writeLoginStatusJSON(out loginStatusJSONOutput) error { data, err := json.MarshalIndent(out, "", " ") if err != nil { @@ -290,26 +329,23 @@ func writeLoginStatusJSON(out loginStatusJSONOutput) error { func renderHuman(ctx loginStatusContext) { label := principalLabel(ctx) - expired := isContextExpired(ctx) + status := contextStatus(ctx) + + if status == statusAuthenticated { + util.PrintfStdout("%s Authenticated as %s\n", greenStyle.Sprint("✓"), boldStyle.Sprint(label)) + } else { + util.PrintfStdout("%s Failed to authenticate as %s\n", redStyle.Sprint("x"), boldStyle.Sprint(label)) + } - if expired { - util.PrintfStdout("%s Authenticated as %s (expired)\n", - redStyle.Sprint("x"), boldStyle.Sprint(label)) - if ctx.domain != "" { - printStatusItem("Domain", ctx.domain) + if status != statusAuthenticated { + if status == statusExpired { + printStatusItem("Reason", "session expired") } - switch ctx.kind { - case principalKindUser: - util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") - case principalKindMachineIdentity: - util.PrintlnStdout(" - Refresh your machine identity access token and re-export it.") + if line := verificationLine(ctx.verification); line != "" { + printStatusItem("Reason", line) } - return } - util.PrintfStdout("%s Authenticated as %s\n", - greenStyle.Sprint("✓"), boldStyle.Sprint(label)) - if ctx.domain != "" { printStatusItem("Domain", ctx.domain) } @@ -323,7 +359,7 @@ func renderHuman(ctx loginStatusContext) { printStatusItem("Token source", source) } if ctx.kind != principalKindServiceToken { - printStatusItem("Token", tokenStatusLine(ctx.claims, ctx.claimsErr)) + printStatusItem("Token expiration", tokenStatusLine(ctx.claims, ctx.claimsErr)) } if org := organizationLineFor(ctx); org != "" { printStatusItem("Organization", org) @@ -331,6 +367,40 @@ func renderHuman(ctx loginStatusContext) { if ctx.kind == principalKindUser && ctx.claimsErr == nil && ctx.claims.SubOrganizationID != "" { printStatusItem("Sub-organization", ctx.claims.SubOrganizationID) } + + if status != statusAuthenticated { + switch ctx.kind { + case principalKindUser: + util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") + case principalKindMachineIdentity: + util.PrintlnStdout(" - Run `infisical login` to re-authenticate and re-export your token.") + case principalKindServiceToken: + util.PrintlnStdout(" - Verify the service token has not been revoked or expired in your Infisical instance.") + } + } +} + +func verificationLine(v verificationResult) string { + switch v.state { + case verifyStateVerified: + return "verified" + case verifyStateRejected: + if v.reason != "" { + return fmt.Sprintf("rejected (%s)", v.reason) + } + return "rejected" + case verifyStateUnknown: + if v.reason != "" { + return fmt.Sprintf("unreachable (%s)", v.reason) + } + return "unreachable" + case verifyStateSkipped: + if v.reason != "" { + return fmt.Sprintf("skipped (%s)", v.reason) + } + return "skipped" + } + return "" } func principalLabel(ctx loginStatusContext) string { @@ -381,9 +451,9 @@ func organizationLineFor(ctx loginStatusContext) string { func tokenStatusLine(claims loginTokenClaims, claimsErr error) string { if claimsErr == nil && claims.ExpiresAt != nil { - return fmt.Sprintf("true (expires %s)", formatExpiry(claims.ExpiresAt.Time)) + return fmt.Sprintf("%s", formatExpiry(claims.ExpiresAt.Time)) } - return "true" + return "undefined" } func orgStatusLine(claims loginTokenClaims, claimsErr error) string { @@ -443,12 +513,12 @@ func formatExpiry(expiresAt time.Time) string { hours := int(remaining.Hours()) if hours >= 24 { days := hours / 24 - return fmt.Sprintf("in %dd %dh", days, hours%24) + return fmt.Sprintf("%dd %dh", days, hours%24) } if hours > 0 { - return fmt.Sprintf("in %dh %dm", hours, int(remaining.Minutes())%60) + return fmt.Sprintf("%dh %dm", hours, int(remaining.Minutes())%60) } - return fmt.Sprintf("in %dm", int(remaining.Minutes())) + return fmt.Sprintf("%dm", int(remaining.Minutes())) } func renderNotAuthenticated(jsonOutput bool) { @@ -461,6 +531,71 @@ func renderNotAuthenticated(jsonOutput bool) { util.PrintfStdout("%s You are not authenticated.\nRun `infisical login` to log in.\n", redStyle.Sprint("x")) } +// verifySession asks the backend whether the credential associated with the +// context is still valid. Local-only signals (missing token, +// already-expired-by-clock) short-circuit the network call. +func verifySession(ctx loginStatusContext) verificationResult { + if ctx.rawToken == "" { + return verificationResult{state: verifyStateSkipped, reason: "no token available"} + } + if isContextExpired(ctx) { + return verificationResult{state: verifyStateSkipped, reason: "locally expired"} + } + switch ctx.kind { + case principalKindServiceToken: + return performVerification(ctx.rawToken, ctx.domain, "/api/v2/service-token", http.MethodGet) + case principalKindMachineIdentity: + return performVerification(ctx.rawToken, ctx.domain, "/api/v1/identities/details", http.MethodGet) + } + return performVerification(ctx.rawToken, ctx.domain, "/api/v1/auth/checkAuth", http.MethodPost) +} + +func performVerification(token, domain, path, method string) verificationResult { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return verificationResult{state: verifyStateUnknown, reason: err.Error()} + } + httpClient. + SetAuthToken(token). + SetHeader("Accept", "application/json"). + SetTimeout(verifyTimeout) + + url := strings.TrimRight(domain, "/") + path + req := httpClient.R().SetHeader("User-Agent", api.USER_AGENT) + + var ( + statusCode int + callErr error + ) + switch method { + case http.MethodGet: + resp, e := req.Get(url) + callErr = e + if resp != nil { + statusCode = resp.StatusCode() + } + default: + resp, e := req.Post(url) + callErr = e + if resp != nil { + statusCode = resp.StatusCode() + } + } + + if callErr != nil { + log.Debug().Err(callErr).Str("url", url).Msg("login status: backend verification call failed") + return verificationResult{state: verifyStateUnknown, reason: "network error"} + } + switch { + case statusCode >= 200 && statusCode < 300: + return verificationResult{state: verifyStateVerified} + case statusCode == http.StatusUnauthorized, statusCode == http.StatusForbidden: + return verificationResult{state: verifyStateRejected, reason: fmt.Sprintf("HTTP %d", statusCode)} + default: + return verificationResult{state: verifyStateUnknown, reason: fmt.Sprintf("HTTP %d", statusCode)} + } +} + func init() { loginStatusCmd.Flags().Bool("json", false, "Output the login status as JSON") loginStatusCmd.Flags().String("token", "", "Inspect this machine identity access token instead of the active session or environment variables") diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go index 807a9213..91af1b5e 100644 --- a/packages/cmd/login_status_test.go +++ b/packages/cmd/login_status_test.go @@ -3,7 +3,10 @@ package cmd import ( "encoding/base64" "encoding/json" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -195,6 +198,146 @@ func TestDetectMachineIdentityEnvToken(t *testing.T) { }) } +func TestContextStatus(t *testing.T) { + cases := []struct { + name string + ctx loginStatusContext + want string + }{ + { + name: "user not expired, no verification", + ctx: loginStatusContext{kind: principalKindUser}, + want: statusAuthenticated, + }, + { + name: "locally expired user trumps backend", + ctx: loginStatusContext{ + kind: principalKindUser, + loggedInUser: util.LoggedInUserDetails{LoginExpired: true}, + verification: verificationResult{state: verifyStateVerified}, + }, + want: statusExpired, + }, + { + name: "machine identity locally expired", + ctx: loginStatusContext{kind: principalKindMachineIdentity, expired: true}, + want: statusExpired, + }, + { + name: "backend rejected downgrades to rejected", + ctx: loginStatusContext{ + kind: principalKindUser, + verification: verificationResult{state: verifyStateRejected}, + }, + want: statusRejected, + }, + { + name: "unknown verification stays authenticated", + ctx: loginStatusContext{ + kind: principalKindUser, + verification: verificationResult{state: verifyStateUnknown}, + }, + want: statusAuthenticated, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := contextStatus(tc.ctx); got != tc.want { + t.Errorf("contextStatus = %q, want %q", got, tc.want) + } + }) + } +} + +func TestVerifySession_NoToken(t *testing.T) { + got := verifySession(loginStatusContext{kind: principalKindUser}) + if got.state != verifyStateSkipped { + t.Errorf("verifySession no-token = %q, want %q", got.state, verifyStateSkipped) + } +} + +func TestVerifySession_LocallyExpiredSkipsCall(t *testing.T) { + var hit int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hit, 1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + ctx := loginStatusContext{ + kind: principalKindUser, + rawToken: "tok", + domain: server.URL, + loggedInUser: util.LoggedInUserDetails{LoginExpired: true}, + } + got := verifySession(ctx) + if got.state != verifyStateSkipped { + t.Errorf("verifySession locally-expired = %q, want %q", got.state, verifyStateSkipped) + } + if atomic.LoadInt32(&hit) != 0 { + t.Errorf("verifySession hit server %d times for locally-expired ctx, want 0", hit) + } +} + +func TestPerformVerification(t *testing.T) { + cases := []struct { + name string + statusCode int + path string + method string + want string + }{ + {name: "user 200 verified", statusCode: http.StatusOK, path: "/api/v1/auth/checkAuth", method: http.MethodPost, want: verifyStateVerified}, + {name: "user 401 rejected", statusCode: http.StatusUnauthorized, path: "/api/v1/auth/checkAuth", method: http.MethodPost, want: verifyStateRejected}, + {name: "user 403 rejected", statusCode: http.StatusForbidden, path: "/api/v1/auth/checkAuth", method: http.MethodPost, want: verifyStateRejected}, + {name: "user 500 unknown", statusCode: http.StatusInternalServerError, path: "/api/v1/auth/checkAuth", method: http.MethodPost, want: verifyStateUnknown}, + {name: "machine identity 200 verified", statusCode: http.StatusOK, path: "/api/v1/identities/details", method: http.MethodGet, want: verifyStateVerified}, + {name: "machine identity 401 rejected", statusCode: http.StatusUnauthorized, path: "/api/v1/identities/details", method: http.MethodGet, want: verifyStateRejected}, + {name: "machine identity 403 rejected", statusCode: http.StatusForbidden, path: "/api/v1/identities/details", method: http.MethodGet, want: verifyStateRejected}, + {name: "service token 200 verified", statusCode: http.StatusOK, path: "/api/v2/service-token", method: http.MethodGet, want: verifyStateVerified}, + {name: "service token 401 rejected", statusCode: http.StatusUnauthorized, path: "/api/v2/service-token", method: http.MethodGet, want: verifyStateRejected}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var gotPath, gotMethod, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(tc.statusCode) + })) + t.Cleanup(server.Close) + + got := performVerification("tok-abc", server.URL, tc.path, tc.method) + if got.state != tc.want { + t.Errorf("performVerification state = %q, want %q (detail=%q)", got.state, tc.want, got.reason) + } + if gotPath != tc.path { + t.Errorf("server saw path %q, want %q", gotPath, tc.path) + } + if gotMethod != tc.method { + t.Errorf("server saw method %q, want %q", gotMethod, tc.method) + } + if gotAuth != "Bearer tok-abc" { + t.Errorf("server saw Authorization %q, want %q", gotAuth, "Bearer tok-abc") + } + }) + } +} + +func TestPerformVerification_NetworkError(t *testing.T) { + // Close the server immediately so dialing fails. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + server.Close() + + got := performVerification("tok", server.URL, "/api/v1/auth/checkAuth", http.MethodPost) + if got.state != verifyStateUnknown { + t.Errorf("performVerification network-error state = %q, want %q", got.state, verifyStateUnknown) + } +} + func makeUnsignedJWT(t *testing.T, claims map[string]any) string { t.Helper() headerJSON, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"}) From ad4777420ef438c496453e88a95d35b5b470b94b Mon Sep 17 00:00:00 2001 From: Thiago Araujo da Silva Date: Thu, 21 May 2026 12:51:23 -0300 Subject: [PATCH 5/7] refactor to improve readability and fix tests --- packages/cmd/login_status.go | 98 ++++++++++++------------------- packages/cmd/login_status_test.go | 20 +++++-- 2 files changed, 53 insertions(+), 65 deletions(-) diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index ed58e6cc..ec237a2b 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -32,6 +32,11 @@ const ( verifyStateUnknown = "unknown" verifyStateSkipped = "skipped" + authMethodLoginLabel = "login" + authMethodServiceTokenLabel = "service-token" + + tokenSourceLoginSession = "infisical login (keyring)" + verifyTimeout = 10 * time.Second ) @@ -51,25 +56,24 @@ var loginStatusCmd = &cobra.Command{ Run: runLoginStatus, } -const ( - authMethodLoginLabel = "login" - authMethodServiceTokenLabel = "service-token" - - tokenSourceLoginSession = "infisical login (keyring)" -) - func runLoginStatus(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") - // --token is a one-off inspection - if flagToken, _ := cmd.Flags().GetString("token"); strings.TrimSpace(flagToken) != "" { + // Machine identity / service token domain comes from the env/flag-driven + // config.INFISICAL_URL. The user-session domain is whatever the user's + // saved config points at, which GetCurrentLoggedInUserDetails(true) writes + // back into config.INFISICAL_URL — so capture the env value first. + envDomain := strings.TrimSuffix(config.INFISICAL_URL, "/api") + + flagToken, _ := cmd.Flags().GetString("token") + flagToken = strings.TrimSpace(flagToken) + if flagToken != "" { if !cmd.Flags().Changed("domain") { if _, envSet := os.LookupEnv("INFISICAL_API_URL"); !envSet { util.PrintErrorMessageAndExit("--token requires --domain (or INFISICAL_API_URL) to be set so the status reflects the correct Infisical instance") } } - ctx := buildMachineIdentityContext(strings.TrimSpace(flagToken), "--token flag", - strings.TrimSuffix(config.INFISICAL_URL, "/api")) + ctx := buildMachineIdentityContext(flagToken, "--token flag", envDomain) ctx.verification = verifySession(ctx) emitLoginStatus([]loginStatusContext{ctx}, jsonOutput) if shouldExitWithError(ctx) { @@ -80,10 +84,8 @@ func runLoginStatus(cmd *cobra.Command, args []string) { var sessions []loginStatusContext - machineIdentityDomain := strings.TrimSuffix(config.INFISICAL_URL, "/api") - if token, source, ok := detectMachineIdentityEnvToken(); ok { - sessions = append(sessions, buildMachineIdentityContext(token, source, machineIdentityDomain)) + sessions = append(sessions, buildMachineIdentityContext(token, source, envDomain)) } loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) @@ -126,7 +128,6 @@ func buildUserContext(details util.LoggedInUserDetails, domain string) loginStat } func buildMachineIdentityContext(token, source, domain string) loginStatusContext { - // Service tokens (`st..` format) are opaque — no JWT to decode. if strings.HasPrefix(token, "st.") { return loginStatusContext{ kind: principalKindServiceToken, @@ -137,7 +138,6 @@ func buildMachineIdentityContext(token, source, domain string) loginStatusContex } claims, claimsErr := parseLoginJWTClaims(token) - expired := claimsErr == nil && claims.ExpiresAt != nil && !claims.ExpiresAt.After(time.Now()) return loginStatusContext{ kind: principalKindMachineIdentity, @@ -146,7 +146,6 @@ func buildMachineIdentityContext(token, source, domain string) loginStatusContex tokenSource: source, claims: claims, claimsErr: claimsErr, - expired: expired, } } @@ -155,7 +154,7 @@ func isContextExpired(ctx loginStatusContext) bool { case principalKindUser: return ctx.loggedInUser.LoginExpired case principalKindMachineIdentity: - return ctx.expired + return ctx.claimsErr == nil && ctx.claims.ExpiresAt != nil && !ctx.claims.ExpiresAt.After(time.Now()) } return false } @@ -190,10 +189,6 @@ func emitLoginStatus(sessions []loginStatusContext, jsonOutput bool) { } } -// detectMachineIdentityEnvToken returns the machine-identity / service-token -// credential exported in the environment, mirroring the precedence used by -// util.GetInfisicalToken. The legacy `TOKEN` gateway variable is intentionally -// omitted here because its name collides with too many unrelated tools. func detectMachineIdentityEnvToken() (token, source string, ok bool) { candidates := []string{ util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, @@ -213,7 +208,6 @@ type loginStatusContext struct { loggedInUser util.LoggedInUserDetails // populated when kind == principalKindUser rawToken string // bearer credential used for backend verification tokenSource string // populated for machine-identity / service-token - expired bool // populated for machine-identity claims loginTokenClaims claimsErr error verification verificationResult @@ -381,26 +375,20 @@ func renderHuman(ctx loginStatusContext) { } func verificationLine(v verificationResult) string { - switch v.state { - case verifyStateVerified: - return "verified" - case verifyStateRejected: - if v.reason != "" { - return fmt.Sprintf("rejected (%s)", v.reason) - } - return "rejected" - case verifyStateUnknown: - if v.reason != "" { - return fmt.Sprintf("unreachable (%s)", v.reason) - } - return "unreachable" - case verifyStateSkipped: - if v.reason != "" { - return fmt.Sprintf("skipped (%s)", v.reason) - } - return "skipped" + labels := map[string]string{ + verifyStateVerified: "verified", + verifyStateRejected: "rejected", + verifyStateUnknown: "unreachable", + verifyStateSkipped: "skipped", } - return "" + label, ok := labels[v.state] + if !ok { + return "" + } + if v.reason != "" && v.state != verifyStateVerified { + return fmt.Sprintf("%s (%s)", label, v.reason) + } + return label } func principalLabel(ctx loginStatusContext) string { @@ -442,40 +430,32 @@ func tokenSourceLabel(ctx loginStatusContext) string { func organizationLineFor(ctx loginStatusContext) string { switch ctx.kind { case principalKindUser: - return orgStatusLine(ctx.claims, ctx.claimsErr) + return orgStatusLine(ctx.claims.OrganizationID, ctx.claimsErr) case principalKindMachineIdentity: - return machineIdentityOrgStatusLine(ctx.claims, ctx.claimsErr) + return orgStatusLine(ctx.claims.OrgID, ctx.claimsErr) } return "" } func tokenStatusLine(claims loginTokenClaims, claimsErr error) string { - if claimsErr == nil && claims.ExpiresAt != nil { - return fmt.Sprintf("%s", formatExpiry(claims.ExpiresAt.Time)) - } - return "undefined" -} - -func orgStatusLine(claims loginTokenClaims, claimsErr error) string { if claimsErr != nil { - log.Debug().Err(claimsErr).Msg("login status: unable to decode token payload") return "unknown (could not parse token)" } - if claims.OrganizationID == "" { - return "none (token is not scoped to an organization)" + if claims.ExpiresAt == nil { + return "no expiration set" } - return claims.OrganizationID + return formatExpiry(claims.ExpiresAt.Time) } -func machineIdentityOrgStatusLine(claims loginTokenClaims, claimsErr error) string { +func orgStatusLine(orgID string, claimsErr error) string { if claimsErr != nil { - log.Debug().Err(claimsErr).Msg("login status: unable to decode machine identity token") + log.Debug().Err(claimsErr).Msg("login status: unable to decode token payload") return "unknown (could not parse token)" } - if claims.OrgID == "" { + if orgID == "" { return "none (token is not scoped to an organization)" } - return claims.OrgID + return orgID } func printStatusItem(key, value string) { diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go index 91af1b5e..2ba90c6b 100644 --- a/packages/cmd/login_status_test.go +++ b/packages/cmd/login_status_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Infisical/infisical-merge/packages/util" + jwt "github.com/golang-jwt/jwt/v5" ) func TestFormatExpiry(t *testing.T) { @@ -34,18 +35,18 @@ func TestFormatExpiry(t *testing.T) { }, { name: "minutes only", - expiresAt: now.Add(15 * time.Minute), - wantPrefix: "in ", + expiresAt: now.Add(15*time.Minute + 30*time.Second), + wantPrefix: "15m", }, { name: "hours and minutes", expiresAt: now.Add(5*time.Hour + 30*time.Minute), - wantPrefix: "in 5h ", + wantPrefix: "5h ", }, { name: "days and hours", - expiresAt: now.Add(50 * time.Hour), - wantPrefix: "in 2d ", + expiresAt: now.Add(50*time.Hour + 30*time.Second), + wantPrefix: "2d ", }, } @@ -220,7 +221,14 @@ func TestContextStatus(t *testing.T) { }, { name: "machine identity locally expired", - ctx: loginStatusContext{kind: principalKindMachineIdentity, expired: true}, + ctx: loginStatusContext{ + kind: principalKindMachineIdentity, + claims: loginTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)), + }, + }, + }, want: statusExpired, }, { From 4917f68a96c7ce82036cdd432bd021b9e8907892 Mon Sep 17 00:00:00 2001 From: Thiago Araujo da Silva Date: Thu, 21 May 2026 16:54:43 -0300 Subject: [PATCH 6/7] get token type from claim --- packages/cmd/login_status.go | 122 ++++++++++++++++++++-------- packages/cmd/login_status_test.go | 131 ++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 35 deletions(-) diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index ec237a2b..db142551 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -32,12 +32,12 @@ const ( verifyStateUnknown = "unknown" verifyStateSkipped = "skipped" - authMethodLoginLabel = "login" - authMethodServiceTokenLabel = "service-token" - tokenSourceLoginSession = "infisical login (keyring)" verifyTimeout = 10 * time.Second + + authTokenTypeAccess = "accessToken" + authTokenTypeIdentityAccess = "identityAccessToken" ) var ( @@ -73,7 +73,10 @@ func runLoginStatus(cmd *cobra.Command, args []string) { util.PrintErrorMessageAndExit("--token requires --domain (or INFISICAL_API_URL) to be set so the status reflects the correct Infisical instance") } } - ctx := buildMachineIdentityContext(flagToken, "--token flag", envDomain) + ctx, err := buildContextFromToken(flagToken, "--token flag", envDomain) + if err != nil { + util.PrintErrorMessageAndExit(err.Error()) + } ctx.verification = verifySession(ctx) emitLoginStatus([]loginStatusContext{ctx}, jsonOutput) if shouldExitWithError(ctx) { @@ -85,7 +88,11 @@ func runLoginStatus(cmd *cobra.Command, args []string) { var sessions []loginStatusContext if token, source, ok := detectMachineIdentityEnvToken(); ok { - sessions = append(sessions, buildMachineIdentityContext(token, source, envDomain)) + ctx, err := buildContextFromToken(token, source, envDomain) + if err != nil { + util.PrintErrorMessageAndExit(err.Error()) + } + sessions = append(sessions, ctx) } loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) @@ -127,36 +134,71 @@ func buildUserContext(details util.LoggedInUserDetails, domain string) loginStat } } -func buildMachineIdentityContext(token, source, domain string) loginStatusContext { +// classifyToken determines whether a raw credential is a service token, a user +// session JWT, or a machine identity access token JWT. Service tokens are +// recognized by their "st." prefix; JWTs are dispatched on the authTokenType +// claim the backend stamps into every token it signs. For very old JWTs that +// pre-date that claim, falls back to looking for identityId / userId so we +// preserve back-compat without misclassifying a user JWT as a machine identity. +func classifyToken(token string) (string, loginTokenClaims, error) { if strings.HasPrefix(token, "st.") { - return loginStatusContext{ - kind: principalKindServiceToken, - domain: domain, - rawToken: token, - tokenSource: source, + return principalKindServiceToken, loginTokenClaims{}, nil + } + + claims, err := parseLoginJWTClaims(token) + if err != nil { + return "", claims, err + } + + switch claims.AuthTokenType { + case authTokenTypeIdentityAccess: + return principalKindMachineIdentity, claims, nil + case authTokenTypeAccess: + return principalKindUser, claims, nil + case "": + // Legacy tokens issued before authTokenType existed. + if claims.IdentityID != "" { + return principalKindMachineIdentity, claims, nil + } + if claims.UserID != "" { + return principalKindUser, claims, nil } + return principalKindMachineIdentity, claims, nil + default: + return "", claims, fmt.Errorf("unsupported token type %q (CLI only accepts user access tokens and machine identity access tokens)", claims.AuthTokenType) } +} - claims, claimsErr := parseLoginJWTClaims(token) +// buildContextFromToken constructs a status context for any externally-supplied +// token (--token flag or environment variable). The principal kind is derived +// from the token itself rather than from where it came from. +func buildContextFromToken(token, source, domain string) (loginStatusContext, error) { + kind, claims, classifyErr := classifyToken(token) + if classifyErr != nil && kind == "" { + return loginStatusContext{}, classifyErr + } - return loginStatusContext{ - kind: principalKindMachineIdentity, + ctx := loginStatusContext{ + kind: kind, domain: domain, rawToken: token, tokenSource: source, - claims: claims, - claimsErr: claimsErr, } + if kind != principalKindServiceToken { + ctx.claims = claims + ctx.claimsErr = classifyErr + } + return ctx, nil } func isContextExpired(ctx loginStatusContext) bool { - switch ctx.kind { - case principalKindUser: - return ctx.loggedInUser.LoginExpired - case principalKindMachineIdentity: - return ctx.claimsErr == nil && ctx.claims.ExpiresAt != nil && !ctx.claims.ExpiresAt.After(time.Now()) + if ctx.kind == principalKindUser && ctx.loggedInUser.LoginExpired { + return true + } + if ctx.kind == principalKindServiceToken { + return false } - return false + return ctx.claimsErr == nil && ctx.claims.ExpiresAt != nil && !ctx.claims.ExpiresAt.After(time.Now()) } func contextStatus(ctx loginStatusContext) string { @@ -227,6 +269,7 @@ type loginStatusSessionJSON struct { Status string `json:"status,omitempty"` Domain string `json:"domain,omitempty"` Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` AuthMethod string `json:"authMethod,omitempty"` TokenSource string `json:"tokenSource,omitempty"` Identity *loginStatusIdentityJSON `json:"identity,omitempty"` @@ -274,6 +317,9 @@ func buildSessionJSON(ctx loginStatusContext) loginStatusSessionJSON { if ctx.claimsErr != nil { return session } + if ctx.claims.UserID != "" { + session.UserID = ctx.claims.UserID + } if ctx.claims.ExpiresAt != nil { session.Token = &loginStatusTokenJSON{Exp: ctx.claims.ExpiresAt.Unix()} } @@ -355,6 +401,9 @@ func renderHuman(ctx loginStatusContext) { if ctx.kind != principalKindServiceToken { printStatusItem("Token expiration", tokenStatusLine(ctx.claims, ctx.claimsErr)) } + if ctx.kind == principalKindUser && ctx.claimsErr == nil && ctx.claims.UserID != "" { + printStatusItem("User ID", ctx.claims.UserID) + } if org := organizationLineFor(ctx); org != "" { printStatusItem("Organization", org) } @@ -367,7 +416,7 @@ func renderHuman(ctx loginStatusContext) { case principalKindUser: util.PrintlnStdout(" - Run `infisical login` to re-authenticate.") case principalKindMachineIdentity: - util.PrintlnStdout(" - Run `infisical login` to re-authenticate and re-export your token.") + util.PrintlnStdout(" - Verify the domain being used or run `infisical login` to re-authenticate and re-export your token.") case principalKindServiceToken: util.PrintlnStdout(" - Verify the service token has not been revoked or expired in your Infisical instance.") } @@ -394,7 +443,10 @@ func verificationLine(v verificationResult) string { func principalLabel(ctx loginStatusContext) string { switch ctx.kind { case principalKindUser: - return ctx.loggedInUser.UserCredentials.Email + if email := ctx.loggedInUser.UserCredentials.Email; email != "" { + return email + } + return "user" case principalKindMachineIdentity: if ctx.claimsErr == nil && ctx.claims.IdentityName != "" { return ctx.claims.IdentityName @@ -407,24 +459,20 @@ func principalLabel(ctx loginStatusContext) string { } func authMethodLabel(ctx loginStatusContext) string { - switch ctx.kind { - case principalKindUser: - return authMethodLoginLabel - case principalKindMachineIdentity: - if ctx.claimsErr == nil { - return ctx.claims.AuthMethod - } - case principalKindServiceToken: - return authMethodServiceTokenLabel + if ctx.claimsErr == nil { + return ctx.claims.AuthMethod } - return "" + return "unknown" } func tokenSourceLabel(ctx loginStatusContext) string { + if ctx.tokenSource != "" { + return ctx.tokenSource + } if ctx.kind == principalKindUser { return tokenSourceLoginSession } - return ctx.tokenSource + return "" } func organizationLineFor(ctx loginStatusContext) string { @@ -463,7 +511,11 @@ func printStatusItem(key, value string) { } type loginTokenClaims struct { + // Token kind discriminator stamped by the backend on every JWT it issues. + AuthTokenType string `json:"authTokenType"` + // User session JWT claims + UserID string `json:"userId"` OrganizationID string `json:"organizationId"` SubOrganizationID string `json:"subOrganizationId"` diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go index 2ba90c6b..f0103d92 100644 --- a/packages/cmd/login_status_test.go +++ b/packages/cmd/login_status_test.go @@ -346,6 +346,137 @@ func TestPerformVerification_NetworkError(t *testing.T) { } } +func TestClassifyToken(t *testing.T) { + exp := time.Now().Add(time.Hour).Unix() + + t.Run("service token prefix", func(t *testing.T) { + kind, _, err := classifyToken("st.abc.def") + if err != nil { + t.Fatalf("classifyToken: unexpected error: %v", err) + } + if kind != principalKindServiceToken { + t.Errorf("kind = %q, want %q", kind, principalKindServiceToken) + } + }) + + t.Run("identity access token routes to machine identity", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "authTokenType": authTokenTypeIdentityAccess, + "identityId": "id-1", + "exp": exp, + }) + kind, claims, err := classifyToken(token) + if err != nil { + t.Fatalf("classifyToken: unexpected error: %v", err) + } + if kind != principalKindMachineIdentity { + t.Errorf("kind = %q, want %q", kind, principalKindMachineIdentity) + } + if claims.IdentityID != "id-1" { + t.Errorf("claims.IdentityID = %q, want %q", claims.IdentityID, "id-1") + } + }) + + t.Run("user access token routes to user even without keyring", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "authTokenType": authTokenTypeAccess, + "userId": "u-1", + "organizationId": "org-1", + "exp": exp, + }) + kind, claims, err := classifyToken(token) + if err != nil { + t.Fatalf("classifyToken: unexpected error: %v", err) + } + if kind != principalKindUser { + t.Errorf("kind = %q, want %q (user JWT misclassified)", kind, principalKindUser) + } + if claims.OrganizationID != "org-1" { + t.Errorf("claims.OrganizationID = %q, want %q", claims.OrganizationID, "org-1") + } + }) + + t.Run("unsupported authTokenType returns error", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "authTokenType": "refreshToken", + "userId": "u-1", + "exp": exp, + }) + _, _, err := classifyToken(token) + if err == nil { + t.Fatalf("classifyToken: expected error for refresh token, got nil") + } + }) + + t.Run("legacy JWT with identityId falls back to machine identity", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "identityId": "id-1", + "exp": exp, + }) + kind, _, err := classifyToken(token) + if err != nil { + t.Fatalf("classifyToken: unexpected error: %v", err) + } + if kind != principalKindMachineIdentity { + t.Errorf("kind = %q, want %q", kind, principalKindMachineIdentity) + } + }) + + t.Run("legacy JWT with userId falls back to user", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "userId": "u-1", + "exp": exp, + }) + kind, _, err := classifyToken(token) + if err != nil { + t.Fatalf("classifyToken: unexpected error: %v", err) + } + if kind != principalKindUser { + t.Errorf("kind = %q, want %q", kind, principalKindUser) + } + }) + + t.Run("malformed JWT returns parse error", func(t *testing.T) { + _, _, err := classifyToken("not-a-jwt") + if err == nil { + t.Fatalf("classifyToken: expected parse error, got nil") + } + }) +} + +func TestBuildContextFromToken(t *testing.T) { + t.Run("user JWT via --token verifies against auth/checkAuth", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "authTokenType": authTokenTypeAccess, + "userId": "u-1", + "exp": time.Now().Add(time.Hour).Unix(), + }) + ctx, err := buildContextFromToken(token, "--token flag", "https://example.test") + if err != nil { + t.Fatalf("buildContextFromToken: unexpected error: %v", err) + } + if ctx.kind != principalKindUser { + t.Fatalf("ctx.kind = %q, want %q", ctx.kind, principalKindUser) + } + if ctx.tokenSource != "--token flag" { + t.Errorf("ctx.tokenSource = %q, want %q", ctx.tokenSource, "--token flag") + } + if ctx.loggedInUser.IsUserLoggedIn { + t.Errorf("ctx.loggedInUser.IsUserLoggedIn = true, want false (no keyring session)") + } + }) + + t.Run("unsupported token type surfaces error", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "authTokenType": "mfaToken", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if _, err := buildContextFromToken(token, "--token flag", "https://example.test"); err == nil { + t.Errorf("buildContextFromToken: expected error for mfaToken, got nil") + } + }) +} + func makeUnsignedJWT(t *testing.T, claims map[string]any) string { t.Helper() headerJSON, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"}) From 808f5bf0ae757d422aeec470c611a0e7b0a9f349 Mon Sep 17 00:00:00 2001 From: Thiago Araujo da Silva Date: Thu, 21 May 2026 17:39:05 -0300 Subject: [PATCH 7/7] remove env var not publicly on doc --- packages/cmd/login_status.go | 10 ++-------- packages/cmd/login_status_test.go | 27 ++++----------------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index db142551..62bda7ac 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -232,14 +232,8 @@ func emitLoginStatus(sessions []loginStatusContext, jsonOutput bool) { } func detectMachineIdentityEnvToken() (token, source string, ok bool) { - candidates := []string{ - util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, - util.INFISICAL_TOKEN_NAME, - } - for _, name := range candidates { - if v := strings.TrimSpace(os.Getenv(name)); v != "" { - return v, fmt.Sprintf("%s environment variable", name), true - } + if v := strings.TrimSpace(os.Getenv(util.INFISICAL_TOKEN_NAME)); v != "" { + return v, fmt.Sprintf("%s environment variable", util.INFISICAL_TOKEN_NAME), true } return "", "", false } diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go index f0103d92..d5e3dc9d 100644 --- a/packages/cmd/login_status_test.go +++ b/packages/cmd/login_status_test.go @@ -148,33 +148,15 @@ func TestParseLoginJWTClaims(t *testing.T) { } func TestDetectMachineIdentityEnvToken(t *testing.T) { - t.Run("no env vars set", func(t *testing.T) { - t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "") + t.Run("no env var set", func(t *testing.T) { t.Setenv(util.INFISICAL_TOKEN_NAME, "") if _, _, ok := detectMachineIdentityEnvToken(); ok { - t.Errorf("detectMachineIdentityEnvToken() = ok, want !ok when no env vars set") + t.Errorf("detectMachineIdentityEnvToken() = ok, want !ok when no env var set") } }) - t.Run("universal-auth access token takes precedence", func(t *testing.T) { - t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "ua-token") - t.Setenv(util.INFISICAL_TOKEN_NAME, "should-be-ignored") - - token, source, ok := detectMachineIdentityEnvToken() - if !ok { - t.Fatalf("detectMachineIdentityEnvToken() = !ok, want ok") - } - if token != "ua-token" { - t.Errorf("token = %q, want %q", token, "ua-token") - } - if !strings.Contains(source, util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME) { - t.Errorf("source = %q, want it to contain %q", source, util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME) - } - }) - - t.Run("falls back to INFISICAL_TOKEN", func(t *testing.T) { - t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "") + t.Run("returns INFISICAL_TOKEN when set", func(t *testing.T) { t.Setenv(util.INFISICAL_TOKEN_NAME, "st.abc.def") token, source, ok := detectMachineIdentityEnvToken() @@ -190,8 +172,7 @@ func TestDetectMachineIdentityEnvToken(t *testing.T) { }) t.Run("whitespace-only env value is ignored", func(t *testing.T) { - t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, " ") - t.Setenv(util.INFISICAL_TOKEN_NAME, "") + t.Setenv(util.INFISICAL_TOKEN_NAME, " ") if _, _, ok := detectMachineIdentityEnvToken(); ok { t.Errorf("detectMachineIdentityEnvToken() = ok for whitespace-only value, want !ok")