OAuthProvider::getRequestBody()   A
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 12
c 1
b 0
f 0
dl 0
loc 26
rs 9.2222
cc 6
nc 6
nop 2
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
		// no, I won't use a DI container for this. don't @ me
151
		$this->requestFactory = new RequestFactory;
152
		$this->streamFactory  = new StreamFactory;
153
		$this->uriFactory     = new UriFactory;
154
155
		$this->setStorage(new MemoryStorage);
156
	}
157
158
	/**
159
	 * Magic getter for the properties specified in self::ALLOWED_PROPERTIES
160
	 *
161
	 * @return mixed|null
162
	 */
163
	public function __get(string $name):mixed{
164
165
		if(in_array($name, $this::ALLOWED_PROPERTIES, true)){
166
			return $this->{$name};
167
		}
168
169
		return null;
170
	}
171
172
	/**
173
	 * @inheritDoc
174
	 * @codeCoverageIgnore
175
	 */
176
	public function setStorage(OAuthStorageInterface $storage):OAuthInterface{
177
		$this->storage = $storage;
178
		$this->storage->setServiceName($this->serviceName);
179
180
		return $this;
181
	}
182
183
	/**
184
	 * @inheritDoc
185
	 * @codeCoverageIgnore
186
	 */
187
	public function getStorage():OAuthStorageInterface{
188
		return $this->storage;
189
	}
190
191
	/**
192
	 * @inheritDoc
193
	 * @codeCoverageIgnore
194
	 */
195
	public function setLogger(LoggerInterface $logger):OAuthInterface{
196
		$this->logger = $logger;
197
198
		return $this;
199
	}
200
201
	/**
202
	 * @inheritDoc
203
	 * @codeCoverageIgnore
204
	 */
205
	public function setRequestFactory(RequestFactoryInterface $requestFactory):OAuthInterface{
206
		$this->requestFactory = $requestFactory;
207
208
		return $this;
209
	}
210
211
	/**
212
	 * @inheritDoc
213
	 * @codeCoverageIgnore
214
	 */
215
	public function setStreamFactory(StreamFactoryInterface $streamFactory):OAuthInterface{
216
		$this->streamFactory = $streamFactory;
217
218
		return $this;
219
	}
220
221
	/**
222
	 * @inheritDoc
223
	 * @codeCoverageIgnore
224
	 */
225
	public function setUriFactory(UriFactoryInterface $uriFactory):OAuthInterface{
226
		$this->uriFactory = $uriFactory;
227
228
		return $this;
229
	}
230
231
	/**
232
	 * @inheritDoc
233
	 * @codeCoverageIgnore
234
	 */
235
	public function storeAccessToken(AccessToken $token):OAuthInterface{
236
		$this->storage->storeAccessToken($token, $this->serviceName);
237
238
		return $this;
239
	}
240
241
	/**
242
	 * Creates an access token with the provider set to $this->serviceName
243
	 */
244
	protected function createAccessToken():AccessToken{
245
		return new AccessToken(['provider' => $this->serviceName]);
246
	}
247
248
	/**
249
	 * @inheritDoc
250
	 */
251
	public function request(
252
		string $path,
253
		array $params = null,
254
		string $method = null,
255
		StreamInterface|array|string $body = null,
256
		array $headers = null
257
	):ResponseInterface{
258
		$request = $this->requestFactory->createRequest(($method ?? 'GET'), $this->getRequestURL($path, $params));
259
260
		foreach($this->getRequestHeaders($headers) as $header => $value){
261
			$request = $request->withAddedHeader($header, $value);
262
		}
263
264
		if($body !== null){
265
			$body    = $this->getRequestBody($body, $request);
266
			$request = $request
267
				->withBody($body)
268
				->withHeader('Content-length', (string)$body->getSize())
269
			;
270
		}
271
272
		return $this->sendRequest($request);
273
	}
274
275
	/**
276
	 * Prepare request headers
277
	 */
278
	protected function getRequestHeaders(array $headers = null):array{
279
		return array_merge($this->apiHeaders, ($headers ?? []));
280
	}
281
282
	/**
283
	 * Prepares the request URL
284
	 */
285
	protected function getRequestURL(string $path, array $params = null):string{
286
		return QueryUtil::merge($this->getRequestTarget($path), $this->cleanQueryParams(($params ?? [])));
287
	}
288
289
	/**
290
	 * Cleans an array of query parameters
291
	 */
292
	protected function cleanQueryParams(iterable $params):array{
293
		return QueryUtil::cleanParams($params, QueryUtil::BOOLEANS_AS_INT_STRING, true);
294
	}
