Passed
Push — main ( 8b7853...c05ac9 )
by smiley
02:10
created

OAuthProvider::me()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 2
rs 10
cc 1
nc 1
nop 0
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\HTTP\Psr17\{RequestFactory, StreamFactory, UriFactory};
14
use chillerlan\HTTP\Utils\QueryUtil;
15
use chillerlan\OAuth\OAuthOptions;
16
use chillerlan\OAuth\Storage\OAuthStorageInterface;
17
use chillerlan\Settings\SettingsContainerInterface;
18
use Psr\Http\Client\ClientInterface;
19
use Psr\Http\Message\{
20
	RequestFactoryInterface, RequestInterface, ResponseInterface,
21
	StreamFactoryInterface, StreamInterface, UriFactoryInterface
22
};
23
use Psr\Log\{LoggerAwareTrait, LoggerInterface, NullLogger};
24
use ReflectionClass;
25
use function array_merge;
26
use function in_array;
27
use function is_array;
28
use function is_string;
29
use function json_encode;
30
use function sprintf;
31
use function str_starts_with;
32
use function strtolower;
33
use const PHP_QUERY_RFC1738;
34
35
/**
36
 * Implements an abstract OAuth provider with all methods required by the OAuthInterface.
37
 * It also implements a magic getter that allows to access the properties listed below.
38
 *
39
 * @property string|null $apiDocs
40
 * @property string      $apiURL
41
 * @property string|null $applicationURL
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', '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
	protected OAuthOptions|SettingsContainerInterface $options;
66
67
	/**
68
	 * an optional PSR-17 request factory
69
	 */
70
	protected RequestFactoryInterface $requestFactory;
71
72
	/**
73
	 * an optional PSR-17 stream factory
74
	 */
75
	protected StreamFactoryInterface  $streamFactory;
76
77
	/**
78
	 * an optional PSR-17 URI factory
79
	 */
80
	protected UriFactoryInterface $uriFactory;
81
82
	/**
83
	 * the name of the provider (class) (magic)
84
	 */
85
	protected ?string $serviceName = null;
86
87
	/**
88
	 * the authentication URL
89
	 */
90
	protected string $authURL;
91
92
	/**
93
	 * an optional link to the provider's API docs (magic)
94
	 */
95
	protected ?string $apiDocs = null;
96
97
	/**
98
	 * the API base URL (magic)
99
	 */
100
	protected ?string $apiURL = null;
101
102
	/**
103
	 * an optional URL to the provider's credential registration/application page (magic)
104
	 */
105
	protected ?string $applicationURL = null;
106
107
	/**
108
	 * an optional link to the page where a user can revoke access tokens (magic)
109
	 */
110
	protected ?string $userRevokeURL = null;
111
112
	/**
113
	 * an optional URL for application side token revocation
114
	 */
115
	protected ?string $revokeURL = null;
116
117
	/**
118
	 * the provider's access token exchange URL
119
	 */
120
	protected string $accessTokenURL;
121
122
	/**
123
	 * additional headers to use during authentication
124
	 */
125
	protected array $authHeaders = [];
126
127
	/**
128
	 * additional headers to use during API access
129
	 */
130
	protected array $apiHeaders = [];
131
132
	/**
133
	 * OAuthProvider constructor.
134
	 */
135
	public function __construct(
136
		ClientInterface $http,
137
		OAuthStorageInterface $storage,
138
		OAuthOptions|SettingsContainerInterface $options,
139
		LoggerInterface $logger = null
140
	){
141
		$this->http    = $http;
142
		$this->storage = $storage;
143
		$this->options = $options;
144
		$this->logger  = $logger ?? new NullLogger;
145
146
		// i hate this, but i also hate adding 3 more params to the constructor
147
		// no, i won't use a DI container for this. don't @ me
148
		$this->requestFactory = new RequestFactory;
149
		$this->streamFactory  = new StreamFactory;
150
		$this->uriFactory     = new UriFactory;
151
		$this->serviceName    = (new ReflectionClass($this))->getShortName();
152
	}
153
154
	/**
155
	 * Magic getter for the properties specified in self::ALLOWED_PROPERTIES
156
	 *
157
	 * @return mixed|null
158
	 */
159
	public function __get(string $name):mixed{
160
161
		if(in_array($name, $this::ALLOWED_PROPERTIES, true)){
162
			return $this->{$name};
163
		}
164
165
		return null;
166
	}
167
168
	/**
169
	 * @inheritDoc
170
	 * @codeCoverageIgnore
171
	 */
172
	public function setStorage(OAuthStorageInterface $storage):OAuthInterface{
173
		$this->storage = $storage;
174
175
		return $this;
176
	}
177
178
	/**
179
	 * @inheritDoc
180
	 * @codeCoverageIgnore
181
	 */
182
	public function getStorage():OAuthStorageInterface{
183
		return $this->storage;
184
	}
185
186
	/**
187
	 * @inheritDoc
188
	 * @codeCoverageIgnore
189
	 */
