Passed
Push — main ( 01f43c...1653ad )
by smiley
01:54
created

OAuthProvider::request()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 22
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 9
Bugs 1 Features 0
Metric Value
eloc 9
c 9
b 1
f 0
dl 0
loc 22
rs 9.9666
cc 3
nc 4
nop 5
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
11
namespace chillerlan\OAuth\Core;
12
13
use chillerlan\OAuth\Storage\MemoryStorage;
14
use chillerlan\HTTP\Psr17\{RequestFactory, StreamFactory, UriFactory};
15
use chillerlan\HTTP\Utils\QueryUtil;
16
use chillerlan\OAuth\OAuthOptions;
17
use chillerlan\OAuth\Storage\OAuthStorageInterface;
18
use chillerlan\Settings\SettingsContainerInterface;
19
use Psr\Http\Client\ClientInterface;
20
use Psr\Http\Message\{
21
	RequestFactoryInterface, RequestInterface, ResponseInterface,
22
	StreamFactoryInterface, StreamInterface, UriFactoryInterface
23
};
24
use Psr\Log\{LoggerInterface, NullLogger};
25
use ReflectionClass;
26
use function array_merge;
27
use function in_array;
28
use function is_array;
29
use function is_string;
30
use function json_encode;
31
use function sprintf;
32
use function str_starts_with;
33
use function strtolower;
34
use const PHP_QUERY_RFC1738;
35
36
/**
37
 * Implements an abstract OAuth provider with all methods required by the OAuthInterface.
38
 * It also implements a magic getter that allows to access the properties listed below.
39
 */
