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..40187a93f --- /dev/null +++ b/internal/cmd/auth/api/get-access-token/get_access_token.go @@ -0,0 +1,77 @@ +package getaccesstoken + +import ( + "github.com/spf13/cobra" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(p *types.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(p.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(p.Printer, auth.StorageContextAPI) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get valid access token: %v", err) + return &cliErr.SessionExpiredError{} + } + + result := map[string]string{ + "access_token": accessToken, + } + return p.Printer.OutputResult(model.OutputFormat, result, func() error { + p.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) { + cobra.CheckErr(command.Flags().MarkHidden(globalflags.ProjectIdFlag)) + 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..f51dc2c80 --- /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/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/types" +) + +func NewCmd(p *types.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(p.Printer, auth.StorageContextAPI, false) + if err != nil { + return fmt.Errorf("authorization failed: %w", err) + } + + p.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..e405a6b24 --- /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/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/types" +) + +func NewCmd(p *types.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) + } + + p.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..1ba51ec69 --- /dev/null +++ b/internal/cmd/auth/api/provider.go @@ -0,0 +1,39 @@ +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/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(p *types.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. + +Tokens are stored separately from tokens received from "stackit auth login", this allows using separate accounts when using the cli directly vs. using it to manage credentials for the SDK and TF provider. + +Tokens are stored in the OS keychain with a fallback to local storage.`, + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *types.CmdParams) { + cmd.AddCommand(login.NewCmd(p)) + cmd.AddCommand(logout.NewCmd(p)) + cmd.AddCommand(getaccesstoken.NewCmd(p)) + cmd.AddCommand(status.NewCmd(p)) +} diff --git a/internal/cmd/auth/api/status/status.go b/internal/cmd/auth/api/status/status.go new file mode 100644 index 000000000..a393258a5 --- /dev/null +++ b/internal/cmd/auth/api/status/status.go @@ -0,0 +1,98 @@ +package status + +import ( + "github.com/spf13/cobra" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +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(p *types.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(p.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(p.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(p.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) { + cobra.CheckErr(command.Flags().MarkHidden(globalflags.ProjectIdFlag)) + 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 { + return p.OutputResult(model.OutputFormat, status, func() error { + 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 d54f3fb01..92bd4cbb7 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 *types.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/get-access-token/get_access_token.go b/internal/cmd/auth/get-access-token/get_access_token.go index a3c1246e6..2317f74d1 100644 --- a/internal/cmd/auth/get-access-token/get_access_token.go +++ b/internal/cmd/auth/get-access-token/get_access_token.go @@ -71,7 +71,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { // 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 + cobra.CheckErr(command.Flags().MarkHidden(globalflags.ProjectIdFlag)) command.Parent().HelpFunc()(command, strings) }) diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go index 23efd0a4e..39aa95e4b 100644 --- a/internal/cmd/auth/login/login.go +++ b/internal/cmd/auth/login/login.go @@ -26,7 +26,7 @@ func NewCmd(params *types.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 dd56536d3..48af9cac2 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) } @@ -142,18 +155,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 { @@ -166,7 +208,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) } @@ -201,6 +243,7 @@ func GetValidAccessToken(p *print.Printer) (string, error) { utf := &userTokenFlow{ printer: p, client: &http.Client{}, + context: context, authFlow: flow, accessToken: accessToken, refreshToken: refreshToken, @@ -216,3 +259,59 @@ 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 + } + if !p.IsVerbosityDebug() { + 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 + } + if !p.IsVerbosityDebug() { + 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 054c74c89..71c5d67be 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) } @@ -164,7 +167,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 @@ -172,21 +175,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 @@ -200,7 +204,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 @@ -216,7 +220,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) } @@ -270,7 +274,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"+ @@ -283,6 +287,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 { @@ -296,6 +304,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) @@ -355,8 +367,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) @@ -369,6 +385,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) @@ -391,7 +411,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 cdb852f77..7e867ce8b 100644 --- a/internal/pkg/auth/user_token_flow.go +++ b/internal/pkg/auth/user_token_flow.go @@ -15,8 +15,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 @@ -27,15 +28,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 @@ -73,7 +85,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) } @@ -82,7 +94,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) } @@ -95,7 +107,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) } @@ -134,6 +146,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) @@ -145,13 +160,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 {