Skip to content

Commit 25afe85

Browse files
committed
feat(oauth2): add jitter to access token lifetime on refresh_token grant (#172)
* feat(oauth2): add jitter to access token lifetime on refresh_token grant Tokens issued by the refresh_token grant now get lifetime - random_int(0, OAuth2.AccessToken.RefreshJitter), so clients that refresh in the same minute spread out instead of hitting /oauth2/token in synchronized bursts. The jittered value feeds expires_in, the access token DB row and the Redis TTL. Other grants are unchanged. The jitter defaults to 720s (server.OAuth2_AccessToken_RefreshJitter), is clamped so the issued lifetime stays >= 60s and <= the configured lifetime, and is editable from the admin server-config screen (0 disables). * test(oauth2): assert refreshed token ttl right after each refresh The redis ttl starts counting down as soon as the access token is stored, so reading it only after all five refreshes made the 5s lower bound depend on how long the remaining refreshes took. Run the DB lifetime and ttl checks in a per-refresh callback instead. * fix(oauth2): load access tokens with their stored lifetime TokenService::getAccessToken rebuilt the token with the configured OAuth2.AccessToken.Lifetime instead of the lifetime it was issued with, which is already present on the redis hash and on DB. Access tokens issued by the refresh_token grant now carry a jittered lifetime, so introspection reported a remaining lifetime up to the jitter longer than the real one and resource servers that cache the introspection result (summit-api) kept accepting the token after the IDP had expired it. Use the stored lifetime when reloading the token and assert on the test that introspection expires_in matches the refresh response. * test(oauth2): cover the refresh jitter clamp at the 60s floor With a configured lifetime 40s above MinRefreshedAccessTokenLifetime and a 720s jitter the issued lifetime stays within [60, lifetime]; with the lifetime at the floor the jitter is disabled and the lifetime is issued unchanged. * fix(oauth2): raise the refreshed access token lifetime floor to 300s openstack-uicore-foundation subtracts a 60s skew from expires_in before deciding to refresh, so a jittered lifetime at the previous 60s floor would have made summit-admin and event-site refresh on every API call. Keep the floor well above that skew; the clamp test follows the constant.
1 parent eb41814 commit 25afe85

6 files changed

Lines changed: 227 additions & 2 deletions

File tree

‎app/Http/Controllers/AdminController.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@ public function listServerConfig(){
496496
'OpenId.Nonce.Lifetime',
497497
'OAuth2.AuthorizationCode.Lifetime',
498498
'OAuth2.AccessToken.Lifetime',
499+
'OAuth2.AccessToken.RefreshJitter',
499500
'OAuth2.IdToken.Lifetime',
500501
'OAuth2.RefreshToken.Lifetime',
501502
'OAuth2.AccessToken.Revoked.Lifetime',
@@ -535,6 +536,7 @@ public function saveServerConfig(){
535536
'oauth2-auth-code-lifetime' => 'required|integer',
536537
'oauth2-refresh-token-lifetime' => 'required|integer',
537538
'oauth2-access-token-lifetime' => 'required|integer',
539+
'oauth2-access-token-refresh-jitter' => 'required|integer|min:0',
538540
'oauth2-id-token-lifetime' => 'required|integer',
539541
'oauth2-id-access-token-revoked-lifetime' => 'required|integer',
540542
'oauth2-id-access-token-void-lifetime' => 'required|integer',
@@ -555,6 +557,7 @@ public function saveServerConfig(){
555557
'openid-nonce-lifetime' => 'OpenId.Nonce.Lifetime',
556558
'oauth2-auth-code-lifetime' => 'OAuth2.AuthorizationCode.Lifetime',
557559
'oauth2-access-token-lifetime' => 'OAuth2.AccessToken.Lifetime',
560+
'oauth2-access-token-refresh-jitter' => 'OAuth2.AccessToken.RefreshJitter',
558561
'oauth2-id-token-lifetime' => 'OAuth2.IdToken.Lifetime',
559562
'oauth2-refresh-token-lifetime' => 'OAuth2.RefreshToken.Lifetime',
560563
'oauth2-id-access-token-revoked-lifetime' => 'OAuth2.AccessToken.Revoked.Lifetime',

‎app/Services/OAuth2/TokenService.php‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ final class TokenService extends AbstractService implements ITokenService
106106
const ClientAccessTokensQty = '.atokens.qty';
107107
const ClientAccessTokensQtyLifetime = 86400;
108108

109+
/**
110+
* floor (seconds) for the jittered lifetime of access tokens issued on refresh.
111+
* Kept well above the 60s skew the js clients (openstack-uicore-foundation) subtract from
112+
* expires_in before refreshing: a lifetime at or near that skew would make them refresh on
113+
* every request.
114+
*/
115+
const MinRefreshedAccessTokenLifetime = 300;
116+
109117
const ClientRefreshTokensQty = '.rtokens.qty';
110118
const ClientRefreshTokensQtyLifetime = 86400;
111119

@@ -604,7 +612,7 @@ public function createAccessTokenFromRefreshToken(RefreshToken $refresh_token, $
604612
(
605613
$refresh_token,
606614
$scope,
607-
$this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime')
615+
$this->getRefreshedAccessTokenLifetime()
608616
)
609617
);
610618

@@ -653,6 +661,25 @@ public function createAccessTokenFromRefreshToken(RefreshToken $refresh_token, $
653661
});
654662
}
655663

664+
/**
665+
* Lifetime for access tokens issued by the refresh_token grant:
666+
* configured lifetime minus a random reduction in [0, jitter], so clients that refresh
667+
* together drift apart. Never above the configured lifetime, never below
668+
* MinRefreshedAccessTokenLifetime (unless the configured lifetime itself is lower).
669+
* @return int
670+
*/
671+
private function getRefreshedAccessTokenLifetime(): int
672+
{
673+
$lifetime = intval($this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime'));
674+
$jitter = intval($this->configuration_service->getConfigValue('OAuth2.AccessToken.RefreshJitter'));
675+
$jitter = min(max(0, $jitter), max(0, $lifetime - self::MinRefreshedAccessTokenLifetime));
676+
677+
$issued_lifetime = $jitter > 0 ? $lifetime - random_int(0, $jitter) : $lifetime;
678+
679+
Log::debug(sprintf("TokenService::getRefreshedAccessTokenLifetime lifetime %s jitter %s issued %s", $lifetime, $jitter, $issued_lifetime));
680+
return $issued_lifetime;
681+
}
682+
656683
/**
657684
* @param AccessToken $access_token
658685
* @return bool
@@ -813,6 +840,10 @@ public function getAccessToken($value, $is_hashed = false)
813840
'refresh_token'
814841
]);
815842

843+
// the lifetime this token was issued with (jittered on refresh); it must win over the
844+
// configured one so the remaining lifetime reported matches the DB value and the redis ttl
845+
$access_token_lifetime = intval($payload['lifetime']);
846+
816847
// reload auth code ...
817848
$payload['value'] = $payload['auth_code'];
818849

@@ -830,7 +861,7 @@ public function getAccessToken($value, $is_hashed = false)
830861
$value,
831862
$auth_code,
832863
$payload['issued'],
833-
$this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime'),
864+
$access_token_lifetime,
834865
);
835866

836867
$refresh_token_value = $payload['refresh_token'];

‎app/Services/Utils/ServerConfigurationService.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ public function __construct(
144144

145145
$this->default_config_params["OAuth2.AuthorizationCode.Lifetime"] = Config::get('server.OAuth2_AuthorizationCode_Lifetime', 240);
146146
$this->default_config_params["OAuth2.AccessToken.Lifetime"] = Config::get('server.OAuth2_AccessToken_Lifetime', 3600);
147+
//max random reduction (seconds) applied to access tokens issued by the refresh_token grant, 0 disables
148+
$this->default_config_params["OAuth2.AccessToken.RefreshJitter"] = Config::get('server.OAuth2_AccessToken_RefreshJitter', 720);
147149
$this->default_config_params["OAuth2.IdToken.Lifetime"] = Config::get('server.OAuth2_IdToken_Lifetime', 3600);
148150
//infinite by default
149151
$this->default_config_params["OAuth2.RefreshToken.Lifetime"] = Config::get('server.OAuth2_RefreshToken_Lifetime', 0);

‎resources/views/admin/server-config.blade.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
<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>
4141
<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']!!}"/>
4242
</div>
43+
<div class="form-group">
44+
<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>
45+
<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']!!}"/>
46+
</div>
4347
<div class="form-group">
4448
<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>
4549
<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']!!}"/>

‎tests/OAuth2ProtocolTest.php‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,187 @@ public function testRefreshToken()
700700
}
701701
}
702702

703+
/**
704+
* Runs auth code -> token and returns the decoded token response (access + refresh token).
705+
* @param string $client_id
706+
* @param string $client_secret
707+
* @return object
708+
*/
709+
private function getTokensFromAuthCode(string $client_id, string $client_secret)
710+
{
711+
Session::put("openid.authorization.response", IAuthService::AuthorizationResponse_AllowOnce);
712+
713+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", [
714+
OAuth2Protocol::OAuth2Protocol_ClientId => $client_id,
715+
OAuth2Protocol::OAuth2Protocol_RedirectUri => 'https://www.test.com/oauth2',
716+
OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_Code,
717+
OAuth2Protocol::OAuth2Protocol_Scope => sprintf('%s/resource-server/read', $this->current_realm),
718+
OAuth2Protocol::OAuth2Protocol_AccessType => OAuth2Protocol::OAuth2Protocol_AccessType_Offline
719+
], [], [], []);
720+
721+
$output = [];
722+
parse_str(@parse_url($response->getTargetUrl())['query'], $output);
723+
724+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", [
725+
'code' => $output['code'],
726+
'redirect_uri' => 'https://www.test.com/oauth2',
727+
'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode,
728+
], [], [], [],
729+
array("HTTP_Authorization" => " Basic " . base64_encode($client_id . ':' . $client_secret)));
730+
731+
$this->assertResponseStatus(200);
732+
return json_decode($response->getContent());
733+
}
734+
735+
/**
736+
* Refreshes $count times, each time with the newest refresh token (they rotate).
737+
* $after_each runs with the decoded response right after every refresh, while its access token is
738+
* still the newest one (rotating the refresh token invalidates the previous access token).
739+
* @return array list of decoded refresh responses
740+
*/
741+
private function refreshTokens(object $tokens, int $count, string $client_id, string $client_secret, ?callable $after_each = null): array
742+
{
743+
$res = [];
744+
$refresh_token = $tokens->refresh_token;
745+
for ($i = 0; $i < $count; $i++) {
746+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", [
747+
'refresh_token' => $refresh_token,
748+
'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_RefreshToken,
749+
], [], [], [],
750+
array("HTTP_Authorization" => " Basic " . base64_encode($client_id . ':' . $client_secret)));
751+
$this->assertResponseStatus(200);
752+
$json = json_decode($response->getContent());
753+
$refresh_token = $json->refresh_token;
754+
$res[] = $json;
755+
if (!is_null($after_each)) {
756+
$after_each($json);
757+
}
758+
}
759+
return $res;
760+
}
761+
762+
/**
763+
* @return object decoded introspection response for $access_token
764+
*/
765+
private function introspect(string $access_token, string $client_id, string $client_secret): object
766+
{
767+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@introspection", [
768+
'token' => $access_token,
769+
], [], [], [],
770+
array("HTTP_Authorization" => " Basic " . base64_encode($client_id . ':' . $client_secret)));
771+
$this->assertResponseStatus(200);
772+
return json_decode($response->getContent());
773+
}
774+
775+
/**
776+
* refresh grant lifetime is lifetime - random_int(0, jitter); other grants stay unjittered
777+
* @throws Exception
778+
*/
779+
public function testRefreshTokenJitterBounds()
780+
{
781+
$client_id = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client';
782+
$client_secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg';
783+
$lifetime = 3600;
784+
$jitter = 720;
785+
786+
try {
787+
$_ENV['access.token.lifetime'] = $lifetime;
788+
$_ENV['access.token.refresh.jitter'] = $jitter;
789+
790+
$tokens = $this->getTokensFromAuthCode($client_id, $client_secret);
791+
// authorization code grant is never jittered
792+
$this->assertEquals($lifetime, $tokens->expires_in);
793+
794+
$access_token_repository = app(\OAuth2\Repositories\IAccessTokenRepository::class);
795+
$cache_service = app(UtilsServiceCatalog::CacheService);
796+
$values = [];
797+
// checked right after each refresh: the redis ttl starts counting down as soon as the
798+
// token is stored, so reading it after the remaining refreshes would drift out of bounds
799+
$this->refreshTokens($tokens, 5, $client_id, $client_secret, function (object $json) use ($lifetime, $jitter, $client_id, $client_secret, $access_token_repository, $cache_service, &$values) {
800+
$expires_in = $json->expires_in;
801+
$values[] = $expires_in;
802+
$this->assertGreaterThanOrEqual($lifetime - $jitter, $expires_in);
803+
$this->assertLessThanOrEqual($lifetime, $expires_in);
804+
805+
// same value stored on DB and as redis ttl
806+
$hashed = \Laminas\Crypt\Hash::compute('sha256', $json->access_token);
807+
$access_token_db = $access_token_repository->getByValue($hashed);
808+
$this->assertNotNull($access_token_db);
809+
$this->assertEquals($expires_in, $access_token_db->getLifetime());
810+
$ttl = $cache_service->ttl($hashed);
811+
$this->assertLessThanOrEqual($expires_in, $ttl);
812+
$this->assertGreaterThanOrEqual($expires_in - 5, $ttl);
813+
814+
// introspection must report the jittered lifetime, not the configured one
815+
$introspection = $this->introspect($json->access_token, $client_id, $client_secret);
816+
$this->assertLessThanOrEqual($expires_in, $introspection->expires_in);
817+
$this->assertGreaterThanOrEqual($expires_in - 5, $introspection->expires_in);
818+
});
819+
$this->assertGreaterThan(1, count(array_unique($values)), 'refresh lifetimes are not jittered');
820+
} finally {
821+
unset($_ENV['access.token.lifetime'], $_ENV['access.token.refresh.jitter']);
822+
}
823+
}
824+
825+
/**
826+
* jitter larger than lifetime - MinRefreshedAccessTokenLifetime is clamped so the issued
827+
* lifetime never drops below the floor, and a lifetime at the floor is issued unchanged
828+
* @throws Exception
829+
*/
830+
public function testRefreshTokenJitterClampedToMinLifetime()
831+
{
832+
$client_id = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client';
833+
$client_secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg';
834+
$min_lifetime = \Services\OAuth2\TokenService::MinRefreshedAccessTokenLifetime;
835+
836+
try {
837+
// headroom above the floor is 40s, jitter asks for 720s
838+
$lifetime = $min_lifetime + 40;
839+
$_ENV['access.token.lifetime'] = $lifetime;
840+
$_ENV['access.token.refresh.jitter'] = 720;
841+
842+
$tokens = $this->getTokensFromAuthCode($client_id, $client_secret);
843+
$refreshed = $this->refreshTokens($tokens, 5, $client_id, $client_secret);
844+
foreach ($refreshed as $json) {
845+
$this->assertGreaterThanOrEqual($min_lifetime, $json->expires_in);
846+
$this->assertLessThanOrEqual($lifetime, $json->expires_in);
847+
}
848+
849+
// no headroom at all: the jitter is disabled and the lifetime is issued as configured.
850+
// keep refreshing the same chain: a new auth code would not get a refresh token
851+
// (consent was already given), the refresh grant rotates it.
852+
$_ENV['access.token.lifetime'] = $min_lifetime;
853+
854+
foreach ($this->refreshTokens(end($refreshed), 2, $client_id, $client_secret) as $json) {
855+
$this->assertEquals($min_lifetime, $json->expires_in);
856+
}
857+
} finally {
858+
unset($_ENV['access.token.lifetime'], $_ENV['access.token.refresh.jitter']);
859+
}
860+
}
861+
862+
/**
863+
* jitter = 0 keeps the current behaviour
864+
* @throws Exception
865+
*/
866+
public function testRefreshTokenJitterZero()
867+
{
868+
$client_id = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client';
869+
$client_secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg';
870+
871+
try {
872+
$_ENV['access.token.lifetime'] = 3600;
873+
$_ENV['access.token.refresh.jitter'] = 0;
874+
875+
$tokens = $this->getTokensFromAuthCode($client_id, $client_secret);
876+
foreach ($this->refreshTokens($tokens, 2, $client_id, $client_secret) as $json) {
877+
$this->assertEquals(3600, $json->expires_in);
878+
}
879+
} finally {
880+
unset($_ENV['access.token.lifetime'], $_ENV['access.token.refresh.jitter']);
881+
}
882+
}
883+
703884
/**
704885
* test refresh token replay attack
705886
* @throws Exception

‎tests/StubServerConfigurationService.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public function getConfigValue($value)
2424
return intval($_ENV['access.token.lifetime']);
2525
}
2626

27+
if ($value === 'OAuth2.AccessToken.RefreshJitter' && isset($_ENV['access.token.refresh.jitter'])) {
28+
return intval($_ENV['access.token.refresh.jitter']);
29+
}
30+
2731
if ($value === 'OAuth2.IdToken.Lifetime' && isset($_ENV['id.token.lifetime'])) {
2832
return intval($_ENV['id.token.lifetime']);
2933
}

0 commit comments

Comments
 (0)