friendship ended with social-app. php is my new best friend
1<?php
2/**
3 * Class OAuth2Provider
4 *
5 * @created 09.07.2017
6 * @author Smiley <smiley@chillerlan.net>
7 * @copyright 2017 Smiley
8 * @license MIT
9 *
10 * @filesource
11 */
12declare(strict_types=1);
13
14namespace chillerlan\OAuth\Core;
15
16use chillerlan\HTTP\Utils\{MessageUtil, QueryUtil, UriUtil};
17use chillerlan\OAuth\Providers\ProviderException;
18use Psr\Http\Message\{RequestInterface, ResponseInterface, UriInterface};
19use Throwable;
20use function array_merge, date, explode, hash_equals, implode, in_array, is_array, sprintf;
21use const PHP_QUERY_RFC1738;
22
23/**
24 * Implements an abstract OAuth2 provider with all methods required by the OAuth2Interface.
25 * It also implements the ClientCredentials, CSRFToken, TokenRefresh and [...] interfaces in favor over traits.
26 *
27 * @link https://oauth.net/2/
28 * @link https://datatracker.ietf.org/doc/html/rfc6749
29 * @link https://datatracker.ietf.org/doc/html/rfc7636
30 * @link https://datatracker.ietf.org/doc/html/rfc9126
31 */
32abstract class OAuth2Provider extends OAuthProvider implements OAuth2Interface{
33
34 /**
35 * An optional refresh token endpoint in case the provider supports TokenRefresh.
36 * If the provider supports token refresh and $refreshTokenURL is null, $accessTokenURL will be used instead.
37 *
38 * @see \chillerlan\OAuth\Core\TokenRefresh::refreshAccessToken()
39 */
40 protected string|null $refreshTokenURL = null;
41
42 /**
43 * An optional client credentials token endpoint in case the provider supports ClientCredentials.
44 * If the provider supports client credentials and $clientCredentialsTokenURL is null, $accessTokenURL will be used instead.
45 *
46 * @see \chillerlan\OAuth\Core\ClientCredentials::getClientCredentialsToken()
47 */
48 protected string|null $clientCredentialsTokenURL = null;
49
50 /**
51 * An optional PAR (Pushed Authorization Request) endpoint URL
52 *
53 * @see \chillerlan\OAuth\Core\PAR::getParRequestUri()
54 * @see \chillerlan\OAuth\Core\PARTrait::getParRequestUri()
55 */
56 protected string $parAuthorizationURL = '';
57
58 /**
59 * @param array<string, scalar>|null $params
60 * @param string[]|null $scopes
61 */
62 public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface{
63 $queryParams = $this->getAuthorizationURLRequestParams(($params ?? []), ($scopes ?? $this::DEFAULT_SCOPES));
64
65 if($this instanceof PAR){
66 return $this->getParRequestUri($queryParams);
67 }
68
69 return $this->uriFactory->createUri(QueryUtil::merge($this->authorizationURL, $queryParams));
70 }
71
72 /**
73 * prepares the query parameters for the auth URL
74 *
75 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAuthorizationURL()
76 *
77 * @param array<string, scalar> $params
78 * @param string[] $scopes
79 * @return array<string, string>
80 */
81 protected function getAuthorizationURLRequestParams(array $params, array $scopes):array{
82
83 // this should NEVER be set in the given params
84 unset($params['client_secret']);
85
86 $params = array_merge($params, [
87 'client_id' => $this->options->key,
88 'redirect_uri' => $this->options->callbackURL,
89 'response_type' => 'code',
90 'type' => 'web_server',
91 ]);
92
93 if($scopes !== []){
94 $params['scope'] = implode($this::SCOPES_DELIMITER, $scopes);
95 }
96
97 if($this instanceof CSRFToken){
98 $params = $this->setState($params);
99 }
100
101 if($this instanceof PKCE){
102 $params = $this->setCodeChallenge($params, PKCE::CHALLENGE_METHOD_S256);
103 }
104
105 return $params;
106 }
107
108 /**
109 * Parses the response from a request to the token endpoint
110 *
111 * @link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4
112 * @link https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
113 *
114 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken()
115 * @see \chillerlan\OAuth\Core\OAuth2Provider::refreshAccessToken()
116 * @see \chillerlan\OAuth\Core\OAuth2Provider::getClientCredentialsToken()
117 *
118 * @throws \chillerlan\OAuth\Providers\ProviderException
119 */
120 protected function parseTokenResponse(ResponseInterface $response):AccessToken{
121
122 try{
123 $data = $this->getTokenResponseData($response);
124 }
125 catch(Throwable $e){
126 throw new ProviderException(sprintf('unable to parse token response: %s', $e->getMessage()));
127 }
128
129 // deezer: "error_reason", paypal: "message" (along with "links", "name")
130 // reddit sends "message" and "error" as int, which will throw a TypeError when handed into the exception
131 // detection order changed accordingly
132 foreach(['message', 'error', 'error_description', 'error_reason'] as $field){
133 if(isset($data[$field])){
134
135 if(in_array($response->getStatusCode(), [400, 401, 403], true)){
136 throw new UnauthorizedAccessException($data[$field]);
137 }
138
139 throw new ProviderException(sprintf('error retrieving access token: "%s"', $data[$field]));
140 }
141 }
142
143 if(!isset($data['access_token'])){
144 throw new ProviderException('access token missing');
145 }
146
147 $scopes = ($data['scope'] ?? $data['scopes'] ?? []);
148
149 if(!is_array($scopes)){
150 $scopes = explode($this::SCOPES_DELIMITER, $scopes);
151 }
152
153 $token = $this->createAccessToken();
154 $token->accessToken = $data['access_token'];
155 $token->expires = (int)($data['expires'] ?? $data['expires_in'] ?? AccessToken::NEVER_EXPIRES);
156 $token->refreshToken = ($data['refresh_token'] ?? null);
157 $token->scopes = $scopes;
158
159 foreach(['access_token', 'refresh_token', 'expires', 'expires_in', 'scope', 'scopes'] as $var){
160 unset($data[$var]);
161 }
162
163 $token->extraParams = $data;
164
165 return $token;
166 }
167
168 /**
169 * extracts the data from the access token response and returns an array with the key->value pairs contained
170 *
171 * we don't bother checking the content type here as it's sometimes vendor specific, not set or plain wrong:
172 * the spec mandates a JSON body which is what almost all providers send - weird exceptions:
173 *
174 * - mixcloud sends JSON with a "text/javascript" header
175 * - deezer sends form-data with a "text/html" header (???)
176 * - silly amazon sends gzip compressed data... (handled by decodeJSON)
177 *
178 * @see \chillerlan\OAuth\Core\OAuth2Provider::parseTokenResponse()
179 *
180 * @return array<string, string|mixed>
181 * @throws \JsonException
182 */
183 protected function getTokenResponseData(ResponseInterface $response):array{
184 $data = MessageUtil::decodeJSON($response, true);
185
186 if(!is_array($data)){
187 // nearly impossible to run into this as json_decode() would throw first
188 throw new ProviderException('decoded json is not an array');
189 }
190
191 return $data;
192 }
193
194 public function getAccessToken(string $code, string|null $state = null):AccessToken{
195
196 if($this instanceof CSRFToken){
197 $this->checkState($state);
198 }
199
200 $body = $this->getAccessTokenRequestBodyParams($code);
201 $response = $this->sendAccessTokenRequest($this->accessTokenURL, $body);
202 $token = $this->parseTokenResponse($response);
203
204 $this->storage->storeAccessToken($token, $this->name);
205
206 return $token;
207 }
208
209 /**
210 * prepares the request body parameters for the access token request
211 *
212 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken()
213 *
214 * @return array<string, string>
215 */
216 protected function getAccessTokenRequestBodyParams(string $code):array{
217
218 $params = [
219 'code' => $code,
220 'grant_type' => 'authorization_code',
221 'redirect_uri' => $this->options->callbackURL,
222 ];
223
224 if(!$this::USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST){
225 $params['client_id'] = $this->options->key;
226 $params['client_secret'] = $this->options->secret;
227 }
228
229 if($this instanceof PKCE){
230 $params = $this->setCodeVerifier($params);
231 }
232
233 return $params;
234 }
235
236 /**
237 * sends a request to the access/refresh token endpoint $url with the given $body as form data
238 *
239 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken()
240 * @see \chillerlan\OAuth\Core\OAuth2Provider::refreshAccessToken()
241 * @see \chillerlan\OAuth\Core\OAuth2Provider::getParRequestUri()
242 *
243 * @param array<string, scalar|bool|null> $body
244 */
245 protected function sendAccessTokenRequest(string $url, array $body):ResponseInterface{
246
247 $request = $this->requestFactory
248 ->createRequest('POST', $url)
249 ->withHeader('Accept', 'application/json')
250 ->withHeader('Accept-Encoding', 'identity')
251 ->withHeader('Content-Type', 'application/x-www-form-urlencoded')
252 ->withBody($this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738)))
253 ;
254
255 foreach($this::HEADERS_AUTH as $header => $value){
256 $request = $request->withHeader($header, $value);
257 }
258
259 if($this::USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST){
260 $request = $this->addBasicAuthHeader($request);
261 }
262
263 return $this->http->sendRequest($request);
264 }
265
266 public function getRequestAuthorization(RequestInterface $request, AccessToken|null $token = null):RequestInterface{
267 $token ??= $this->storage->getAccessToken($this->name);
268
269 if($token->isExpired()){
270
271 if(!$this instanceof TokenRefresh || $this->options->tokenAutoRefresh !== true){
272 throw new InvalidAccessTokenException;
273 }
274
275 $token = $this->refreshAccessToken($token);
276 }
277
278 if($this::AUTH_METHOD === OAuth2Interface::AUTH_METHOD_HEADER){
279 return $request->withHeader('Authorization', $this::AUTH_PREFIX_HEADER.' '.$token->accessToken);
280 }
281
282 if($this::AUTH_METHOD === OAuth2Interface::AUTH_METHOD_QUERY){
283 $uri = UriUtil::withQueryValue($request->getUri(), $this::AUTH_PREFIX_QUERY, $token->accessToken);
284
285 return $request->withUri($uri);
286 }
287
288 // it's near impossible to run into this in any other scenario than development...
289 throw new ProviderException('invalid auth AUTH_METHOD'); // @codeCoverageIgnore
290 }
291
292
293 /*
294 * TokenRefresh
295 */
296
297 /**
298 * implements TokenRefresh::refreshAccessToken()
299 *
300 * @see \chillerlan\OAuth\Core\TokenRefresh::refreshAccessToken()
301 * @throws \chillerlan\OAuth\Providers\ProviderException
302 */
303 public function refreshAccessToken(AccessToken|null $token = null):AccessToken{
304
305 if(!$this instanceof TokenRefresh){
306 throw new ProviderException('token refresh not supported');
307 }
308
309 $token ??= $this->storage->getAccessToken($this->name);
310 $refreshToken = $token->refreshToken;
311
312 if(empty($refreshToken)){
313 $msg = 'no refresh token available, token expired [%s]';
314
315 throw new ProviderException(sprintf($msg, date('Y-m-d h:i:s A', $token->expires)));
316 }
317
318 $body = $this->getRefreshAccessTokenRequestBodyParams($refreshToken);
319 $response = $this->sendAccessTokenRequest(($this->refreshTokenURL ?? $this->accessTokenURL), $body);
320 $newToken = $this->parseTokenResponse($response);
321
322 if(empty($newToken->refreshToken)){
323 $newToken->refreshToken = $refreshToken;
324 }
325
326 $this->storage->storeAccessToken($newToken, $this->name);
327
328 return $newToken;
329 }
330
331 /**
332 * prepares the request body parameters for the token refresh
333 *
334 * @see \chillerlan\OAuth\Core\OAuth2Provider::refreshAccessToken()
335 *
336 * @return array<string, string|null>
337 */
338 protected function getRefreshAccessTokenRequestBodyParams(string $refreshToken):array{
339 return [
340 'client_id' => $this->options->key,
341 'client_secret' => $this->options->secret,
342 'grant_type' => 'refresh_token',
343 'refresh_token' => $refreshToken,
344 'type' => 'web_server',
345 ];
346 }
347
348
349 /*
350 * CSRFToken
351 */
352
353 /**
354 * implements CSRFToken::setState()
355 *
356 * @see \chillerlan\OAuth\Core\CSRFToken::setState()
357 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAuthorizationURLRequestParams()
358 *
359 * @param array<string, string> $params
360 * @return array<string, string>
361 * @throws \chillerlan\OAuth\Providers\ProviderException
362 */
363 final public function setState(array $params):array{
364
365 if(!$this instanceof CSRFToken){
366 throw new ProviderException('CSRF protection not supported');
367 }
368
369 // don't touch the parameter if it has been deliberately set
370 if(!isset($params['state'])){
371 $params['state'] = $this->nonce();
372 }
373
374 $this->storage->storeCSRFState($params['state'], $this->name);
375
376 return $params;
377 }
378
379 /**
380 * implements CSRFToken::checkState()
381 *
382 * @see \chillerlan\OAuth\Core\CSRFToken::checkState()
383 * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessToken()
384 * @throws \chillerlan\OAuth\Providers\ProviderException|\chillerlan\OAuth\Core\CSRFStateMismatchException
385 */
386 final public function checkState(string|null $state = null):void{
387
388 if(!$this instanceof CSRFToken){
389 throw new ProviderException('CSRF protection not supported');
390 }
391
392 if(empty($state)){
393 throw new ProviderException('invalid CSRF state');
394 }
395
396 $knownState = $this->storage->getCSRFState($this->name);
397 // delete the used token
398 $this->storage->clearCSRFState($this->name);
399
400 if(!hash_equals($knownState, $state)){
401 throw new CSRFStateMismatchException(sprintf('CSRF state mismatch for provider "%s": %s', $this->name, $state));
402 }
403
404 }
405
406}