Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/Http/Controllers/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ public function listServerConfig(){
'OpenId.Nonce.Lifetime',
'OAuth2.AuthorizationCode.Lifetime',
'OAuth2.AccessToken.Lifetime',
'OAuth2.AccessToken.RefreshJitter',
'OAuth2.IdToken.Lifetime',
'OAuth2.RefreshToken.Lifetime',
'OAuth2.AccessToken.Revoked.Lifetime',
Expand Down Expand Up @@ -535,6 +536,7 @@ public function saveServerConfig(){
'oauth2-auth-code-lifetime' => 'required|integer',
'oauth2-refresh-token-lifetime' => 'required|integer',
'oauth2-access-token-lifetime' => 'required|integer',
'oauth2-access-token-refresh-jitter' => 'required|integer|min:0',
'oauth2-id-token-lifetime' => 'required|integer',
'oauth2-id-access-token-revoked-lifetime' => 'required|integer',
'oauth2-id-access-token-void-lifetime' => 'required|integer',
Expand All @@ -555,6 +557,7 @@ public function saveServerConfig(){
'openid-nonce-lifetime' => 'OpenId.Nonce.Lifetime',
'oauth2-auth-code-lifetime' => 'OAuth2.AuthorizationCode.Lifetime',
'oauth2-access-token-lifetime' => 'OAuth2.AccessToken.Lifetime',
'oauth2-access-token-refresh-jitter' => 'OAuth2.AccessToken.RefreshJitter',
'oauth2-id-token-lifetime' => 'OAuth2.IdToken.Lifetime',
'oauth2-refresh-token-lifetime' => 'OAuth2.RefreshToken.Lifetime',
'oauth2-id-access-token-revoked-lifetime' => 'OAuth2.AccessToken.Revoked.Lifetime',
Expand Down
35 changes: 33 additions & 2 deletions app/Services/OAuth2/TokenService.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ final class TokenService extends AbstractService implements ITokenService
const ClientAccessTokensQty = '.atokens.qty';
const ClientAccessTokensQtyLifetime = 86400;

/**
* floor (seconds) for the jittered lifetime of access tokens issued on refresh.
* Kept well above the 60s skew the js clients (openstack-uicore-foundation) subtract from
* expires_in before refreshing: a lifetime at or near that skew would make them refresh on
* every request.
*/
const MinRefreshedAccessTokenLifetime = 300;

const ClientRefreshTokensQty = '.rtokens.qty';
const ClientRefreshTokensQtyLifetime = 86400;

Expand Down Expand Up @@ -604,7 +612,7 @@ public function createAccessTokenFromRefreshToken(RefreshToken $refresh_token, $
(
$refresh_token,
$scope,
$this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime')
$this->getRefreshedAccessTokenLifetime()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
);

Expand Down Expand Up @@ -653,6 +661,25 @@ public function createAccessTokenFromRefreshToken(RefreshToken $refresh_token, $
});
}

/**
* Lifetime for access tokens issued by the refresh_token grant:
* configured lifetime minus a random reduction in [0, jitter], so clients that refresh
* together drift apart. Never above the configured lifetime, never below
* MinRefreshedAccessTokenLifetime (unless the configured lifetime itself is lower).
* @return int
*/
private function getRefreshedAccessTokenLifetime(): int
{
$lifetime = intval($this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime'));
$jitter = intval($this->configuration_service->getConfigValue('OAuth2.AccessToken.RefreshJitter'));
$jitter = min(max(0, $jitter), max(0, $lifetime - self::MinRefreshedAccessTokenLifetime));

$issued_lifetime = $jitter > 0 ? $lifetime - random_int(0, $jitter) : $lifetime;

Log::debug(sprintf("TokenService::getRefreshedAccessTokenLifetime lifetime %s jitter %s issued %s", $lifetime, $jitter, $issued_lifetime));
return $issued_lifetime;
}

/**
* @param AccessToken $access_token
* @return bool
Expand Down Expand Up @@ -813,6 +840,10 @@ public function getAccessToken($value, $is_hashed = false)
'refresh_token'
]);

// the lifetime this token was issued with (jittered on refresh); it must win over the
// configured one so the remaining lifetime reported matches the DB value and the redis ttl
$access_token_lifetime = intval($payload['lifetime']);

// reload auth code ...
$payload['value'] = $payload['auth_code'];

Expand All @@ -830,7 +861,7 @@ public function getAccessToken($value, $is_hashed = false)
$value,
$auth_code,
$payload['issued'],
$this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime'),
$access_token_lifetime,
);

$refresh_token_value = $payload['refresh_token'];
Expand Down
2 changes: 2 additions & 0 deletions app/Services/Utils/ServerConfigurationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ public function __construct(

$this->default_config_params["OAuth2.AuthorizationCode.Lifetime"] = Config::get('server.OAuth2_AuthorizationCode_Lifetime', 240);
$this->default_config_params["OAuth2.AccessToken.Lifetime"] = Config::get('server.OAuth2_AccessToken_Lifetime', 3600);
//max random reduction (seconds) applied to access tokens issued by the refresh_token grant, 0 disables
$this->default_config_params["OAuth2.AccessToken.RefreshJitter"] = Config::get('server.OAuth2_AccessToken_RefreshJitter', 720);
$this->default_config_params["OAuth2.IdToken.Lifetime"] = Config::get('server.OAuth2_IdToken_Lifetime', 3600);
//infinite by default
$this->default_config_params["OAuth2.RefreshToken.Lifetime"] = Config::get('server.OAuth2_RefreshToken_Lifetime', 0);
Expand Down
4 changes: 4 additions & 0 deletions resources/views/admin/server-config.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
<label for="oauth2-access-token-lifetime">Access Token Lifetime&nbsp;<span class="glyphicon glyphicon-info-sign accordion-toggle" aria-hidden="true" title="in seconds"></span></label>
<input class="form-control" type="number" min="60" step="1" id="oauth2-access-token-lifetime" name="oauth2-access-token-lifetime" value="{!!$config_values['OAuth2.AccessToken.Lifetime']!!}"/>
</div>
<div class="form-group">
<label for="oauth2-access-token-refresh-jitter">Access Token Refresh Jitter&nbsp;<span class="glyphicon glyphicon-info-sign accordion-toggle" aria-hidden="true" title="in seconds - max random reduction applied to the lifetime of access tokens issued on refresh - zero value disables"></span></label>
<input class="form-control" type="number" min="0" step="1" id="oauth2-access-token-refresh-jitter" name="oauth2-access-token-refresh-jitter" value="{!!$config_values['OAuth2.AccessToken.RefreshJitter']!!}"/>
</div>
<div class="form-group">
<label for="oauth2-id-token-lifetime">Id Token Lifetime&nbsp;<span class="glyphicon glyphicon-info-sign accordion-toggle" aria-hidden="true" title="in seconds"></span></label>
<input class="form-control" type="number" min="60" step="1" id="oauth2-id-token-lifetime" name="oauth2-id-token-lifetime" value="{!!$config_values['OAuth2.IdToken.Lifetime']!!}"/>
Expand Down
181 changes: 181 additions & 0 deletions tests/OAuth2ProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,187 @@ public function testRefreshToken()
}
}

/**
* Runs auth code -> token and returns the decoded token response (access + refresh token).
* @param string $client_id
* @param string $client_secret
* @return object
*/
private function getTokensFromAuthCode(string $client_id, string $client_secret)
{
Session::put("openid.authorization.response", IAuthService::AuthorizationResponse_AllowOnce);

$response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", [
OAuth2Protocol::OAuth2Protocol_ClientId => $client_id,
OAuth2Protocol::OAuth2Protocol_RedirectUri => 'https://www.test.com/oauth2',
OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_Code,
OAuth2Protocol::OAuth2Protocol_Scope => sprintf('%s/resource-server/read', $this->current_realm),
OAuth2Protocol::OAuth2Protocol_AccessType => OAuth2Protocol::OAuth2Protocol_AccessType_Offline
], [], [], []);

$output = [];
parse_str(@parse_url($response->getTargetUrl())['query'], $output);

$response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", [
'code' => $output['code'],
'redirect_uri' => 'https://www.test.com/oauth2',
'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode,
], [], [], [],
array("HTTP_Authorization" => " Basic " . base64_encode($client_id . ':' . $client_secret)));

