| @@ -47,11 +47,11 @@ | ||
| 47 | 47 | |
| 48 | 48 | /** |
| 49 | 49 | * OAuth_Client constructor. |
| 50 | 50 | * |
| 51 | - * @param string $token_option The option's name to save the token as. | |
| 52 | - * @param Wincher_PKCE_Provider|GenericProvider $provider The provider. | |
| 53 | - * @param Options_Helper $options_helper The Options_Helper instance. | |
| 51 | + * @param string $token_option The option's name to save the token as. | |
| 52 | + * @param Wincher_PKCE_Provider|GenericProvider $provider The provider. | |
| 53 | + * @param Options_Helper $options_helper The Options_Helper instance. | |
| 54 | 54 | * |
| 55 | 55 | * @throws Empty_Property_Exception Exception thrown if a token property is empty. |
| 56 | 56 | */ |
| 57 | 57 | public function __construct( |
| @@ -70,9 +70,10 @@ | ||
| 70 | 70 | $tokens['access_token'], |
| 71 | 71 | $tokens['refresh_token'], |
| 72 | 72 | $tokens['expires'], |
| 73 | 73 | $tokens['has_expired'], |
| 74 | - $tokens['created_at'] | |
| 74 | + $tokens['created_at'], | |
| 75 | + ( $tokens['error_count'] ?? 0 ), | |
| 75 | 76 | ); |
| 76 | 77 | } |
| 77 | 78 | } |
| 78 | 79 | |
| @@ -91,9 +92,9 @@ | ||
| 91 | 92 | ->getAccessToken( |
| 92 | 93 | 'authorization_code', |
| 93 | 94 | [ |
| 94 | 95 | 'code' => $code, |
| 95 | - ] | |
| 96 | + ], | |
| 96 | 97 | ); |
| 97 | 98 | |
| 98 | 99 | $token = OAuth_Token::from_response( $response ); |
| 99 | 100 | |
| @@ -201,8 +202,25 @@ | ||
| 201 | 202 | return $token; |
| 202 | 203 | } |
| 203 | 204 | |
| 204 | 205 | /** |
| 206 | + * Clears the stored token from storage. | |
| 207 | + * | |
| 208 | + * @return bool The stored token. | |
| 209 | + * | |
| 210 | + * @throws Failed_Storage_Exception Exception thrown if clearing of the token fails. | |
| 211 | + */ | |
| 212 | + public function clear_token() { | |
| 213 | + $saved = $this->options_helper->set( $this->token_option, [] ); | |
| 214 | + | |
| 215 | + if ( $saved === false ) { | |
| 216 | + throw new Failed_Storage_Exception(); | |
| 217 | + } | |
| 218 | + | |
| 219 | + return true; | |
| 220 | + } | |
| 221 | + | |
| 222 | + /** | |
| 205 | 223 | * Performs the specified request. |
| 206 | 224 | * |
| 207 | 225 | * @param string $method The HTTP method to use. |
| 208 | 226 | * @param string $url The URL to send the request to. |
| @@ -241,20 +259,50 @@ | ||
| 241 | 259 | * |
| 242 | 260 | * @throws Authentication_Failed_Exception Exception thrown if authentication has failed. |
| 243 | 261 | */ |
| 244 | 262 | protected function refresh_tokens( OAuth_Token $tokens ) { |
| 263 | + // We do this dance with transients since we need to make sure we don't | |
| 264 | + // delete valid tokens because of a race condition when two calls are | |
| 265 | + // made simultaneously to this function and refresh token rotation is | |
| 266 | + // turned on in the OAuth server. This is not 100% safe, but should at | |
| 267 | + // least be much better than not having any lock at all. | |
| 268 | + $lock_name = \sprintf( 'lock:%s', $this->token_option ); | |
| 269 | + $can_lock = \get_transient( $lock_name ) === false; | |
| 270 | + $has_lock = $can_lock && \set_transient( $lock_name, true, 30 ); | |
| 271 | + | |
| 245 | 272 | try { |
| 246 | 273 | $new_tokens = $this->provider->getAccessToken( |
| 247 | 274 | 'refresh_token', |
| 248 | 275 | [ |
| 249 | 276 | 'refresh_token' => $tokens->refresh_token, |
| 250 | - ] | |
| 277 | + ], | |
| 251 | 278 | ); |
| 252 | 279 | |
| 253 | - $token = OAuth_Token::from_response( $new_tokens ); | |
| 280 | + $token_obj = OAuth_Token::from_response( $new_tokens ); | |
| 254 | 281 | |
| 255 | - return $this->store_token( $token ); | |
| 282 | + return $this->store_token( $token_obj ); | |
| 256 | 283 | } catch ( Exception $exception ) { |
| 284 | + // If we tried to refresh but the refresh token is invalid, delete | |
| 285 | + // the tokens so that we don't try again. Only do this if we got the | |
| 286 | + // lock at the beginning of the call. | |
| 287 | + if ( $has_lock && $exception->getMessage() === 'invalid_grant' ) { | |
| 288 | + try { | |
| 289 | + // To protect from race conditions, only do this if we've | |
| 290 | + // seen an error before with the same token. | |
| 291 | + if ( $tokens->error_count >= 1 ) { | |
| 292 | + $this->clear_token(); | |
| 293 | + } | |
| 294 | + else { | |
| 295 | + $tokens->error_count += 1; | |
| 296 | + $this->store_token( $tokens ); | |
| 297 | + } | |
| 298 | + } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch | |
| 299 | + // Pass through. | |
| 300 | + } | |
| 301 | + } | |
| 302 | + | |
| 257 | 303 | throw new Authentication_Failed_Exception( $exception ); |
| 304 | + } finally { | |
| 305 | + \delete_transient( $lock_name ); | |
| 258 | 306 | } |
| 259 | 307 | } |
| 260 | 308 | } |