Passed
Push — main ( 4fe852...df0c17 )
by smiley
01:49
created

OAuthProvider   A

Complexity

Total Complexity 31

Size/Duplication

Total Lines 328
Duplicated Lines 0 %

Importance

Changes 25
Bugs 1 Features 1
Metric Value
wmc 31
eloc 85
c 25
b 1
f 1
dl 0
loc 328
rs 9.92

15 Methods

Rating   Name   Duplication   Size   Complexity  
A setLogger() 0 4 1
A setStorage() 0 5 1
A setStreamFactory() 0 4 1
A __get() 0 7 2
A storeAccessToken() 0 4 1
A setRequestFactory() 0 4 1
A createAccessToken() 0 2 1
B request() 0 40 8
A me() 0 2 1
A getStorage() 0 2 1
A getRequestTarget() 0 23 4
A __construct() 0 18 1
A setUriFactory() 0 4 1
A invalidateAccessToken() 0 2 1
A sendRequest() 0 19 6
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;
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->storage = new MemoryStorage;
147
		$this->options = $options;
148
		$this->logger  = $logger ?? new NullLogger;
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
		$this->serviceName    = (new ReflectionClass($this))->getShortName();
156
157
		$this->storage->setServiceName($this->serviceName);
158
	}
159
160
	/**
161
	 * Magic getter for the properties specified in self::ALLOWED_PROPERTIES
162
	 *
163
	 * @return mixed|null
164
	 */
165
	public function __get(string $name):mixed{
166
167
		if(in_array($name, $this::ALLOWED_PROPERTIES, true)){
168
			return $this->{$name};
169
		}
170
171
		return null;
172
	}
173
174
	/**
175
	 * @inheritDoc
176
	 * @codeCoverageIgnore
177
	 */
178
	public function setStorage(OAuthStorageInterface $storage):OAuthInterface{
179
		$this->storage = $storage;
180
		$this->storage->setServiceName($this->serviceName);
181
182
		return $this;
183
	}
184
185
	/**
186
	 * @inheritDoc
187
	 * @codeCoverageIgnore
188
	 */
189
	public function getStorage():OAuthStorageInterface{
190
		return $this->storage;
191
	}
192
193
	/**
194
	 * @inheritDoc
195
	 * @codeCoverageIgnore
196
	 */
197
	public function storeAccessToken(AccessToken $token):OAuthInterface{
198
		$this->storage->storeAccessToken($token, $this->serviceName);
199
200
		return $this;
201
	}
202
203
	/**
204
	 * @inheritDoc
205
	 * @codeCoverageIgnore
206
	 */
207
	public function setLogger(LoggerInterface $logger):OAuthInterface{
208
		$this->logger = $logger;
209
210
		return $this;
211
	}
212
213
	/**
214
	 * @inheritDoc
215
	 * @codeCoverageIgnore
216
	 */
217
	public function setRequestFactory(RequestFactoryInterface $requestFactory):OAuthInterface{
218
		$this->requestFactory = $requestFactory;
219
220
		return $this;
221
	}
222
223
	/**
224
	 * @inheritDoc
225
	 * @codeCoverageIgnore
226
	 */
227
	public function setStreamFactory(StreamFactoryInterface $streamFactory):OAuthInterface{
228
		$this->streamFactory = $streamFactory;
229
230
		return $this;
231
	}
232
233
	/**
234
	 * @inheritDoc
235
	 * @codeCoverageIgnore
236
	 */
237
	public function setUriFactory(UriFactoryInterface $uriFactory):OAuthInterface{
238
		$this->uriFactory = $uriFactory;
239
240
		return $this;
241
	}
242
243
	/**
244
	 * Creates an access token with the provider set to $this->serviceName
245
	 */
246
	protected function createAccessToken():AccessToken{
247
		return new AccessToken(['provider' => $this->serviceName]);
248
	}
249
250
	/**
251
	 * @inheritDoc
252
	 */
