diff --git a/src/Authenticator/InstagramAuthenticator.php b/src/Authenticator/InstagramAuthenticator.php index 6545e49..2a7bb5f 100755 --- a/src/Authenticator/InstagramAuthenticator.php +++ b/src/Authenticator/InstagramAuthenticator.php @@ -3,6 +3,7 @@ namespace CodebarAg\LaravelInstagram\Authenticator; use DateTimeImmutable; +use Illuminate\Support\Carbon; use Saloon\Contracts\OAuthAuthenticator; use Saloon\Http\PendingRequest; @@ -52,10 +53,12 @@ public function getAccessToken(): string /** * Get the refresh token + * + * @throws \Exception */ public function getRefreshToken(): ?string { - return $this->refreshToken; + throw new \Exception('Instagram does not provide refresh tokens. use getAccessToken() instead.'); } /** @@ -71,7 +74,9 @@ public function getExpiresAt(): ?DateTimeImmutable */ public function isRefreshable(): bool { - return isset($this->refreshToken); + $created = Carbon::createFromTimestamp($this->getExpiresAt()->getTimestamp())->subDays(60); + + return now()->diffInHours($created) > 24; } /** diff --git a/src/Connectors/InstagramConnector.php b/src/Connectors/InstagramConnector.php index 3287f33..47e553f 100755 --- a/src/Connectors/InstagramConnector.php +++ b/src/Connectors/InstagramConnector.php @@ -3,17 +3,11 @@ namespace CodebarAg\LaravelInstagram\Connectors; use CodebarAg\LaravelInstagram\Authenticator\InstagramAuthenticator; -use CodebarAg\LaravelInstagram\Requests\Authentication\GetAccessTokenRequest; -use CodebarAg\LaravelInstagram\Requests\Authentication\GetShortLivedAccessTokenRequest; +use CodebarAg\LaravelInstagram\Traits\AuthorizationCodeGrant; use DateTimeImmutable; use Saloon\Contracts\OAuthAuthenticator; -use Saloon\Exceptions\InvalidStateException; -use Saloon\Exceptions\OAuthConfigValidationException; use Saloon\Helpers\OAuth2\OAuthConfig; use Saloon\Http\Connector; -use Saloon\Http\Request; -use Saloon\Http\Response; -use Saloon\Traits\OAuth2\AuthorizationCodeGrant; class InstagramConnector extends Connector { @@ -61,51 +55,4 @@ protected function defaultOauthConfig(): OAuthConfig ->setTokenEndpoint('https://api.instagram.com/oauth/access_token') ->setUserEndpoint('/me'); } - - /** - * Get the short lived access token. - * - * @template TRequest of \Saloon\Http\Request - * - * @param callable(TRequest): (void)|null $requestModifier - * - * @throws \Saloon\Exceptions\InvalidStateException - * @throws OAuthConfigValidationException - */ - public function getShortLivedAccessToken(string $code, ?string $state = null, ?string $expectedState = null, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response - { - $this->oauthConfig()->validate(); - - if (! empty($state) && ! empty($expectedState) && $state !== $expectedState) { - throw new InvalidStateException; - } - - $request = $this->resolveShortLivedAccessTokenRequest($code, $this->oauthConfig()); - - $request = $this->oauthConfig()->invokeRequestModifier($request); - - if (is_callable($requestModifier)) { - $requestModifier($request); - } - - $response = $this->send($request); - - if ($returnResponse === true) { - return $response; - } - - $response->throw(); - - return $this->createOAuthAuthenticatorFromResponse($response); - } - - protected function resolveAccessTokenRequest(string $code, OAuthConfig $oauthConfig): Request - { - return new GetAccessTokenRequest($code, $oauthConfig); - } - - protected function resolveShortLivedAccessTokenRequest(string $code, OAuthConfig $oauthConfig): Request - { - return new GetShortLivedAccessTokenRequest($code, $oauthConfig); - } } diff --git a/src/Requests/Authentication/GetRefreshAccessTokenRequest.php b/src/Requests/Authentication/GetRefreshAccessTokenRequest.php new file mode 100755 index 0000000..d3ef8bc --- /dev/null +++ b/src/Requests/Authentication/GetRefreshAccessTokenRequest.php @@ -0,0 +1,48 @@ + 'ig_refresh_token', + 'access_token' => $this->code, + ]; + } +} diff --git a/src/Traits/AuthorizationCodeGrant.php b/src/Traits/AuthorizationCodeGrant.php new file mode 100755 index 0000000..6067b46 --- /dev/null +++ b/src/Traits/AuthorizationCodeGrant.php @@ -0,0 +1,259 @@ + $scopes + */ + public function getAuthorizationUrl(array $scopes = [], ?string $state = null, string $scopeSeparator = ' ', array $additionalQueryParameters = []): string + { + $config = $this->oauthConfig(); + + $config->validate(); + + $clientId = $config->getClientId(); + $redirectUri = $config->getRedirectUri(); + $defaultScopes = $config->getDefaultScopes(); + + $this->state = $state ?? StringHelpers::random(32); + + $queryParameters = array_filter([ + 'response_type' => 'code', + 'scope' => implode($scopeSeparator, array_filter(array_merge($defaultScopes, $scopes))), + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'state' => $this->state, + ...$additionalQueryParameters, + ]); + + $query = http_build_query($queryParameters, '', '&', PHP_QUERY_RFC3986); + $query = trim($query, '?&'); + + $url = URLHelper::join($this->resolveBaseUrl(), $config->getAuthorizeEndpoint()); + + $glue = str_contains($url, '?') ? '&' : '?'; + + return $url.$glue.$query; + } + + /** + * Get the access token. + * + * @template TRequest of \Saloon\Http\Request + * + * @param callable(TRequest): (void)|null $requestModifier + * + * @throws \Saloon\Exceptions\InvalidStateException + */ + public function getAccessToken(string $code, ?string $state = null, ?string $expectedState = null, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response + { + $this->oauthConfig()->validate(); + + if (! empty($state) && ! empty($expectedState) && $state !== $expectedState) { + throw new InvalidStateException; + } + + $request = $this->resolveAccessTokenRequest($code, $this->oauthConfig()); + + $request = $this->oauthConfig()->invokeRequestModifier($request); + + if (is_callable($requestModifier)) { + $requestModifier($request); + } + + $response = $this->send($request); + + if ($returnResponse === true) { + return $response; + } + + $response->throw(); + + return $this->createOAuthAuthenticatorFromResponse($response); + } + + /** + * Get the short lived access token. + * + * @template TRequest of \Saloon\Http\Request + * + * @param callable(TRequest): (void)|null $requestModifier + * + * @throws \Saloon\Exceptions\InvalidStateException + * @throws OAuthConfigValidationException + */ + public function getShortLivedAccessToken(string $code, ?string $state = null, ?string $expectedState = null, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response + { + $this->oauthConfig()->validate(); + + if (! empty($state) && ! empty($expectedState) && $state !== $expectedState) { + throw new InvalidStateException; + } + + $request = $this->resolveShortLivedAccessTokenRequest($code, $this->oauthConfig()); + + $request = $this->oauthConfig()->invokeRequestModifier($request); + + if (is_callable($requestModifier)) { + $requestModifier($request); + } + + $response = $this->send($request); + + if ($returnResponse === true) { + return $response; + } + + $response->throw(); + + return $this->createOAuthAuthenticatorFromResponse($response); + } + + /** + * Refresh the access token. + * + * @template TRequest of \Saloon\Http\Request + * + * @param callable(TRequest): (void)|null $requestModifier + */ + public function refreshAccessToken(OAuthAuthenticator|string $refreshToken, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response + { + $this->oauthConfig()->validate(); + + if ($refreshToken instanceof OAuthAuthenticator) { + if ($refreshToken->isNotRefreshable()) { + throw new InvalidArgumentException('Not refreshable.'); + } + + $refreshToken = $refreshToken->getAccessToken(); + } + + $request = $this->resolveRefreshTokenRequest($refreshToken); + + $request = $this->oauthConfig()->invokeRequestModifier($request); + + if (is_callable($requestModifier)) { + $requestModifier($request); + } + + $response = $this->send($request); + + if ($returnResponse === true) { + return $response; + } + + $response->throw(); + + return $this->createOAuthAuthenticatorFromResponse($response, $refreshToken); + } + + /** + * Create the OAuthAuthenticator from a response. + */ + protected function createOAuthAuthenticatorFromResponse(Response $response, ?string $fallbackRefreshToken = null): OAuthAuthenticator + { + $responseData = $response->object(); + + $accessToken = $responseData->access_token; + + $expiresAt = null; + + if (isset($responseData->expires_in) && is_numeric($responseData->expires_in)) { + $expiresAt = (new DateTimeImmutable)->add( + DateInterval::createFromDateString((int) $responseData->expires_in.' seconds') + ); + } + + return $this->createOAuthAuthenticator($accessToken, null, $expiresAt); + } + + /** + * Create the authenticator. + */ + protected function createOAuthAuthenticator(string $accessToken, ?string $refreshToken = null, ?DateTimeImmutable $expiresAt = null): OAuthAuthenticator + { + return new AccessTokenAuthenticator($accessToken, $refreshToken, $expiresAt); + } + + /** + * Get the authenticated user. + * + * @template TRequest of \Saloon\Http\Request + * + * @param callable(TRequest): (void)|null $requestModifier + */ + public function getUser(OAuthAuthenticator $oauthAuthenticator, ?callable $requestModifier = null): Response + { + $request = $this->resolveUserRequest($this->oauthConfig())->authenticate($oauthAuthenticator); + + if (is_callable($requestModifier)) { + $requestModifier($request); + } + + $request = $this->oauthConfig()->invokeRequestModifier($request); + + return $this->send($request); + } + + /** + * Get the state that was generated in the getAuthorizationUrl() method. + */ + public function getState(): ?string + { + return $this->state; + } + + /** + * Resolve the user request + */ + protected function resolveUserRequest(OAuthConfig $oauthConfig): Request + { + return new GetUserRequest($oauthConfig); + } + + protected function resolveAccessTokenRequest(string $code, OAuthConfig $oauthConfig): Request + { + return new \CodebarAg\LaravelInstagram\Requests\Authentication\GetAccessTokenRequest($code, $oauthConfig); + } + + protected function resolveShortLivedAccessTokenRequest(string $code, OAuthConfig $oauthConfig): Request + { + return new GetShortLivedAccessTokenRequest($code, $oauthConfig); + } + + protected function resolveRefreshTokenRequest(string $code): Request + { + return new GetRefreshAccessTokenRequest($code); + } +} diff --git a/tests/Feature/Connectors/InstagramConnectorTest.php b/tests/Feature/Connectors/InstagramConnectorTest.php index 664e84f..a306e91 100755 --- a/tests/Feature/Connectors/InstagramConnectorTest.php +++ b/tests/Feature/Connectors/InstagramConnectorTest.php @@ -35,7 +35,7 @@ ]), GetAccessTokenRequest::class => MockResponse::make([ 'access_token' => 'some_long_access_token', - 'refresh_token' => 'some_refresh_token', + 'refresh_token' => null, 'expires_in' => 5184000, ]), ]); @@ -61,7 +61,7 @@ expect($authenticator) ->toBeInstanceOf(InstagramAuthenticator::class) ->accessToken->toBe('some_long_access_token') - ->refreshToken->toBe('some_refresh_token') + ->refreshToken->toBe(null) ->expiresAt->toBeInstanceOf(DateTimeImmutable::class) ->expiresAt->format('Y-m-d H:i:s')->toBe($date->format('Y-m-d H:i:s')); })->group('authorization');