1
0
Fork 0

Added token refresh for GitLab to support GitLab 15+ (#10988)

Co-authored-by: Jordi Boggiano <j.boggiano@seld.be>
pull/10996/head
Thomas Lüder 2022-08-16 13:34:18 +02:00 committed by GitHub
parent 41a13fa0a1
commit 41d6467b3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 131 additions and 7 deletions

View File

@ -4585,7 +4585,7 @@ parameters:
- -
message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#" message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#"
count: 1 count: 2
path: ../src/Composer/Util/GitLab.php path: ../src/Composer/Util/GitLab.php
- -

View File

@ -340,9 +340,24 @@
}, },
"gitlab-oauth": { "gitlab-oauth": {
"type": "object", "type": "object",
"description": "An object of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":\"<token>\"}.", "description": "An object of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":{\"expires-at\":\"<expiration date>\", \"refresh-token\":\"<refresh token>\", \"token\":\"<token>\"}}.",
"additionalProperties": { "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": { "gitlab-token": {

View File

@ -137,6 +137,7 @@ abstract class BaseIO implements IOInterface
} }
foreach ($gitlabOauth as $domain => $token) { foreach ($gitlabOauth as $domain => $token) {
$token = is_array($token) ? $token["token"] : $token;
$this->checkAndSetAuthentication($domain, $token, 'oauth2'); $this->checkAndSetAuthentication($domain, $token, 'oauth2');
} }

View File

@ -537,6 +537,10 @@ class GitLabDriver extends VcsDriver
return parent::getContents($url); return parent::getContents($url);
} }
if ($gitLabUtil->isOAuthExpired($this->originUrl) && $gitLabUtil->authorizeOAuthRefresh($this->scheme, $this->originUrl)) {
return parent::getContents($url);
}
if (!$this->io->isInteractive()) { if (!$this->io->isInteractive()) {
$this->attemptCloneFallback(); $this->attemptCloneFallback();

View File

@ -126,7 +126,8 @@ class GitLab
} }
$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(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; $attemptCounter = 0;
@ -160,7 +161,18 @@ class GitLab
$this->io->setAuthentication($originUrl, $response['access_token'], 'oauth2'); $this->io->setAuthentication($originUrl, $response['access_token'], 'oauth2');
// store value in user config in auth file // 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; return true;
} }
@ -168,11 +180,46 @@ class GitLab
throw new \RuntimeException('Invalid GitLab credentials 5 times in a row, aborting.'); 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 $scheme
* @param string $originUrl * @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 * @see https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow
*/ */
@ -204,4 +251,59 @@ class GitLab
return $token; 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;
}
} }

View File

@ -30,6 +30,8 @@ class GitLabTest extends TestCase
private $origin = 'gitlab.com'; private $origin = 'gitlab.com';
/** @var string */ /** @var string */
private $token = 'gitlabtoken'; private $token = 'gitlabtoken';
/** @var string */
private $refreshtoken = 'gitlabrefreshtoken';
public function testUsernamePasswordAuthenticationFlow(): void public function testUsernamePasswordAuthenticationFlow(): void
{ {
@ -54,7 +56,7 @@ class GitLabTest extends TestCase
$httpDownloader = $this->getHttpDownloaderMock(); $httpDownloader = $this->getHttpDownloaderMock();
$httpDownloader->expects( $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 true
); );