Skip to content

Commit

Permalink
Refresh token and retry request on 401, in :curl commands
Browse files Browse the repository at this point in the history
  • Loading branch information
pjcdawkins committed Dec 15, 2024
1 parent 10c6d65 commit c4c1d97
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 28 deletions.
63 changes: 63 additions & 0 deletions go-tests/api_curl_test.go
Original file line number Diff line number Diff line change
@@ -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": "[email protected]"})
})
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)
}
10 changes: 7 additions & 3 deletions go-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions go-tests/go.sum
Original file line number Diff line number Diff line change
@@ -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=
6 changes: 4 additions & 2 deletions src/Service/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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')) {
Expand Down
99 changes: 76 additions & 23 deletions src/Service/CurlCli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: <info>%s</info>', $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),
Expand All @@ -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');
Expand All @@ -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: <info>%s</info>', $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;
}
}

0 comments on commit c4c1d97

Please sign in to comment.