Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added token refresh for GitLab #10988

Merged
merged 6 commits into from Aug 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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