253
	public function request(
254
		string $path,
255
		array $params = null,
256
		string $method = null,
257
		StreamInterface|array|string $body = null,
258
		array $headers = null
259
	):ResponseInterface{
260
261
		$request = $this->requestFactory
262
			->createRequest($method ?? 'GET', QueryUtil::merge($this->getRequestTarget($path), $params ?? []));
263
264
		foreach(array_merge($this->apiHeaders, $headers ?? []) as $header => $value){
265
			$request = $request->withAddedHeader($header, $value);
266
		}
267
268
		if($request->hasHeader('content-type')){
269
			$contentType = strtolower($request->getHeaderLine('content-type'));
270
271
			if(is_array($body)){
272
				if($contentType === 'application/x-www-form-urlencoded'){
273
					$body = $this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738));
274
				}
275
				elseif(in_array($contentType, ['application/json', 'application/vnd.api+json'])){
276
					$body = $this->streamFactory->createStream(json_encode($body));
277
				}
278
			}
279
			elseif(is_string($body)){
0 ignored issues
show
introduced by
The condition is_string($body) is always false.
Loading history...
280
				// we don't check if the given string matches the content type - this is the implementor's responsibility
281
				$body = $this->streamFactory->createStream($body);
282
			}
283
		}
284
285
		if($body instanceof StreamInterface){
286
			$request = $request
287
				->withBody($body)
288
				->withHeader('Content-length', (string)$body->getSize())
289
			;
290
		}
291
292
		return $this->sendRequest($request);
293
	}
294
295
	/**
296
	 * Determine the request target from the given URI (path segment or URL) with respect to $apiURL,
297
	 * anything except host and path will be ignored, scheme will always be set to "https".
298
	 * Throws if the given path is invalid or if the host of a given URL does not match $apiURL.
299
	 *
300
	 * @see \chillerlan\OAuth\Core\OAuthInterface::request()
301
	 *
302
	 * @throws \chillerlan\OAuth\Core\ProviderException
303
	 */
304
	protected function getRequestTarget(string $uri):string{
305
		$parsedURL = QueryUtil::parseUrl($uri);
306
307
		if(!isset($parsedURL['path'])){
308
			throw new ProviderException('invalid path');
309
		}
310
311
		// for some reason we were given a host name
312
		if(isset($parsedURL['host'])){
313
			$api  = QueryUtil::parseUrl($this->apiURL);
314
			$host = $api['host'] ?? null;
315
316
			// back out if it doesn't match
317
			if($parsedURL['host'] !== $host){
318
				throw new ProviderException(sprintf('given host (%s) does not match provider (%s)', $parsedURL['host'] , $host));
319
			}
320
321
			// we explicitly ignore any existing parameters here
322
			return 'https://'.$parsedURL['host'].$parsedURL['path'];
323
		}
324
325
		// $apiURL may already include a part of the path
326
		return $this->apiURL.$parsedURL['path'];
327
	}
328
329
	/**
330
	 * @inheritDoc
331
	 */
332
	public function sendRequest(RequestInterface $request):ResponseInterface{
333
334
		// get authorization only if we request the provider API
335
		if(str_starts_with((string)$request->getUri(), $this->apiURL)){
336
			$token = $this->storage->getAccessToken($this->serviceName);
337
338
			// attempt to refresh an expired token
339
			if(
340
				$this instanceof TokenRefresh
341
				&& $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...
342
				&& ($token->isExpired() || $token->expires === $token::EOL_UNKNOWN)
343
			){
344
				$token = $this->refreshAccessToken($token);
345
			}
346
347
			$request = $this->getRequestAuthorization($request, $token);
348
		}
349
350
		return $this->http->sendRequest($request);
351
	}
352
353
	/**
354
	 * @inheritDoc
355
	 * @codeCoverageIgnore
356
	 */
357
	public function me():ResponseInterface{
358
		throw new ProviderException('not implemented');
359
	}
360
361
	/**
362
	 * @implements \chillerlan\OAuth\Core\TokenInvalidate
363
	 * @inheritDoc
364
	 * @codeCoverageIgnore
365
	 */
366
	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

366
	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...
367
		throw new ProviderException('not implemented');
368
	}
369
370
}
371