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']); }