190
	public function setRequestFactory(RequestFactoryInterface $requestFactory):OAuthInterface{
191
		$this->requestFactory = $requestFactory;
192
193
		return $this;
194
	}
195
196
	/**
197
	 * @inheritDoc
198
	 * @codeCoverageIgnore
199
	 */
200
	public function setStreamFactory(StreamFactoryInterface $streamFactory):OAuthInterface{
201
		$this->streamFactory = $streamFactory;
202
203
		return $this;
204
	}
205
206
	/**
207
	 * @inheritDoc
208
	 * @codeCoverageIgnore
209
	 */
210
	public function setUriFactory(UriFactoryInterface $uriFactory):OAuthInterface{
211
		$this->uriFactory = $uriFactory;
212
213
		return $this;
214
	}
215
216
	/**
217
	 * Creates an access token with the provider set to $this->serviceName
218
	 */
219
	protected function createAccessToken():AccessToken{
220
		return new AccessToken(['provider' => $this->serviceName]);
221
	}
222
223
	/**
224
	 * @inheritDoc
225
	 */
226
	public function request(
227
		string $path,
228
		array $params = null,
229
		string $method = null,
230
		StreamInterface|array|string $body = null,
231
		array $headers = null
232
	):ResponseInterface{
233
234
		$request = $this->requestFactory
235
			->createRequest($method ?? 'GET', QueryUtil::merge($this->getRequestTarget($path), $params ?? []));
236
237
		foreach(array_merge($this->apiHeaders, $headers ?? []) as $header => $value){
238
			$request = $request->withAddedHeader($header, $value);
239
		}
240
241
		if($request->hasHeader('content-type')){
242
			$contentType = strtolower($request->getHeaderLine('content-type'));
243
244
			if(is_array($body)){
245
				if($contentType === 'application/x-www-form-urlencoded'){
246
					$body = $this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738));
247
				}
248
				elseif(in_array($contentType, ['application/json', 'application/vnd.api+json'])){
249
					$body = $this->streamFactory->createStream(json_encode($body));
250
				}
251
			}
252
			elseif(is_string($body)){
0 ignored issues
show
introduced by
The condition is_string($body) is always false.
Loading history...
253
				// we don't check if the given string matches the content type - this is the implementor's responsibility
254
				$body = $this->streamFactory->createStream($body);
255
			}
256
		}
257
258
		if($body instanceof StreamInterface){
259
			$request = $request
260
				->withBody($body)
261
				->withHeader('Content-length', (string)$body->getSize())
262
			;
263
		}
264
265
		return $this->sendRequest($request);
266
	}
267
268
	/**
269
	 * Determine the request target from the given URI (path segment or URL) with respect to $apiURL,
270
	 * anything except host and path will be ignored, scheme will always be set to "https".
271
	 * Throws if the given path is invalid or if the host of a given URL does not match $apiURL.
272
	 *
273
	 * @see \chillerlan\OAuth\Core\OAuthInterface::request()
274
	 *
275
	 * @throws \chillerlan\OAuth\Core\ProviderException
276
	 */
277
	protected function getRequestTarget(string $uri):string{
278
		$parsedURL = QueryUtil::parseUrl($uri);
279
280
		if(!isset($parsedURL['path'])){
281
			throw new ProviderException('invalid path');
282
		}
283
284
		// for some reason we were given a host name
285
		if(isset($parsedURL['host'])){
286
			$api  = QueryUtil::parseUrl($this->apiURL);
287
			$host = $api['host'] ?? null;
288
289
			// back out if it doesn't match
290
			if($parsedURL['host'] !== $host){
291
				throw new ProviderException(sprintf('given host (%s) does not match provider (%s)', $parsedURL['host'] , $host));
292
			}
293
294
			// we explicitly ignore any existing parameters here
295
			return 'https://'.$parsedURL['host'].$parsedURL['path'];
296
		}
297
298
		// $apiURL may already include a part of the path
299
		return $this->apiURL.$parsedURL['path'];
300
	}
301
302
	/**
303
	 * @inheritDoc
304
	 */
305
	public function sendRequest(RequestInterface $request):ResponseInterface{
306
307
		// get authorization only if we request the provider API
308
		if(str_starts_with((string)$request->getUri(), $this->apiURL)){
309
			$token = $this->storage->getAccessToken($this->serviceName);
310
311
			// attempt to refresh an expired token
312
			if(
313
				$this instanceof TokenRefresh
314
				&& $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...
315
				&& ($token->isExpired() || $token->expires === $token::EOL_UNKNOWN)
316
			){
317
				$token = $this->refreshAccessToken($token);
318
			}
319
320
			$request = $this->getRequestAuthorization($request, $token);
321
		}
322
323
		return $this->http->sendRequest($request);
324
	}
325
326
	/**
327
	 * @inheritDoc
328
	 * @codeCoverageIgnore
329
	 */
330
	public function me():ResponseInterface{
331
		throw new ProviderException('not implemented');
332
	}
333
334
}
335