Skip to content

Commit

Permalink
Added token refresh for GitLab to support GitLab 15+ (#10988)
Browse files Browse the repository at this point in the history
Co-authored-by: Jordi Boggiano <j.boggiano@seld.be>
  • Loading branch information
tlueder and Seldaek committed Aug 16, 2022
1 parent 41a13fa commit 41d6467
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 7 deletions.
2 changes: 1 addition & 1 deletion phpstan/baseline.neon
Expand Up @@ -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

-
Expand Down
19 changes: 17 additions & 2 deletions res/composer-schema.json
Expand Up @@ -340,9 +340,24 @@
},
"gitlab-oauth": {
"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": {
"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": {
Expand Down
1 change: 1 addition & 0 deletions src/Composer/IO/BaseIO.php
Expand Up @@ -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');
}

Expand Down
4 changes: 4 additions & 0 deletions src/Composer/Repository/Vcs/GitLabDriver.php
Expand Up @@ -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();

Expand Down
108 changes: 105 additions & 3 deletions src/Composer/Util/GitLab.php
Expand Up @@ -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;

Expand Down Expand Up @@ -160,19 +161,65 @@ 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;
}

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
*/
Expand Down Expand Up @@ -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;
}
}
4 changes: 3 additions & 1 deletion tests/Composer/Test/Util/GitLabTest.php
Expand Up @@ -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
{
Expand All @@ -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
);

Expand Down

0 comments on commit 41d6467

Please sign in to comment.