friendship ended with social-app. php is my new best friend
1<?php 2/** 3 * Class OAuthProvider 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}; 17use chillerlan\OAuth\OAuthOptions; 18use chillerlan\OAuth\Providers\ProviderException; 19use chillerlan\OAuth\Storage\{MemoryStorage, OAuthStorageInterface}; 20use chillerlan\Settings\SettingsContainerInterface; 21use chillerlan\Utilities\Str; 22use Psr\Http\Client\ClientInterface; 23use Psr\Http\Message\{ 24 RequestFactoryInterface, RequestInterface, ResponseInterface, 25 StreamFactoryInterface, StreamInterface, UriFactoryInterface 26}; 27use Psr\Log\{LoggerInterface, NullLogger}; 28use ReflectionClass; 29use function array_merge, array_shift, explode, implode, in_array, is_array, is_string, ltrim, 30 random_bytes, rtrim, sodium_bin2hex, sprintf, str_contains, str_starts_with, strip_tags, strtolower; 31use const PHP_QUERY_RFC1738; 32 33/** 34 * Implements an abstract OAuth provider with all methods required by the OAuthInterface. 35 * It also implements a magic getter that allows to access the properties listed below. 36 */ 37abstract class OAuthProvider implements OAuthInterface{ 38 39 /** 40 * The PSR-18 HTTP client 41 */ 42 protected ClientInterface $http; 43 44 /** 45 * A PSR-17 request factory 46 */ 47 protected RequestFactoryInterface $requestFactory; 48 49 /** 50 * A PSR-17 stream factory 51 */ 52 protected StreamFactoryInterface $streamFactory; 53 54 /** 55 * A PSR-17 URI factory 56 */ 57 protected UriFactoryInterface $uriFactory; 58 59 /** 60 * A PSR-3 logger 61 */ 62 protected LoggerInterface $logger; 63 64 /** 65 * The options instance 66 */ 67 protected OAuthOptions|SettingsContainerInterface $options; 68 69 /** 70 * A storage instance 71 */ 72 protected OAuthStorageInterface $storage; 73 74 /** 75 * The authorization URL 76 */ 77 protected string $authorizationURL = ''; 78 79 /** 80 * The access token exchange URL 81 */ 82 protected string $accessTokenURL = ''; 83 84 /** 85 * An optional URL for application side token revocation 86 * 87 * @see \chillerlan\OAuth\Core\TokenInvalidate 88 * @see \chillerlan\OAuth\Core\TokenInvalidateTrait::invalidateAccessToken() 89 */ 90 protected string $revokeURL = ''; 91 92 /** 93 * The API base URL 94 */ 95 protected string $apiURL = ''; 96 97 /** 98 * The name of the provider/class 99 */ 100 protected string $name = ''; 101 102 /** 103 * An optional link to the provider's API docs 104 */ 105 protected string|null $apiDocs = null; 106 107 /** 108 * An optional URL to the provider's credential registration/application page 109 */ 110 protected string|null $applicationURL = null; 111 112 /** 113 * An optional link to the page where a user can revoke access tokens 114 */ 115 protected string|null $userRevokeURL = null; 116 117 /** 118 * OAuthProvider constructor. 119 */ 120 final public function __construct( 121 OAuthOptions|SettingsContainerInterface $options, 122 ClientInterface $http, 123 RequestFactoryInterface $requestFactory, 124 StreamFactoryInterface $streamFactory, 125 UriFactoryInterface $uriFactory, 126 OAuthStorageInterface $storage = new MemoryStorage, 127 LoggerInterface $logger = new NullLogger, 128 ){ 129 $this->options = $options; 130 $this->http = $http; 131 $this->requestFactory = $requestFactory; 132 $this->streamFactory = $streamFactory; 133 $this->uriFactory = $uriFactory; 134 $this->storage = $storage; 135 $this->logger = $logger; 136 137 $this->name = (new ReflectionClass($this))->getShortName(); 138 139 $this->construct(); 140 } 141 142 /** 143 * A replacement constructor that you can call in extended classes, 144 * so that you don't have to implement the monstrous original `__construct()` 145 */ 146 protected function construct():void{ 147 // noop 148 } 149 150 /** @codeCoverageIgnore */ 151 final public function getName():string{ 152 return $this->name; 153 } 154 155 /** @codeCoverageIgnore */ 156 final public function getApiDocURL():string|null{ 157 return $this->apiDocs; 158 } 159 160 /** @codeCoverageIgnore */ 161 final public function getApplicationURL():string|null{ 162 return $this->applicationURL; 163 } 164 165 /** @codeCoverageIgnore */ 166 final public function getUserRevokeURL():string|null{ 167 return $this->userRevokeURL; 168 } 169 170 /** @codeCoverageIgnore */ 171 final public function setStorage(OAuthStorageInterface $storage):static{ 172 $this->storage = $storage; 173 174 return $this; 175 } 176 177 /** @codeCoverageIgnore */ 178 final public function getStorage():OAuthStorageInterface{ 179 return $this->storage; 180 } 181 182 /** @codeCoverageIgnore */ 183 final public function setLogger(LoggerInterface $logger):static{ 184 $this->logger = $logger; 185 186 return $this; 187 } 188 189 /** @codeCoverageIgnore */ 190 final public function setRequestFactory(RequestFactoryInterface $requestFactory):static{ 191 $this->requestFactory = $requestFactory; 192 193 return $this; 194 } 195 196 /** @codeCoverageIgnore */ 197 final public function setStreamFactory(StreamFactoryInterface $streamFactory):static{ 198 $this->streamFactory = $streamFactory; 199 200 return $this; 201 } 202 203 /** @codeCoverageIgnore */ 204 final public function setUriFactory(UriFactoryInterface $uriFactory):static{ 205 $this->uriFactory = $uriFactory; 206 207 return $this; 208 } 209 210 /** @codeCoverageIgnore */ 211 final public function storeAccessToken(AccessToken $token):static{ 212 $this->storage->storeAccessToken($token, $this->name); 213 214 return $this; 215 } 216 217 /** @codeCoverageIgnore */ 218 final public function getAccessTokenFromStorage():AccessToken{ 219 return $this->storage->getAccessToken($this->name); 220 } 221 222 /** 223 * Creates an access token with the provider set to $this->name 224 * 225 * @codeCoverageIgnore 226 */ 227 final protected function createAccessToken():AccessToken{ 228 return new AccessToken(['provider' => $this->name]); 229 } 230 231 /** 232 * Prepare request headers 233 * 234 * @param array<string, string>|null $headers 235 * @return array<string, string> 236 */ 237 final protected function getRequestHeaders(array|null $headers = null):array{ 238 return array_merge($this::HEADERS_API, ($headers ?? [])); 239 } 240 241 /** 242 * Prepares the request URL 243 * 244 * @param array<string, scalar|bool|null>|null $params 245 */ 246 final protected function getRequestURL(string $path, array|null $params = null):string{ 247 return QueryUtil::merge($this->getRequestTarget($path), $this->cleanQueryParams(($params ?? []))); 248 } 249 250 /** 251 * Cleans an array of query parameters 252 * 253 * @param array<string, scalar|bool|null> $params 254 * @return array<string, string> 255 */ 256 protected function cleanQueryParams(iterable $params):array{ 257 return QueryUtil::cleanParams($params, QueryUtil::BOOLEANS_AS_INT_STRING, true); 258 } 259 260 /** 261 * Cleans an array of body parameters 262 * 263 * @param array<string, scalar|bool|null> $params 264 * @return array<string, string> 265 */ 266 protected function cleanBodyParams(iterable $params):array{ 267 return QueryUtil::cleanParams($params, QueryUtil::BOOLEANS_AS_BOOL, true); 268 } 269 270 /** 271 * Adds an "Authorization: Basic <base64(key:secret)>" header to the given request 272 */ 273 protected function addBasicAuthHeader(RequestInterface $request):RequestInterface{ 274 $auth = Str::base64encode(sprintf('%s:%s', $this->options->key, $this->options->secret)); 275 276 return $request->withHeader('Authorization', sprintf('Basic %s', $auth)); 277 } 278 279 /** 280 * returns a 32 byte random string (in hexadecimal representation) for use as a nonce 281 * 282 * @link https://datatracker.ietf.org/doc/html/rfc5849#section-3.3 283 * @link https://datatracker.ietf.org/doc/html/rfc6749#section-10.12 284 */ 285 protected function nonce(int $bytes = 32):string{ 286 return sodium_bin2hex(random_bytes($bytes)); 287 } 288 289 /** 290 * implements TokenInvalidate 291 * 292 * @see \chillerlan\OAuth\Core\TokenInvalidate 293 * @codeCoverageIgnore 294 * @throws \chillerlan\OAuth\Providers\ProviderException 295 */ 296 public function InvalidateAccessToken(AccessToken|null $token = null):bool{ 297 throw new ProviderException('not implemented'); 298 } 299 300 /** 301 * @throws \chillerlan\OAuth\Core\InvalidAccessTokenException 302 */ 303 final public function sendRequest(RequestInterface $request):ResponseInterface{ 304 // get authorization only if we request the provider API, 305 // shortcut reroute to the http client otherwise. 306 // avoid sending bearer tokens to unknown hosts 307 if(!str_starts_with((string)$request->getUri(), $this->apiURL)){ 308 return $this->http->sendRequest($request); 309 } 310 311 $request = $this->getRequestAuthorization($request); 312 313 return $this->http->sendRequest($request); 314 } 315 316 /** 317 * @throws \chillerlan\OAuth\Core\UnauthorizedAccessException 318 */ 319 public function request( 320 string $path, 321 array|null $params = null, 322 string|null $method = null, 323 StreamInterface|array|string|null $body = null, 324 array|null $headers = null, 325 string|null $protocolVersion = null, 326 ):ResponseInterface{ 327 $request = $this->requestFactory->createRequest(($method ?? 'GET'), $this->getRequestURL($path, $params)); 328 329 foreach($this->getRequestHeaders($headers) as $header => $value){ 330 $request = $request->withAddedHeader($header, $value); 331 } 332 333 if($body !== null){ 334 $request = $this->setRequestBody($body, $request); 335 } 336 337 if($protocolVersion !== null){ 338 $request = $request->withProtocolVersion($protocolVersion); 339 } 340 341 $response = $this->sendRequest($request); 342 343 // we're gonna throw here immideately on unauthorized/forbidden 344 if(in_array($response->getStatusCode(), [401, 403], true)){ 345 throw new UnauthorizedAccessException; 346 } 347 348 return $response; 349 } 350 351 /** 352 * Prepares the request body and sets it in the given RequestInterface, along with a Content-Length header 353 * 354 * @param StreamInterface|array<string, scalar|bool|null>|string $body 355 * @throws \chillerlan\OAuth\Providers\ProviderException 356 */ 357 final protected function setRequestBody(StreamInterface|array|string $body, RequestInterface $request):RequestInterface{ 358 359 // convert the array to a string according to the Content-Type header 360 if(is_array($body)){ 361 $body = $this->cleanBodyParams($body); 362 $contentType = strtolower($request->getHeaderLine('content-type')); 363 364 $body = match($contentType){ 365 'application/x-www-form-urlencoded' => QueryUtil::build($body, PHP_QUERY_RFC1738), 366 'application/json', 'application/vnd.api+json' => Str::jsonEncode($body, 0), 367 default => throw new ProviderException( 368 sprintf('invalid content-type "%s" for the given array body', $contentType), 369 ), 370 }; 371 372 } 373 374 // we don't check if the given string matches the content type - this is the implementor's responsibility 375 if(!$body instanceof StreamInterface){ 376 $body = $this->streamFactory->createStream($body); 377 } 378 379 return $request 380 ->withHeader('Content-length', (string)$body->getSize()) 381 ->withBody($body) 382 ; 383 } 384 385 /** 386 * Determine the request target from the given URI (path segment or URL) with respect to $apiURL, 387 * anything except host and path will be ignored, scheme will always be set to "https". 388 * Throws if the host of a given URL does not match the host of $apiURL. 389 * 390 * @see \chillerlan\OAuth\Core\OAuthInterface::request() 391 * 392 * @throws \chillerlan\OAuth\Providers\ProviderException 393 */ 394 protected function getRequestTarget(string $uri):string{ 395 $parsedURL = $this->uriFactory->createUri($uri); 396 $parsedHost = $parsedURL->getHost(); 397 $api = $this->uriFactory->createUri($this->apiURL); 398 399 if($parsedHost === ''){ 400 $parsedPath = $parsedURL->getPath(); 401 $apiURL = rtrim((string)$api, '/'); 402 403 if($parsedPath === ''){ 404 return $apiURL; 405 } 406 407 return sprintf('%s/%s', $apiURL, ltrim($parsedPath, '/')); 408 } 409 410 // for some reason we were given a host name 411 412 // we explicitly ignore any existing parameters here and enforce https 413 $parsedURL = $parsedURL->withScheme('https')->withQuery('')->withFragment(''); 414 $apiHost = $api->getHost(); 415 416 if($parsedHost === $apiHost){ 417 return (string)$parsedURL; 418 } 419 420 // ok, one last chance - we might have a subdomain in any of the hosts (messy) 421 $strip_subdomains = function(string $host):string{ 422 $host = explode('.', $host); 423 // don't come at me with .co.uk 424 // phpcs:ignore 425 while(count($host) > 2){ 426 array_shift($host); 427 } 428 429 return implode('.', $host); 430 }; 431 432 if($strip_subdomains($parsedHost) === $strip_subdomains($apiHost)){ 433 return (string)$parsedURL; 434 } 435 436 throw new ProviderException(sprintf('given host (%s) does not match provider (%s)', $parsedHost , $apiHost)); 437 } 438 439 /** 440 * prepares and sends the request to the provider's "me" endpoint and returns a ResponseInterface 441 * 442 * @param array<string, scalar|bool|null>|null $params 443 */ 444 protected function sendMeRequest(string $endpoint, array|null $params = null):ResponseInterface{ 445 // we'll bypass the API check here as not all "me" endpoints align with the provider APIs 446 $url = $this->getRequestURL($endpoint, $params); 447 $request = $this->requestFactory->createRequest('GET', $url); 448 449 foreach($this->getRequestHeaders() as $header => $value){ 450 $request = $request->withAddedHeader($header, $value); 451 } 452 453 $request = $this->getRequestAuthorization($request); 454 455 return $this->http->sendRequest($request); 456 } 457 458 /** 459 * fetches the provider's "me" endpoint and returns the JSON data as an array 460 * 461 * @see \chillerlan\OAuth\Core\UserInfo::me() 462 * @see \chillerlan\OAuth\Core\OAuthProvider::sendMeRequest() 463 * @see \chillerlan\OAuth\Core\OAuthProvider::handleMeResponseError() 464 * 465 * @param array<string, scalar|bool|null>|null $params 466 * @return array<int|string, mixed> 467 * @throws \chillerlan\OAuth\Providers\ProviderException 468 */ 469 final protected function getMeResponseData(string $endpoint, array|null $params = null):array{ 470 $response = $this->sendMeRequest($endpoint, $params); 471 472 if($response->getStatusCode() === 200){ 473 $contentType = $response->getHeaderLine('Content-Type'); 474 475 // mixcloud sends a javascript content type for json... 476 if(!str_contains($contentType, 'json') && !str_contains($contentType, 'javascript')){ 477 throw new ProviderException(sprintf('invalid content type "%s", expected JSON', $contentType)); 478 } 479 480 return MessageUtil::decodeJSON($response, true); 481 } 482 483 // handle and throw the error 484 $this->handleMeResponseError($response); 485 486 /** @noinspection PhpUnreachableStatementInspection this is here because phpstan silly */ 487 return []; // @codeCoverageIgnore 488 } 489 490 /** 491 * handles errors for the `me()` endpoints - one horrible block of code to catch them all 492 * 493 * we could simply throw a ProviderException and be done with it, but we're nice and try to provide a message too 494 * 495 * @throws \chillerlan\OAuth\Providers\ProviderException|\chillerlan\OAuth\Core\UnauthorizedAccessException 496 */ 497 final protected function handleMeResponseError(ResponseInterface $response):void{ 498 $status = $response->getStatusCode(); 499 500 // in case these slipped through 501 if(in_array($status, [400, 401, 403], true)){ 502 throw new UnauthorizedAccessException; 503 } 504 505 // the error response may be plain text or html in some cases 506 if(!str_contains($response->getHeaderLine('Content-Type'), 'json')){ 507 $body = strip_tags(MessageUtil::getContents($response)); 508 509 throw new ProviderException(sprintf('user info error HTTP/%s, "%s"', $status, $body)); 510 } 511 512 // json error, fine 513 $json = MessageUtil::decodeJSON($response, true); 514 515 // let's try the common fields 516 foreach(['error_description', 'message', 'error', 'meta', 'data', 'detail', 'status', 'text'] as $err){ 517 518 if(isset($json[$err]) && is_string($json[$err])){ 519 throw new ProviderException($json[$err]); 520 } 521 elseif(is_array($json[$err])){ 522 foreach(['message', 'error', 'errorDetail', 'developer_message', 'msg', 'code'] as $errDetail){ 523 if(isset($json[$err][$errDetail]) && is_string($json[$err][$errDetail])){ 524 throw new ProviderException($json[$err][$errDetail]); 525 } 526 } 527 } 528 } 529 530 // throw the status if we can't find a message 531 throw new ProviderException(sprintf('user info error HTTP/%s', $status)); 532 } 533 534}