From b2b1db9cdb7b4ed48c5422602dbbc068bb92bfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20L=C3=BCder?= Date: Tue, 9 Aug 2022 01:54:05 +0200 Subject: [PATCH 1/6] Added token refresh for GitLab --- res/composer-schema.json | 19 ++- src/Composer/IO/BaseIO.php | 1 + src/Composer/Repository/Vcs/GitLabDriver.php | 4 + src/Composer/Util/GitLab.php | 117 ++++++++++++++++++- 4 files changed, 137 insertions(+), 4 deletions(-) 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..4df934dfd254 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, 'Your token has expired and must be refreshed.')){ + 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..5d1c22273540 100644 --- a/src/Composer/Util/GitLab.php +++ b/src/Composer/Util/GitLab.php @@ -160,7 +160,13 @@ 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']); + $this->config->getAuthConfigSource()->addConfigSetting('gitlab-oauth.'.$originUrl, + array( + 'expires-at' => intval($response['created_at']) + intval($response['expires_in']), + 'refresh-token' => $response['refresh_token'], + 'token' => $response['access_token'], + ) + ); return true; } @@ -168,11 +174,58 @@ 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 + * @param string $message The reason this authorization is required + * + * @throws \RuntimeException + * @throws TransportException|\Exception + * + * @return bool true on success + */ + public function authorizeOAuthRefresh(string $scheme, string $originUrl, string $message = null): bool + { + if ($this->io->isInteractive()) { + if ($message) { + $this->io->writeError($message); + } + + $this->io->writeError(sprintf('A new token will be created and stored in "%s".', $this->config->getAuthConfigSource()->getName())); + $this->io->writeError('To revoke access to this token you can visit '.$scheme.'://'.$originUrl.'/-/profile/personal_access_tokens'); + } + + try { + $response = $this->refreshToken($scheme, $originUrl); + } catch (TransportException $e) { + + if ($this->io->isInteractive()) { + $this->io->writeError("Couldn't refresh access token."); + } + 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, + array( + '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, 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 +257,64 @@ private function createToken(string $scheme, string $originUrl): array return $token; } + + /** + * Is the OAuth access token expired? + * + * @return bool true on expired token or expiration date is not set + */ + public function isOAuthExpired(string $originUrl): bool { + + $authTokens = $this->config->get('gitlab-oauth'); + if (isset($authTokens[$originUrl], $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, 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 + { + $refreshToken = ''; + $authTokens = $this->config->get('gitlab-oauth'); + if (isset($authTokens[$originUrl], $authTokens[$originUrl]['refresh-token'])) { + $refreshToken = $authTokens[$originUrl]['refresh-token']; + } else { + throw new \RuntimeException('Invalid GitLab refresh token.'); + } + + $headers = array('Content-Type: application/x-www-form-urlencoded'); + + $apiUrl = $originUrl; + $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.'://'.$apiUrl.'/oauth/token', $options)->decodeJson(); + + if ($this->io->isInteractive()) { + $this->io->writeError('Token successfully created'); + } + + return $token; + } } From d87a58ba1f07043a5076e14e60c47f6d50d48f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20L=C3=BCder?= Date: Tue, 9 Aug 2022 02:26:16 +0200 Subject: [PATCH 2/6] Fixed return type --- src/Composer/Util/GitLab.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php index 5d1c22273540..a0dc17dae552 100644 --- a/src/Composer/Util/GitLab.php +++ b/src/Composer/Util/GitLab.php @@ -225,7 +225,7 @@ public function authorizeOAuthRefresh(string $scheme, string $originUrl, string * @param string $scheme * @param string $originUrl * - * @return array{access_token: non-empty-string, token_type: non-empty-string, expires_in: positive-int, created_at: 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 */ @@ -279,7 +279,7 @@ public function isOAuthExpired(string $originUrl): bool { * @param string $scheme * @param string $originUrl * - * @return array{access_token: non-empty-string, token_type: non-empty-string, expires_in: positive-int, created_at: 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 */ From 740f9e2f8fc76f12e8a3ae0ec8bfeeb22cba3083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20L=C3=BCder?= Date: Tue, 9 Aug 2022 02:27:27 +0200 Subject: [PATCH 3/6] Ignore error --- phpstan/baseline.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 - From ff533efdd0c2ab4f9c15065e124de3be96757e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20L=C3=BCder?= Date: Tue, 9 Aug 2022 02:28:57 +0200 Subject: [PATCH 4/6] Added missing mock data --- tests/Composer/Test/Util/GitLabTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ); From ca8333ff49ae6132e7a0e28be84e8a700a0f152a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20L=C3=BCder?= Date: Fri, 12 Aug 2022 15:53:15 +0200 Subject: [PATCH 5/6] Update notes --- src/Composer/Util/GitLab.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php index a0dc17dae552..183586d55da7 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; @@ -194,7 +195,8 @@ public function authorizeOAuthRefresh(string $scheme, string $originUrl, string } $this->io->writeError(sprintf('A new token will be created and stored in "%s".', $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.'); } try { From 09ddd614853eb276d8af89e3457ae149763daa0a Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Tue, 16 Aug 2022 13:30:55 +0200 Subject: [PATCH 6/6] Clean ups --- src/Composer/Repository/Vcs/GitLabDriver.php | 2 +- src/Composer/Util/GitLab.php | 71 ++++++++------------ 2 files changed, 30 insertions(+), 43 deletions(-) diff --git a/src/Composer/Repository/Vcs/GitLabDriver.php b/src/Composer/Repository/Vcs/GitLabDriver.php index 4df934dfd254..bbcd2a0052fa 100644 --- a/src/Composer/Repository/Vcs/GitLabDriver.php +++ b/src/Composer/Repository/Vcs/GitLabDriver.php @@ -537,7 +537,7 @@ protected function getContents(string $url, bool $fetchingRepoData = false): Res return parent::getContents($url); } - if ($gitLabUtil->isOAuthExpired($this->originUrl) && $gitLabUtil->authorizeOAuthRefresh($this->scheme, $this->originUrl, 'Your token has expired and must be refreshed.')){ + if ($gitLabUtil->isOAuthExpired($this->originUrl) && $gitLabUtil->authorizeOAuthRefresh($this->scheme, $this->originUrl)) { return parent::getContents($url); } diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php index 183586d55da7..ce32e9535bdd 100644 --- a/src/Composer/Util/GitLab.php +++ b/src/Composer/Util/GitLab.php @@ -161,13 +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, - array( - 'expires-at' => intval($response['created_at']) + intval($response['expires_in']), - 'refresh-token' => $response['refresh_token'], - 'token' => $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; } @@ -180,44 +185,31 @@ public function authorizeOAuthInteractively(string $scheme, string $originUrl, s * * @param string $scheme Scheme used in the origin URL * @param string $originUrl The host this GitLab instance is located at - * @param string $message The reason this authorization is required * * @throws \RuntimeException * @throws TransportException|\Exception * * @return bool true on success */ - public function authorizeOAuthRefresh(string $scheme, string $originUrl, string $message = null): bool + public function authorizeOAuthRefresh(string $scheme, string $originUrl): bool { - if ($this->io->isInteractive()) { - if ($message) { - $this->io->writeError($message); - } - - $this->io->writeError(sprintf('A new token will be created and stored in "%s".', $this->config->getAuthConfigSource()->getName())); - $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.'); - } - try { $response = $this->refreshToken($scheme, $originUrl); } catch (TransportException $e) { - - if ($this->io->isInteractive()) { - $this->io->writeError("Couldn't refresh access token."); - } + $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, - array( + $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; @@ -227,7 +219,7 @@ public function authorizeOAuthRefresh(string $scheme, string $originUrl, string * @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} + * @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 */ @@ -263,12 +255,12 @@ private function createToken(string $scheme, string $originUrl): array /** * Is the OAuth access token expired? * - * @return bool true on expired token or expiration date is not set + * @return bool true on expired token, false if token is fresh or expiration date is not set */ - public function isOAuthExpired(string $originUrl): bool { - + public function isOAuthExpired(string $originUrl): bool + { $authTokens = $this->config->get('gitlab-oauth'); - if (isset($authTokens[$originUrl], $authTokens[$originUrl]['expires-at'])) { + if (isset($authTokens[$originUrl]['expires-at'])) { if ($authTokens[$originUrl]['expires-at'] < time()) { return true; } @@ -287,17 +279,14 @@ public function isOAuthExpired(string $originUrl): bool { */ private function refreshToken(string $scheme, string $originUrl): array { - $refreshToken = ''; $authTokens = $this->config->get('gitlab-oauth'); - if (isset($authTokens[$originUrl], $authTokens[$originUrl]['refresh-token'])) { - $refreshToken = $authTokens[$originUrl]['refresh-token']; - } else { - throw new \RuntimeException('Invalid GitLab refresh token.'); + 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'); - $apiUrl = $originUrl; $data = http_build_query(array( 'refresh_token' => $refreshToken, 'grant_type' => 'refresh_token', @@ -311,11 +300,9 @@ private function refreshToken(string $scheme, string $originUrl): array ), ); - $token = $this->httpDownloader->get($scheme.'://'.$apiUrl.'/oauth/token', $options)->decodeJson(); - - if ($this->io->isInteractive()) { - $this->io->writeError('Token successfully created'); - } + $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; }