$this->assertResponseStatus(200);
return json_decode($response->getContent());
}

/**
* Refreshes $count times, each time with the newest refresh token (they rotate).
* $after_each runs with the decoded response right after every refresh, while its access token is
* still the newest one (rotating the refresh token invalidates the previous access token).
* @return array list of decoded refresh responses
*/
private function refreshTokens(object $tokens, int $count, string $client_id, string $client_secret, ?callable $after_each = null): array
{
$res = [];
$refresh_token = $tokens->refresh_token;
for ($i = 0; $i < $count; $i++) {
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", [
'refresh_token' => $refresh_token,
'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_RefreshToken,
], [], [], [],
array("HTTP_Authorization" => " Basic " . base64_encode($client_id . ':' . $client_secret)));
$this->assertResponseStatus(200);
$json = json_decode($response->getContent());
$refresh_token = $json->refresh_token;
$res[] = $json;
if (!is_null($after_each)) {
$after_each($json);
}
}
return $res;
}

/**
* @return object decoded introspection response for $access_token
*/
private function introspect(string $access_token, string $client_id, string $client_secret): object
{
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@introspection", [
'token' => $access_token,
], [], [], [],
array("HTTP_Authorization" => " Basic " . base64_encode($client_id . ':' . $client_secret)));
$this->assertResponseStatus(200);
return json_decode($response->getContent());
}

