diff --git a/internal/logout/command.go b/internal/logout/command.go index e30fdd6dec..1d625fc8b8 100644 --- a/internal/logout/command.go +++ b/internal/logout/command.go @@ -2,6 +2,8 @@ package logout import ( "fmt" + "net/http" + "net/url" "github.com/spf13/cobra" @@ -12,6 +14,7 @@ import ( "github.com/confluentinc/cli/v4/pkg/ccloudv2" pcmd "github.com/confluentinc/cli/v4/pkg/cmd" "github.com/confluentinc/cli/v4/pkg/config" + "github.com/confluentinc/cli/v4/pkg/log" "github.com/confluentinc/cli/v4/pkg/output" ) @@ -51,8 +54,11 @@ func (c *command) logout(_ *cobra.Command, _ []string) error { ctx := c.Config.Context() if ctx != nil { if ccloudv2.IsCCloudURL(ctx.Platform.Server, c.cfg.IsTest) { - if _, err := c.revokeCCloudRefreshToken(ctx); err != nil { - return err + if err := c.revokeCCloudSession(ctx); err != nil { + // Local credentials are cleared regardless, so a failed revocation + // cannot strand the user in a logged-in state. + log.CliLogger.Warnf("Failed to revoke session: %v", err) + output.ErrPrintln(c.Config.EnableColor, "Warning: your session could not be revoked and may still be active. Local credentials were removed.") } } } @@ -65,16 +71,45 @@ func (c *command) logout(_ *cobra.Command, _ []string) error { return nil } -func (c *command) revokeCCloudRefreshToken(ctx *config.Context) (*ccloudv1.AuthenticateReply, error) { - contextState := c.Config.ContextStates[ctx.Name] - if err := contextState.DecryptAuthToken(ctx.Name); err != nil { - return nil, err +func (c *command) revokeCCloudSession(ctx *config.Context) error { + if sso.IsOkta(ctx.Platform.Server) { + contextState := c.Config.ContextStates[ctx.Name] + if err := contextState.DecryptAuthToken(ctx.Name); err != nil { + return err + } + + _, err := c.Client.Auth.OktaLogout(&ccloudv1.AuthenticateRequest{IdToken: contextState.AuthToken}) + return err } - req := &ccloudv1.AuthenticateRequest{IdToken: contextState.AuthToken} - if sso.IsOkta(ctx.Platform.Server) { - return c.Client.Auth.OktaLogout(req) - } else { - return c.Client.Auth.Logout(req) + return c.deleteSession() +} + +// deleteSession scopes revocation to the CLI's own Auth0 client, leaving the user's +// browser and IDE sessions intact. +func (c *command) deleteSession() error { + u, err := url.Parse(c.Client.BaseURL) + if err != nil { + return err + } + u = u.JoinPath("api", "iam", "v2", "sessions") + u.RawQuery = url.Values{"client_id": []string{sso.GetAuth0CCloudClientIdFromBaseUrl(c.Client.BaseURL)}}.Encode() + + req, err := http.NewRequest(http.MethodDelete, u.String(), nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", c.Client.UserAgent) + + res, err := c.Client.HttpClient.Do(req) + if err != nil { + return err } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("%s returned %s", req.URL.Path, res.Status) + } + + return nil } diff --git a/internal/logout/command_test.go b/internal/logout/command_test.go index a4a865251f..885b79ae54 100644 --- a/internal/logout/command_test.go +++ b/internal/logout/command_test.go @@ -1,6 +1,8 @@ package logout import ( + "net/http" + "net/http/httptest" "testing" "github.com/spf13/cobra" @@ -14,6 +16,7 @@ import ( pauth "github.com/confluentinc/cli/v4/pkg/auth" pcmd "github.com/confluentinc/cli/v4/pkg/cmd" "github.com/confluentinc/cli/v4/pkg/config" + "github.com/confluentinc/cli/v4/pkg/log" ) const ( @@ -53,6 +56,48 @@ func TestLogout(t *testing.T) { verifyLoggedOutState(t, cfg, contextName) } +func TestDeleteSession(t *testing.T) { + var req *http.Request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req = r + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + require.NoError(t, newDeleteSessionCmd(server.URL).deleteSession()) + + require.Equal(t, http.MethodDelete, req.Method) + require.Equal(t, "/api/iam/v2/sessions", req.URL.Path) + // A httptest URL matches no known environment, so the client ID falls through to prod's. + require.Equal(t, "oX2nvSKl5jvBKVgwehZfvR4K8RhsZIEs", req.URL.Query().Get("client_id")) +} + +func TestDeleteSessionNoContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + require.NoError(t, newDeleteSessionCmd(server.URL).deleteSession()) +} + +func TestDeleteSessionError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := newDeleteSessionCmd(server.URL).deleteSession() + require.Error(t, err) + require.Contains(t, err.Error(), "/api/iam/v2/sessions") +} + +func newDeleteSessionCmd(baseUrl string) *command { + return &command{AuthenticatedCLICommand: &pcmd.AuthenticatedCLICommand{ + Client: ccloudv1.NewClient(&ccloudv1.Params{BaseURL: baseUrl, HttpClient: ccloudv1.BaseClient, Logger: log.CliLogger}), + }} +} + func newLogoutCmd(auth *ccloudv1mock.Auth, userInterface *ccloudv1mock.UserInterface, isCloud bool, req *require.Assertions, authTokenHandler pauth.AuthTokenHandler, contextName string) (*cobra.Command, *config.Config) { config.SetTempHomeDir() cfg := config.AuthenticatedConfigMockWithContextName(contextName) diff --git a/test/test-server/ccloud_handlers.go b/test/test-server/ccloud_handlers.go index 3244c56c4a..11e42a9be1 100644 --- a/test/test-server/ccloud_handlers.go +++ b/test/test-server/ccloud_handlers.go @@ -109,6 +109,15 @@ func handleMe(t *testing.T, isAuditLogEnabled bool) http.HandlerFunc { } } +// Handler for: "/api/iam/v2/sessions" +func handleDeleteSession(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + require.NotEmpty(t, r.URL.Query().Get("client_id")) + w.WriteHeader(http.StatusOK) + } +} + // Handler for: "/api/sessions" func handleLogin(t *testing.T) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/test/test-server/ccloud_router.go b/test/test-server/ccloud_router.go index 7b211e6cd7..b5c5ec1e5f 100644 --- a/test/test-server/ccloud_router.go +++ b/test/test-server/ccloud_router.go @@ -13,6 +13,7 @@ var ccloudHandlers = []route{ {"/api/env_metadata", handleEnvMetadata}, {"/api/external_identities", handleExternalIdentities}, {"/api/growth/v1/free-trial-info", handleFreeTrialInfo}, + {"/api/iam/v2/sessions", handleDeleteSession}, {"/api/login/realm", handleLoginRealm}, {"/api/metadata/security/v2alpha1/authenticate", handleV2Authenticate}, {"/api/organizations/{id}/payment_info", handlePaymentInfo},