friendship ended with social-app. php is my new best friend
1<?php 2/** 3 * Class OAuth1Provider 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}; 17use chillerlan\OAuth\Providers\ProviderException; 18use chillerlan\Utilities\Str; 19use Psr\Http\Message\{RequestInterface, ResponseInterface, UriInterface}; 20use function array_merge, hash_hmac, implode, in_array, sprintf, strtoupper, time; 21 22/** 23 * Implements an abstract OAuth1 (1.0a) provider with all methods required by the OAuth1Interface. 24 * 25 * @link https://oauth.net/core/1.0a/ 26 * @link https://datatracker.ietf.org/doc/html/rfc5849 27 */ 28abstract class OAuth1Provider extends OAuthProvider implements OAuth1Interface{ 29 30 /** 31 * The request token URL 32 */ 33 protected string $requestTokenURL = ''; 34 35 /** 36 * @param array<string, scalar>|null $params 37 */ 38 public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface{ 39 $response = $this->sendRequestTokenRequest($this->requestTokenURL); 40 $token = $this->parseTokenResponse($response, true); 41 $params = array_merge(($params ?? []), ['oauth_token' => $token->accessToken]); 42 43 return $this->uriFactory->createUri(QueryUtil::merge($this->authorizationURL, $params)); 44 } 45 46 /** 47 * Sends a request to the request token endpoint 48 * 49 * @see \chillerlan\OAuth\Core\OAuth1Provider::getAuthorizationURL() 50 */ 51 protected function sendRequestTokenRequest(string $url):ResponseInterface{ 52 $params = $this->getRequestTokenRequestParams(); 53 54 $request = $this->requestFactory 55 ->createRequest('POST', $url) 56 ->withHeader('Authorization', 'OAuth '.QueryUtil::build($params, null, ', ', '"')) 57 // try to avoid compression 58 ->withHeader('Accept-Encoding', 'identity') 59 // tumblr requires a content-length header set 60 ->withHeader('Content-Length', '0') 61 ; 62 63 foreach($this::HEADERS_AUTH as $header => $value){ 64 $request = $request->withHeader($header, $value); 65 } 66 67 return $this->http->sendRequest($request); 68 } 69 70 /** 71 * prepares the parameters for the request token request header 72 * 73 * @see \chillerlan\OAuth\Core\OAuth1Provider::sendRequestTokenRequest() 74 * @link https://datatracker.ietf.org/doc/html/rfc5849#section-2.1 75 * 76 * @return array<string, scalar> 77 */ 78 protected function getRequestTokenRequestParams():array{ 79 80 $params = [ 81 'oauth_callback' => $this->options->callbackURL, 82 'oauth_consumer_key' => $this->options->key, 83 'oauth_nonce' => $this->nonce(), 84 'oauth_signature_method' => 'HMAC-SHA1', 85 'oauth_timestamp' => time(), 86 'oauth_version' => '1.0', 87 ]; 88 89 $params['oauth_signature'] = $this->getSignature($this->requestTokenURL, $params, 'POST'); 90 91 return $params; 92 } 93 94 /** 95 * Parses the response from a request to the token endpoint 96 * 97 * Note: "oauth_callback_confirmed" is only sent in the request token response 98 * 99 * @link https://datatracker.ietf.org/doc/html/rfc5849#section-2.1 100 * @link https://datatracker.ietf.org/doc/html/rfc5849#section-2.3 101 * @see \chillerlan\OAuth\Core\OAuth1Provider::getAuthorizationURL() 102 * @see \chillerlan\OAuth\Core\OAuth1Provider::getAccessToken() 103 * 104 * @throws \chillerlan\OAuth\Providers\ProviderException 105 */ 106 protected function parseTokenResponse(ResponseInterface $response, bool|null $checkCallbackConfirmed = null):AccessToken{ 107 /** @var array<string, string> $data */ 108 $data = QueryUtil::parse(MessageUtil::decompress($response)); 109 110 if($data === []){ 111 throw new ProviderException('unable to parse token response'); 112 } 113 114 if(isset($data['error'])){ 115 116 if(in_array($response->getStatusCode(), [400, 401, 403], true)){ 117 throw new UnauthorizedAccessException($data['error']); 118 } 119 120 throw new ProviderException(sprintf('error retrieving access token: "%s"', $data['error'])); 121 } 122 123 if(!isset($data['oauth_token']) || !isset($data['oauth_token_secret'])){ 124 throw new ProviderException('invalid token'); 125 } 126 127 // MUST be present and set to "true". The parameter is used to differentiate from previous versions of the protocol 128 if( 129 $checkCallbackConfirmed === true 130 && (!isset($data['oauth_callback_confirmed']) || $data['oauth_callback_confirmed'] !== 'true') 131 ){ 132 throw new ProviderException('invalid OAuth 1.0a response'); 133 } 134 135 $token = $this->createAccessToken(); 136 $token->accessToken = $data['oauth_token']; 137 $token->accessTokenSecret = $data['oauth_token_secret']; 138 $token->expires = AccessToken::NEVER_EXPIRES; 139 140 unset($data['oauth_token'], $data['oauth_token_secret']); 141 142 $token->extraParams = $data; 143 144 $this->storage->storeAccessToken($token, $this->name); 145 146 return $token; 147 } 148 149 /** 150 * Generates a request signature 151 * 152 * @link https://datatracker.ietf.org/doc/html/rfc5849#section-3.4 153 * @see \chillerlan\OAuth\Core\OAuth1Provider::getRequestTokenRequestParams() 154 * @see \chillerlan\OAuth\Core\OAuth1Provider::getRequestAuthorization() 155 * 156 * @param array<string, scalar> $params 157 * 158 * @throws \chillerlan\OAuth\Providers\ProviderException 159 */ 160 protected function getSignature( 161 UriInterface|string $url, 162 array $params, 163 string $method, 164 string|null $accessTokenSecret = null, 165 ):string{ 166 167 if(!$url instanceof UriInterface){ 168 $url = $this->uriFactory->createUri($url); 169 } 170 171 if($url->getHost() === '' || $url->getScheme() !== 'https'){ 172 throw new ProviderException(sprintf('getSignature: invalid url: "%s"', $url)); 173 } 174 175 $signatureParams = array_merge(QueryUtil::parse($url->getQuery()), $params); 176 $url = $url->withQuery('')->withFragment(''); 177 178 // make sure we have no unwanted params in the array 179 unset($signatureParams['oauth_signature']); 180 181 // @link https://datatracker.ietf.org/doc/html/rfc5849#section-3.4.1.1 182 $data = QueryUtil::recursiveRawurlencode([strtoupper($method), (string)$url, QueryUtil::build($signatureParams)]); 183 184 // @link https://datatracker.ietf.org/doc/html/rfc5849#section-3.4.2 185 $key = QueryUtil::recursiveRawurlencode([$this->options->secret, ($accessTokenSecret ?? '')]); 186 187 $hash = hash_hmac('sha1', implode('&', $data), implode('&', $key), true); 188 189 return Str::base64encode($hash); 190 } 191 192 /** 193 * @inheritDoc 194 * @throws \chillerlan\OAuth\Providers\ProviderException 195 */ 196 public function getAccessToken(string $requestToken, string $verifier):AccessToken{ 197 $token = $this->storage->getAccessToken($this->name); 198 199 if($requestToken !== $token->accessToken){ 200 throw new ProviderException('request token mismatch'); 201 } 202 203 $params = $this->getAccessTokenRequestHeaderParams($token, $verifier); 204 $response = $this->sendAccessTokenRequest($params); 205 206 return $this->parseTokenResponse($response); 207 } 208 209 /** 210 * Prepares the header params for the access token request 211 * 212 * @return array<string, scalar> 213 */ 214 protected function getAccessTokenRequestHeaderParams(AccessToken $requestToken, string $verifier):array{ 215 /** @var array<string, scalar> $params */ 216 $params = [ 217 'oauth_consumer_key' => $this->options->key, 218 'oauth_nonce' => $this->nonce(), 219 'oauth_signature_method' => 'HMAC-SHA1', 220 'oauth_timestamp' => time(), 221 'oauth_token' => $requestToken->accessToken, 222 'oauth_version' => '1.0', 223 'oauth_verifier' => $verifier, 224 ]; 225 226 $params['oauth_signature'] = $this->getSignature( 227 $this->accessTokenURL, 228 $params, 229 'POST', 230 $requestToken->accessTokenSecret, 231 ); 232 233 return $params; 234 } 235 236 /** 237 * Adds the "Authorization" header to the given `RequestInterface` using the given array or parameters 238 * 239 * @param array<string, scalar> $params 240 */ 241 protected function setAuthorizationHeader(RequestInterface $request, array $params):RequestInterface{ 242 return $request->withHeader('Authorization', sprintf('OAuth %s', QueryUtil::build($params, null, ', ', '"'))); 243 } 244 245 /** 246 * Sends the access token request 247 * 248 * @see \chillerlan\OAuth\Core\OAuth1Provider::getAccessToken() 249 * 250 * @param array<string, scalar> $headerParams 251 */ 252 protected function sendAccessTokenRequest(array $headerParams):ResponseInterface{ 253 254 $request = $this->requestFactory 255 ->createRequest('POST', $this->accessTokenURL) 256 ->withHeader('Accept-Encoding', 'identity') 257 ->withHeader('Content-Length', '0') 258 ; 259 260 $request = $this->setAuthorizationHeader($request, $headerParams); 261 262 return $this->http->sendRequest($request); 263 } 264 265 /** 266 * @inheritDoc 267 * @see \chillerlan\OAuth\Core\OAuth1Provider::sendAccessTokenRequest() 268 */ 269 public function getRequestAuthorization(RequestInterface $request, AccessToken|null $token = null):RequestInterface{ 270 $token ??= $this->storage->getAccessToken($this->name); 271 272 if($token->isExpired()){ 273 throw new InvalidAccessTokenException; 274 } 275 276 /** @var array<string, scalar> $params */ 277 $params = [ 278 'oauth_consumer_key' => $this->options->key, 279 'oauth_nonce' => $this->nonce(), 280 'oauth_signature_method' => 'HMAC-SHA1', 281 'oauth_timestamp' => time(), 282 'oauth_token' => $token->accessToken, 283 'oauth_version' => '1.0', 284 ]; 285 286 $params['oauth_signature'] = $this->getSignature( 287 $request->getUri(), 288 $params, 289 $request->getMethod(), 290 $token->accessTokenSecret, 291 ); 292 293 return $this->setAuthorizationHeader($request, $params); 294 } 295 296}