Passed
Push — main ( cf021d...1522c6 )
by smiley
02:01
created

OAuthProvider::setUriFactory()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
/**
3
 * Class OAuthProvider
4
 *
5
 * @created      09.07.2017
6
 * @author       Smiley <[email protected]>
7
 * @copyright    2017 Smiley
8
 * @license      MIT
9
 *
10
 * @phan-file-suppress PhanUndeclaredProperty (MagicAPI\ApiClientInterface)
11
 */
12
13
namespace chillerlan\OAuth\Core;
14
15
use chillerlan\HTTP\Psr17\{RequestFactory, StreamFactory, UriFactory};
16
use chillerlan\HTTP\Utils\Query;
17
use chillerlan\OAuth\MagicAPI\{ApiClientException, EndpointMap, EndpointMapInterface};
18
use chillerlan\OAuth\Storage\OAuthStorageInterface;
19
use chillerlan\Settings\SettingsContainerInterface;
20
use Psr\Http\Client\ClientInterface;
21
use Psr\Http\Message\{
22
	RequestFactoryInterface, RequestInterface, ResponseInterface,
23
	StreamFactoryInterface, StreamInterface, UriFactoryInterface
24
};
25
use Psr\Log\{LoggerAwareTrait, LoggerInterface, NullLogger};
26
use ReflectionClass;
27
28
use function array_slice, class_exists, count, implode, in_array, is_array,
29
	is_scalar, is_string, json_encode, sprintf, strpos, strtolower;
30
31
use function chillerlan\HTTP\Utils\parseUrl;
32
use const PHP_QUERY_RFC1738;
33
34
/**
35
 * Implements an abstract OAuth provider with all methods required by the OAuthInterface.
36
 * It also implements a magic getter that allows to access the properties listed below.
37
 *
38
 * @property string|null                                     $apiDocs
39
 * @property string                                          $apiURL
40
 * @property string|null                                     $applicationURL
41
 * @property \chillerlan\OAuth\MagicAPI\EndpointMapInterface $endpoints
42
 * @property string                                          $serviceName
43
 * @property string|null                                     $userRevokeURL
44
 */