40
abstract class OAuthProvider implements OAuthInterface{
41
42
	protected const ALLOWED_PROPERTIES = [
43
		'apiDocs', 'apiURL', 'applicationURL', 'serviceName', 'userRevokeURL'
44
	];
45
46
	/**
47
	 * the options instance
48
	 */
49
	protected OAuthOptions|SettingsContainerInterface $options;
50
51
	/**
52
	 * the token storage instance
53
	 */
54
	protected OAuthStorageInterface $storage;
55
56
	/**
57
	 * a PSR-3 logger instance.
58
	 */
59
	protected LoggerInterface $logger;
60
61
	/**
62
	 * the PSR-18 http client instance
63
	 */
64
	protected ClientInterface $http;
65
66
	/**
67
	 * a PSR-17 request factory
68
	 */
69
	protected RequestFactoryInterface $requestFactory;
70
71
	/**
72
	 * a PSR-17 stream factory
73
	 */
74
	protected StreamFactoryInterface  $streamFactory;
75
76
	/**
77
	 * a PSR-17 URI factory
78
	 */
79
	protected UriFactoryInterface $uriFactory;
80
81
	/**
82
	 * the authentication URL
83
	 */
84
	protected string $authURL;
85
86
	/**
87
	 * an optional URL for application side token revocation
88
	 *
89
	 * @see \chillerlan\OAuth\Core\TokenInvalidate
90
	 */
91
	protected ?string $revokeURL = null;
92
93
	/**
94
	 * the provider's access token exchange URL
95
	 */
96
	protected string $accessTokenURL;
97
98
	/**
99
	 * additional headers to use during authentication
100
	 */
101
	protected array $authHeaders = [];
102
103
	/**
104
	 * additional headers to use during API access
105
	 */
106
	protected array $apiHeaders = [];
107
108
	/*
109
	 * magic properties (public readonly would be cool it the implementation wasn't fucking stupid)
110
	 */
111
112
	/**
113
	 * the name of the provider (class) (magic)
114
	 */
115
	protected string $serviceName;
116
117
	/**
118
	 * the API base URL (magic)
119
	 */
120
	protected string $apiURL;
121
122
	/**
123
	 * an optional link to the provider's API docs (magic)
124
	 */
125
	protected ?string $apiDocs = null;
126
127
	/**
128
	 * an optional URL to the provider's credential registration/application page (magic)
129
	 */
130
	protected ?string $applicationURL = null;
131
132
	/**
133
	 * an optional link to the page where a user can revoke access tokens (magic)
134
	 */
135
	protected ?string $userRevokeURL = null;
136
137
	/**
138
	 * OAuthProvider constructor.
139
	 */
140
	public function __construct(
141
		ClientInterface $http,
142
		OAuthOptions|SettingsContainerInterface $options,
143
		LoggerInterface $logger = null
144
	){
145
		$this->http           = $http;
146
		$this->options        = $options;
147
		$this->logger         = $logger ?? new NullLogger;
148
		$this->serviceName    = (new ReflectionClass($this))->getShortName();
149
150
		// i hate this, but i also hate adding 3 more params to the constructor
151
		// no, i won't use a DI container for this. don't @ me
152
		$this->requestFactory = new RequestFactory;
153
		$this->streamFactory  = new StreamFactory;
154
		$this->uriFactory     = new UriFactory;
155
156
		$this->setStorage(new MemoryStorage);
157
	}
158
159
	/**
160
	 * Magic getter for the properties specified in self::ALLOWED_PROPERTIES
161
	 *
162
	 * @return mixed|null
163
	 */
164
	public function __get(string $name):mixed{
165
166
		if(in_array($name, $this::ALLOWED_PROPERTIES, true)){
167
			return $this->{$name};
168
		}
169
170
		return null;
171
	}
172
173
	/**
174
	 * @inheritDoc
175
	 * @codeCoverageIgnore
176
	 */
177
	public function setStorage(OAuthStorageInterface $storage):OAuthInterface{
178
		$this->storage = $storage;
179
		$this->storage->setServiceName($this->serviceName);
180
181
		return $this;
182
	}
183
184
	/**
185
	 * @inheritDoc
186
	 * @codeCoverageIgnore
187
	 */
188
	public function getStorage():OAuthStorageInterface{
189
		return $this->storage;
190
	}
191
192
	/**
193
	 * @inheritDoc
194
	 * @codeCoverageIgnore
195
	 */
196
	public function setLogger(LoggerInterface $logger):OAuthInterface{
197
		$this->logger = $logger;
198
199
		return $this;
200
	}
201
202
	/**
203
	 * @inheritDoc
204
	 * @codeCoverageIgnore
205
	 */
206
	public function setRequestFactory(RequestFactoryInterface $requestFactory):OAuthInterface{
207
		$this->requestFactory = $requestFactory;
208
209
		return $this;
210
	}
211
212
	/**
213
	 * @inheritDoc
214
	 * @codeCoverageIgnore
215
	 */
216
	public function setStreamFactory(StreamFactoryInterface $streamFactory):OAuthInterface{
217
		$this->streamFactory = $streamFactory;
218
219
		return $this;
220
	}
221
222
	/**
223
	 * @inheritDoc
224
	 * @codeCoverageIgnore
225
	 */
226
	public function setUriFactory(UriFactoryInterface $uriFactory):OAuthInterface{
227
		$this->uriFactory = $uriFactory;
228
229
		return $this;
230
	}
231
232
	/**
233
	 * @inheritDoc
234
	 * @codeCoverageIgnore
235
	 */
236
	public function storeAccessToken(AccessToken $token):OAuthInterface{
237
		$this->storage->storeAccessToken($token, $this->serviceName);
238
239
		return $this;
240
	}
241
242
	/**
243
	 * Creates an access token with the provider set to $this->serviceName
244
	 */
245
	protected function createAccessToken():AccessToken{
246
		return new AccessToken(['provider' => $this->serviceName]);
247
	}
248
249
	/**
250
	 * @inheritDoc
251
	 */
252
	public function request(
253
		string $path,
254
		array $params = null,
255
		string $method = null,
256
		StreamInterface|array|string $body = null,
257
		array $headers = null
258
	):ResponseInterface{
259
		$request = $this->requestFactory->createRequest($method ?? 'GET', $this->getRequestURL($path, $params));
260
261
		foreach($this->getRequestHeaders($headers) as $header => $value){
262
			$request = $request->withAddedHeader($header, $value);
263
		}
264
265
		if($body !== null){
266
			$body    = $this->getRequestBody($body, $request);
267
			$request = $request
268
				->withBody($body)
269
				->withHeader('Content-length', (string)$body->getSize())
270
			;
271
		}
272
273
		return $this->sendRequest($request);
274
	}
275
276
	/**
277
	 * Prepare request headers
278
	 */
279
	protected function getRequestHeaders(array $headers = null):array{
280
		return array_merge($this->apiHeaders, $headers ?? []);
281
	}
282
283
	/**
284
	 * Prepares the request URL
285
	 */
286
	protected function getRequestURL(string $path, array $params = null):string{
287
		return QueryUtil::merge($this->getRequestTarget($path), $this->cleanQueryParams($params ?? []));
288
	}
289
290
	/**
291
	 * Cleans an array of query parameters
292
	 */
293
	protected function cleanQueryParams(iterable $params):array{
294
		return QueryUtil::cleanParams($params, QueryUtil::BOOLEANS_AS_INT_STRING, true);
295
	}
296
297
	/**
298
	 * Prepares the request body
299
	 *
300
	 * @throws \chillerlan\OAuth\Core\ProviderException
301
	 */
302
	protected function getRequestBody(StreamInterface|array|string $body, RequestInterface $request):StreamInterface{
303
304
		if($body instanceof StreamInterface){
0 ignored issues
show
introduced by
$body is never a sub-type of Psr\Http\Message\StreamInterface.
Loading history...
305
			return $body; // @codeCoverageIgnore
306
		}
307
308
		if(is_string($body)){
0 ignored issues
show
introduced by
The condition is_string($body) is always false.
Loading history...
309
			// we don't check if the given string matches the content type - this is the implementor's responsibility
310
			return $this->streamFactory->createStream($body);
311
		}
312
313
		if(is_array($body)){
0 ignored issues
show
introduced by
The condition is_array($body) is always true.
Loading history...
314
			$body        = $this->cleanBodyParams($body);
315
			$contentType = strtolower($request->getHeaderLine('content-type'));
316
317
			if($contentType === 'application/x-www-form-urlencoded'){
318
				return $this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738));
319
			}
320
321
			if(in_array($contentType, ['application/json', 'application/vnd.api+json'])){
322
				return $this->streamFactory->createStream(json_encode($body));
323
			}
324
325
		}
