OAuth2Provider::refreshAccessToken()   B
last analyzed

Complexity

Conditions 6
Paths 11

Size

Total Lines 46
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 26
c 1
b 0
f 0
dl 0
loc 46
rs 8.8817
cc 6
nc 11
nop 1
1
<?php
2
/**
3
 * Class OAuth2Provider
4
 *
5
 * @link https://tools.ietf.org/html/rfc6749
6
 *
7
 * @created      09.07.2017
8
 * @author       Smiley <[email protected]>
9
 * @copyright    2017 Smiley
10
 * @license      MIT
11
 *
12
 * @phan-file-suppress PhanUndeclaredMethod (CSRFToken, ClientCredentials, TokenRefresh)
13
 */
14
15
namespace chillerlan\OAuth\Core;
16
17
use chillerlan\HTTP\Utils\MessageUtil;
18
use chillerlan\HTTP\Utils\QueryUtil;
19
use Psr\Http\Message\{RequestInterface, ResponseInterface, UriInterface};
20
use function array_merge;
21
use function base64_encode;
22
use function date;
23
use function hash_equals;
24
use function implode;
25
use function is_array;
26
use function json_decode;
27
use function random_bytes;
28
use function sha1;
29
use function sprintf;
30
use const JSON_THROW_ON_ERROR;
31
use const PHP_QUERY_RFC1738;
32
33
/**
34
 * Implements an abstract OAuth2 provider with all methods required by the OAuth2Interface.
35
 * It also implements the ClientCredentials, CSRFToken and TokenRefresh interfaces in favor over traits.
36
 */
37
abstract class OAuth2Provider extends OAuthProvider implements OAuth2Interface{
38
39
	/**
40
	 * Specifies the authentication method:
41
	 *   - OAuth2Interface::AUTH_METHOD_HEADER (Bearer, OAuth, ...)
42
	 *   - OAuth2Interface::AUTH_METHOD_QUERY (access_token, ...)
43
	 */
44
	protected int $authMethod = self::AUTH_METHOD_HEADER;
45
46
	/**
47
	 * The name of the authentication header in case of OAuth2Interface::AUTH_METHOD_HEADER
48
	 */
49
	protected string $authMethodHeader = 'Bearer';
50
51
	/**
52
	 * The name of the authentication query parameter in case of OAuth2Interface::AUTH_METHOD_QUERY
53
	 */
54
	protected string $authMethodQuery = 'access_token';
55
56
	/**
57
	 * The delimiter string for scopes
58
	 */
59
	protected string $scopesDelimiter = ' ';
60
61
	/**
62
	 * An optional refresh token endpoint in case the provider supports TokenRefresh.
63
	 * If the provider supports token refresh and $refreshTokenURL is null, $accessTokenURL will be used instead.
64
	 *
65
	 * @see \chillerlan\OAuth\Core\TokenRefresh
66
	 */
67
	protected string $refreshTokenURL;
68
69
	/**
70
	 * An optional client credentials token endpoint in case the provider supports ClientCredentials.
71
	 * If the provider supports client credentials and $clientCredentialsTokenURL is null, $accessTokenURL will be used instead.
72
	 */
73
	protected ?string $clientCredentialsTokenURL = null;
74
75
	/**
76
	 * Default scopes to apply if none were provided via the $scopes parameter in OAuth2Provider::getAuthURL()
77
	 */
78
	protected array $defaultScopes = [];
79
80
	/**
81
	 * @inheritDoc
82
	 */
83
	public function getAuthURL(array $params = null, array $scopes = null):UriInterface{
84
		$params ??= [];
85
		$scopes ??= $this->defaultScopes;
86
87
		unset($params['client_secret']);
88
89
		$params = array_merge($params, [
90
			'client_id'     => $this->options->key,
0 ignored issues
show
Bug Best Practice introduced by
The property $key is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
91
			'redirect_uri'  => $this->options->callbackURL,
0 ignored issues
show
Bug Best Practice introduced by
The property $callbackURL is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
92
			'response_type' => 'code',
93
			'type'          => 'web_server',
94
		]);
95
96
		if(!empty($scopes)){
97
			$params['scope'] = implode($this->scopesDelimiter, $scopes);
98
		}
99
100
		if($this instanceof CSRFToken){
101
			$params = $this->setState($params);
102
		}
103
104
		return $this->uriFactory->createUri(QueryUtil::merge($this->authURL, $params));
105
	}
106
107
	/**
108
	 * Parses the response from a request to the token endpoint
109
	 *
110
	 * @link https://tools.ietf.org/html/rfc6749#section-4.1.4
111
	 *
112
	 * @throws \chillerlan\OAuth\Core\ProviderException
113
	 * @throws \JsonException
114
	 */
115
	protected function parseTokenResponse(ResponseInterface $response):AccessToken{
116
		// silly amazon sends compressed data...
117
		$data = json_decode(MessageUtil::decompress($response), true, 512, JSON_THROW_ON_ERROR);
118
119
		if(!is_array($data)){
120
			throw new ProviderException('unable to parse token response');
121
		}
122
123
		foreach(['error_description', 'error'] as $field){
124
125
			if(isset($data[$field])){
126
				throw new ProviderException('error retrieving access token: "'.$data[$field].'"');
127
			}
128
129
		}
130
131
		if(!isset($data['access_token'])){
132
			throw new ProviderException('token missing');
133
		}
134
135
		$token = $this->createAccessToken();
136
137
		$token->accessToken  = $data['access_token'];
138
		$token->expires      = ($data['expires_in'] ?? AccessToken::EOL_NEVER_EXPIRES);
139
		$token->refreshToken = ($data['refresh_token'] ?? null);
140
141
		unset($data['expires_in'], $data['refresh_token'], $data['access_token']);
142
143
		$token->extraParams = $data;
144
145
		return $token;
146
	}
147
148
	/**
149
	 * @inheritDoc
150
	 */
151
	public function getAccessToken(string $code, string $state = null):AccessToken{
152
153
		if($this instanceof CSRFToken){
154
			$this->checkState($state);
155
		}
156
157
		$body = [
158
			'client_id'     => $this->options->key,
0 ignored issues
show
Bug Best Practice introduced by
The property $key is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
159
			'client_secret' => $this->options->secret,
0 ignored issues
show
Bug Best Practice introduced by
The property $secret is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
160
			'code'          => $code,
161
			'grant_type'    => 'authorization_code',
162
			'redirect_uri'  => $this->options->callbackURL,
0 ignored issues
show
Bug Best Practice introduced by
The property $callbackURL is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
163
		];
164
165
		$request = $this->requestFactory
166
			->createRequest('POST', $this->accessTokenURL)
167
			->withHeader('Content-Type', 'application/x-www-form-urlencoded')
168
			->withHeader('Accept-Encoding', 'identity')
169
			->withBody($this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738)));