45
abstract class OAuthProvider implements OAuthInterface{
46
	use LoggerAwareTrait;
47
48
	protected const ALLOWED_PROPERTIES = [
49
		'apiDocs', 'apiURL', 'applicationURL', 'endpoints', 'serviceName', 'userRevokeURL'
50
	];
51
52
	/**
53
	 * the http client instance
54
	 */
55
	protected ClientInterface $http;
56
57
	/**
58
	 * the token storage instance
59
	 */
60
	protected OAuthStorageInterface $storage;
61
62
	/**
63
	 * the options instance
64
	 *
65
	 * @var \chillerlan\OAuth\OAuthOptions|\chillerlan\Settings\SettingsContainerInterface
66
	 */
67
	protected SettingsContainerInterface $options;
68
69
	/**
70
	 * the API endpoints (optional) (magic)
71
	 */
72
	protected ?EndpointMapInterface $endpoints = null;
73
74
	/**
75
	 * an optional PSR-17 request factory
76
	 */
77
	protected RequestFactoryInterface $requestFactory;
78
79
	/**
80
	 * an optional PSR-17 stream factory
81
	 */
82
	protected StreamFactoryInterface  $streamFactory;
83
84
	/**
85
	 * an optional PSR-17 URI factory
86
	 */
87
	protected UriFactoryInterface $uriFactory;
88
89
	/**
90
	 * the name of the provider (class) (magic)
91
	 */
92
	protected ?string $serviceName = null;
93
94
	/**
95
	 * the authentication URL
96
	 */
97
	protected string $authURL;
98
99
	/**
100
	 * an optional link to the provider's API docs (magic)
101
	 */
102
	protected ?string $apiDocs = null;
103
104
	/**
105
	 * the API base URL (magic)
106
	 */
107
	protected ?string $apiURL = null;
108
109
	/**
110
	 * an optional URL to the provider's credential registration/application page (magic)
111
	 */
112
	protected ?string $applicationURL = null;
113
114
	/**
115
	 * an optional link to the page where a user can revoke access tokens (magic)
116
	 */
117
	protected ?string $userRevokeURL = null;
118
119
	/**
120
	 * an optional URL for application side token revocation
121
	 */
122
	protected ?string $revokeURL = null;
123
124
	/**
125
	 * the provider's access token exchange URL
126
	 */
127
	protected string $accessTokenURL;
128
129
	/**
130
	 * an optional EndpointMapInterface FQCN
131
	 */
132
	protected ?string $endpointMap = null;
133
134
	/**
135
	 * additional headers to use during authentication
136
	 */
137
	protected array $authHeaders = [];
138
139
	/**
140
	 * additional headers to use during API access
141
	 */
142
	protected array $apiHeaders = [];
143
144
	/**
145
	 * OAuthProvider constructor.
146
	 *
147
	 * @param \Psr\Http\Client\ClientInterface                $http
148
	 * @param \chillerlan\OAuth\Storage\OAuthStorageInterface $storage
149
	 * @param \chillerlan\Settings\SettingsContainerInterface $options
150
	 * @param \Psr\Log\LoggerInterface|null                   $logger
151
	 *
152
	 * @throws \chillerlan\OAuth\MagicAPI\ApiClientException
153
	 */
154
	public function __construct(
155
		ClientInterface $http,
156
		OAuthStorageInterface $storage,
157
		SettingsContainerInterface $options,
158
		LoggerInterface $logger = null
159
	){
160
		$this->http    = $http;
161
		$this->storage = $storage;
162
		$this->options = $options;
163
		$this->logger  = $logger ?? new NullLogger;
164
165
		// i hate this, but i also hate adding 3 more params to the constructor
166
		// no i won't use a DI container for this. don't @ me
167
		$this->requestFactory = new RequestFactory;
168
		$this->streamFactory  = new StreamFactory;
169
		$this->uriFactory     = new UriFactory;
170
171
		$this->serviceName = (new ReflectionClass($this))->getShortName();
172
173
		if(!empty($this->endpointMap) && class_exists($this->endpointMap)){
174
			$this->endpoints = new $this->endpointMap;
175
176
			if(!$this->endpoints instanceof EndpointMapInterface){
177
				throw new ApiClientException('invalid endpoint map'); // @codeCoverageIgnore
178
			}
179
180
		}
181
182
	}
183
184
	/**
185
	 * Magic getter for the properties specified in self::ALLOWED_PROPERTIES
186
	 *
187
	 * @param string $name
188
	 *
189
	 * @return mixed|null
190
	 */
191
	public function __get(string $name){
192
193
		if(in_array($name, $this::ALLOWED_PROPERTIES, true)){
194
			return $this->{$name};
195
		}
196
197
		return null;
198
	}
199
200
	/**
201
	 * @inheritDoc
202
	 * @codeCoverageIgnore
203
	 */
204
	public function setStorage(OAuthStorageInterface $storage):OAuthInterface{
205
		$this->storage = $storage;
206
207
		return $this;
208
	}
209
210
	/**
211
	 * @inheritDoc
212
	 * @codeCoverageIgnore
213
	 */
214
	public function setRequestFactory(RequestFactoryInterface $requestFactory):OAuthInterface{
215
		$this->requestFactory = $requestFactory;
216
217
		return $this;
218
	}
219
220
	/**
221
	 * @inheritDoc
222
	 * @codeCoverageIgnore
223
	 */
224
	public function setStreamFactory(StreamFactoryInterface $streamFactory):OAuthInterface{
225
		$this->streamFactory = $streamFactory;
226
227
		return $this;
228
	}
229
230
	/**
231
	 * @inheritDoc
232
	 * @codeCoverageIgnore
233
	 */
234
	public function setUriFactory(UriFactoryInterface $uriFactory):OAuthInterface{
235
		$this->uriFactory = $uriFactory;
236
237
		return $this;
238
	}
239
240
	/**
241
	 * Magic API endpoint access. ugly, isn't it?
242
	 *
243
	 * @param string $endpointName
244
	 * @param array  $arguments
245
	 *
246
	 * @return \Psr\Http\Message\ResponseInterface
247
	 * @throws \chillerlan\OAuth\MagicAPI\ApiClientException
248
	 */
249
	public function __call(string $endpointName, array $arguments):ResponseInterface{
250
251
		if(!$this->endpoints instanceof EndpointMap){
252
			throw new ApiClientException('MagicAPI not available'); // @codeCoverageIgnore
253
		}
254
255
		if(!isset($this->endpoints->{$endpointName})){
256
			throw new ApiClientException('endpoint not found: "'.$endpointName.'"');
257
		}
258
259
		// metadata for the current endpoint
260
		$endpointMeta  = $this->endpoints->{$endpointName};
261
		$path          = $this->endpoints->API_BASE.($endpointMeta['path'] ?? '');
0 ignored issues
show
Bug Best Practice introduced by
The property $API_BASE is declared protected in chillerlan\OAuth\MagicAPI\EndpointMap. Since you implement __get, consider adding a @property or @property-read.
Loading history...
262
		$method        = $endpointMeta['method'] ?? 'GET';
263
		$path_elements = $endpointMeta['path_elements'] ?? [];
264
		$query_params  = $endpointMeta['query'] ?? [];
265
		$headers       = $endpointMeta['headers'] ?? [];
266
		// the body value of the metadata is only informational
267
		$has_body      = isset($endpointMeta['body']) && !empty($endpointMeta['body']);
268
269
		$params = null;
270
		$body   = null;
271
272
		$path_element_count = count($path_elements);
273
		$query_param_count  = count($query_params);
274
275
		if($path_element_count > 0){
276
			$path = $this->parsePathElements($path, $path_elements, $path_element_count, $arguments);
277
		}
278
279
		if($query_param_count > 0){
280
			// $params is the first argument after path segments
281
			$params = $arguments[$path_element_count] ?? null;
282
283
			if(is_array($params)){
284
				$params = $this->cleanQueryParams($this->removeUnlistedParams($params, $query_params));
285
			}
286
		}
287
288
		if(in_array($method, ['POST', 'PATCH', 'PUT', 'DELETE']) && $has_body){
289
			// if no query params are present, $body is the first argument after any path segments
290
			$argPos = $query_param_count > 0 ? 1 : 0;
291
			$body   = $arguments[$path_element_count + $argPos] ?? null;
292
293
			if(is_array($body)){
294
				$body = $this->cleanBodyParams($body);
295
			}
296
		}
297
298
		$this->logger->debug('OAuthProvider::__call() -> '.$this->serviceName.'::'.$endpointName.'()', [
299
			'$endpoint' => $path, '$params' => $params, '$method' => $method, '$body' => $body, '$headers' => $headers,
300
		]);
301
302
		return $this->request($path, $params, $method, $body, $headers);
303
	}
304
305
	/**
306
	 * Checks the given path elements and returns the given path with placeholders replaced
307
	 *
308
	 * @throws \chillerlan\OAuth\MagicAPI\ApiClientException
309
	 */
310
	protected function parsePathElements(string $path, array $path_elements, int $path_element_count, array $arguments):string{
311
		// we don't know if all of the given arguments are path elements...
312
		$urlparams = array_slice($arguments, 0, $path_element_count);
313
314
		if(count($urlparams) !== $path_element_count){
315
			throw new APIClientException('too few URL params, required: '.implode(', ', $path_elements));
316
		}
317
318
		foreach($urlparams as $i => $param){
319
			// ...but we do know that the arguments after the path elements are usually array or null
320
			if(!is_scalar($param)){
321
				$msg = 'invalid path element value for "%s": %s';
322
323
				throw new APIClientException(sprintf($msg, $path_elements[$i], var_export($param, true)));
324
			}
325
		}
326
327
		return sprintf($path, ...$urlparams);
328
	}
329
330
	/**
331
	 * Checks an array against an allowlist and removes any parameter that is not allowed
332
	 */
333
	protected function removeUnlistedParams(array $params, array $allowed):array{
334
		$query = [];
335
		// remove any params that are not listed
336
		foreach($params as $key => $value){
337
338
			if(!in_array($key, $allowed, true)){
339
				continue;
340
			}
341
342
			$query[$key] = $value;
343
		}
344
345
		return $query;
346
	}
347
348
	/**
349
	 * Cleans an array of query parameters
350
	 */
351
	protected function cleanQueryParams(iterable $params):array{
352
		return Query::cleanParams($params, Query::BOOLEANS_AS_INT_STRING, true);
353
	}
354
355
	/**
356
	 * Cleans an array of body parameters
357
	 */
358
	protected function cleanBodyParams(iterable $params):array{
359
		return Query::cleanParams($params, Query::BOOLEANS_AS_BOOL, true);
360
	}
361
362
	/**
363
	 * Merges a set of parameters into the given querystring and returns the result querystring
364
	 */
365
	protected function mergeQuery(string $uri, array $query):string{
366
		return Query::merge($uri, $query);
367
	}
368
369
	/**
370
	 * Builds a query string from the given parameters
371
	 */
372
	protected function buildQuery(array $params, int $encoding = null, string $delimiter = null, string $enclosure = null):string{
373
		return Query::build($params, $encoding, $delimiter, $enclosure);
374
	}
375
376
	/**
377
	 * Parses the given querystring into an associative array
378
	 */
379
	protected function parseQuery(string $querystring, int $urlEncoding = null):array{
380
		return Query::parse($querystring, $urlEncoding);
381
	}
382
383
	/**
384
	 * @inheritDoc
385
	 */
386
	public function request(
387
		string $path,
388
		array $params = null,
389
		string $method = null,
390
		$body = null,
391
		array $headers = null
392
	):ResponseInterface{
393
394
		$request = $this->requestFactory
395
			->createRequest($method ?? 'GET', $this->mergeQuery($this->getRequestTarget($path), $params ?? []));
396
397
		foreach(array_merge($this->apiHeaders, $headers ?? []) as $header => $value){
398
			$request = $request->withAddedHeader($header, $value);
399
		}
400
401
		if($request->hasHeader('content-type')){
402
			$contentType = strtolower($request->getHeaderLine('content-type'));
403
404
			if(is_array($body)){
405
				if($contentType === 'application/x-www-form-urlencoded'){
406
					$body = $this->streamFactory->createStream($this->buildQuery($body, PHP_QUERY_RFC1738));
407
				}
408
				elseif($contentType === 'application/json' || $contentType === 'application/vnd.api+json'){
409
					$body = $this->streamFactory->createStream(json_encode($body));
410
				}
411
			}
412
			elseif(is_string($body)){
413
				// we don't check if the given string matches the content type - this is the implementor's responsibility
414
				$body = $this->streamFactory->createStream($body);
415
			}
416
		}
417
418
		if($body instanceof StreamInterface){
419
			$request = $request
420
				->withBody($body)
421
				->withHeader('Content-length', (string)$body->getSize())
422
			;
423
		}
424
425
		return $this->sendRequest($request);
426
	}
427
428
	/**
429
	 * Determine the request target from the given URI (path segment or URL) with respect to $apiURL,
430
	 * anything except host and path will be ignored, scheme will always be set to "https".
431
	 * Throws if the given path is invalid or if the host of a given URL does not match $apiURL.
432
	 *
433
	 * @see \chillerlan\OAuth\Core\OAuthInterface::request()
434
	 *
435
	 * @throws \chillerlan\OAuth\Core\ProviderException
436
	 */
437
	protected function getRequestTarget(string $uri):string{
438
		$parsedURL = parseUrl($uri);
439
440
		if(!isset($parsedURL['path'])){
441
			throw new ProviderException('invalid path');
442
		}
443
444
		// for some reason we were given a host name
445
		if(isset($parsedURL['host'])){
446
447
			// back out if it doesn't match
448
			/** @phan-suppress-next-line PhanTypeArraySuspiciousNullable - $this->>apiURL should always return a host */
449
			if($parsedURL['host'] !== parseUrl($this->apiURL)['host']){
450
				throw new ProviderException('given host does not match provider host');
451
			}
452
453
			// we explicitly ignore any existing parameters here
454
			return 'https://'.$parsedURL['host'].$parsedURL['path'];
455
		}
456
457
		// $apiURL may already include a part of the path
458
		return $this->apiURL.$parsedURL['path'];
459
	}
460
461
	/**
462
	 * @inheritDoc
463
	 */
464
	public function sendRequest(RequestInterface $request):ResponseInterface{
465
466
		// get authorization only if we request the provider API
467
		if(strpos((string)$request->getUri(), $this->apiURL) === 0){
468
			$token = $this->storage->getAccessToken($this->serviceName);
469
470
			// attempt to refresh an expired token
471
			if(
472
				$this instanceof TokenRefresh
473
				&& $this->options->tokenAutoRefresh
474
				&& ($token->isExpired() || $token->expires === $token::EOL_UNKNOWN)
475
			){
476
				$token = $this->refreshAccessToken($token);
0 ignored issues
show
Bug introduced by
The method refreshAccessToken() does not exist on chillerlan\OAuth\Core\OAuthProvider. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

476
				/** @scrutinizer ignore-call */ 
477
    $token = $this->refreshAccessToken($token);
Loading history...
477
			}
478
479
			$request = $this->getRequestAuthorization($request, $token);
0 ignored issues
show
Bug introduced by
It seems like $token can also be of type Psr\Http\Message\ResponseInterface; however, parameter $token of chillerlan\OAuth\Core\OA...tRequestAuthorization() does only seem to accept chillerlan\OAuth\Core\AccessToken, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

479
			$request = $this->getRequestAuthorization($request, /** @scrutinizer ignore-type */ $token);
Loading history...
480
		}
481
482
		return $this->http->sendRequest($request);
483
	}
484
485
}
486