diff --git a/docs/stackit_auth.md b/docs/stackit_auth.md index 3f9406c46..ffe8fb3a2 100644 --- a/docs/stackit_auth.md +++ b/docs/stackit_auth.md @@ -31,6 +31,7 @@ stackit auth [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit auth activate-service-account](./stackit_auth_activate-service-account.md) - Authenticates using a service account +* [stackit auth api](./stackit_auth_api.md) - Manages authentication for the STACKIT Terraform Provider and SDK * [stackit auth get-access-token](./stackit_auth_get-access-token.md) - Prints a short-lived access token. * [stackit auth login](./stackit_auth_login.md) - Logs in to the STACKIT CLI * [stackit auth logout](./stackit_auth_logout.md) - Logs the user account out of the STACKIT CLI diff --git a/docs/stackit_auth_api.md b/docs/stackit_auth_api.md new file mode 100644 index 000000000..5c879b168 --- /dev/null +++ b/docs/stackit_auth_api.md @@ -0,0 +1,41 @@ +## stackit auth api + +Manages authentication for the STACKIT Terraform Provider and SDK + +### Synopsis + +Manages authentication for the STACKIT Terraform Provider and SDK. + +These commands allow you to authenticate with your personal STACKIT account +and share the credentials with the STACKIT Terraform Provider and SDK. +This provides an alternative to using service accounts for local development. + +``` +stackit auth api [flags] +``` + +### Options + +``` + -h, --help Help for "stackit auth api" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit auth](./stackit_auth.md) - Authenticates the STACKIT CLI +* [stackit auth api get-access-token](./stackit_auth_api_get-access-token.md) - Prints a short-lived access token for the STACKIT Terraform Provider and SDK +* [stackit auth api login](./stackit_auth_api_login.md) - Logs in for the STACKIT Terraform Provider and SDK +* [stackit auth api logout](./stackit_auth_api_logout.md) - Logs out from the STACKIT Terraform Provider and SDK +* [stackit auth api status](./stackit_auth_api_status.md) - Shows authentication status for the STACKIT Terraform Provider and SDK + diff --git a/docs/stackit_auth_api_get-access-token.md b/docs/stackit_auth_api_get-access-token.md new file mode 100644 index 000000000..3e050105f --- /dev/null +++ b/docs/stackit_auth_api_get-access-token.md @@ -0,0 +1,40 @@ +## stackit auth api get-access-token + +Prints a short-lived access token for the STACKIT Terraform Provider and SDK + +### Synopsis + +Prints a short-lived access token for the STACKIT Terraform Provider and SDK which can be used e.g. for API calls. + +``` +stackit auth api get-access-token [flags] +``` + +### Examples + +``` + Print a short-lived access token for the STACKIT Terraform Provider and SDK + $ stackit auth api get-access-token +``` + +### Options + +``` + -h, --help Help for "stackit auth api get-access-token" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit auth api](./stackit_auth_api.md) - Manages authentication for the STACKIT Terraform Provider and SDK + diff --git a/docs/stackit_auth_api_login.md b/docs/stackit_auth_api_login.md new file mode 100644 index 000000000..66b8af450 --- /dev/null +++ b/docs/stackit_auth_api_login.md @@ -0,0 +1,42 @@ +## stackit auth api login + +Logs in for the STACKIT Terraform Provider and SDK + +### Synopsis + +Logs in for the STACKIT Terraform Provider and SDK using a user account. +The authentication is done via a web-based authorization flow, where the command will open a browser window in which you can login to your STACKIT account. +The credentials are stored separately from the CLI authentication and will be used by the STACKIT Terraform Provider and SDK. + +``` +stackit auth api login [flags] +``` + +### Examples + +``` + Login for the STACKIT Terraform Provider and SDK. This command will open a browser window where you can login to your STACKIT account + $ stackit auth api login +``` + +### Options + +``` + -h, --help Help for "stackit auth api login" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit auth api](./stackit_auth_api.md) - Manages authentication for the STACKIT Terraform Provider and SDK + diff --git a/docs/stackit_auth_api_logout.md b/docs/stackit_auth_api_logout.md new file mode 100644 index 000000000..646dea97a --- /dev/null +++ b/docs/stackit_auth_api_logout.md @@ -0,0 +1,40 @@ +## stackit auth api logout + +Logs out from the STACKIT Terraform Provider and SDK + +### Synopsis + +Logs out from the STACKIT Terraform Provider and SDK. This does not affect CLI authentication. + +``` +stackit auth api logout [flags] +``` + +### Examples + +``` + Log out from the STACKIT Terraform Provider and SDK + $ stackit auth api logout +``` + +### Options + +``` + -h, --help Help for "stackit auth api logout" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit auth api](./stackit_auth_api.md) - Manages authentication for the STACKIT Terraform Provider and SDK + diff --git a/docs/stackit_auth_api_status.md b/docs/stackit_auth_api_status.md new file mode 100644 index 000000000..e71b6bf31 --- /dev/null +++ b/docs/stackit_auth_api_status.md @@ -0,0 +1,40 @@ +## stackit auth api status + +Shows authentication status for the STACKIT Terraform Provider and SDK + +### Synopsis + +Shows authentication status for the STACKIT Terraform Provider and SDK, including whether you are authenticated and with which account. + +``` +stackit auth api status [flags] +``` + +### Examples + +``` + Show authentication status for the STACKIT Terraform Provider and SDK + $ stackit auth api status +``` + +### Options + +``` + -h, --help Help for "stackit auth api status" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit auth api](./stackit_auth_api.md) - Manages authentication for the STACKIT Terraform Provider and SDK + diff --git a/internal/cmd/auth/api/get-access-token/get_access_token.go b/internal/cmd/auth/api/get-access-token/get_access_token.go new file mode 100644 index 000000000..1535acc7b --- /dev/null +++ b/internal/cmd/auth/api/get-access-token/get_access_token.go @@ -0,0 +1,89 @@ +package getaccesstoken + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "get-access-token", + Short: "Prints a short-lived access token for the STACKIT Terraform Provider and SDK", + Long: "Prints a short-lived access token for the STACKIT Terraform Provider and SDK which can be used e.g. for API calls.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Print a short-lived access token for the STACKIT Terraform Provider and SDK`, + "$ stackit auth api get-access-token"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + userSessionExpired, err := auth.UserSessionExpiredWithContext(auth.StorageContextAPI) + if err != nil { + return err + } + if userSessionExpired { + return &cliErr.SessionExpiredError{} + } + + accessToken, err := auth.GetValidAccessTokenWithContext(params.Printer, auth.StorageContextAPI) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get valid access token: %v", err) + return &cliErr.SessionExpiredError{} + } + + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(map[string]string{ + "access_token": accessToken, + }, "", " ") + if err != nil { + return fmt.Errorf("marshal access token: %w", err) + } + params.Printer.Outputln(string(details)) + + return nil + default: + params.Printer.Outputln(accessToken) + + return nil + } + }, + } + + // hide project id flag from help command because it could mislead users + cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + _ = command.Flags().MarkHidden(globalflags.ProjectIdFlag) // nolint:errcheck // there's no chance to handle the error here + command.Parent().HelpFunc()(command, strings) + }) + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/auth/api/login/login.go b/internal/cmd/auth/api/login/login.go new file mode 100644 index 000000000..573439223 --- /dev/null +++ b/internal/cmd/auth/api/login/login.go @@ -0,0 +1,39 @@ +package login + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Logs in for the STACKIT Terraform Provider and SDK", + Long: fmt.Sprintf("%s\n%s\n%s", + "Logs in for the STACKIT Terraform Provider and SDK using a user account.", + "The authentication is done via a web-based authorization flow, where the command will open a browser window in which you can login to your STACKIT account.", + "The credentials are stored separately from the CLI authentication and will be used by the STACKIT Terraform Provider and SDK."), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Login for the STACKIT Terraform Provider and SDK. This command will open a browser window where you can login to your STACKIT account`, + "$ stackit auth api login"), + ), + RunE: func(_ *cobra.Command, _ []string) error { + err := auth.AuthorizeUser(params.Printer, auth.StorageContextAPI, false) + if err != nil { + return fmt.Errorf("authorization failed: %w", err) + } + + params.Printer.Outputln("Successfully logged in for STACKIT Terraform Provider and SDK.\n") + + return nil + }, + } + return cmd +} diff --git a/internal/cmd/auth/api/logout/logout.go b/internal/cmd/auth/api/logout/logout.go new file mode 100644 index 000000000..8d5c5f616 --- /dev/null +++ b/internal/cmd/auth/api/logout/logout.go @@ -0,0 +1,35 @@ +package logout + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Logs out from the STACKIT Terraform Provider and SDK", + Long: "Logs out from the STACKIT Terraform Provider and SDK. This does not affect CLI authentication.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Log out from the STACKIT Terraform Provider and SDK`, + "$ stackit auth api logout"), + ), + RunE: func(_ *cobra.Command, _ []string) error { + err := auth.LogoutUserWithContext(auth.StorageContextAPI) + if err != nil { + return fmt.Errorf("log out failed: %w", err) + } + + params.Printer.Info("Successfully logged out from STACKIT Terraform Provider and SDK.\n") + return nil + }, + } + return cmd +} diff --git a/internal/cmd/auth/api/provider.go b/internal/cmd/auth/api/provider.go new file mode 100644 index 000000000..32e093c51 --- /dev/null +++ b/internal/cmd/auth/api/provider.go @@ -0,0 +1,35 @@ +package api + +import ( + "github.com/spf13/cobra" + getaccesstoken "github.com/stackitcloud/stackit-cli/internal/cmd/auth/api/get-access-token" + "github.com/stackitcloud/stackit-cli/internal/cmd/auth/api/login" + "github.com/stackitcloud/stackit-cli/internal/cmd/auth/api/logout" + "github.com/stackitcloud/stackit-cli/internal/cmd/auth/api/status" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "api", + Short: "Manages authentication for the STACKIT Terraform Provider and SDK", + Long: `Manages authentication for the STACKIT Terraform Provider and SDK. + +These commands allow you to authenticate with your personal STACKIT account +and share the credentials with the STACKIT Terraform Provider and SDK. +This provides an alternative to using service accounts for local development.`, + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(login.NewCmd(params)) + cmd.AddCommand(logout.NewCmd(params)) + cmd.AddCommand(getaccesstoken.NewCmd(params)) + cmd.AddCommand(status.NewCmd(params)) +} diff --git a/internal/cmd/auth/api/status/status.go b/internal/cmd/auth/api/status/status.go new file mode 100644 index 000000000..c7e9951e1 --- /dev/null +++ b/internal/cmd/auth/api/status/status.go @@ -0,0 +1,109 @@ +package status + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +type statusOutput struct { + Authenticated bool `json:"authenticated"` + Email string `json:"email,omitempty"` + AuthFlow string `json:"auth_flow,omitempty"` +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Shows authentication status for the STACKIT Terraform Provider and SDK", + Long: "Shows authentication status for the STACKIT Terraform Provider and SDK, including whether you are authenticated and with which account.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Show authentication status for the STACKIT Terraform Provider and SDK`, + "$ stackit auth api status"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Check if access token exists (primary credential check) + accessToken, err := auth.GetAuthFieldWithContext(auth.StorageContextAPI, auth.ACCESS_TOKEN) + if err != nil || accessToken == "" { + // Not authenticated + return outputStatus(params.Printer, model, statusOutput{ + Authenticated: false, + }) + } + + // Get optional fields for display + flow, _ := auth.GetAuthFlowWithContext(auth.StorageContextAPI) + email, err := auth.GetAuthFieldWithContext(auth.StorageContextAPI, auth.USER_EMAIL) + if err != nil { + email = "" + } + + return outputStatus(params.Printer, model, statusOutput{ + Authenticated: true, + Email: email, + AuthFlow: string(flow), + }) + }, + } + + // hide project id flag from help command because it could mislead users + cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + _ = command.Flags().MarkHidden(globalflags.ProjectIdFlag) // nolint:errcheck // there's no chance to handle the error here + command.Parent().HelpFunc()(command, strings) + }) + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputStatus(p *print.Printer, model *inputModel, status statusOutput) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(status, "", " ") + if err != nil { + return fmt.Errorf("marshal status: %w", err) + } + p.Outputln(string(details)) + return nil + default: + if status.Authenticated { + p.Outputln("API Authentication Status: Authenticated") + if status.Email != "" { + p.Outputf("Email: %s\n", status.Email) + } + p.Outputf("Auth Flow: %s\n", status.AuthFlow) + } else { + p.Outputln("API Authentication Status: Not authenticated") + p.Outputln("\nTo authenticate, run: stackit auth api login") + } + return nil + } +} diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 7e1c020cf..ba3e0ec8f 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( activateserviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/auth/activate-service-account" + "github.com/stackitcloud/stackit-cli/internal/cmd/auth/api" getaccesstoken "github.com/stackitcloud/stackit-cli/internal/cmd/auth/get-access-token" "github.com/stackitcloud/stackit-cli/internal/cmd/auth/login" "github.com/stackitcloud/stackit-cli/internal/cmd/auth/logout" @@ -29,4 +30,5 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(logout.NewCmd(params)) cmd.AddCommand(activateserviceaccount.NewCmd(params)) cmd.AddCommand(getaccesstoken.NewCmd(params)) + cmd.AddCommand(api.NewCmd(params)) } diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go index 8740fead7..839241cfd 100644 --- a/internal/cmd/auth/login/login.go +++ b/internal/cmd/auth/login/login.go @@ -25,7 +25,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { "$ stackit auth login"), ), RunE: func(_ *cobra.Command, _ []string) error { - err := auth.AuthorizeUser(params.Printer, false) + err := auth.AuthorizeUser(params.Printer, auth.StorageContextCLI, false) if err != nil { return fmt.Errorf("authorization failed: %w", err) } diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go index ea549a2cb..863a5c335 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -1,7 +1,9 @@ package auth import ( + "bytes" "fmt" + "io" "net/http" "os" "strconv" @@ -25,7 +27,10 @@ type tokenClaims struct { // // If the user was logged in and the user session expired, reauthorizeUserRoutine is called to reauthenticate the user again. // If the environment variable STACKIT_ACCESS_TOKEN is set this token is used instead. -func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print.Printer, _ bool) error) (authCfgOption sdkConfig.ConfigurationOption, err error) { +func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print.Printer, context StorageContext, _ bool) error) (authCfgOption sdkConfig.ConfigurationOption, err error) { + // Set the storage printer so debug messages use the correct verbosity + SetStoragePrinter(p) + // Get access token from env and use this if present accessToken := os.Getenv(envAccessTokenName) if accessToken != "" { @@ -70,7 +75,7 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print case AUTH_FLOW_USER_TOKEN: p.Debug(print.DebugLevel, "authenticating using user token") if userSessionExpired { - err = reauthorizeUserRoutine(p, true) + err = reauthorizeUserRoutine(p, StorageContextCLI, true) if err != nil { return nil, fmt.Errorf("user login: %w", err) } @@ -84,7 +89,11 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print } func UserSessionExpired() (bool, error) { - sessionExpiresAtString, err := GetAuthField(SESSION_EXPIRES_AT_UNIX) + return UserSessionExpiredWithContext(StorageContextCLI) +} + +func UserSessionExpiredWithContext(context StorageContext) (bool, error) { + sessionExpiresAtString, err := GetAuthFieldWithContext(context, SESSION_EXPIRES_AT_UNIX) if err != nil { return false, fmt.Errorf("get %s: %w", SESSION_EXPIRES_AT_UNIX, err) } @@ -98,7 +107,11 @@ func UserSessionExpired() (bool, error) { } func GetAccessToken() (string, error) { - accessToken, err := GetAuthField(ACCESS_TOKEN) + return GetAccessTokenWithContext(StorageContextCLI) +} + +func GetAccessTokenWithContext(context StorageContext) (string, error) { + accessToken, err := GetAuthFieldWithContext(context, ACCESS_TOKEN) if err != nil { return "", fmt.Errorf("get %s: %w", ACCESS_TOKEN, err) } @@ -134,18 +147,47 @@ func getEmailFromToken(token string) (string, error) { return claims.Email, nil } +func getAccessTokenExpiresAtUnix(accessToken string) (string, error) { + // Parse the access token to get its expiration time + parsedAccessToken, _, err := jwt.NewParser().ParseUnverified(accessToken, &jwt.RegisteredClaims{}) + if err != nil { + return "", fmt.Errorf("parse access token: %w", err) + } + + claims, ok := parsedAccessToken.Claims.(*jwt.RegisteredClaims) + if !ok { + return "", fmt.Errorf("get claims from parsed token: unknown claims type") + } + + if claims.ExpiresAt == nil { + return "", fmt.Errorf("access token has no expiration claim") + } + + return strconv.FormatInt(claims.ExpiresAt.Unix(), 10), nil +} + // GetValidAccessToken returns a valid access token for the current authentication flow. // For user token flows, it refreshes the token if necessary. // For service account flows, it returns the current access token. func GetValidAccessToken(p *print.Printer) (string, error) { - flow, err := GetAuthFlow() + return GetValidAccessTokenWithContext(p, StorageContextCLI) +} + +// GetValidAccessTokenWithContext returns a valid access token for the specified storage context. +// For user token flows, it refreshes the token if necessary. +// For service account flows, it returns the current access token. +func GetValidAccessTokenWithContext(p *print.Printer, context StorageContext) (string, error) { + // Set the storage printer so debug messages use the correct verbosity + SetStoragePrinter(p) + + flow, err := GetAuthFlowWithContext(context) if err != nil { return "", fmt.Errorf("get authentication flow: %w", err) } // For service account flows, just return the current token if flow == AUTH_FLOW_SERVICE_ACCOUNT_TOKEN || flow == AUTH_FLOW_SERVICE_ACCOUNT_KEY { - return GetAccessToken() + return GetAccessTokenWithContext(context) } if flow != AUTH_FLOW_USER_TOKEN { @@ -158,7 +200,7 @@ func GetValidAccessToken(p *print.Printer) (string, error) { REFRESH_TOKEN: "", IDP_TOKEN_ENDPOINT: "", } - err = GetAuthFieldMap(authFields) + err = GetAuthFieldMapWithContext(context, authFields) if err != nil { return "", fmt.Errorf("get tokens from auth storage: %w", err) } @@ -193,6 +235,7 @@ func GetValidAccessToken(p *print.Printer) (string, error) { utf := &userTokenFlow{ printer: p, client: &http.Client{}, + context: context, authFlow: flow, accessToken: accessToken, refreshToken: refreshToken, @@ -208,3 +251,53 @@ func GetValidAccessToken(p *print.Printer) (string, error) { // Return the new access token return utf.accessToken, nil } + +// debugHTTPRequest logs the raw HTTP request details for debugging purposes +func debugHTTPRequest(p *print.Printer, req *http.Request) { + if p == nil || req == nil { + return + } + + p.Debug(print.DebugLevel, "=== HTTP REQUEST ===") + p.Debug(print.DebugLevel, "Method: %s", req.Method) + p.Debug(print.DebugLevel, "URL: %s", req.URL.String()) + p.Debug(print.DebugLevel, "Headers:") + for name, values := range req.Header { + for _, value := range values { + p.Debug(print.DebugLevel, " %s: %s", name, value) + } + } + p.Debug(print.DebugLevel, "===================") +} + +// debugHTTPResponse logs the raw HTTP response details for debugging purposes +func debugHTTPResponse(p *print.Printer, resp *http.Response) { + if p == nil || resp == nil { + return + } + + p.Debug(print.DebugLevel, "=== HTTP RESPONSE ===") + p.Debug(print.DebugLevel, "Status: %s", resp.Status) + p.Debug(print.DebugLevel, "Status Code: %d", resp.StatusCode) + p.Debug(print.DebugLevel, "Headers:") + for name, values := range resp.Header { + for _, value := range values { + p.Debug(print.DebugLevel, " %s: %s", name, value) + } + } + + // Read and log body (need to restore it for later use) + if resp.Body != nil { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + p.Debug(print.ErrorLevel, "Error reading response body: %v", err) + } else { + // Restore the body for later use + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Show raw body without sanitization + p.Debug(print.DebugLevel, "Body: %s", string(bodyBytes)) + } + } + p.Debug(print.DebugLevel, "====================") +} diff --git a/internal/pkg/auth/auth_test.go b/internal/pkg/auth/auth_test.go index f7355f365..e735439b4 100644 --- a/internal/pkg/auth/auth_test.go +++ b/internal/pkg/auth/auth_test.go @@ -188,7 +188,7 @@ func TestAuthenticationConfig(t *testing.T) { } reauthorizeUserCalled := false - reauthenticateUser := func(_ *print.Printer, _ bool) error { + reauthenticateUser := func(_ *print.Printer, _ StorageContext, _ bool) error { if reauthorizeUserCalled { t.Errorf("user reauthorized more than once") } diff --git a/internal/pkg/auth/service_account.go b/internal/pkg/auth/service_account.go index 1f1b01729..5f2610817 100644 --- a/internal/pkg/auth/service_account.go +++ b/internal/pkg/auth/service_account.go @@ -37,6 +37,9 @@ var _ http.RoundTripper = &keyFlowWithStorage{} // It returns the email associated with the service account // If disableWriting is set to true the credentials are not stored on disk (keyring, file). func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableWriting bool) (email, accessToken string, err error) { + // Set the storage printer so debug messages use the correct verbosity + SetStoragePrinter(p) + authFields := make(map[authFieldKey]string) var authFlowType AuthFlow switch flow := rt.(type) { diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go index 5e857f6a7..1a38817ed 100644 --- a/internal/pkg/auth/storage.go +++ b/internal/pkg/auth/storage.go @@ -10,19 +10,43 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/config" pkgErrors "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/zalando/go-keyring" ) +// Package-level printer for debug logging in storage operations +var storagePrinter = print.NewPrinter() //nolint:unused // set via SetStoragePrinter, may be used for future debug logging + +// SetStoragePrinter sets the printer used for storage debug logging +// This should be called with the main command's printer to ensure consistent verbosity +func SetStoragePrinter(p *print.Printer) { + if p != nil { + storagePrinter = p + } +} + // Name of an auth-related field type authFieldKey string // Possible values of authentication flows type AuthFlow string +// StorageContext represents the context in which credentials are stored +// CLI context is for the CLI's own authentication +// API context is for Terraform Provider and SDK authentication +type StorageContext string + +const ( + StorageContextCLI StorageContext = "cli" + StorageContextAPI StorageContext = "api" +) + const ( - keyringService = "stackit-cli" - textFileName = "cli-auth-storage.txt" + keyringServiceCLI = "stackit-cli" + keyringServiceAPI = "stackit-cli-api" + textFileNameCLI = "cli-auth-storage.txt" + textFileNameAPI = "cli-api-auth-storage.txt" envAccessTokenName = "STACKIT_ACCESS_TOKEN" ) @@ -33,7 +57,7 @@ const ( SERVICE_ACCOUNT_TOKEN authFieldKey = "service_account_token" SERVICE_ACCOUNT_EMAIL authFieldKey = "service_account_email" USER_EMAIL authFieldKey = "user_email" - SERVICE_ACCOUNT_KEY authFieldKey = "service_account_key" + SERVICE_ACCOUNT_KEY authFieldKey = "service_account_key" //nolint:gosec // linter false positive PRIVATE_KEY authFieldKey = "private_key" TOKEN_CUSTOM_ENDPOINT authFieldKey = "token_custom_endpoint" IDP_TOKEN_ENDPOINT authFieldKey = "idp_token_endpoint" //nolint:gosec // linter false positive @@ -70,10 +94,40 @@ var loginAuthFieldKeys = []authFieldKey{ USER_EMAIL, } +// getKeyringServiceName returns the keyring service name for the given context and profile +func getKeyringServiceName(context StorageContext, profile string) string { + var baseService string + switch context { + case StorageContextAPI: + baseService = keyringServiceAPI + default: + baseService = keyringServiceCLI + } + + if profile != config.DefaultProfileName { + return filepath.Join(baseService, profile) + } + return baseService +} + +// getTextFileName returns the text file name for the given context +func getTextFileName(context StorageContext) string { + switch context { + case StorageContextAPI: + return textFileNameAPI + default: + return textFileNameCLI + } +} + func SetAuthFlow(value AuthFlow) error { return SetAuthField(authFlowType, string(value)) } +func SetAuthFlowWithContext(context StorageContext, value AuthFlow) error { + return SetAuthFieldWithContext(context, authFlowType, string(value)) +} + // Sets the values in the auth storage according to the given map func SetAuthFieldMap(keyMap map[authFieldKey]string) error { for key, value := range keyMap { @@ -85,19 +139,39 @@ func SetAuthFieldMap(keyMap map[authFieldKey]string) error { return nil } +// SetAuthFieldMapWithContext sets the values in the auth storage according to the given map for a specific context +func SetAuthFieldMapWithContext(context StorageContext, keyMap map[authFieldKey]string) error { + for key, value := range keyMap { + err := SetAuthFieldWithContext(context, key, value) + if err != nil { + return fmt.Errorf("set auth field \"%s\": %w", key, err) + } + } + return nil +} + func SetAuthField(key authFieldKey, value string) error { + return SetAuthFieldWithContext(StorageContextCLI, key, value) +} + +// SetAuthFieldWithContext sets an auth field for a specific storage context +func SetAuthFieldWithContext(context StorageContext, key authFieldKey, value string) error { activeProfile, err := config.GetProfile() if err != nil { return fmt.Errorf("get profile: %w", err) } - return setAuthFieldWithProfile(activeProfile, key, value) + return setAuthFieldWithProfileAndContext(context, activeProfile, key, value) } func setAuthFieldWithProfile(profile string, key authFieldKey, value string) error { - err := setAuthFieldInKeyring(profile, key, value) + return setAuthFieldWithProfileAndContext(StorageContextCLI, profile, key, value) +} + +func setAuthFieldWithProfileAndContext(context StorageContext, profile string, key authFieldKey, value string) error { + err := setAuthFieldInKeyringWithContext(context, profile, key, value) if err != nil { - errFallback := setAuthFieldInEncodedTextFile(profile, key, value) + errFallback := setAuthFieldInEncodedTextFileWithContext(context, profile, key, value) if errFallback != nil { return fmt.Errorf("write to keyring failed (%w), try writing to encoded text file: %w", err, errFallback) } @@ -106,27 +180,37 @@ func setAuthFieldWithProfile(profile string, key authFieldKey, value string) err } func setAuthFieldInKeyring(activeProfile string, key authFieldKey, value string) error { - if activeProfile != config.DefaultProfileName { - activeProfileKeyring := filepath.Join(keyringService, activeProfile) - return keyring.Set(activeProfileKeyring, string(key), value) - } - return keyring.Set(keyringService, string(key), value) + return setAuthFieldInKeyringWithContext(StorageContextCLI, activeProfile, key, value) +} + +func setAuthFieldInKeyringWithContext(context StorageContext, activeProfile string, key authFieldKey, value string) error { + keyringServiceName := getKeyringServiceName(context, activeProfile) + return keyring.Set(keyringServiceName, string(key), value) } func DeleteAuthField(key authFieldKey) error { + return DeleteAuthFieldWithContext(StorageContextCLI, key) +} + +// DeleteAuthFieldWithContext deletes an auth field for a specific storage context +func DeleteAuthFieldWithContext(context StorageContext, key authFieldKey) error { activeProfile, err := config.GetProfile() if err != nil { return fmt.Errorf("get profile: %w", err) } - return deleteAuthFieldWithProfile(activeProfile, key) + return deleteAuthFieldWithProfileAndContext(context, activeProfile, key) } func deleteAuthFieldWithProfile(profile string, key authFieldKey) error { - err := deleteAuthFieldInKeyring(profile, key) + return deleteAuthFieldWithProfileAndContext(StorageContextCLI, profile, key) +} + +func deleteAuthFieldWithProfileAndContext(context StorageContext, profile string, key authFieldKey) error { + err := deleteAuthFieldInKeyringWithContext(context, profile, key) if err != nil { // if the key is not found, we can ignore the error if !errors.Is(err, keyring.ErrNotFound) { - errFallback := deleteAuthFieldInEncodedTextFile(profile, key) + errFallback := deleteAuthFieldInEncodedTextFileWithContext(context, profile, key) if errFallback != nil { return fmt.Errorf("delete from keyring failed (%w), try deleting from encoded text file: %w", err, errFallback) } @@ -136,13 +220,18 @@ func deleteAuthFieldWithProfile(profile string, key authFieldKey) error { } func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error { - err := createEncodedTextFile(activeProfile) + return deleteAuthFieldInEncodedTextFileWithContext(StorageContextCLI, activeProfile, key) +} + +func deleteAuthFieldInEncodedTextFileWithContext(context StorageContext, activeProfile string, key authFieldKey) error { + err := createEncodedTextFileWithContext(context, activeProfile) if err != nil { return err } textFileDir := config.GetProfileFolderPath(activeProfile) - textFilePath := filepath.Join(textFileDir, textFileName) + fileName := getTextFileName(context) + textFilePath := filepath.Join(textFileDir, fileName) contentEncoded, err := os.ReadFile(textFilePath) if err != nil { @@ -173,21 +262,27 @@ func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) er } func deleteAuthFieldInKeyring(activeProfile string, key authFieldKey) error { - keyringServiceLocal := keyringService - if activeProfile != config.DefaultProfileName { - keyringServiceLocal = filepath.Join(keyringService, activeProfile) - } + return deleteAuthFieldInKeyringWithContext(StorageContextCLI, activeProfile, key) +} - return keyring.Delete(keyringServiceLocal, string(key)) +func deleteAuthFieldInKeyringWithContext(context StorageContext, activeProfile string, key authFieldKey) error { + keyringServiceName := getKeyringServiceName(context, activeProfile) + return keyring.Delete(keyringServiceName, string(key)) } func setAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey, value string) error { - err := createEncodedTextFile(activeProfile) + return setAuthFieldInEncodedTextFileWithContext(StorageContextCLI, activeProfile, key, value) +} + +func setAuthFieldInEncodedTextFileWithContext(context StorageContext, activeProfile string, key authFieldKey, value string) error { + textFileDir := config.GetProfileFolderPath(activeProfile) + fileName := getTextFileName(context) + textFilePath := filepath.Join(textFileDir, fileName) + + err := createEncodedTextFileWithContext(context, activeProfile) if err != nil { return err } - textFileDir := config.GetProfileFolderPath(activeProfile) - textFilePath := filepath.Join(textFileDir, textFileName) contentEncoded, err := os.ReadFile(textFilePath) if err != nil { @@ -219,8 +314,13 @@ func setAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey, value // Populates the values in the given map according to the auth storage func GetAuthFieldMap(keyMap map[authFieldKey]string) error { + return GetAuthFieldMapWithContext(StorageContextCLI, keyMap) +} + +// GetAuthFieldMapWithContext populates the values in the given map according to the auth storage for a specific context +func GetAuthFieldMapWithContext(context StorageContext, keyMap map[authFieldKey]string) error { for key := range keyMap { - value, err := GetAuthField(key) + value, err := GetAuthFieldWithContext(context, key) if err != nil { return fmt.Errorf("get auth field \"%s\": %w", key, err) } @@ -230,23 +330,36 @@ func GetAuthFieldMap(keyMap map[authFieldKey]string) error { } func GetAuthFlow() (AuthFlow, error) { - value, err := GetAuthField(authFlowType) + return GetAuthFlowWithContext(StorageContextCLI) +} + +func GetAuthFlowWithContext(context StorageContext) (AuthFlow, error) { + value, err := GetAuthFieldWithContext(context, authFlowType) return AuthFlow(value), err } func GetAuthField(key authFieldKey) (string, error) { + return GetAuthFieldWithContext(StorageContextCLI, key) +} + +// GetAuthFieldWithContext retrieves an auth field for a specific storage context +func GetAuthFieldWithContext(context StorageContext, key authFieldKey) (string, error) { activeProfile, err := config.GetProfile() if err != nil { return "", fmt.Errorf("get profile: %w", err) } - return getAuthFieldWithProfile(activeProfile, key) + return getAuthFieldWithProfileAndContext(context, activeProfile, key) } func getAuthFieldWithProfile(profile string, key authFieldKey) (string, error) { - value, err := getAuthFieldFromKeyring(profile, key) + return getAuthFieldWithProfileAndContext(StorageContextCLI, profile, key) +} + +func getAuthFieldWithProfileAndContext(context StorageContext, profile string, key authFieldKey) (string, error) { + value, err := getAuthFieldFromKeyringWithContext(context, profile, key) if err != nil { var errFallback error - value, errFallback = getAuthFieldFromEncodedTextFile(profile, key) + value, errFallback = getAuthFieldFromEncodedTextFileWithContext(context, profile, key) if errFallback != nil { return "", fmt.Errorf("read from keyring: %w, read from encoded file as fallback: %w", err, errFallback) } @@ -255,21 +368,27 @@ func getAuthFieldWithProfile(profile string, key authFieldKey) (string, error) { } func getAuthFieldFromKeyring(activeProfile string, key authFieldKey) (string, error) { - if activeProfile != config.DefaultProfileName { - activeProfileKeyring := filepath.Join(keyringService, activeProfile) - return keyring.Get(activeProfileKeyring, string(key)) - } - return keyring.Get(keyringService, string(key)) + return getAuthFieldFromKeyringWithContext(StorageContextCLI, activeProfile, key) +} + +func getAuthFieldFromKeyringWithContext(context StorageContext, activeProfile string, key authFieldKey) (string, error) { + keyringServiceName := getKeyringServiceName(context, activeProfile) + return keyring.Get(keyringServiceName, string(key)) } func getAuthFieldFromEncodedTextFile(activeProfile string, key authFieldKey) (string, error) { - err := createEncodedTextFile(activeProfile) + return getAuthFieldFromEncodedTextFileWithContext(StorageContextCLI, activeProfile, key) +} + +func getAuthFieldFromEncodedTextFileWithContext(context StorageContext, activeProfile string, key authFieldKey) (string, error) { + err := createEncodedTextFileWithContext(context, activeProfile) if err != nil { return "", err } textFileDir := config.GetProfileFolderPath(activeProfile) - textFilePath := filepath.Join(textFileDir, textFileName) + fileName := getTextFileName(context) + textFilePath := filepath.Join(textFileDir, fileName) contentEncoded, err := os.ReadFile(textFilePath) if err != nil { @@ -291,12 +410,13 @@ func getAuthFieldFromEncodedTextFile(activeProfile string, key authFieldKey) (st return value, nil } -// Checks if the encoded text file exist. +// createEncodedTextFileWithContext checks if the encoded text file exist. // If it doesn't, creates it with the content "{}" encoded. // If it does, does nothing (and returns nil). -func createEncodedTextFile(activeProfile string) error { +func createEncodedTextFileWithContext(context StorageContext, activeProfile string) error { textFileDir := config.GetProfileFolderPath(activeProfile) - textFilePath := filepath.Join(textFileDir, textFileName) + fileName := getTextFileName(context) + textFilePath := filepath.Join(textFileDir, fileName) err := os.MkdirAll(textFileDir, 0o750) if err != nil { @@ -364,6 +484,11 @@ func GetAuthEmail() (string, error) { } func LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix string) error { + return LoginUserWithContext(StorageContextCLI, email, accessToken, refreshToken, sessionExpiresAtUnix) +} + +// LoginUserWithContext stores user login credentials for a specific storage context +func LoginUserWithContext(context StorageContext, email, accessToken, refreshToken, sessionExpiresAtUnix string) error { authFields := map[authFieldKey]string{ SESSION_EXPIRES_AT_UNIX: sessionExpiresAtUnix, ACCESS_TOKEN: accessToken, @@ -371,7 +496,7 @@ func LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix string) er USER_EMAIL: email, } - err := SetAuthFieldMap(authFields) + err := SetAuthFieldMapWithContext(context, authFields) if err != nil { return fmt.Errorf("set auth fields: %w", err) } @@ -379,8 +504,13 @@ func LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix string) er } func LogoutUser() error { + return LogoutUserWithContext(StorageContextCLI) +} + +// LogoutUserWithContext removes user authentication for a specific storage context +func LogoutUserWithContext(context StorageContext) error { for _, key := range loginAuthFieldKeys { - err := DeleteAuthField(key) + err := DeleteAuthFieldWithContext(context, key) if err != nil { return fmt.Errorf("delete auth field \"%s\": %w", key, err) } diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go index 37eeee33e..12f9ea0eb 100644 --- a/internal/pkg/auth/storage_test.go +++ b/internal/pkg/auth/storage_test.go @@ -1100,6 +1100,469 @@ func makeProfileNameUnique(profile string) string { return fmt.Sprintf("%s-%s", profile, time.Now().Format("20060102150405")) } +// TestStorageContextSeparation tests that CLI and Provider contexts use different keyring services +func TestStorageContextSeparation(t *testing.T) { + var testField authFieldKey = "test-field-context" + testValueCLI := fmt.Sprintf("cli-value-%s", time.Now().Format(time.RFC3339)) + testValueProvider := fmt.Sprintf("provider-value-%s", time.Now().Format(time.RFC3339)) + + tests := []struct { + description string + keyringFails bool + }{ + { + description: "with keyring", + }, + { + description: "with file fallback", + keyringFails: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if !tt.keyringFails { + keyring.MockInit() + } else { + keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing")) + } + + // Set value in CLI context + err := SetAuthFieldWithContext(StorageContextCLI, testField, testValueCLI) + if err != nil { + t.Fatalf("Failed to set CLI context field: %v", err) + } + + // Set value in Provider context + err = SetAuthFieldWithContext(StorageContextAPI, testField, testValueProvider) + if err != nil { + t.Fatalf("Failed to set Provider context field: %v", err) + } + + // Verify CLI context value + valueCLI, err := GetAuthFieldWithContext(StorageContextCLI, testField) + if err != nil { + t.Fatalf("Failed to get CLI context field: %v", err) + } + if valueCLI != testValueCLI { + t.Errorf("CLI context value incorrect: expected %s, got %s", testValueCLI, valueCLI) + } + + // Verify Provider context value + valueProvider, err := GetAuthFieldWithContext(StorageContextAPI, testField) + if err != nil { + t.Fatalf("Failed to get Provider context field: %v", err) + } + if valueProvider != testValueProvider { + t.Errorf("Provider context value incorrect: expected %s, got %s", testValueProvider, valueProvider) + } + + // Cleanup + activeProfile, _ := config.GetProfile() + if !tt.keyringFails { + _ = deleteAuthFieldInKeyringWithContext(StorageContextCLI, activeProfile, testField) + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, activeProfile, testField) + } else { + _ = deleteAuthFieldInEncodedTextFileWithContext(StorageContextCLI, activeProfile, testField) + _ = deleteAuthFieldInEncodedTextFileWithContext(StorageContextAPI, activeProfile, testField) + } + }) + } +} + +// TestStorageContextIsolation tests that changes in one context don't affect the other +func TestStorageContextIsolation(t *testing.T) { + var testField authFieldKey = "test-field-isolation" + testValueCLI := fmt.Sprintf("cli-value-%s", time.Now().Format(time.RFC3339)) + testValueProvider := fmt.Sprintf("provider-value-%s", time.Now().Format(time.RFC3339)) + updatedValueCLI := fmt.Sprintf("cli-updated-%s", time.Now().Format(time.RFC3339)) + + keyring.MockInit() + + // Set values in both contexts + err := SetAuthFieldWithContext(StorageContextCLI, testField, testValueCLI) + if err != nil { + t.Fatalf("Failed to set CLI context field: %v", err) + } + + err = SetAuthFieldWithContext(StorageContextAPI, testField, testValueProvider) + if err != nil { + t.Fatalf("Failed to set Provider context field: %v", err) + } + + // Update CLI context value + err = SetAuthFieldWithContext(StorageContextCLI, testField, updatedValueCLI) + if err != nil { + t.Fatalf("Failed to update CLI context field: %v", err) + } + + // Verify CLI context was updated + valueCLI, err := GetAuthFieldWithContext(StorageContextCLI, testField) + if err != nil { + t.Fatalf("Failed to get CLI context field: %v", err) + } + if valueCLI != updatedValueCLI { + t.Errorf("CLI context value not updated: expected %s, got %s", updatedValueCLI, valueCLI) + } + + // Verify Provider context was NOT affected + valueProvider, err := GetAuthFieldWithContext(StorageContextAPI, testField) + if err != nil { + t.Fatalf("Failed to get Provider context field: %v", err) + } + if valueProvider != testValueProvider { + t.Errorf("Provider context value changed unexpectedly: expected %s, got %s", testValueProvider, valueProvider) + } + + // Cleanup + activeProfile, _ := config.GetProfile() + _ = deleteAuthFieldInKeyringWithContext(StorageContextCLI, activeProfile, testField) + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, activeProfile, testField) +} + +// TestStorageContextDeletion tests that deleting from one context doesn't affect the other +func TestStorageContextDeletion(t *testing.T) { + var testField authFieldKey = "test-field-deletion" + testValueCLI := fmt.Sprintf("cli-value-%s", time.Now().Format(time.RFC3339)) + testValueProvider := fmt.Sprintf("provider-value-%s", time.Now().Format(time.RFC3339)) + + keyring.MockInit() + + // Set values in both contexts + err := SetAuthFieldWithContext(StorageContextCLI, testField, testValueCLI) + if err != nil { + t.Fatalf("Failed to set CLI context field: %v", err) + } + + err = SetAuthFieldWithContext(StorageContextAPI, testField, testValueProvider) + if err != nil { + t.Fatalf("Failed to set Provider context field: %v", err) + } + + // Delete from CLI context + err = DeleteAuthFieldWithContext(StorageContextCLI, testField) + if err != nil { + t.Fatalf("Failed to delete CLI context field: %v", err) + } + + // Verify CLI context field is deleted + _, err = GetAuthFieldWithContext(StorageContextCLI, testField) + if err == nil { + t.Errorf("CLI context field still exists after deletion") + } + + // Verify Provider context field still exists + valueProvider, err := GetAuthFieldWithContext(StorageContextAPI, testField) + if err != nil { + t.Errorf("Provider context field was deleted unexpectedly: %v", err) + } + if valueProvider != testValueProvider { + t.Errorf("Provider context value changed: expected %s, got %s", testValueProvider, valueProvider) + } + + // Cleanup + activeProfile, _ := config.GetProfile() + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, activeProfile, testField) +} + +// TestStorageContextWithProfiles tests context separation with custom profiles +func TestStorageContextWithProfiles(t *testing.T) { + var testField authFieldKey = "test-field-profile-context" + testProfile := makeProfileNameUnique("test-profile") + + // Make sure profile name is valid + err := config.ValidateProfile(testProfile) + if err != nil { + t.Fatalf("Profile name \"%s\" is invalid: %v", testProfile, err) + } + + testValueCLI := fmt.Sprintf("cli-value-%s", time.Now().Format(time.RFC3339)) + testValueProvider := fmt.Sprintf("provider-value-%s", time.Now().Format(time.RFC3339)) + + keyring.MockInit() + + // Set values in both contexts for custom profile + err = setAuthFieldWithProfileAndContext(StorageContextCLI, testProfile, testField, testValueCLI) + if err != nil { + t.Fatalf("Failed to set CLI context field for profile: %v", err) + } + + err = setAuthFieldWithProfileAndContext(StorageContextAPI, testProfile, testField, testValueProvider) + if err != nil { + t.Fatalf("Failed to set Provider context field for profile: %v", err) + } + + // Verify both contexts have correct values for the profile + valueCLI, err := getAuthFieldWithProfileAndContext(StorageContextCLI, testProfile, testField) + if err != nil { + t.Fatalf("Failed to get CLI context field for profile: %v", err) + } + if valueCLI != testValueCLI { + t.Errorf("CLI context value incorrect: expected %s, got %s", testValueCLI, valueCLI) + } + + valueProvider, err := getAuthFieldWithProfileAndContext(StorageContextAPI, testProfile, testField) + if err != nil { + t.Fatalf("Failed to get Provider context field for profile: %v", err) + } + if valueProvider != testValueProvider { + t.Errorf("Provider context value incorrect: expected %s, got %s", testValueProvider, valueProvider) + } + + // Cleanup + _ = deleteAuthFieldInKeyringWithContext(StorageContextCLI, testProfile, testField) + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, testProfile, testField) + _ = deleteProfileFiles(testProfile) +} + +// TestLoginLogoutWithContext tests login/logout with different contexts +func TestLoginLogoutWithContext(t *testing.T) { + email := "test@example.com" + accessToken := "test-access-token" + refreshToken := "test-refresh-token" + sessionExpires := "1234567890" + + emailProvider := "provider@example.com" + accessTokenProvider := "provider-access-token" + refreshTokenProvider := "provider-refresh-token" + sessionExpiresProvider := "9876543210" + + keyring.MockInit() + + // Login to CLI context + err := LoginUserWithContext(StorageContextCLI, email, accessToken, refreshToken, sessionExpires) + if err != nil { + t.Fatalf("Failed to login to CLI context: %v", err) + } + + // Login to Provider context + err = LoginUserWithContext(StorageContextAPI, emailProvider, accessTokenProvider, refreshTokenProvider, sessionExpiresProvider) + if err != nil { + t.Fatalf("Failed to login to Provider context: %v", err) + } + + // Verify CLI context credentials + cliEmail, err := GetAuthFieldWithContext(StorageContextCLI, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get CLI email: %v", err) + } + if cliEmail != email { + t.Errorf("CLI email incorrect: expected %s, got %s", email, cliEmail) + } + + cliAccessToken, err := GetAuthFieldWithContext(StorageContextCLI, ACCESS_TOKEN) + if err != nil { + t.Fatalf("Failed to get CLI access token: %v", err) + } + if cliAccessToken != accessToken { + t.Errorf("CLI access token incorrect") + } + + // Verify Provider context credentials + providerEmail, err := GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get Provider email: %v", err) + } + if providerEmail != emailProvider { + t.Errorf("Provider email incorrect: expected %s, got %s", emailProvider, providerEmail) + } + + providerAccessToken, err := GetAuthFieldWithContext(StorageContextAPI, ACCESS_TOKEN) + if err != nil { + t.Fatalf("Failed to get Provider access token: %v", err) + } + if providerAccessToken != accessTokenProvider { + t.Errorf("Provider access token incorrect") + } + + // Logout from CLI context + err = LogoutUserWithContext(StorageContextCLI) + if err != nil { + t.Fatalf("Failed to logout from CLI context: %v", err) + } + + // Verify CLI context is logged out + _, err = GetAuthFieldWithContext(StorageContextCLI, USER_EMAIL) + if err == nil { + t.Errorf("CLI context still has credentials after logout") + } + + // Verify Provider context still has credentials + providerEmailAfter, err := GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err != nil { + t.Fatalf("Provider context lost credentials after CLI logout: %v", err) + } + if providerEmailAfter != emailProvider { + t.Errorf("Provider email changed after CLI logout") + } + + // Cleanup Provider context + err = LogoutUserWithContext(StorageContextAPI) + if err != nil { + t.Fatalf("Failed to logout from Provider context: %v", err) + } +} + +// TestAuthFlowWithContext tests auth flow operations with contexts +func TestAuthFlowWithContext(t *testing.T) { + keyring.MockInit() + + // Set different auth flows for different contexts + err := SetAuthFlowWithContext(StorageContextCLI, AUTH_FLOW_USER_TOKEN) + if err != nil { + t.Fatalf("Failed to set CLI auth flow: %v", err) + } + + err = SetAuthFlowWithContext(StorageContextAPI, AUTH_FLOW_SERVICE_ACCOUNT_KEY) + if err != nil { + t.Fatalf("Failed to set Provider auth flow: %v", err) + } + + // Verify CLI context auth flow + cliFlow, err := GetAuthFlowWithContext(StorageContextCLI) + if err != nil { + t.Fatalf("Failed to get CLI auth flow: %v", err) + } + if cliFlow != AUTH_FLOW_USER_TOKEN { + t.Errorf("CLI auth flow incorrect: expected %s, got %s", AUTH_FLOW_USER_TOKEN, cliFlow) + } + + // Verify Provider context auth flow + providerFlow, err := GetAuthFlowWithContext(StorageContextAPI) + if err != nil { + t.Fatalf("Failed to get Provider auth flow: %v", err) + } + if providerFlow != AUTH_FLOW_SERVICE_ACCOUNT_KEY { + t.Errorf("Provider auth flow incorrect: expected %s, got %s", AUTH_FLOW_SERVICE_ACCOUNT_KEY, providerFlow) + } + + // Cleanup + activeProfile, _ := config.GetProfile() + _ = deleteAuthFieldInKeyringWithContext(StorageContextCLI, activeProfile, authFlowType) + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, activeProfile, authFlowType) +} + +// TestGetKeyringServiceName tests the keyring service name generation +func TestGetKeyringServiceName(t *testing.T) { + tests := []struct { + description string + context StorageContext + profile string + expectedService string + }{ + { + description: "CLI context, default profile", + context: StorageContextCLI, + profile: config.DefaultProfileName, + expectedService: "stackit-cli", + }, + { + description: "CLI context, custom profile", + context: StorageContextCLI, + profile: "my-profile", + expectedService: "stackit-cli/my-profile", + }, + { + description: "Provider context, default profile", + context: StorageContextAPI, + profile: config.DefaultProfileName, + expectedService: "stackit-cli-api", + }, + { + description: "Provider context, custom profile", + context: StorageContextAPI, + profile: "my-profile", + expectedService: "stackit-cli-api/my-profile", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + serviceName := getKeyringServiceName(tt.context, tt.profile) + if serviceName != tt.expectedService { + t.Errorf("Keyring service name incorrect: expected %s, got %s", tt.expectedService, serviceName) + } + }) + } +} + +// TestGetTextFileName tests the text file name generation +func TestGetTextFileName(t *testing.T) { + tests := []struct { + description string + context StorageContext + expectedName string + }{ + { + description: "CLI context", + context: StorageContextCLI, + expectedName: "cli-auth-storage.txt", + }, + { + description: "Provider context", + context: StorageContextAPI, + expectedName: "cli-api-auth-storage.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + fileName := getTextFileName(tt.context) + if fileName != tt.expectedName { + t.Errorf("Text file name incorrect: expected %s, got %s", tt.expectedName, fileName) + } + }) + } +} + +// TestAuthFieldMapWithContext tests bulk operations with contexts +func TestAuthFieldMapWithContext(t *testing.T) { + testFields := map[authFieldKey]string{ + "test-field-1": fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339)), + "test-field-2": fmt.Sprintf("value-2-%s", time.Now().Format(time.RFC3339)), + "test-field-3": fmt.Sprintf("value-3-%s", time.Now().Format(time.RFC3339)), + } + + keyring.MockInit() + + // Set fields in Provider context + err := SetAuthFieldMapWithContext(StorageContextAPI, testFields) + if err != nil { + t.Fatalf("Failed to set field map in Provider context: %v", err) + } + + // Read fields from Provider context + readFields := make(map[authFieldKey]string) + for key := range testFields { + readFields[key] = "" + } + err = GetAuthFieldMapWithContext(StorageContextAPI, readFields) + if err != nil { + t.Fatalf("Failed to get field map from Provider context: %v", err) + } + + // Verify all fields match + for key, expectedValue := range testFields { + if readFields[key] != expectedValue { + t.Errorf("Field %s incorrect: expected %s, got %s", key, expectedValue, readFields[key]) + } + } + + // Verify fields don't exist in CLI context + for key := range testFields { + _, err := GetAuthFieldWithContext(StorageContextCLI, key) + if err == nil { + t.Errorf("Field %s unexpectedly exists in CLI context", key) + } + } + + // Cleanup + activeProfile, _ := config.GetProfile() + for key := range testFields { + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, activeProfile, key) + } +} + func TestAuthorizeDeauthorizeUserProfileAuth(t *testing.T) { type args struct { sessionExpiresAtUnix string @@ -1215,3 +1678,309 @@ func TestAuthorizeDeauthorizeUserProfileAuth(t *testing.T) { }) } } + +// TestProviderAuthWorkflow tests the complete provider authentication workflow +func TestProviderAuthWorkflow(t *testing.T) { + keyring.MockInit() + + email := "provider@example.com" + accessToken := "provider-access-token" + refreshToken := "provider-refresh-token" + sessionExpires := fmt.Sprintf("%d", time.Now().Add(2*time.Hour).Unix()) + + // Login to provider context + err := LoginUserWithContext(StorageContextAPI, email, accessToken, refreshToken, sessionExpires) + if err != nil { + t.Fatalf("Failed to login to provider context: %v", err) + } + + // Verify provider credentials exist + providerEmail, err := GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get provider email: %v", err) + } + if providerEmail != email { + t.Errorf("Provider email incorrect: expected %s, got %s", email, providerEmail) + } + + providerAccessToken, err := GetAuthFieldWithContext(StorageContextAPI, ACCESS_TOKEN) + if err != nil { + t.Fatalf("Failed to get provider access token: %v", err) + } + if providerAccessToken != accessToken { + t.Errorf("Provider access token incorrect") + } + + // Verify CLI context is empty + _, err = GetAuthFieldWithContext(StorageContextCLI, USER_EMAIL) + if err == nil { + t.Errorf("CLI context should be empty but has credentials") + } + + // Set auth flow + err = SetAuthFlowWithContext(StorageContextAPI, AUTH_FLOW_USER_TOKEN) + if err != nil { + t.Fatalf("Failed to set provider auth flow: %v", err) + } + + // Verify auth flow + providerFlow, err := GetAuthFlowWithContext(StorageContextAPI) + if err != nil { + t.Fatalf("Failed to get provider auth flow: %v", err) + } + if providerFlow != AUTH_FLOW_USER_TOKEN { + t.Errorf("Provider auth flow incorrect: expected %s, got %s", AUTH_FLOW_USER_TOKEN, providerFlow) + } + + // Logout from provider context + err = LogoutUserWithContext(StorageContextAPI) + if err != nil { + t.Fatalf("Failed to logout from provider context: %v", err) + } + + // Verify provider credentials are deleted + _, err = GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err == nil { + t.Errorf("Provider credentials still exist after logout") + } + + // Cleanup + activeProfile, _ := config.GetProfile() + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, activeProfile, authFlowType) +} + +// TestConcurrentCLIAndProviderAuth tests that CLI and Provider can be authenticated simultaneously +func TestConcurrentCLIAndProviderAuth(t *testing.T) { + keyring.MockInit() + + cliEmail := "cli@example.com" + cliAccessToken := "cli-access-token" + cliRefreshToken := "cli-refresh-token" //nolint:gosec // test credential, not a real secret + cliSessionExpires := fmt.Sprintf("%d", time.Now().Add(2*time.Hour).Unix()) + + providerEmail := "provider@example.com" + providerAccessToken := "provider-access-token" + providerRefreshToken := "provider-refresh-token" + providerSessionExpires := fmt.Sprintf("%d", time.Now().Add(3*time.Hour).Unix()) + + // Login to both contexts + err := LoginUserWithContext(StorageContextCLI, cliEmail, cliAccessToken, cliRefreshToken, cliSessionExpires) + if err != nil { + t.Fatalf("Failed to login to CLI context: %v", err) + } + + err = LoginUserWithContext(StorageContextAPI, providerEmail, providerAccessToken, providerRefreshToken, providerSessionExpires) + if err != nil { + t.Fatalf("Failed to login to Provider context: %v", err) + } + + // Verify CLI credentials + gotCLIEmail, err := GetAuthFieldWithContext(StorageContextCLI, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get CLI email: %v", err) + } + if gotCLIEmail != cliEmail { + t.Errorf("CLI email incorrect: expected %s, got %s", cliEmail, gotCLIEmail) + } + + gotCLIAccessToken, err := GetAuthFieldWithContext(StorageContextCLI, ACCESS_TOKEN) + if err != nil { + t.Fatalf("Failed to get CLI access token: %v", err) + } + if gotCLIAccessToken != cliAccessToken { + t.Errorf("CLI access token incorrect") + } + + // Verify Provider credentials + gotProviderEmail, err := GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get Provider email: %v", err) + } + if gotProviderEmail != providerEmail { + t.Errorf("Provider email incorrect: expected %s, got %s", providerEmail, gotProviderEmail) + } + + gotProviderAccessToken, err := GetAuthFieldWithContext(StorageContextAPI, ACCESS_TOKEN) + if err != nil { + t.Fatalf("Failed to get Provider access token: %v", err) + } + if gotProviderAccessToken != providerAccessToken { + t.Errorf("Provider access token incorrect") + } + + // Update CLI token + newCLIAccessToken := "cli-access-token-updated" + err = SetAuthFieldWithContext(StorageContextCLI, ACCESS_TOKEN, newCLIAccessToken) + if err != nil { + t.Fatalf("Failed to update CLI access token: %v", err) + } + + // Verify CLI token was updated + gotCLIAccessToken, err = GetAuthFieldWithContext(StorageContextCLI, ACCESS_TOKEN) + if err != nil { + t.Fatalf("Failed to get updated CLI access token: %v", err) + } + if gotCLIAccessToken != newCLIAccessToken { + t.Errorf("CLI access token not updated: expected %s, got %s", newCLIAccessToken, gotCLIAccessToken) + } + + // Verify Provider token unchanged + gotProviderAccessToken, err = GetAuthFieldWithContext(StorageContextAPI, ACCESS_TOKEN) + if err != nil { + t.Fatalf("Failed to get Provider access token after CLI update: %v", err) + } + if gotProviderAccessToken != providerAccessToken { + t.Errorf("Provider access token changed unexpectedly: expected %s, got %s", providerAccessToken, gotProviderAccessToken) + } + + // Logout from CLI only + err = LogoutUserWithContext(StorageContextCLI) + if err != nil { + t.Fatalf("Failed to logout from CLI context: %v", err) + } + + // Verify CLI credentials are deleted + _, err = GetAuthFieldWithContext(StorageContextCLI, USER_EMAIL) + if err == nil { + t.Errorf("CLI credentials still exist after logout") + } + + // Verify Provider credentials still exist + gotProviderEmail, err = GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err != nil { + t.Fatalf("Provider credentials deleted after CLI logout: %v", err) + } + if gotProviderEmail != providerEmail { + t.Errorf("Provider email changed after CLI logout") + } + + // Cleanup + err = LogoutUserWithContext(StorageContextAPI) + if err != nil { + t.Fatalf("Failed to logout from provider context: %v", err) + } +} + +// TestProviderStatusReporting tests the status reporting for provider authentication +func TestProviderStatusReporting(t *testing.T) { + keyring.MockInit() + + // Initially not authenticated + flow, err := GetAuthFlowWithContext(StorageContextAPI) + if err == nil && flow != "" { + t.Errorf("Provider should not be authenticated initially, but has flow: %s", flow) + } + + // Login + email := "provider@example.com" + accessToken := "provider-access-token" + refreshToken := "provider-refresh-token" + sessionExpires := fmt.Sprintf("%d", time.Now().Add(2*time.Hour).Unix()) + + err = LoginUserWithContext(StorageContextAPI, email, accessToken, refreshToken, sessionExpires) + if err != nil { + t.Fatalf("Failed to login: %v", err) + } + + err = SetAuthFlowWithContext(StorageContextAPI, AUTH_FLOW_USER_TOKEN) + if err != nil { + t.Fatalf("Failed to set auth flow: %v", err) + } + + // Verify authenticated status + flow, err = GetAuthFlowWithContext(StorageContextAPI) + if err != nil { + t.Fatalf("Failed to get auth flow: %v", err) + } + if flow != AUTH_FLOW_USER_TOKEN { + t.Errorf("Auth flow incorrect: expected %s, got %s", AUTH_FLOW_USER_TOKEN, flow) + } + + gotEmail, err := GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get email: %v", err) + } + if gotEmail != email { + t.Errorf("Email incorrect: expected %s, got %s", email, gotEmail) + } + + // Logout + err = LogoutUserWithContext(StorageContextAPI) + if err != nil { + t.Fatalf("Failed to logout: %v", err) + } + + // Verify credentials are deleted after logout + _, err = GetAuthFieldWithContext(StorageContextAPI, USER_EMAIL) + if err == nil { + t.Errorf("User email should not exist after logout") + } + + _, err = GetAuthFieldWithContext(StorageContextAPI, ACCESS_TOKEN) + if err == nil { + t.Errorf("Access token should not exist after logout") + } + + // Cleanup + activeProfile, _ := config.GetProfile() + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, activeProfile, authFlowType) +} + +// TestProviderAuthWithProfiles tests provider authentication with custom profiles +func TestProviderAuthWithProfiles(t *testing.T) { + keyring.MockInit() + + testProfile := makeProfileNameUnique("test-profile") + err := config.ValidateProfile(testProfile) + if err != nil { + t.Fatalf("Profile name \"%s\" is invalid: %v", testProfile, err) + } + + email := "provider@example.com" + accessToken := "provider-access-token" + refreshToken := "provider-refresh-token" + sessionExpires := fmt.Sprintf("%d", time.Now().Add(2*time.Hour).Unix()) + + // Login to provider context with custom profile + err = setAuthFieldWithProfileAndContext(StorageContextAPI, testProfile, USER_EMAIL, email) + if err != nil { + t.Fatalf("Failed to set provider email for profile: %v", err) + } + + err = setAuthFieldWithProfileAndContext(StorageContextAPI, testProfile, ACCESS_TOKEN, accessToken) + if err != nil { + t.Fatalf("Failed to set provider access token for profile: %v", err) + } + + err = setAuthFieldWithProfileAndContext(StorageContextAPI, testProfile, REFRESH_TOKEN, refreshToken) + if err != nil { + t.Fatalf("Failed to set provider refresh token for profile: %v", err) + } + + err = setAuthFieldWithProfileAndContext(StorageContextAPI, testProfile, SESSION_EXPIRES_AT_UNIX, sessionExpires) + if err != nil { + t.Fatalf("Failed to set provider session expiry for profile: %v", err) + } + + // Verify provider credentials for custom profile + gotEmail, err := getAuthFieldWithProfileAndContext(StorageContextAPI, testProfile, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get provider email for profile: %v", err) + } + if gotEmail != email { + t.Errorf("Provider email incorrect: expected %s, got %s", email, gotEmail) + } + + // Verify CLI context for same profile is empty + _, err = getAuthFieldWithProfileAndContext(StorageContextCLI, testProfile, USER_EMAIL) + if err == nil { + t.Errorf("CLI context for profile should be empty but has credentials") + } + + // Cleanup + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, testProfile, USER_EMAIL) + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, testProfile, ACCESS_TOKEN) + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, testProfile, REFRESH_TOKEN) + _ = deleteAuthFieldInKeyringWithContext(StorageContextAPI, testProfile, SESSION_EXPIRES_AT_UNIX) + _ = deleteProfileFiles(testProfile) +} diff --git a/internal/pkg/auth/user_login.go b/internal/pkg/auth/user_login.go index 2ec2040dd..92825304a 100644 --- a/internal/pkg/auth/user_login.go +++ b/internal/pkg/auth/user_login.go @@ -50,7 +50,10 @@ type apiClient interface { } // AuthorizeUser implements the PKCE OAuth2 flow. -func AuthorizeUser(p *print.Printer, isReauthentication bool) error { +func AuthorizeUser(p *print.Printer, context StorageContext, isReauthentication bool) error { + // Set the storage printer so debug messages use the correct verbosity + SetStoragePrinter(p) + idpWellKnownConfigURL, err := getIDPWellKnownConfigURL() if err != nil { return fmt.Errorf("get IDP well-known configuration: %w", err) @@ -65,7 +68,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { p.Debug(print.DebugLevel, "get IDP well-known configuration from %s", idpWellKnownConfigURL) httpClient := &http.Client{} - idpWellKnownConfig, err := parseWellKnownConfiguration(httpClient, idpWellKnownConfigURL) + idpWellKnownConfig, err := parseWellKnownConfiguration(p, httpClient, idpWellKnownConfigURL, context) if err != nil { return fmt.Errorf("parse IDP well-known configuration: %w", err) } @@ -159,7 +162,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { p.Debug(print.DebugLevel, "trading authorization code for access and refresh tokens") // Trade the authorization code and the code verifier for access and refresh tokens - accessToken, refreshToken, err := getUserAccessAndRefreshTokens(idpWellKnownConfig, idpClientID, codeVerifier, code, redirectURL) + accessToken, refreshToken, err := getUserAccessAndRefreshTokens(p, idpWellKnownConfig, idpClientID, codeVerifier, code, redirectURL) if err != nil { errServer = fmt.Errorf("retrieve tokens: %w", err) return @@ -167,21 +170,22 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { p.Debug(print.DebugLevel, "received response from the authentication server") - sessionExpiresAtUnix, err := getStartingSessionExpiresAtUnix() + // Get access token expiration from the token itself (not session time limit) + sessionExpiresAtUnix, err := getAccessTokenExpiresAtUnix(accessToken) if err != nil { - errServer = fmt.Errorf("compute session expiration timestamp: %w", err) + errServer = fmt.Errorf("get access token expiration: %w", err) return } sessionExpiresAtUnixInt, err := strconv.Atoi(sessionExpiresAtUnix) if err != nil { - p.Debug(print.ErrorLevel, "parse session expiration value \"%s\": %s", sessionExpiresAtUnix, err) + p.Debug(print.ErrorLevel, "parse access token expiration value \"%s\": %s", sessionExpiresAtUnix, err) } else { sessionExpiresAt := time.Unix(int64(sessionExpiresAtUnixInt), 0) - p.Debug(print.DebugLevel, "session expires at %s", sessionExpiresAt) + p.Debug(print.DebugLevel, "access token expires at %s", sessionExpiresAt) } - err = SetAuthFlow(AUTH_FLOW_USER_TOKEN) + err = SetAuthFlowWithContext(context, AUTH_FLOW_USER_TOKEN) if err != nil { errServer = fmt.Errorf("set auth flow type: %w", err) return @@ -195,7 +199,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { p.Debug(print.DebugLevel, "user %s logged in successfully", email) - err = LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix) + err = LoginUserWithContext(context, email, accessToken, refreshToken, sessionExpiresAtUnix) if err != nil { errServer = fmt.Errorf("set in auth storage: %w", err) return @@ -211,7 +215,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { mux.HandleFunc(loginSuccessPath, func(w http.ResponseWriter, _ *http.Request) { defer cleanup(server) - email, err := GetAuthField(USER_EMAIL) + email, err := GetAuthFieldWithContext(context, USER_EMAIL) if err != nil { errServer = fmt.Errorf("read user email: %w", err) } @@ -265,7 +269,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { } // getUserAccessAndRefreshTokens trades the authorization code retrieved from the first OAuth2 leg for an access token and a refresh token -func getUserAccessAndRefreshTokens(idpWellKnownConfig *wellKnownConfig, clientID, codeVerifier, authorizationCode, callbackURL string) (accessToken, refreshToken string, err error) { +func getUserAccessAndRefreshTokens(p *print.Printer, idpWellKnownConfig *wellKnownConfig, clientID, codeVerifier, authorizationCode, callbackURL string) (accessToken, refreshToken string, err error) { // Set form-encoded data for the POST to the access token endpoint data := fmt.Sprintf( "grant_type=authorization_code&client_id=%s"+ @@ -278,6 +282,10 @@ func getUserAccessAndRefreshTokens(idpWellKnownConfig *wellKnownConfig, clientID // Create the request and execute it req, _ := http.NewRequest("POST", idpWellKnownConfig.TokenEndpoint, payload) req.Header.Add("content-type", "application/x-www-form-urlencoded") + + // Debug log the request + debugHTTPRequest(p, req) + httpClient := &http.Client{} res, err := httpClient.Do(req) if err != nil { @@ -291,6 +299,10 @@ func getUserAccessAndRefreshTokens(idpWellKnownConfig *wellKnownConfig, clientID err = fmt.Errorf("close response body: %w", closeErr) } }() + + // Debug log the response + debugHTTPResponse(p, res) + body, err := io.ReadAll(res.Body) if err != nil { return "", "", fmt.Errorf("read response body: %w", err) @@ -350,8 +362,12 @@ func openBrowser(pageUrl string) error { // parseWellKnownConfiguration gets the well-known OpenID configuration from the provided URL and returns it as a JSON // the method also stores the IDP token endpoint in the authentication storage -func parseWellKnownConfiguration(httpClient apiClient, wellKnownConfigURL string) (wellKnownConfig *wellKnownConfig, err error) { +func parseWellKnownConfiguration(p *print.Printer, httpClient apiClient, wellKnownConfigURL string, context StorageContext) (wellKnownConfig *wellKnownConfig, err error) { req, _ := http.NewRequest("GET", wellKnownConfigURL, http.NoBody) + + // Debug log the request + debugHTTPRequest(p, req) + res, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("make the request: %w", err) @@ -364,6 +380,10 @@ func parseWellKnownConfiguration(httpClient apiClient, wellKnownConfigURL string err = fmt.Errorf("close response body: %w", closeErr) } }() + + // Debug log the response + debugHTTPResponse(p, res) + body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("read response body: %w", err) @@ -386,7 +406,7 @@ func parseWellKnownConfiguration(httpClient apiClient, wellKnownConfigURL string return nil, fmt.Errorf("found no token endpoint") } - err = SetAuthField(IDP_TOKEN_ENDPOINT, wellKnownConfig.TokenEndpoint) + err = SetAuthFieldWithContext(context, IDP_TOKEN_ENDPOINT, wellKnownConfig.TokenEndpoint) if err != nil { return nil, fmt.Errorf("set token endpoint in the authentication storage: %w", err) } diff --git a/internal/pkg/auth/user_login_test.go b/internal/pkg/auth/user_login_test.go index 7b61a4af5..4bec68ad4 100644 --- a/internal/pkg/auth/user_login_test.go +++ b/internal/pkg/auth/user_login_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/zalando/go-keyring" ) @@ -93,7 +94,9 @@ func TestParseWellKnownConfig(t *testing.T) { tt.getResponse, } - got, err := parseWellKnownConfiguration(&testClient, "") + p := print.NewPrinter() + + got, err := parseWellKnownConfiguration(p, &testClient, "", StorageContextCLI) if tt.isValid && err != nil { t.Fatalf("expected no error, got %v", err) diff --git a/internal/pkg/auth/user_token_flow.go b/internal/pkg/auth/user_token_flow.go index 215db2fa3..823f01fbc 100644 --- a/internal/pkg/auth/user_token_flow.go +++ b/internal/pkg/auth/user_token_flow.go @@ -14,8 +14,9 @@ import ( type userTokenFlow struct { printer *print.Printer - reauthorizeUserRoutine func(p *print.Printer, isReauthentication bool) error // Called if the user needs to login again + reauthorizeUserRoutine func(p *print.Printer, context StorageContext, isReauthentication bool) error // Called if the user needs to login again client *http.Client + context StorageContext authFlow AuthFlow accessToken string refreshToken string @@ -26,15 +27,26 @@ type userTokenFlow struct { var _ http.RoundTripper = &userTokenFlow{} // Returns a round tripper that adds authentication according to the user token flow +// Uses the CLI storage context by default func UserTokenFlow(p *print.Printer) *userTokenFlow { + return UserTokenFlowWithContext(p, StorageContextCLI) +} + +// Returns a round tripper that adds authentication according to the user token flow +// with the specified storage context +func UserTokenFlowWithContext(p *print.Printer, context StorageContext) *userTokenFlow { return &userTokenFlow{ printer: p, reauthorizeUserRoutine: AuthorizeUser, client: &http.Client{}, + context: context, } } func (utf *userTokenFlow) RoundTrip(req *http.Request) (*http.Response, error) { + // Set the storage printer so debug messages use the correct verbosity + SetStoragePrinter(utf.printer) + err := loadVarsFromStorage(utf) if err != nil { return nil, err @@ -72,7 +84,7 @@ func (utf *userTokenFlow) RoundTrip(req *http.Request) (*http.Response, error) { } func loadVarsFromStorage(utf *userTokenFlow) error { - authFlow, err := GetAuthFlow() + authFlow, err := GetAuthFlowWithContext(utf.context) if err != nil { return fmt.Errorf("get auth flow type: %w", err) } @@ -81,7 +93,7 @@ func loadVarsFromStorage(utf *userTokenFlow) error { REFRESH_TOKEN: "", IDP_TOKEN_ENDPOINT: "", } - err = GetAuthFieldMap(authFields) + err = GetAuthFieldMapWithContext(utf.context, authFields) if err != nil { return fmt.Errorf("get tokens from auth storage: %w", err) } @@ -94,7 +106,7 @@ func loadVarsFromStorage(utf *userTokenFlow) error { } func reauthenticateUser(utf *userTokenFlow) error { - err := utf.reauthorizeUserRoutine(utf.printer, true) + err := utf.reauthorizeUserRoutine(utf.printer, utf.context, true) if err != nil { return fmt.Errorf("authenticate user: %w", err) } @@ -133,6 +145,9 @@ func refreshTokens(utf *userTokenFlow) (err error) { return fmt.Errorf("build request: %w", err) } + // Debug log the request + debugHTTPRequest(utf.printer, req) + resp, err := utf.client.Do(req) if err != nil { return fmt.Errorf("call API: %w", err) @@ -144,13 +159,24 @@ func refreshTokens(utf *userTokenFlow) (err error) { } }() + // Debug log the response + debugHTTPResponse(utf.printer, resp) + accessToken, refreshToken, err := parseRefreshTokensResponse(resp) if err != nil { return fmt.Errorf("parse API response: %w", err) } - err = SetAuthFieldMap(map[authFieldKey]string{ - ACCESS_TOKEN: accessToken, - REFRESH_TOKEN: refreshToken, + + // Get the new access token's expiration time + expiresAtUnix, err := getAccessTokenExpiresAtUnix(accessToken) + if err != nil { + return fmt.Errorf("get access token expiration: %w", err) + } + + err = SetAuthFieldMapWithContext(utf.context, map[authFieldKey]string{ + ACCESS_TOKEN: accessToken, + REFRESH_TOKEN: refreshToken, + SESSION_EXPIRES_AT_UNIX: expiresAtUnix, }) if err != nil { return fmt.Errorf("set refreshed tokens in auth storage: %w", err) diff --git a/internal/pkg/auth/user_token_flow_test.go b/internal/pkg/auth/user_token_flow_test.go index cd31350ad..55d8ea8f0 100644 --- a/internal/pkg/auth/user_token_flow_test.go +++ b/internal/pkg/auth/user_token_flow_test.go @@ -278,7 +278,7 @@ func TestRoundTrip(t *testing.T) { authorizeUserCalled: &authorizeUserCalled, tokensRefreshed: &tokensRefreshed, } - authorizeUserRoutine := func(_ *print.Printer, _ bool) error { + authorizeUserRoutine := func(_ *print.Printer, _ StorageContext, _ bool) error { return reauthorizeUser(authorizeUserContext) } @@ -292,6 +292,7 @@ func TestRoundTrip(t *testing.T) { printer: p, reauthorizeUserRoutine: authorizeUserRoutine, client: client, + context: StorageContextCLI, } req, err := http.NewRequest(http.MethodGet, "request/url", http.NoBody) if err != nil {