diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php
index e9e36739e..c885633f2 100644
--- a/app/Http/Controllers/AdminController.php
+++ b/app/Http/Controllers/AdminController.php
@@ -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',
@@ -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',
@@ -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',
diff --git a/app/Services/OAuth2/TokenService.php b/app/Services/OAuth2/TokenService.php
index 442013185..cbdd79233 100644
--- a/app/Services/OAuth2/TokenService.php
+++ b/app/Services/OAuth2/TokenService.php
@@ -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;
@@ -604,7 +612,7 @@ public function createAccessTokenFromRefreshToken(RefreshToken $refresh_token, $
(
$refresh_token,
$scope,
- $this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime')
+ $this->getRefreshedAccessTokenLifetime()
)
);
@@ -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
@@ -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'];
@@ -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'];
diff --git a/app/Services/Utils/ServerConfigurationService.php b/app/Services/Utils/ServerConfigurationService.php
index 70870f628..74782560d 100644
--- a/app/Services/Utils/ServerConfigurationService.php
+++ b/app/Services/Utils/ServerConfigurationService.php
@@ -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);
diff --git a/resources/views/admin/server-config.blade.php b/resources/views/admin/server-config.blade.php
index 3daa9b68a..9aab097af 100644
--- a/resources/views/admin/server-config.blade.php
+++ b/resources/views/admin/server-config.blade.php
@@ -40,6 +40,10 @@
+
+
+
+
diff --git a/tests/OAuth2ProtocolTest.php b/tests/OAuth2ProtocolTest.php
index bcabbe651..f22b341e1 100644
--- a/tests/OAuth2ProtocolTest.php
+++ b/tests/OAuth2ProtocolTest.php
@@ -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);
+
+ // 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
diff --git a/tests/StubServerConfigurationService.php b/tests/StubServerConfigurationService.php
index d5a808c7d..ce10af156 100644
--- a/tests/StubServerConfigurationService.php
+++ b/tests/StubServerConfigurationService.php
@@ -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']);
}