170
171
		foreach($this->authHeaders as $header => $value){
172
			$request = $request->withHeader($header, $value);
173
		}
174
175
		$token = $this->parseTokenResponse($this->http->sendRequest($request));
176
177
		$this->storage->storeAccessToken($token, $this->serviceName);
178
179
		return $token;
180
	}
181
182
	/**
183
	 * @inheritDoc
184
	 */
185
	public function getRequestAuthorization(RequestInterface $request, AccessToken $token):RequestInterface{
186
187
		if($this->authMethod === OAuth2Interface::AUTH_METHOD_HEADER){
188
			return $request->withHeader('Authorization', $this->authMethodHeader.' '.$token->accessToken);
189
		}
190
191
		if($this->authMethod === OAuth2Interface::AUTH_METHOD_QUERY){
192
			$uri = QueryUtil::merge((string)$request->getUri(), [$this->authMethodQuery => $token->accessToken]);
193
194
			return $request->withUri($this->uriFactory->createUri($uri));
195
		}
196
197
		throw new ProviderException('invalid auth type');
198
	}
199
200
	/**
201
	 * @implements \chillerlan\OAuth\Core\ClientCredentials
202
	 * @throws \chillerlan\OAuth\Core\ProviderException
203
	 */
204
	public function getClientCredentialsToken(array $scopes = null):AccessToken{
205
206
		if(!$this instanceof ClientCredentials){
207
			throw new ProviderException('client credentials token not supported');
208
		}
209
210
		$params = ['grant_type' => 'client_credentials'];
211
212
		if(!empty($scopes)){
213
			$params['scope'] = implode($this->scopesDelimiter, $scopes);
214
		}
215
216
		$request = $this->requestFactory
217
			->createRequest('POST', ($this->clientCredentialsTokenURL ?? $this->accessTokenURL))
218
			->withHeader('Authorization', 'Basic '.base64_encode($this->options->key.':'.$this->options->secret))
0 ignored issues
show
Bug Best Practice introduced by
The property $key is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
Bug Best Practice introduced by
The property $secret is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
219
			->withHeader('Content-Type', 'application/x-www-form-urlencoded')
220
			->withHeader('Accept-Encoding', 'identity')
221
			->withBody($this->streamFactory->createStream(QueryUtil::build($params, PHP_QUERY_RFC1738)))
222
		;
223
224
		foreach($this->authHeaders as $header => $value){
225
			$request = $request->withAddedHeader($header, $value);
226
		}
227
228
		$token = $this->parseTokenResponse($this->http->sendRequest($request));
229
		$token->scopes = ($scopes ?? []);
230
231
		$this->storage->storeAccessToken($token, $this->serviceName);
232
233
		return $token;
234
	}