/**
* refresh grant lifetime is lifetime - random_int(0, jitter); other grants stay unjittered
* @throws Exception
*/
public function testRefreshTokenJitterBounds()
{
$client_id = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client';
$client_secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg';
$lifetime = 3600;
$jitter = 720;

try {
$_ENV['access.token.lifetime'] = $lifetime;
$_ENV['access.token.refresh.jitter'] = $jitter;

$tokens = $this->getTokensFromAuthCode($client_id, $client_secret);
// authorization code grant is never jittered
$this->assertEquals($lifetime, $tokens->expires_in);

$access_token_repository = app(\OAuth2\Repositories\IAccessTokenRepository::class);
$cache_service = app(UtilsServiceCatalog::CacheService);
$values = [];
// checked right after each refresh: the redis ttl starts counting down as soon as the
// token is stored, so reading it after the remaining refreshes would drift out of bounds
$this->refreshTokens($tokens, 5, $client_id, $client_secret, function (object $json) use ($lifetime, $jitter, $client_id, $client_secret, $access_token_repository, $cache_service, &$values) {
$expires_in = $json->expires_in;
$values[] = $expires_in;
$this->assertGreaterThanOrEqual($lifetime - $jitter, $expires_in);
$this->assertLessThanOrEqual($lifetime, $expires_in);

// same value stored on DB and as redis ttl
$hashed = \Laminas\Crypt\Hash::compute('sha256', $json->access_token);
$access_token_db = $access_token_repository->getByValue($hashed);
$this->assertNotNull($access_token_db);
$this->assertEquals($expires_in, $access_token_db->getLifetime());
$ttl = $cache_service->ttl($hashed);
$this->assertLessThanOrEqual($expires_in, $ttl);
$this->assertGreaterThanOrEqual($expires_in - 5, $ttl);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// introspection must report the jittered lifetime, not the configured one
$introspection = $this->introspect($json->access_token, $client_id, $client_secret);
$this->assertLessThanOrEqual($expires_in, $introspection->expires_in);
$this->assertGreaterThanOrEqual($expires_in - 5, $introspection->expires_in);
});
$this->assertGreaterThan(1, count(array_unique($values)), 'refresh lifetimes are not jittered');
} finally {
unset($_ENV['access.token.lifetime'], $_ENV['access.token.refresh.jitter']);
}
}

/**
* jitter larger than lifetime - MinRefreshedAccessTokenLifetime is clamped so the issued
* lifetime never drops below the floor, and a lifetime at the floor is issued unchanged
* @throws Exception
*/
public function testRefreshTokenJitterClampedToMinLifetime()
{
$client_id = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client';
$client_secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg';
$min_lifetime = \Services\OAuth2\TokenService::MinRefreshedAccessTokenLifetime;

try {
// headroom above the floor is 40s, jitter asks for 720s
$lifetime = $min_lifetime + 40;
$_ENV['access.token.lifetime'] = $lifetime;
$_ENV['access.token.refresh.jitter'] = 720;

$tokens = $this->getTokensFromAuthCode($client_id, $client_secret);
$refreshed = $this->refreshTokens($tokens, 5, $client_id, $client_secret);
foreach ($refreshed as $json) {
$this->assertGreaterThanOrEqual($min_lifetime, $json->expires_in);
$this->assertLessThanOrEqual($lifetime, $json->expires_in);
}

// no headroom at all: the jitter is disabled and the lifetime is issued as configured.
// keep refreshing the same chain: a new auth code would not get a refresh token
// (consent was already given), the refresh grant rotates it.
$_ENV['access.token.lifetime'] = $min_lifetime;

foreach ($this->refreshTokens(end($refreshed), 2, $client_id, $client_secret) as $json) {
$this->assertEquals($min_lifetime, $json->expires_in);
}
} finally {
unset($_ENV['access.token.lifetime'], $_ENV['access.token.refresh.jitter']);
}
}

/**
* jitter = 0 keeps the current behaviour
* @throws Exception
*/
public function testRefreshTokenJitterZero()
{
$client_id = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client';
$client_secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg';

try {
$_ENV['access.token.lifetime'] = 3600;
$_ENV['access.token.refresh.jitter'] = 0;

$tokens = $this->getTokensFromAuthCode($client_id, $client_secret);
foreach ($this->refreshTokens($tokens, 2, $client_id, $client_secret) as $json) {
$this->assertEquals(3600, $json->expires_in);
}
} finally {
unset($_ENV['access.token.lifetime'], $_ENV['access.token.refresh.jitter']);
}
}

/**
* test refresh token replay attack
* @throws Exception
Expand Down
4 changes: 4 additions & 0 deletions tests/StubServerConfigurationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public function getConfigValue($value)
return intval($_ENV['access.token.lifetime']);
}

if ($value === 'OAuth2.AccessToken.RefreshJitter' && isset($_ENV['access.token.refresh.jitter'])) {
return intval($_ENV['access.token.refresh.jitter']);
}

if ($value === 'OAuth2.IdToken.Lifetime' && isset($_ENV['id.token.lifetime'])) {
return intval($_ENV['id.token.lifetime']);
}
Expand Down
Loading