1<?php
2
3namespace OAuth\OAuth2\Service;
4
5use OAuth\Common\Consumer\CredentialsInterface;
6use OAuth\Common\Http\Client\ClientInterface;
7use OAuth\Common\Http\Uri\UriInterface;
8use OAuth\Common\Service\AbstractService as BaseAbstractService;
9use OAuth\Common\Storage\TokenStorageInterface;
10use OAuth\Common\Token\Exception\ExpiredTokenException;
11use OAuth\Common\Token\TokenInterface;
12use OAuth\OAuth2\Service\Exception\InvalidAuthorizationStateException;
13use OAuth\OAuth2\Service\Exception\InvalidScopeException;
14use OAuth\OAuth2\Service\Exception\MissingRefreshTokenException;
15use ReflectionClass;
16
17abstract class AbstractService extends BaseAbstractService implements ServiceInterface
18{
19    /** @const OAUTH_VERSION */
20    const OAUTH_VERSION = 2;
21
22    /** @var array */
23    protected $scopes;
24
25    /** @var null|UriInterface */
26    protected $baseApiUri;
27
28    /** @var bool */
29    protected $stateParameterInAuthUrl;
30
31    /** @var string */
32    protected $apiVersion;
33
34    /**
35     * @param array                 $scopes
36     * @param bool $stateParameterInAutUrl
37     * @param string                $apiVersion
38     */
39    public function __construct(
40        CredentialsInterface $credentials,
41        ClientInterface $httpClient,
42        TokenStorageInterface $storage,
43        $scopes = [],
44        ?UriInterface $baseApiUri = null,
45        $stateParameterInAutUrl = false,
46        $apiVersion = ''
47    ) {
48        parent::__construct($credentials, $httpClient, $storage);
49        $this->stateParameterInAuthUrl = $stateParameterInAutUrl;
50
51        foreach ($scopes as $scope) {
52            if (!$this->isValidScope($scope)) {
53                throw new InvalidScopeException('Scope ' . $scope . ' is not valid for service ' . get_class($this));
54            }
55        }
56
57        $this->scopes = $scopes;
58
59        $this->baseApiUri = $baseApiUri;
60
61        $this->apiVersion = $apiVersion;
62    }
63
64    /**
65     * {@inheritdoc}
66     */
67    public function getAuthorizationUri(array $additionalParameters = [])
68    {
69        $parameters = array_merge(
70            $additionalParameters,
71            [
72                'type' => 'web_server',
73                'client_id' => $this->credentials->getConsumerId(),
74                'redirect_uri' => $this->credentials->getCallbackUrl(),
75                'response_type' => 'code',
76            ]
77        );
78
79        $parameters['scope'] = implode($this->getScopesDelimiter(), $this->scopes);
80
81        if ($this->needsStateParameterInAuthUrl()) {
82            if (!isset($parameters['state'])) {
83                $parameters['state'] = $this->generateAuthorizationState();
84            }
85            $this->storeAuthorizationState($parameters['state']);
86        }
87
88        // Build the url
89        $url = clone $this->getAuthorizationEndpoint();
90        foreach ($parameters as $key => $val) {
91            $url->addToQuery($key, $val);
92        }
93
94        return $url;
95    }
96
97    /**
98     * {@inheritdoc}
99     */
100    public function requestAccessToken($code, $state = null)
101    {
102        if (null !== $state) {
103            $this->validateAuthorizationState($state);
104        }
105
106        $bodyParams = [
107            'code' => $code,
108            'client_id' => $this->credentials->getConsumerId(),
109            'client_secret' => $this->credentials->getConsumerSecret(),
110            'redirect_uri' => $this->credentials->getCallbackUrl(),
111            'grant_type' => 'authorization_code',
112        ];
113
114        $responseBody = $this->httpClient->retrieveResponse(
115            $this->getAccessTokenEndpoint(),
116            $bodyParams,
117            $this->getExtraOAuthHeaders()
118        );
119
120        $token = $this->parseAccessTokenResponse($responseBody);
121        $this->storage->storeAccessToken($this->service(), $token);
122
123        return $token;
124    }
125
126    /**
127     * Sends an authenticated API request to the path provided.
128     * If the path provided is not an absolute URI, the base API Uri (must be passed into constructor) will be used.
129     *
130     * @param string|UriInterface $path
131     * @param string              $method       HTTP method
132     * @param array               $body         request body if applicable
133     * @param array               $extraHeaders Extra headers if applicable. These will override service-specific
134     *                                          any defaults.
135     *
136     * @return string
137     */
138    public function request($path, $method = 'GET', $body = null, array $extraHeaders = [])
139    {
140        $uri = $this->determineRequestUriFromPath($path, $this->baseApiUri);
141        $token = $this->storage->retrieveAccessToken($this->service());
142
143        if ($token->getEndOfLife() !== TokenInterface::EOL_NEVER_EXPIRES
144            && $token->getEndOfLife() !== TokenInterface::EOL_UNKNOWN
145            && time() > $token->getEndOfLife()
146        ) {
147            throw new ExpiredTokenException(
148                sprintf(
149                    'Token expired on %s at %s',
150                    date('m/d/Y', $token->getEndOfLife()),
151                    date('h:i:s A', $token->getEndOfLife())
152                )
153            );
154        }
155
156        // add the token where it may be needed
157        if (static::AUTHORIZATION_METHOD_HEADER_OAUTH === $this->getAuthorizationMethod()) {
158            $extraHeaders = array_merge(['Authorization' => 'OAuth ' . $token->getAccessToken()], $extraHeaders);
159        } elseif (static::AUTHORIZATION_METHOD_QUERY_STRING === $this->getAuthorizationMethod()) {
160            $uri->addToQuery('access_token', $token->getAccessToken());
161        } elseif (static::AUTHORIZATION_METHOD_QUERY_STRING_V2 === $this->getAuthorizationMethod()) {
162            $uri->addToQuery('oauth2_access_token', $token->getAccessToken());
163        } elseif (static::AUTHORIZATION_METHOD_QUERY_STRING_V3 === $this->getAuthorizationMethod()) {
164            $uri->addToQuery('apikey', $token->getAccessToken());
165        } elseif (static::AUTHORIZATION_METHOD_QUERY_STRING_V4 === $this->getAuthorizationMethod()) {
166            $uri->addToQuery('auth', $token->getAccessToken());
167        } elseif (static::AUTHORIZATION_METHOD_HEADER_BEARER === $this->getAuthorizationMethod()) {
168            $extraHeaders = array_merge(['Authorization' => 'Bearer ' . $token->getAccessToken()], $extraHeaders);
169        } elseif (static::AUTHORIZATION_METHOD_HEADER_TOKEN === $this->getAuthorizationMethod()) {
170            $extraHeaders = array_merge(array('Authorization' => 'token ' . $token->getAccessToken()), $extraHeaders);
171        }
172
173        $extraHeaders = array_merge($this->getExtraApiHeaders(), $extraHeaders);
174
175        return $this->httpClient->retrieveResponse($uri, $body, $extraHeaders, $method);
176    }
177
178    /**
179     * Accessor to the storage adapter to be able to retrieve tokens.
180     *
181     * @return TokenStorageInterface
182     */
183    public function getStorage()
184    {
185        return $this->storage;
186    }
187
188    /**
189     * Refreshes an OAuth2 access token.
190     *
191     * @return TokenInterface $token
192     */
193    public function refreshAccessToken(TokenInterface $token)
194    {
195        $refreshToken = $token->getRefreshToken();
196
197        if (empty($refreshToken)) {
198            throw new MissingRefreshTokenException();
199        }
200
201        $parameters = [
202            'grant_type' => 'refresh_token',
203            'type' => 'web_server',
204            'client_id' => $this->credentials->getConsumerId(),
205            'client_secret' => $this->credentials->getConsumerSecret(),
206            'refresh_token' => $refreshToken,
207        ];
208
209        $responseBody = $this->httpClient->retrieveResponse(
210            $this->getAccessTokenEndpoint(),
211            $parameters,
212            $this->getExtraOAuthHeaders()
213        );
214        $token = $this->parseAccessTokenResponse($responseBody);
215        $this->storage->storeAccessToken($this->service(), $token);
216
217        return $token;
218    }
219
220    /**
221     * Return whether or not the passed scope value is valid.
222     *
223     * @param string $scope
224     *
225     * @return bool
226     */
227    public function isValidScope($scope)
228    {
229        $reflectionClass = new ReflectionClass(get_class($this));
230
231        return in_array($scope, $reflectionClass->getConstants(), true);
232    }
233
234    /**
235     * Check if the given service need to generate a unique state token to build the authorization url.
236     *
237     * @return bool
238     */
239    public function needsStateParameterInAuthUrl()
240    {
241        return $this->stateParameterInAuthUrl;
242    }
243
244    /**
245     * Validates the authorization state against a given one.
246     *
247     * @param string $state
248     */
249    protected function validateAuthorizationState($state): void
250    {
251        if ($this->retrieveAuthorizationState() !== $state) {
252            throw new InvalidAuthorizationStateException();
253        }
254    }
255
256    /**
257     * Generates a random string to be used as state.
258     *
259     * @return string
260     */
261    protected function generateAuthorizationState()
262    {
263        return md5(mt_rand());
264    }
265
266    /**
267     * Retrieves the authorization state for the current service.
268     *
269     * @return string
270     */
271    protected function retrieveAuthorizationState()
272    {
273        return $this->storage->retrieveAuthorizationState($this->service());
274    }
275
276    /**
277     * Stores a given authorization state into the storage.
278     *
279     * @param string $state
280     */
281    protected function storeAuthorizationState($state): void
282    {
283        $this->storage->storeAuthorizationState($this->service(), $state);
284    }
285
286    /**
287     * Return any additional headers always needed for this service implementation's OAuth calls.
288     *
289     * @return array
290     */
291    protected function getExtraOAuthHeaders()
292    {
293        return [];
294    }
295
296    /**
297     * Return any additional headers always needed for this service implementation's API calls.
298     *
299     * @return array
300     */
301    protected function getExtraApiHeaders()
302    {
303        return [];
304    }
305
306    /**
307     * Parses the access token response and returns a TokenInterface.
308     *
309     * @abstract
310     *
311     * @param string $responseBody
312     *
313     * @return TokenInterface
314     */
315    abstract protected function parseAccessTokenResponse($responseBody);
316
317    /**
318     * Returns a class constant from ServiceInterface defining the authorization method used for the API
319     * Header is the sane default.
320     *
321     * @return int
322     */
323    protected function getAuthorizationMethod()
324    {
325        return static::AUTHORIZATION_METHOD_HEADER_OAUTH;
326    }
327
328    /**
329     * Returns api version string if is set else retrun empty string.
330     *
331     * @return string
332     */
333    protected function getApiVersionString()
334    {
335        return !(empty($this->apiVersion)) ? '/' . $this->apiVersion : '';
336    }
337
338    /**
339     * Returns delimiter to scopes in getAuthorizationUri
340     * For services that do not fully respect the Oauth's RFC,
341     * and use scopes with commas as delimiter.
342     *
343     * @return string
344     */
345    protected function getScopesDelimiter()
346    {
347        return ' ';
348    }
349}
350