From 41d6467b3bf6e86262f90b6fd70f1d1e4b01fa72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20L=C3=BCder?= Date: Tue, 16 Aug 2022 13:34:18 +0200 Subject: [PATCH] Added token refresh for GitLab to support GitLab 15+ (#10988) Co-authored-by: Jordi Boggiano --- phpstan/baseline.neon | 2 +- res/composer-schema.json | 19 +++- src/Composer/IO/BaseIO.php | 1 + src/Composer/Repository/Vcs/GitLabDriver.php | 4 + src/Composer/Util/GitLab.php | 108 ++++++++++++++++++- tests/Composer/Test/Util/GitLabTest.php | 4 +- 6 files changed, 131 insertions(+), 7 deletions(-) diff --git a/phpstan/baseline.neon b/phpstan/baseline.neon index 1dd2336c15f4..ce404836ca49 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -4585,7 +4585,7 @@ parameters: - message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#" - count: 1 + count: 2 path: ../src/Composer/Util/GitLab.php - diff --git a/res/composer-schema.json b/res/composer-schema.json index 19669a000418..c384e5b1ffba 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -340,9 +340,24 @@ }, "gitlab-oauth": { "type": "object", - "description": "An object of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":\"\"}.", + "description": "An object of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":{\"expires-at\":\"\", \"refresh-token\":\"\", \"token\":\"\"}}.", "additionalProperties": { - "type": "string" + "type": ["string", "object"], + "required": [ "token"], + "properties": { + "expires-at": { + "type": "integer", + "description": "The expiration date for this GitLab token" + }, + "refresh-token": { + "type": "string", + "description": "The refresh token used for GitLab authentication" + }, + "token": { + "type": "string", + "description": "The token used for GitLab authentication" + } + } } }, "gitlab-token": { diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php index d53d0b0978ab..60eec1882b00 100644 --- a/src/Composer/IO/BaseIO.php +++ b/src/Composer/IO/BaseIO.php @@ -137,6 +137,7 @@ public function loadConfiguration(Config $config) } foreach ($gitlabOauth as $domain => $token) { + $token = is_array($token) ? $token["token"] : $token; $this->checkAndSetAuthentication($domain, $token, 'oauth2'); } diff --git a/src/Composer/Repository/Vcs/GitLabDriver.php b/src/Composer/Repository/Vcs/GitLabDriver.php index 7f2db1f55899..bbcd2a0052fa 100644 --- a/src/Composer/Repository/Vcs/GitLabDriver.php +++ b/src/Composer/Repository/Vcs/GitLabDriver.php @@ -537,6 +537,10 @@ protected function getContents(string $url, bool $fetchingRepoData = false): Res return parent::getContents($url); } + if ($gitLabUtil->isOAuthExpired($this->originUrl) && $gitLabUtil->authorizeOAuthRefresh($this->scheme, $this->originUrl)) { + return parent::getContents($url); + } + if (!$this->io->isInteractive()) { $this->attemptCloneFallback(); diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php index ed05b46c5b3e..ce32e9535bdd 100644 --- a/src/Composer/Util/GitLab.php +++ b/src/Composer/Util/GitLab.php @@ -126,7 +126,8 @@ public function authorizeOAuthInteractively(string $scheme, string $originUrl, s } $this->io->writeError(sprintf('A token will be created and stored in "%s", your password will never be stored', $this->config->getAuthConfigSource()->getName())); - $this->io->writeError('To revoke access to this token you can visit '.$scheme.'://'.$originUrl.'/-/profile/personal_access_tokens'); + $this->io->writeError('To revoke access to this token you can visit '.$scheme.'://'.$originUrl.'/-/profile/applications'); + $this->io->writeError('Alternatively you can setup an personal access token on '.$scheme.'://'.$originUrl.'/-/profile/personal_access_token and store it under "gitlab-token" see https://getcomposer.org/doc/articles/authentication-for-private-packages.md#gitlab-token for more details.'); $attemptCounter = 0; @@ -160,7 +161,18 @@ public function authorizeOAuthInteractively(string $scheme, string $originUrl, s $this->io->setAuthentication($originUrl, $response['access_token'], 'oauth2'); // store value in user config in auth file - $this->config->getAuthConfigSource()->addConfigSetting('gitlab-oauth.'.$originUrl, $response['access_token']); + if (isset($response['expires_in'])) { + $this->config->getAuthConfigSource()->addConfigSetting( + 'gitlab-oauth.'.$originUrl, + [ + 'expires-at' => intval($response['created_at']) + intval($response['expires_in']), + 'refresh-token' => $response['refresh_token'], + 'token' => $response['access_token'], + ] + ); + } else { + $this->config->getAuthConfigSource()->addConfigSetting('gitlab-oauth.'.$originUrl, $response['access_token']); + } return true; } @@ -168,11 +180,46 @@ public function authorizeOAuthInteractively(string $scheme, string $originUrl, s throw new \RuntimeException('Invalid GitLab credentials 5 times in a row, aborting.'); } + /** + * Authorizes a GitLab domain interactively via OAuth. + * + * @param string $scheme Scheme used in the origin URL + * @param string $originUrl The host this GitLab instance is located at + * + * @throws \RuntimeException + * @throws TransportException|\Exception + * + * @return bool true on success + */ + public function authorizeOAuthRefresh(string $scheme, string $originUrl): bool + { + try { + $response = $this->refreshToken($scheme, $originUrl); + } catch (TransportException $e) { + $this->io->writeError("Couldn't refresh access token: ".$e->getMessage()); + return false; + } + + $this->io->setAuthentication($originUrl, $response['access_token'], 'oauth2'); + + // store value in user config in auth file + $this->config->getAuthConfigSource()->addConfigSetting( + 'gitlab-oauth.'.$originUrl, + [ + 'expires-at' => intval($response['created_at']) + intval($response['expires_in']), + 'refresh-token' => $response['refresh_token'], + 'token' => $response['access_token'], + ] + ); + + return true; + } + /** * @param string $scheme * @param string $originUrl * - * @return array{access_token: non-empty-string, token_type: non-empty-string, expires_in: positive-int} + * @return array{access_token: non-empty-string, refresh_token: non-empty-string, token_type: non-empty-string, expires_in?: positive-int, created_at: positive-int} * * @see https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow */ @@ -204,4 +251,59 @@ private function createToken(string $scheme, string $originUrl): array return $token; } + + /** + * Is the OAuth access token expired? + * + * @return bool true on expired token, false if token is fresh or expiration date is not set + */ + public function isOAuthExpired(string $originUrl): bool + { + $authTokens = $this->config->get('gitlab-oauth'); + if (isset($authTokens[$originUrl]['expires-at'])) { + if ($authTokens[$originUrl]['expires-at'] < time()) { + return true; + } + } + + return false; + } + + /** + * @param string $scheme + * @param string $originUrl + * + * @return array{access_token: non-empty-string, refresh_token: non-empty-string, token_type: non-empty-string, expires_in: positive-int, created_at: positive-int} + * + * @see https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow + */ + private function refreshToken(string $scheme, string $originUrl): array + { + $authTokens = $this->config->get('gitlab-oauth'); + if (!isset($authTokens[$originUrl]['refresh-token'])) { + throw new \RuntimeException('No GitLab refresh token present for '.$originUrl.'.'); + } + + $refreshToken = $authTokens[$originUrl]['refresh-token']; + $headers = array('Content-Type: application/x-www-form-urlencoded'); + + $data = http_build_query(array( + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ), '', '&'); + $options = array( + 'retry-auth-failure' => false, + 'http' => array( + 'method' => 'POST', + 'header' => $headers, + 'content' => $data, + ), + ); + + $token = $this->httpDownloader->get($scheme.'://'.$originUrl.'/oauth/token', $options)->decodeJson(); + $this->io->writeError('GitLab token successfully refreshed', true, IOInterface::VERY_VERBOSE); + $this->io->writeError('To revoke access to this token you can visit '.$scheme.'://'.$originUrl.'/-/profile/applications', true, IOInterface::VERY_VERBOSE); + + return $token; + } } diff --git a/tests/Composer/Test/Util/GitLabTest.php b/tests/Composer/Test/Util/GitLabTest.php index d1f44d946469..1ce746176361 100644 --- a/tests/Composer/Test/Util/GitLabTest.php +++ b/tests/Composer/Test/Util/GitLabTest.php @@ -30,6 +30,8 @@ class GitLabTest extends TestCase private $origin = 'gitlab.com'; /** @var string */ private $token = 'gitlabtoken'; + /** @var string */ + private $refreshtoken = 'gitlabrefreshtoken'; public function testUsernamePasswordAuthenticationFlow(): void { @@ -54,7 +56,7 @@ public function testUsernamePasswordAuthenticationFlow(): void $httpDownloader = $this->getHttpDownloaderMock(); $httpDownloader->expects( - [['url' => sprintf('http://%s/oauth/token', $this->origin), 'body' => sprintf('{"access_token": "%s", "token_type": "bearer", "expires_in": 7200}', $this->token)]], + [['url' => sprintf('http://%s/oauth/token', $this->origin), 'body' => sprintf('{"access_token": "%s", "refresh_token": "%s", "token_type": "bearer", "expires_in": 7200, "created_at": 0}', $this->token, $this->refreshtoken)]], true );