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}