235
236
	/**
237
	 * @implements \chillerlan\OAuth\Core\TokenRefresh
238
	 * @throws \chillerlan\OAuth\Core\ProviderException
239
	 */
240
	public function refreshAccessToken(AccessToken $token = null):AccessToken{
241
242
		if(!$this instanceof TokenRefresh){
243
			throw new ProviderException('token refresh not supported');
244
		}
245
246
		if($token === null){
247
			$token = $this->storage->getAccessToken($this->serviceName);
248
		}
249
250
		$refreshToken = $token->refreshToken;
251
252
		if(empty($refreshToken)){
253
			throw new ProviderException(
254
				sprintf('no refresh token available, token expired [%s]', date('Y-m-d h:i:s A', $token->expires))
255
			);
256
		}
257
258
		$body = [
259
			'client_id'     => $this->options->key,
0 ignored issues
show
Bug Best Practice introduced by
The property $key is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
260
			'client_secret' => $this->options->secret,
0 ignored issues
show
Bug Best Practice introduced by
The property $secret is declared protected in chillerlan\OAuth\OAuthOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
261
			'grant_type'    => 'refresh_token',
262
			'refresh_token' => $refreshToken,
263
			'type'          => 'web_server',
264
		];
265
266
		$request = $this->requestFactory
267
			->createRequest('POST', ($this->refreshTokenURL ?? $this->accessTokenURL))
268
			->withHeader('Content-Type', 'application/x-www-form-urlencoded')
269
			->withHeader('Accept-Encoding', 'identity')
270
			->withBody($this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738)))
271
		;
272
273
		foreach($this->authHeaders as $header => $value){
274
			$request = $request->withAddedHeader($header, $value);
275
		}
276
277
		$newToken = $this->parseTokenResponse($this->http->sendRequest($request));
278
279
		if(empty($newToken->refreshToken)){
280
			$newToken->refreshToken = $refreshToken;
281
		}
282
283
		$this->storage->storeAccessToken($newToken, $this->serviceName);
284
285
		return $newToken;
286
	}
287
288
	/**
289
	 * @implements \chillerlan\OAuth\Core\CSRFToken
290
	 * @throws \chillerlan\OAuth\Core\ProviderException
291
	 * @internal
292
	 */
293
	public function checkState(string $state = null):void{
294
295
		if(!$this instanceof CSRFToken){
296
			throw new ProviderException('CSRF protection not supported');
297
		}
298
299
		if(empty($state) || !$this->storage->hasCSRFState($this->serviceName)){
300
			throw new ProviderException('invalid state for '.$this->serviceName);
301
		}
302
303
		$knownState = $this->storage->getCSRFState($this->serviceName);
304
305
		if(!hash_equals($knownState, $state)){
306
			throw new ProviderException('invalid CSRF state: '.$this->serviceName.' '.$state);
307
		}
308
309
	}
310
311
	/**
312
	 * @implements \chillerlan\OAuth\Core\CSRFToken
313
	 * @throws \chillerlan\OAuth\Core\ProviderException
314
	 * @internal
315
	 */
316
	public function setState(array $params):array{
317
318
		if(!$this instanceof CSRFToken){
319
			throw new ProviderException('CSRF protection not supported');
320
		}
321
322
		if(!isset($params['state'])){
323
			$params['state'] = sha1(random_bytes(256));
324
		}
325
326
		$this->storage->storeCSRFState($params['state'], $this->serviceName);
327
328
		return $params;
329
	}
330
331
}
332