friendship ended with social-app. php is my new best friend
1<?php 2/** 3 * Class OAuth2Provider 4 * 5 * @created 09.07.2017 6 * @author Smiley <smiley@chillerlan.net> 7 * @copyright 2017 Smiley 8 * @license MIT 9 * 10 * @filesource 11 */ 12declare(strict_types=1); 13 14namespace chillerlan\OAuth\Core; 15 16use chillerlan\HTTP\Utils\{MessageUtil, QueryUtil, UriUtil}; 17use chillerlan\OAuth\Providers\ProviderException; 18use Psr\Http\Message\{RequestInterface, ResponseInterface, UriInterface}; 19use Throwable; 20use function array_merge, date, explode, hash_equals, implode, in_array, is_array, sprintf; 21use const PHP_QUERY_RFC1738; 22 23/** 24 * Implements an abstract OAuth2 provider with all methods required by the OAuth2Interface. 25 * It also implements the ClientCredentials, CSRFToken, TokenRefresh and [...] interfaces in favor over traits. 26 * 27 * @link https://oauth.net/2/ 28 * @link https://datatracker.ietf.org/doc/html/rfc6749 29 * @link https://datatracker.ietf.org/doc/html/rfc7636 30 * @link https://datatracker.ietf.org/doc/html/rfc9126 31 */ 32abstract class OAuth2Provider extends OAuthProvider implements OAuth2Interface{ 33 34 /** 35 * An optional refresh token endpoint in case the provider supports TokenRefresh. 36 * If the provider supports token refresh and $refreshTokenURL is null, $accessTokenURL will be used instead. 37 * 38 * @see \chillerlan\OAuth\Core\TokenRefresh::refreshAccessToken() 39 */ 40 protected string|null $refreshTokenURL = null; 41 42 /** 43 * An optional client credentials token endpoint in case the provider supports ClientCredentials. 44 * If the provider supports client credentials and $clientCredentialsTokenURL is null, $accessTokenURL will be used instead. 45 * 46 * @see \chillerlan\OAuth\Core\ClientCredentials::getClientCredentialsToken() 47 */ 48 protected string|null $clientCredentialsTokenURL = null; 49 50 /** 51 * An optional PAR (Pushed Authorization Request) endpoint URL 52 * 53 * @see \chillerlan\OAuth\Core\PAR::getParRequestUri() 54 * @see \chillerlan\OAuth\Core\PARTrait::getParRequestUri() 55 */ 56 protected string $parAuthorizationURL = ''; 57 58 /** 59 * @param array<string, scalar>|null $params 60 * @param string[]|null $scopes 61 */ 62 public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface{ 63 $queryParams = $this->getAuthorizationURLRequestParams(($params ?? []), ($scopes ?? $this::DEFAULT_SCOPES)); 64 65 if($this instanceof PAR){ 66 return $this->getParRequestUri($queryParams); 67 } 68 69 return $this->uriFactory->createUri(QueryUtil::merge($this->authorizationURL, $queryParams)); 70 } 71 72 /** 73 * prepares the query parameters for the auth URL 74 * 75 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAuthorizationURL() 76 * 77 * @param array<string, scalar> $params 78 * @param string[] $scopes 79 * @return array<string, string> 80 */ 81 protected function getAuthorizationURLRequestParams(array $params, array $scopes):array{ 82 83 // this should NEVER be set in the given params 84 unset($params['client_secret']); 85 86 $params = array_merge($params, [ 87 'client_id' => $this->options->key, 88 'redirect_uri' => $this->options->callbackURL, 89 'response_type' => 'code', 90 'type' => 'web_server', 91 ]); 92 93 if($scopes !== []){ 94 $params['scope'] = implode($this::SCOPES_DELIMITER, $scopes); 95 } 96 97 if($this instanceof CSRFToken){ 98 $params = $this->setState($params); 99 } 100 101 if($this instanceof PKCE){ 102 $params = $this->setCodeChallenge($params, PKCE::CHALLENGE_METHOD_S256); 103 } 104 105 return $params; 106 } 107 108 /** 109 * Parses the response from a request to the token endpoint 110 * 111 * @link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4 112 * @link https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 113 * 114 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken() 115 * @see \chillerlan\OAuth\Core\OAuth2Provider::refreshAccessToken() 116 * @see \chillerlan\OAuth\Core\OAuth2Provider::getClientCredentialsToken() 117 * 118 * @throws \chillerlan\OAuth\Providers\ProviderException 119 */ 120 protected function parseTokenResponse(ResponseInterface $response):AccessToken{ 121 122 try{ 123 $data = $this->getTokenResponseData($response); 124 } 125 catch(Throwable $e){ 126 throw new ProviderException(sprintf('unable to parse token response: %s', $e->getMessage())); 127 } 128 129 // deezer: "error_reason", paypal: "message" (along with "links", "name") 130 // reddit sends "message" and "error" as int, which will throw a TypeError when handed into the exception 131 // detection order changed accordingly 132 foreach(['message', 'error', 'error_description', 'error_reason'] as $field){ 133 if(isset($data[$field])){ 134 135 if(in_array($response->getStatusCode(), [400, 401, 403], true)){ 136 throw new UnauthorizedAccessException($data[$field]); 137 } 138 139 throw new ProviderException(sprintf('error retrieving access token: "%s"', $data[$field])); 140 } 141 } 142 143 if(!isset($data['access_token'])){ 144 throw new ProviderException('access token missing'); 145 } 146 147 $scopes = ($data['scope'] ?? $data['scopes'] ?? []); 148 149 if(!is_array($scopes)){ 150 $scopes = explode($this::SCOPES_DELIMITER, $scopes); 151 } 152 153 $token = $this->createAccessToken(); 154 $token->accessToken = $data['access_token']; 155 $token->expires = (int)($data['expires'] ?? $data['expires_in'] ?? AccessToken::NEVER_EXPIRES); 156 $token->refreshToken = ($data['refresh_token'] ?? null); 157 $token->scopes = $scopes; 158 159 foreach(['access_token', 'refresh_token', 'expires', 'expires_in', 'scope', 'scopes'] as $var){ 160 unset($data[$var]); 161 } 162 163 $token->extraParams = $data; 164 165 return $token; 166 } 167 168 /** 169 * extracts the data from the access token response and returns an array with the key->value pairs contained 170 * 171 * we don't bother checking the content type here as it's sometimes vendor specific, not set or plain wrong: 172 * the spec mandates a JSON body which is what almost all providers send - weird exceptions: 173 * 174 * - mixcloud sends JSON with a "text/javascript" header 175 * - deezer sends form-data with a "text/html" header (???) 176 * - silly amazon sends gzip compressed data... (handled by decodeJSON) 177 * 178 * @see \chillerlan\OAuth\Core\OAuth2Provider::parseTokenResponse() 179 * 180 * @return array<string, string|mixed> 181 * @throws \JsonException 182 */ 183 protected function getTokenResponseData(ResponseInterface $response):array{ 184 $data = MessageUtil::decodeJSON($response, true); 185 186 if(!is_array($data)){ 187 // nearly impossible to run into this as json_decode() would throw first 188 throw new ProviderException('decoded json is not an array'); 189 } 190 191 return $data; 192 } 193 194 public function getAccessToken(string $code, string|null $state = null):AccessToken{ 195 196 if($this instanceof CSRFToken){ 197 $this->checkState($state); 198 } 199 200 $body = $this->getAccessTokenRequestBodyParams($code); 201 $response = $this->sendAccessTokenRequest($this->accessTokenURL, $body); 202 $token = $this->parseTokenResponse($response); 203 204 $this->storage->storeAccessToken($token, $this->name); 205 206 return $token; 207 } 208 209 /** 210 * prepares the request body parameters for the access token request 211 * 212 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken() 213 * 214 * @return array<string, string> 215 */ 216 protected function getAccessTokenRequestBodyParams(string $code):array{ 217 218 $params = [ 219 'code' => $code, 220 'grant_type' => 'authorization_code', 221 'redirect_uri' => $this->options->callbackURL, 222 ]; 223 224 if(!$this::USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST){ 225 $params['client_id'] = $this->options->key; 226 $params['client_secret'] = $this->options->secret; 227 } 228 229 if($this instanceof PKCE){ 230 $params = $this->setCodeVerifier($params); 231 } 232 233 return $params; 234 } 235 236 /** 237 * sends a request to the access/refresh token endpoint $url with the given $body as form data 238 * 239 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken() 240 * @see \chillerlan\OAuth\Core\OAuth2Provider::refreshAccessToken() 241 * @see \chillerlan\OAuth\Core\OAuth2Provider::getParRequestUri() 242 * 243 * @param array<string, scalar|bool|null> $body 244 */ 245 protected function sendAccessTokenRequest(string $url, array $body):ResponseInterface{ 246 247 $request = $this->requestFactory 248 ->createRequest('POST', $url) 249 ->withHeader('Accept', 'application/json') 250 ->withHeader('Accept-Encoding', 'identity') 251 ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 252 ->withBody($this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738))) 253 ; 254 255 foreach($this::HEADERS_AUTH as $header => $value){ 256 $request = $request->withHeader($header, $value); 257 } 258 259 if($this::USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST){ 260 $request = $this->addBasicAuthHeader($request); 261 } 262 263 return $this->http->sendRequest($request); 264 } 265 266 public function getRequestAuthorization(RequestInterface $request, AccessToken|null $token = null):RequestInterface{ 267 $token ??= $this->storage->getAccessToken($this->name); 268 269 if($token->isExpired()){ 270 271 if(!$this instanceof TokenRefresh || $this->options->tokenAutoRefresh !== true){ 272 throw new InvalidAccessTokenException; 273 } 274 275 $token = $this->refreshAccessToken($token); 276 } 277 278 if($this::AUTH_METHOD === OAuth2Interface::AUTH_METHOD_HEADER){ 279 return $request->withHeader('Authorization', $this::AUTH_PREFIX_HEADER.' '.$token->accessToken); 280 } 281 282 if($this::AUTH_METHOD === OAuth2Interface::AUTH_METHOD_QUERY){ 283 $uri = UriUtil::withQueryValue($request->getUri(), $this::AUTH_PREFIX_QUERY, $token->accessToken); 284 285 return $request->withUri($uri); 286 } 287 288 // it's near impossible to run into this in any other scenario than development... 289 throw new ProviderException('invalid auth AUTH_METHOD'); // @codeCoverageIgnore 290 } 291 292 293 /* 294 * TokenRefresh 295 */ 296 297 /** 298 * implements TokenRefresh::refreshAccessToken() 299 * 300 * @see \chillerlan\OAuth\Core\TokenRefresh::refreshAccessToken() 301 * @throws \chillerlan\OAuth\Providers\ProviderException 302 */ 303 public function refreshAccessToken(AccessToken|null $token = null):AccessToken{ 304 305 if(!$this instanceof TokenRefresh){ 306 throw new ProviderException('token refresh not supported'); 307 } 308 309 $token ??= $this->storage->getAccessToken($this->name); 310 $refreshToken = $token->refreshToken; 311 312 if(empty($refreshToken)){ 313 $msg = 'no refresh token available, token expired [%s]'; 314 315 throw new ProviderException(sprintf($msg, date('Y-m-d h:i:s A', $token->expires))); 316 } 317 318 $body = $this->getRefreshAccessTokenRequestBodyParams($refreshToken); 319 $response = $this->sendAccessTokenRequest(($this->refreshTokenURL ?? $this->accessTokenURL), $body); 320 $newToken = $this->parseTokenResponse($response); 321 322 if(empty($newToken->refreshToken)){ 323 $newToken->refreshToken = $refreshToken; 324 } 325 326 $this->storage->storeAccessToken($newToken, $this->name); 327 328 return $newToken; 329 } 330 331 /** 332 * prepares the request body parameters for the token refresh 333 * 334 * @see \chillerlan\OAuth\Core\OAuth2Provider::refreshAccessToken() 335 * 336 * @return array<string, string|null> 337 */ 338 protected function getRefreshAccessTokenRequestBodyParams(string $refreshToken):array{ 339 return [ 340 'client_id' => $this->options->key, 341 'client_secret' => $this->options->secret, 342 'grant_type' => 'refresh_token', 343 'refresh_token' => $refreshToken, 344 'type' => 'web_server', 345 ]; 346 } 347 348 349 /* 350 * CSRFToken 351 */ 352 353 /** 354 * implements CSRFToken::setState() 355 * 356 * @see \chillerlan\OAuth\Core\CSRFToken::setState() 357 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAuthorizationURLRequestParams() 358 * 359 * @param array<string, string> $params 360 * @return array<string, string> 361 * @throws \chillerlan\OAuth\Providers\ProviderException 362 */ 363 final public function setState(array $params):array{ 364 365 if(!$this instanceof CSRFToken){ 366 throw new ProviderException('CSRF protection not supported'); 367 } 368 369 // don't touch the parameter if it has been deliberately set 370 if(!isset($params['state'])){ 371 $params['state'] = $this->nonce(); 372 } 373 374 $this->storage->storeCSRFState($params['state'], $this->name); 375 376 return $params; 377 } 378 379 /** 380 * implements CSRFToken::checkState() 381 * 382 * @see \chillerlan\OAuth\Core\CSRFToken::checkState() 383 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken() 384 * @throws \chillerlan\OAuth\Providers\ProviderException|\chillerlan\OAuth\Core\CSRFStateMismatchException 385 */ 386 final public function checkState(string|null $state = null):void{ 387 388 if(!$this instanceof CSRFToken){ 389 throw new ProviderException('CSRF protection not supported'); 390 } 391 392 if(empty($state)){ 393 throw new ProviderException('invalid CSRF state'); 394 } 395 396 $knownState = $this->storage->getCSRFState($this->name); 397 // delete the used token 398 $this->storage->clearCSRFState($this->name); 399 400 if(!hash_equals($knownState, $state)){ 401 throw new CSRFStateMismatchException(sprintf('CSRF state mismatch for provider "%s": %s', $this->name, $state)); 402 } 403 404 } 405 406}