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}