326
327
		throw new ProviderException('invalid body/content-type');  // @codeCoverageIgnore
328
	}
329
330
	/**
331
	 * Cleans an array of body parameters
332
	 */
333
	protected function cleanBodyParams(iterable $params):array{
334
		return QueryUtil::cleanParams($params, QueryUtil::BOOLEANS_AS_BOOL, true);
335
	}
336
337
	/**
338
	 * Determine the request target from the given URI (path segment or URL) with respect to $apiURL,
339
	 * anything except host and path will be ignored, scheme will always be set to "https".
340
	 * Throws if the given path is invalid or if the host of a given URL does not match $apiURL.
341
	 *
342
	 * @see \chillerlan\OAuth\Core\OAuthInterface::request()
343
	 *
344
	 * @throws \chillerlan\OAuth\Core\ProviderException
345
	 */
346
	protected function getRequestTarget(string $uri):string{
347
		$parsedURL = QueryUtil::parseUrl($uri);
348
349
		if(!isset($parsedURL['path'])){
350
			throw new ProviderException('invalid path');
351
		}
352
353
		// for some reason we were given a host name
354
		if(isset($parsedURL['host'])){
355
			$api  = QueryUtil::parseUrl($this->apiURL);
356
			$host = $api['host'] ?? null;
357
358
			// back out if it doesn't match
359
			if($parsedURL['host'] !== $host){
360
				throw new ProviderException(sprintf('given host (%s) does not match provider (%s)', $parsedURL['host'] , $host));
361
			}
362
363
			// we explicitly ignore any existing parameters here
364
			return 'https://'.$parsedURL['host'].$parsedURL['path'];
365
		}
366
367
		// $apiURL may already include a part of the path
368
		return $this->apiURL.$parsedURL['path'];
369
	}
370
371
	/**
372
	 * @inheritDoc
373
	 */
374
	public function sendRequest(RequestInterface $request):ResponseInterface{
375
376
		// get authorization only if we request the provider API
377
		if(str_starts_with((string)$request->getUri(), $this->apiURL)){
378
			$token = $this->storage->getAccessToken($this->serviceName);
379
380
			// attempt to refresh an expired token
381
			if(
382
				$this instanceof TokenRefresh
383
				&& $this->options->tokenAutoRefresh
0 ignored issues
show
Bug Best Practice introduced by
The property $tokenAutoRefresh is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
384
				&& ($token->isExpired() || $token->expires === $token::EOL_UNKNOWN)
385
			){
386
				$token = $this->refreshAccessToken($token);
387
			}
388
389
			$request = $this->getRequestAuthorization($request, $token);
390
		}
391
392
		return $this->http->sendRequest($request);
393
	}
394
395
	/**
396
	 * @inheritDoc
397
	 * @codeCoverageIgnore
398
	 */
399
	public function me():ResponseInterface{
400
		throw new ProviderException('not implemented');
401
	}
402
403
	/**
404
	 * @implements \chillerlan\OAuth\Core\TokenInvalidate
405
	 * @inheritDoc
406
	 * @codeCoverageIgnore
407
	 */
408
	public function invalidateAccessToken(AccessToken $token = null):bool{
0 ignored issues
show
Unused Code introduced by
The parameter $token is not used and could be removed. ( Ignorable by Annotation )

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

408
	public function invalidateAccessToken(/** @scrutinizer ignore-unused */ AccessToken $token = null):bool{

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
409
		throw new ProviderException('not implemented');
410
	}
411
412
}
413