From 813c70444a8c58fb83d25bd09d98a42024e14733 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sat, 14 Dec 2024 19:03:14 +0000 Subject: [PATCH] Refresh token and retry request on 401, in :curl commands --- .editorconfig | 3 ++ go-tests/api_curl_test.go | 63 +++++++++++++++++++++++++ go-tests/go.mod | 10 ++-- go-tests/go.sum | 12 +++++ go-tests/org_list_test.go | 60 ++++++++++++------------ src/Service/Api.php | 6 ++- src/Service/CurlCli.php | 99 ++++++++++++++++++++++++++++++--------- 7 files changed, 195 insertions(+), 58 deletions(-) create mode 100644 go-tests/api_curl_test.go diff --git a/.editorconfig b/.editorconfig index 167c11a5f..4bda89301 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,3 +19,6 @@ indent_size = 2 [*.yml] indent_size = 2 + +[*.go] +indent_style = tab diff --git a/go-tests/api_curl_test.go b/go-tests/api_curl_test.go new file mode 100644 index 000000000..30aa27a16 --- /dev/null +++ b/go-tests/api_curl_test.go @@ -0,0 +1,63 @@ +package tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/stretchr/testify/assert" +) + +func TestApiCurlCommand(t *testing.T) { + validToken := "valid-token" + + mux := chi.NewMux() + if testing.Verbose() { + mux.Use(middleware.DefaultLogger) + } + mux.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/oauth2") { + if r.Header.Get("Authorization") != "Bearer "+validToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + } + next.ServeHTTP(w, r) + }) + }) + var tokenFetches int + mux.Post("/oauth2/token", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + tokenFetches++ + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": validToken, "expires_in": 900, "token_type": "bearer"}) + }) + mux.Get("/users/me", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"id": "userID", "email": "me@example.com"}) + }) + mux.Get("/fake-api-path", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("success")) + }) + mockServer := httptest.NewServer(mux) + defer mockServer.Close() + + run := runnerWithAuth(t, mockServer.URL, mockServer.URL) + + // Load the first token. + assert.Equal(t, "success", run("api:curl", "/fake-api-path")) + assert.Equal(t, 1, tokenFetches) + + // Revoke the access token and try the command again. + // The old token should be considered invalid, so the API call should return 401, + // and then the CLI should refresh the token and retry. + validToken = "new-valid-token" + assert.Equal(t, "success", run("api:curl", "/fake-api-path")) + assert.Equal(t, 2, tokenFetches) + + assert.Equal(t, "success", run("api:curl", "/fake-api-path")) + assert.Equal(t, 2, tokenFetches) +} diff --git a/go-tests/go.mod b/go-tests/go.mod index 0a023b5b9..93222f09e 100644 --- a/go-tests/go.mod +++ b/go-tests/go.mod @@ -2,13 +2,17 @@ module github.com/platformsh/legacy-cli/tests go 1.22.9 +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/platformsh/cli v0.0.0-20241126124927-2e901f7c6a3b + github.com/stretchr/testify v1.9.0 +) + require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-chi/chi/v5 v5.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/oklog/ulid/v2 v2.1.0 // indirect - github.com/platformsh/cli v0.0.0-20241126124927-2e901f7c6a3b // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/stretchr/testify v1.9.0 // indirect golang.org/x/crypto v0.24.0 // indirect golang.org/x/sys v0.21.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go-tests/go.sum b/go-tests/go.sum index 6c4f70255..398411c12 100644 --- a/go-tests/go.sum +++ b/go-tests/go.sum @@ -1,20 +1,32 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/platformsh/cli v0.0.0-20241126124927-2e901f7c6a3b h1:Pbyjf2FNzShe71EJnG/8ezhUl63RjkC8y7VGmBGmIXM= github.com/platformsh/cli v0.0.0-20241126124927-2e901f7c6a3b/go.mod h1:j9Aj8DxVGyn+Jm3ntopLnk6p0XtOeLWVBpF3zhqHh7M= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-tests/org_list_test.go b/go-tests/org_list_test.go index 849b22695..8632ad5d0 100644 --- a/go-tests/org_list_test.go +++ b/go-tests/org_list_test.go @@ -1,36 +1,36 @@ package tests import ( - "net/http/httptest" - "net/url" - "strings" - "testing" + "net/http/httptest" + "net/url" + "strings" + "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/assert" - "github.com/platformsh/cli/pkg/mockapi" + "github.com/platformsh/cli/pkg/mockapi" ) func TestOrgList(t *testing.T) { - authServer := mockapi.NewAuthServer(t) - defer authServer.Close() + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() - myUserID := "user-id-1" + myUserID := "user-id-1" - apiHandler := mockapi.NewHandler(t) - apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) - apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("org-id-1", "acme", "ACME Inc.", myUserID), - makeOrg("org-id-2", "four-seasons", "Four Seasons Total Landscaping", myUserID), - makeOrg("org-id-3", "duff", "Duff Beer", "user-id-2"), - }) + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) + apiHandler.SetOrgs([]*mockapi.Org{ + makeOrg("org-id-1", "acme", "ACME Inc.", myUserID), + makeOrg("org-id-2", "four-seasons", "Four Seasons Total Landscaping", myUserID), + makeOrg("org-id-3", "duff", "Duff Beer", "user-id-2"), + }) - apiServer := httptest.NewServer(apiHandler) - defer apiServer.Close() + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() - run := runnerWithAuth(t, apiServer.URL, authServer.URL) + run := runnerWithAuth(t, apiServer.URL, authServer.URL) - assert.Equal(t, strings.TrimLeft(` + assert.Equal(t, strings.TrimLeft(` +--------------+--------------------------------+-----------------------+ | Name | Label | Owner email | +--------------+--------------------------------+-----------------------+ @@ -40,14 +40,14 @@ func TestOrgList(t *testing.T) { +--------------+--------------------------------+-----------------------+ `, "\n"), run("orgs")) - assert.Equal(t, strings.TrimLeft(` + assert.Equal(t, strings.TrimLeft(` Name Label Owner email acme ACME Inc. user-id-1@example.com duff Duff Beer user-id-2@example.com four-seasons Four Seasons Total Landscaping user-id-1@example.com `, "\n"), run("orgs", "--format", "plain")) - assert.Equal(t, strings.TrimLeft(` + assert.Equal(t, strings.TrimLeft(` org-id-1,acme org-id-3,duff org-id-2,four-seasons @@ -55,12 +55,12 @@ org-id-2,four-seasons } func makeOrg(id, name, label, owner string) *mockapi.Org { - return &mockapi.Org{ - ID: id, - Name: name, - Label: label, - Owner: owner, - Capabilities: []string{}, - Links: mockapi.MakeHALLinks("self=/organizations/" + url.PathEscape(id)), - } + return &mockapi.Org{ + ID: id, + Name: name, + Label: label, + Owner: owner, + Capabilities: []string{}, + Links: mockapi.MakeHALLinks("self=/organizations/" + url.PathEscape(id)), + } } diff --git a/src/Service/Api.php b/src/Service/Api.php index 923bb0f4b..f40e96e5a 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1221,9 +1221,11 @@ public function matchPartialId($id, array $resources, $name = 'Resource') /** * Returns the OAuth 2 access token. * + * @param bool $forceNew + * * @return string */ - public function getAccessToken() + public function getAccessToken($forceNew = false) { // Check for an externally configured access token. if ($accessToken = $this->tokenConfig->getAccessToken()) { @@ -1237,7 +1239,7 @@ public function getAccessToken() // If there is no token, or it has expired, make an API request, which // automatically obtains a token and saves it to the session. - if (!$token || $expires < time()) { + if (!$token || $expires < time() || $forceNew) { $this->getUser(null, true); $newSession = $this->getClient()->getConnector()->getSession(); if (!$token = $newSession->get('accessToken')) { diff --git a/src/Service/CurlCli.php b/src/Service/CurlCli.php index 7eb67df61..f01fccb98 100644 --- a/src/Service/CurlCli.php +++ b/src/Service/CurlCli.php @@ -2,6 +2,7 @@ namespace Platformsh\Cli\Service; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; @@ -55,6 +56,65 @@ public function run($baseUrl, InputInterface $input, OutputInterface $output) { } $token = $this->api->getAccessToken(); + + $showCurlVerboseOutput = $output->isVeryVerbose(); + + // Censor the access token: this can be applied to verbose output. + $censor = function ($str) use (&$token) { + return str_replace($token, '[token]', $str); + }; + + $commandline = $this->buildCurlCommand($url, $token, $input); + + $stdErr->writeln(sprintf('Running command: %s', $censor($commandline)), OutputInterface::VERBOSITY_VERBOSE); + + $process = new Process($commandline); + $newToken = ''; + $onOutput = function ($type, $buffer) use ($censor, $output, $stdErr, $showCurlVerboseOutput, $process, &$newToken) { + if ($type === Process::ERR) { + if ($this->parseCurlStatusCode($buffer) === 401 && $newToken === '' && $this->api->isLoggedIn()) { + $newToken = $this->api->getAccessToken(true); + $stdErr->writeln('The access token has been refreshed. Retrying request.'); + $process->clearErrorOutput(); + $process->clearOutput(); + $process->stop(); + return; + } + if ($showCurlVerboseOutput) { + $stdErr->write($censor($buffer)); + } + } else { + $output->write($buffer); + } + }; + + $process->run($onOutput); + + if ($newToken !== '') { + // Create a new curl process, replacing the access token. + $newCommandline = $this->buildCurlCommand($url, $newToken, $input); + $newProcess = new Process($newCommandline); + + // Update the $token variable in the $censor closure. + $token = $newToken; + + return $newProcess->run($onOutput); + } + + return $process->getExitCode(); + } + + /** + * Builds a curl command with a URL and access token. + * + * @param string $url + * @param string $token + * @param InputInterface $input + * + * @return string + */ + private function buildCurlCommand($url, $token, InputInterface $input) + { $commandline = sprintf( 'curl -H %s %s', escapeshellarg('Authorization: Bearer ' . $token), @@ -74,8 +134,7 @@ public function run($baseUrl, InputInterface $input, OutputInterface $output) { if ($data = $input->getOption('json')) { if (\json_decode($data) === null && \json_last_error() !== JSON_ERROR_NONE) { - $stdErr->writeln('The value of --json contains invalid JSON.'); - return 1; + throw new InvalidArgumentException('The value of --json contains invalid JSON.'); } $commandline .= ' --data ' . escapeshellarg($data); $commandline .= ' --header ' . escapeshellarg('Content-Type: application/json'); @@ -98,28 +157,22 @@ public function run($baseUrl, InputInterface $input, OutputInterface $output) { $commandline .= ' --header ' . escapeshellarg($header); } - if ($output->isVeryVerbose()) { - $commandline .= ' --verbose'; - } else { - $commandline .= ' --silent --show-error'; - } - - // Censor the access token: this can be applied to verbose output. - $censor = function ($str) use ($token) { - return str_replace($token, '[token]', $str); - }; - - $stdErr->writeln(sprintf('Running command: %s', $censor($commandline)), OutputInterface::VERBOSITY_VERBOSE); + $commandline .= ' --no-progress-meter --verbose'; - $process = new Process($commandline); - $process->run(function ($type, $buffer) use ($censor, $output, $stdErr) { - if ($type === Process::ERR) { - $stdErr->write($censor($buffer)); - } else { - $output->write($buffer); - } - }); + return $commandline; + } - return $process->getExitCode(); + /** + * Parses an HTTP response status code from cURL verbose output. + * + * @param string $buffer + * @return int|null + */ + private function parseCurlStatusCode($buffer) + { + if (preg_match('#< HTTP/[1-3]+(?:\.[0-9]+)? ([1-5][0-9]{2})\s#', $buffer, $matches)) { + return (int) $matches[1]; + } + return null; } }