From 78a63e291bed83e995b7843d9eb40e617388da84 Mon Sep 17 00:00:00 2001 From: Rhys Lees <43909932+RhysLees@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:24:35 +0000 Subject: [PATCH 1/4] WIP --- src/Authenticator/InstagramAuthenticator.php | 9 +- src/Connectors/InstagramConnector.php | 55 +--- .../GetRefreshAccessTokenRequest.php | 49 ++++ src/Traits/AuthorizationCodeGrant.php | 260 ++++++++++++++++++ 4 files changed, 317 insertions(+), 56 deletions(-) create mode 100755 src/Requests/Authentication/GetRefreshAccessTokenRequest.php create mode 100755 src/Traits/AuthorizationCodeGrant.php diff --git a/src/Authenticator/InstagramAuthenticator.php b/src/Authenticator/InstagramAuthenticator.php index 6545e49..856cc30 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); + Carbon::createFromTimestamp($this->getExpiresAt()->getTimestamp())->diffInDays(); + + return now()->subHours(24)->gt($this->getExpiresAt()); } /** 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..615daae --- /dev/null +++ b/src/Requests/Authentication/GetRefreshAccessTokenRequest.php @@ -0,0 +1,49 @@ + 'ig_exchange_token', + 'access_token' => $this->code, + ]; + } +} diff --git a/src/Traits/AuthorizationCodeGrant.php b/src/Traits/AuthorizationCodeGrant.php new file mode 100755 index 0000000..a05d4e2 --- /dev/null +++ b/src/Traits/AuthorizationCodeGrant.php @@ -0,0 +1,260 @@ + $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; + $refreshToken = $responseData->refresh_token ?? $fallbackRefreshToken; + + $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, $refreshToken, $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); + } +} From a94f38b9f09458df19a3ccc97479c3a69ea0ff5e Mon Sep 17 00:00:00 2001 From: Rhys Lees <43909932+RhysLees@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:31:00 +0000 Subject: [PATCH 2/4] WIP --- src/Requests/Authentication/GetRefreshAccessTokenRequest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Requests/Authentication/GetRefreshAccessTokenRequest.php b/src/Requests/Authentication/GetRefreshAccessTokenRequest.php index 615daae..f889de2 100755 --- a/src/Requests/Authentication/GetRefreshAccessTokenRequest.php +++ b/src/Requests/Authentication/GetRefreshAccessTokenRequest.php @@ -36,7 +36,6 @@ public function __construct(protected string $code) {} * @return array{ * grant_type: string, * access_token: string, - * client_secret: string, * } */ public function defaultQuery(): array From 91ecf582b72814df891c7665440980101d23c389 Mon Sep 17 00:00:00 2001 From: Rhys Lees <43909932+RhysLees@users.noreply.github.com> Date: Tue, 18 Feb 2025 23:17:24 +0000 Subject: [PATCH 3/4] WIP --- src/Authenticator/InstagramAuthenticator.php | 4 ++-- src/Requests/Authentication/GetRefreshAccessTokenRequest.php | 2 +- src/Traits/AuthorizationCodeGrant.php | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Authenticator/InstagramAuthenticator.php b/src/Authenticator/InstagramAuthenticator.php index 856cc30..2a7bb5f 100755 --- a/src/Authenticator/InstagramAuthenticator.php +++ b/src/Authenticator/InstagramAuthenticator.php @@ -74,9 +74,9 @@ public function getExpiresAt(): ?DateTimeImmutable */ public function isRefreshable(): bool { - Carbon::createFromTimestamp($this->getExpiresAt()->getTimestamp())->diffInDays(); + $created = Carbon::createFromTimestamp($this->getExpiresAt()->getTimestamp())->subDays(60); - return now()->subHours(24)->gt($this->getExpiresAt()); + return now()->diffInHours($created) > 24; } /** diff --git a/src/Requests/Authentication/GetRefreshAccessTokenRequest.php b/src/Requests/Authentication/GetRefreshAccessTokenRequest.php index f889de2..d3ef8bc 100755 --- a/src/Requests/Authentication/GetRefreshAccessTokenRequest.php +++ b/src/Requests/Authentication/GetRefreshAccessTokenRequest.php @@ -41,7 +41,7 @@ public function __construct(protected string $code) {} public function defaultQuery(): array { return [ - 'grant_type' => 'ig_exchange_token', + 'grant_type' => 'ig_refresh_token', 'access_token' => $this->code, ]; } diff --git a/src/Traits/AuthorizationCodeGrant.php b/src/Traits/AuthorizationCodeGrant.php index a05d4e2..6067b46 100755 --- a/src/Traits/AuthorizationCodeGrant.php +++ b/src/Traits/AuthorizationCodeGrant.php @@ -186,7 +186,6 @@ protected function createOAuthAuthenticatorFromResponse(Response $response, ?str $responseData = $response->object(); $accessToken = $responseData->access_token; - $refreshToken = $responseData->refresh_token ?? $fallbackRefreshToken; $expiresAt = null; @@ -196,7 +195,7 @@ protected function createOAuthAuthenticatorFromResponse(Response $response, ?str ); } - return $this->createOAuthAuthenticator($accessToken, $refreshToken, $expiresAt); + return $this->createOAuthAuthenticator($accessToken, null, $expiresAt); } /** From 79b7e294a36c7c9f975148b0163878827b58533e Mon Sep 17 00:00:00 2001 From: Rhys Lees <43909932+RhysLees@users.noreply.github.com> Date: Tue, 18 Feb 2025 23:36:48 +0000 Subject: [PATCH 4/4] WIP --- tests/Feature/Connectors/InstagramConnectorTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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');