295
296
	/**
297
	 * Prepares the request body
298
	 *
299
	 * @throws \chillerlan\OAuth\Core\ProviderException
300
	 */
301
	protected function getRequestBody(StreamInterface|array|string $body, RequestInterface $request):StreamInterface{
302
303
		if($body instanceof StreamInterface){
0 ignored issues
show
introduced by
$body is never a sub-type of Psr\Http\Message\StreamInterface.
Loading history...
304
			return $body; // @codeCoverageIgnore
305
		}
306
307
		if(is_string($body)){
0 ignored issues
show
introduced by
The condition is_string($body) is always false.
Loading history...
308
			// we don't check if the given string matches the content type - this is the implementor's responsibility
309
			return $this->streamFactory->createStream($body);
310
		}
311
312
		if(is_array($body)){
0 ignored issues
show
introduced by
The condition is_array($body) is always true.
Loading history...
313
			$body        = $this->cleanBodyParams($body);
314
			$contentType = strtolower($request->getHeaderLine('content-type'));
315
316
			if($contentType === 'application/x-www-form-urlencoded'){
317
				return $this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738));
318
			}
319
320
			if(in_array($contentType, ['application/json', 'application/vnd.api+json'])){
321
				return $this->streamFactory->createStream(json_encode($body));
322
			}
323
324
		}
325
326
		throw new ProviderException('invalid body/content-type');  // @codeCoverageIgnore
327
	}
328
329
	/**
330
	 * Cleans an array of body parameters
331
	 */
332
	protected function cleanBodyParams(iterable $params):array{
333
		return QueryUtil::cleanParams($params, QueryUtil::BOOLEANS_AS_BOOL, true);
334
	}
335
336
	/**
337
	 * Determine the request target from the given URI (path segment or URL) with respect to $apiURL,
338
	 * anything except host and path will be ignored, scheme will always be set to "https".
339
	 * Throws if the given path is invalid or if the host of a given URL does not match $apiURL.
340
	 *
341
	 * @see \chillerlan\OAuth\Core\OAuthInterface::request()
342
	 *
343
	 * @throws \chillerlan\OAuth\Core\ProviderException
344
	 */
345
	protected function getRequestTarget(string $uri):string{
346
		$parsedURL = QueryUtil::parseUrl($uri);
347
348
		if(!isset($parsedURL['path'])){
349
			throw new ProviderException('invalid path');
350
		}
351
352
		// for some reason we were given a host name
353
		if(isset($parsedURL['host'])){
354
			$api  = QueryUtil::parseUrl($this->apiURL);
355
			$host = ($api['host'] ?? null);
356
357
			// back out if it doesn't match
358
			if($parsedURL['host'] !== $host){
359
				throw new ProviderException(sprintf('given host (%s) does not match provider (%s)', $parsedURL['host'] , $host));
360
			}
361
362
			// we explicitly ignore any existing parameters here
363
			return 'https://'.$parsedURL['host'].$parsedURL['path'];
364
		}
365
366
		// $apiURL may already include a part of the path
367
		return $this->apiURL.$parsedURL['path'];
368
	}
369
370
	/**
371
	 * @inheritDoc
372
	 */
373
	public function sendRequest(RequestInterface $request):ResponseInterface{
374
375
		// get authorization only if we request the provider API
376
		if(str_starts_with((string)$request->getUri(), $this->apiURL)){
377
			$token = $this->storage->getAccessToken($this->serviceName);
378
379
			// attempt to refresh an expired token
380
			if(
381
				$this instanceof TokenRefresh
382
				&& $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...
383
				&& ($token->isExpired() || $token->expires === $token::EOL_UNKNOWN)
384
			){
385
				$token = $this->refreshAccessToken($token);
386
			}
387
388
			$request = $this->getRequestAuthorization($request, $token);
389
		}
390
391
		return $this->http->sendRequest($request);
392
	}
393
394
	/**
395
	 * @inheritDoc
396
	 * @codeCoverageIgnore
397
	 */
398
	public function me():ResponseInterface{
399
		throw new ProviderException('not implemented');
400
	}
401
402
	/**
403
	 * @implements \chillerlan\OAuth\Core\TokenInvalidate
404
	 * @codeCoverageIgnore
405
	 * @throws \chillerlan\OAuth\Core\ProviderException
406
	 */
407
	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

407
	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...
408
		throw new ProviderException('not implemented');
409
	}
410
411
}
412